Files
lazygit/pkg/gui/context/local_commits_context.go
T
Stefan HallerandClaude Opus 4.8 494b9ce7cf 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>
2026-08-08 12:58:59 +02:00

397 lines
12 KiB
Go

package context
import (
"fmt"
"log"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type LocalCommitsContext struct {
*LocalCommitsViewModel
*ListContextTrait
*SearchTrait
dropIndicator *commitDropIndicator
}
type commitDropIndicator struct {
insertionIndex int
moving bool
}
var (
_ 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(
func() []*models.Commit { return c.Model().Commits },
c,
)
getDisplayStrings := func(startIdx int, endIdx int) [][]string {
var selectedCommitHashPtr *string
if c.Context().Current().GetKey() == LOCAL_COMMITS_CONTEXT_KEY {
selectedCommit := viewModel.GetSelected()
if selectedCommit != nil {
selectedCommitHashPtr = selectedCommit.HashPtr()
}
}
hasRebaseUpdateRefsConfig := c.Git().Config.GetRebaseUpdateRefs()
return presentation.GetCommitListDisplayStrings(
c.Common,
c.Model().Commits,
c.Model().Branches,
c.Model().CheckedOutBranch,
hasRebaseUpdateRefsConfig,
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().CherryPicking.SelectedHashSet(),
c.Modes().Diffing.Ref,
c.Modes().MarkedBaseCommit.GetHash(),
c.UserConfig().Gui.TimeFormat,
c.UserConfig().Gui.ShortTimeFormat,
time.Now(),
c.UserConfig().Git.ParseEmoji,
selectedCommitHashPtr,
startIdx,
endIdx,
shouldShowGraph(c),
c.Model().BisectInfo,
)
}
getNonModelItems := func() []*NonModelItem {
result := []*NonModelItem{}
if c.Model().WorkingTreeStateAtLastCommitRefresh.CanShowTodos() {
if c.Model().WorkingTreeStateAtLastCommitRefresh.Rebasing {
result = append(result, &NonModelItem{
Index: 0,
Content: formatListSectionHeader(c.Tr.PendingRebaseTodosSectionHeader),
})
}
if c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking ||
c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting {
_, firstCherryPickOrRevertTodo, found := lo.FindIndexOf(
c.Model().Commits, func(c *models.Commit) bool {
return c.Status == models.StatusCherryPickingOrReverting ||
c.Status == models.StatusConflicted
})
if !found {
firstCherryPickOrRevertTodo = 0
}
label := lo.Ternary(c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking,
c.Tr.PendingCherryPicksSectionHeader,
c.Tr.PendingRevertsSectionHeader)
result = append(result, &NonModelItem{
Index: firstCherryPickOrRevertTodo,
Content: formatListSectionHeader(label),
})
}
result = addCommitDropIndicator(
result,
dropIndicator,
c.Tr.MoveCommitsHere,
c.Tr.MovingCommitsHere,
c.UserConfig().Gui.Spinner,
time.Now(),
)
_, firstRealCommit, found := lo.FindIndexOf(
c.Model().Commits, func(c *models.Commit) bool {
return !c.IsTODO()
})
if !found {
firstRealCommit = 0
}
result = append(result, &NonModelItem{
Index: firstRealCommit,
Content: formatListSectionHeader(c.Tr.CommitsSectionHeader),
})
} else {
result = addCommitDropIndicator(
result,
dropIndicator,
c.Tr.MoveCommitsHere,
c.Tr.MovingCommitsHere,
c.UserConfig().Gui.Spinner,
time.Now(),
)
}
return result
}
ctx := &LocalCommitsContext{
LocalCommitsViewModel: viewModel,
SearchTrait: NewSearchTrait(c),
dropIndicator: dropIndicator,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Commits,
WindowName: "commits",
Key: LOCAL_COMMITS_CONTEXT_KEY,
Kind: types.SIDE_CONTEXT,
Focusable: true,
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_SCREEN_MODE_CHANGES,
NeedsRerenderOnHeightChange: true,
})),
ListRenderer: ListRenderer{
list: viewModel,
getDisplayStrings: getDisplayStrings,
getNonModelItems: getNonModelItems,
},
c: c,
refreshViewportOnChange: true,
renderOnlyVisibleLines: true,
},
}
return ctx
}
func addCommitDropIndicator(
items []*NonModelItem,
indicator *commitDropIndicator,
dropLabel string,
movingLabel string,
spinnerConfig config.SpinnerConfig,
now time.Time,
) []*NonModelItem {
if indicator.insertionIndex < 0 {
return items
}
label := dropLabel
if indicator.moving {
label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig))
}
insertAt := len(items)
for i, item := range items {
if item.Index > indicator.insertionIndex {
insertAt = i
break
}
}
return slices.Insert(items, insertAt, &NonModelItem{
Index: indicator.insertionIndex,
Content: style.FgCyan.SetBold().Sprintf("━━━━━━ %s ━━━━━━", label),
Column: 6, // align with the commit subject
})
}
func (self *LocalCommitsContext) SetDropInsertionIndex(index int) {
self.dropIndicator.insertionIndex = index
self.dropIndicator.moving = false
}
func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) {
self.dropIndicator.insertionIndex = index
self.dropIndicator.moving = true
}
func (self *LocalCommitsContext) ClearDropInsertionIndex() {
self.dropIndicator.insertionIndex = -1
self.dropIndicator.moving = false
}
type LocalCommitsViewModel struct {
*ListViewModel[*models.Commit]
// If this is true we limit the amount of commits we load, for the sake of keeping things fast.
// If the user attempts to scroll past the end of the list, we will load more commits.
// Atomic because a checkout or reset sets it from a worker goroutine while the
// commits refresh reads it on the UI thread to decide how many commits to load.
limitCommits atomic.Bool
// If this is true we'll use git log --all when fetching the commits.
showWholeGitGraph bool
}
func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel {
self := &LocalCommitsViewModel{
ListViewModel: NewListViewModel(getModel),
showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph,
}
self.limitCommits.Store(true)
return self
}
func (self *LocalCommitsContext) CanRebase() bool {
return true
}
func (self *LocalCommitsContext) GetSelectedRef() models.Ref {
commit := self.GetSelected()
if commit == nil {
return nil
}
return commit
}
func (self *LocalCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange {
commits, startIdx, endIdx := self.GetSelectedItems()
if commits == nil || startIdx == endIdx {
return nil
}
from := commits[len(commits)-1]
to := commits[0]
if from.IsTODO() || to.IsTODO() {
return nil
}
return &types.RefRange{From: from, To: to}
}
// Returns the commit hash of the selected commit, or an empty string if no
// commit is selected
func (self *LocalCommitsContext) GetSelectedCommitHash() string {
commit := self.GetSelected()
if commit == nil {
return ""
}
return commit.Hash()
}
func (self *LocalCommitsContext) SelectCommitByHash(hash string) bool {
if hash == "" {
return false
}
if _, idx, found := lo.FindIndexOf(self.GetItems(), func(c *models.Commit) bool { return c.Hash() == hash }); found {
self.SetSelection(idx)
return true
}
return false
}
func (self *LocalCommitsContext) GetDiffTerminals() []string {
itemId := self.GetSelectedItemId()
return []string{itemId}
}
func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string {
commits, _, _ := self.GetSelectedItems()
if commits == nil {
return ""
}
return commits[0].Hash()
}
func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
}
func (self *LocalCommitsViewModel) SetLimitCommits(value bool) {
self.limitCommits.Store(value)
}
func (self *LocalCommitsViewModel) GetLimitCommits() bool {
return self.limitCommits.Load()
}
func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) {
self.showWholeGitGraph = value
}
func (self *LocalCommitsViewModel) GetShowWholeGitGraph() bool {
return self.showWholeGitGraph
}
func (self *LocalCommitsViewModel) GetCommits() []*models.Commit {
return self.getModel()
}
func shouldShowGraph(c *ContextCommon) bool {
// Whether we can draw a graph is a property of the commit list we have
// loaded, not of the filtering mode: turning filtering on or off only
// reaches the screen when the reloaded list does, and until then the graph
// has to keep matching the list that is still on display. Drawing one for a
// filtered list is also ruinously slow, because none of the commits in it
// are connected to each other, so no pipe ever terminates.
if c.Model().CommitsWereFilteredAtLastRefresh {
return false
}
value := c.UserConfig().Git.Log.ShowGraph
switch value {
case "always":
return true
case "never":
return false
case "when-maximised":
return c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL
}
log.Fatalf("Unknown value for git.log.showGraph: %s. Expected one of: 'always', 'never', 'when-maximised'", value)
return false
}
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
modelToViewIndex func(int) int, searchStr string,
) []gocui.SearchPosition {
if columnPositions == nil {
// This should never happen. We are being called at a time where our
// entire view content is scrolled out of view, so that we didn't draw
// anything the last time we rendered. If we run into a scenario where
// this happens, we should fix it, but until we found them all, at least
// make sure we don't crash.
return []gocui.SearchPosition{}
}
normalize := lo.Ternary(caseSensitive, func(s string) string { return s }, strings.ToLower)
return lo.FilterMap(commits, func(commit *models.Commit, idx int) (gocui.SearchPosition, bool) {
// The XStart and XEnd values are only used if the search string can't
// be found in the view. This can really only happen if the user is
// searching for a commit hash that is longer than the truncated hash
// that we render. So we just set the XStart and XEnd values to the
// start and end of the commit hash column, which is the second one.
result := gocui.SearchPosition{XStart: columnPositions[1], XEnd: columnPositions[2] - 1, Y: modelToViewIndex(idx)}
return result, strings.Contains(normalize(commit.Hash()), searchStr) ||
strings.Contains(normalize(commit.Name), searchStr) ||
strings.Contains(normalize(commit.ExtraInfo), searchStr) // allow searching for tags
})
}
func (self *LocalCommitsContext) IndexForGotoBottom() int {
commits := self.GetCommits()
selectedIdx := self.GetSelectedLineIdx()
if selectedIdx >= 0 && selectedIdx < len(commits)-1 {
if commits[selectedIdx+1].Status != models.StatusMerged {
_, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool {
return c.Status == models.StatusMerged
})
if found {
return idx - 1
}
}
}
return self.list.Len() - 1
}