mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
The model<->view index conversions were derived from arrays that only renderLines populated. That made them depend on the list having been rendered (so a conversion before the first render ignored the non-model items), and it made them go stale whenever the model changed after a render: converting an index then returned a wrong result, and once the model had grown past the last rendered length the conversion indexed a too-short array and panicked (seen in cherry_pick under -race). The conversion is a pure function of the current list length and the current non-model items, and needs none of the rendered display strings. Compute it directly and drop the cached arrays, so the result is always consistent with the current model and no longer depends on rendering. searchModelCommits converts every commit's index, and building the non-model items can be O(len) mid-rebase, so it would now be quadratic; snapshot the non-model items once via modelToViewIndexConverter instead of rebuilding them per index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
9.2 KiB
Go
312 lines
9.2 KiB
Go
package context
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type LocalCommitsContext struct {
|
|
*LocalCommitsViewModel
|
|
*ListContextTrait
|
|
*SearchTrait
|
|
}
|
|
|
|
var (
|
|
_ types.IListContext = (*LocalCommitsContext)(nil)
|
|
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
|
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
|
)
|
|
|
|
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
|
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: fmt.Sprintf("--- %s ---", 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: fmt.Sprintf("--- %s ---", label),
|
|
})
|
|
}
|
|
|
|
_, 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: fmt.Sprintf("--- %s ---", c.Tr.CommitsSectionHeader),
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
ctx := &LocalCommitsContext{
|
|
LocalCommitsViewModel: viewModel,
|
|
SearchTrait: NewSearchTrait(c),
|
|
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
|
|
}
|
|
|
|
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 {
|
|
if c.Modes().Filtering.Active() {
|
|
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
|
|
}
|