mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-12-14 11:23:09 +02:00
385 lines
9.5 KiB
Go
385 lines
9.5 KiB
Go
package graph
|
|
|
|
import (
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/jesseduffield/generics/set"
|
|
"github.com/jesseduffield/generics/slices"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type PipeKind uint8
|
|
|
|
const (
|
|
TERMINATES PipeKind = iota
|
|
STARTS
|
|
CONTINUES
|
|
)
|
|
|
|
type Pipe struct {
|
|
fromPos int
|
|
toPos int
|
|
fromSha string
|
|
toSha string
|
|
kind PipeKind
|
|
style style.TextStyle
|
|
}
|
|
|
|
var highlightStyle = style.FgLightWhite.SetBold()
|
|
|
|
func ContainsCommitSha(pipes []*Pipe, sha string) bool {
|
|
for _, pipe := range pipes {
|
|
if equalHashes(pipe.fromSha, sha) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (self Pipe) left() int {
|
|
return utils.Min(self.fromPos, self.toPos)
|
|
}
|
|
|
|
func (self Pipe) right() int {
|
|
return utils.Max(self.fromPos, self.toPos)
|
|
}
|
|
|
|
func RenderCommitGraph(commits []*models.Commit, selectedCommitSha string, getStyle func(c *models.Commit) style.TextStyle) []string {
|
|
pipeSets := GetPipeSets(commits, getStyle)
|
|
if len(pipeSets) == 0 {
|
|
return nil
|
|
}
|
|
|
|
lines := RenderAux(pipeSets, commits, selectedCommitSha)
|
|
|
|
return lines
|
|
}
|
|
|
|
func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) style.TextStyle) [][]*Pipe {
|
|
if len(commits) == 0 {
|
|
return nil
|
|
}
|
|
|
|
pipes := []*Pipe{{fromPos: 0, toPos: 0, fromSha: "START", toSha: commits[0].Sha, kind: STARTS, style: style.FgDefault}}
|
|
|
|
return slices.Map(commits, func(commit *models.Commit) []*Pipe {
|
|
pipes = getNextPipes(pipes, commit, getStyle)
|
|
return pipes
|
|
})
|
|
}
|
|
|
|
func RenderAux(pipeSets [][]*Pipe, commits []*models.Commit, selectedCommitSha string) []string {
|
|
maxProcs := runtime.GOMAXPROCS(0)
|
|
|
|
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
|
|
chunks := make([][]string, maxProcs)
|
|
perProc := len(pipeSets) / maxProcs
|
|
|
|
wg := sync.WaitGroup{}
|
|
wg.Add(maxProcs)
|
|
|
|
for i := 0; i < maxProcs; i++ {
|
|
i := i
|
|
go func() {
|
|
from := i * perProc
|
|
to := (i + 1) * perProc
|
|
if i == maxProcs-1 {
|
|
to = len(pipeSets)
|
|
}
|
|
innerLines := make([]string, 0, to-from)
|
|
for j, pipeSet := range pipeSets[from:to] {
|
|
k := from + j
|
|
var prevCommit *models.Commit
|
|
if k > 0 {
|
|
prevCommit = commits[k-1]
|
|
}
|
|
line := renderPipeSet(pipeSet, selectedCommitSha, prevCommit)
|
|
innerLines = append(innerLines, line)
|
|
}
|
|
chunks[i] = innerLines
|
|
wg.Done()
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
return slices.Flatten(chunks)
|
|
}
|
|
|
|
func getNextPipes(prevPipes []*Pipe, commit *models.Commit, getStyle func(c *models.Commit) style.TextStyle) []*Pipe {
|
|
maxPos := slices.MaxBy(prevPipes, func(pipe *Pipe) int {
|
|
return pipe.toPos
|
|
})
|
|
|
|
// a pipe that terminated in the previous line has no bearing on the current line
|
|
// so we'll filter those out
|
|
currentPipes := slices.Filter(prevPipes, func(pipe *Pipe) bool {
|
|
return pipe.kind != TERMINATES
|
|
})
|
|
|
|
newPipes := make([]*Pipe, 0, len(currentPipes)+len(commit.Parents))
|
|
// start by assuming that we've got a brand new commit not related to any preceding commit.
|
|
// (this only happens when we're doing `git log --all`). These will be tacked onto the far end.
|
|
pos := maxPos + 1
|
|
for _, pipe := range currentPipes {
|
|
if equalHashes(pipe.toSha, commit.Sha) {
|
|
// turns out this commit does have a descendant so we'll place it right under the first instance
|
|
pos = pipe.toPos
|
|
break
|
|
}
|
|
}
|
|
|
|
// a taken spot is one where a current pipe is ending on
|
|
takenSpots := set.New[int]()
|
|
// a traversed spot is one where a current pipe is starting on, ending on, or passing through
|
|
traversedSpots := set.New[int]()
|
|
|
|
if len(commit.Parents) > 0 {
|
|
newPipes = append(newPipes, &Pipe{
|
|
fromPos: pos,
|
|
toPos: pos,
|
|
fromSha: commit.Sha,
|
|
toSha: commit.Parents[0],
|
|
kind: STARTS,
|
|
style: getStyle(commit),
|
|
})
|
|
}
|
|
|
|
traversedSpotsForContinuingPipes := set.New[int]()
|
|
for _, pipe := range currentPipes {
|
|
if !equalHashes(pipe.toSha, commit.Sha) {
|
|
traversedSpotsForContinuingPipes.Add(pipe.toPos)
|
|
}
|
|
}
|
|
|
|
getNextAvailablePosForContinuingPipe := func() int {
|
|
i := 0
|
|
for {
|
|
if !traversedSpots.Includes(i) {
|
|
return i
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
|
|
getNextAvailablePosForNewPipe := func() int {
|
|
i := 0
|
|
for {
|
|
// a newly created pipe is not allowed to end on a spot that's already taken,
|
|
// nor on a spot that's been traversed by a continuing pipe.
|
|
if !takenSpots.Includes(i) && !traversedSpotsForContinuingPipes.Includes(i) {
|
|
return i
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
|
|
traverse := func(from, to int) {
|
|
left, right := from, to
|
|
if left > right {
|
|
left, right = right, left
|
|
}
|
|
for i := left; i <= right; i++ {
|
|
traversedSpots.Add(i)
|
|
}
|
|
takenSpots.Add(to)
|
|
}
|
|
|
|
for _, pipe := range currentPipes {
|
|
if equalHashes(pipe.toSha, commit.Sha) {
|
|
// terminating here
|
|
newPipes = append(newPipes, &Pipe{
|
|
fromPos: pipe.toPos,
|
|
toPos: pos,
|
|
fromSha: pipe.fromSha,
|
|
toSha: pipe.toSha,
|
|
kind: TERMINATES,
|
|
style: pipe.style,
|
|
})
|
|
traverse(pipe.toPos, pos)
|
|
} else if pipe.toPos < pos {
|
|
// continuing here
|
|
availablePos := getNextAvailablePosForContinuingPipe()
|
|
newPipes = append(newPipes, &Pipe{
|
|
fromPos: pipe.toPos,
|
|
toPos: availablePos,
|
|
fromSha: pipe.fromSha,
|
|
toSha: pipe.toSha,
|
|
kind: CONTINUES,
|
|
style: pipe.style,
|
|
})
|
|
traverse(pipe.toPos, availablePos)
|
|
}
|
|
}
|
|
|
|
if commit.IsMerge() {
|
|
for _, parent := range commit.Parents[1:] {
|
|
availablePos := getNextAvailablePosForNewPipe()
|
|
// need to act as if continuing pipes are going to continue on the same line.
|
|
newPipes = append(newPipes, &Pipe{
|
|
fromPos: pos,
|
|
toPos: availablePos,
|
|
fromSha: commit.Sha,
|
|
toSha: parent,
|
|
kind: STARTS,
|
|
style: getStyle(commit),
|
|
})
|
|
|
|
takenSpots.Add(availablePos)
|
|
}
|
|
}
|
|
|
|
for _, pipe := range currentPipes {
|
|
if !equalHashes(pipe.toSha, commit.Sha) && pipe.toPos > pos {
|
|
// continuing on, potentially moving left to fill in a blank spot
|
|
last := pipe.toPos
|
|
for i := pipe.toPos; i > pos; i-- {
|
|
if takenSpots.Includes(i) || traversedSpots.Includes(i) {
|
|
break
|
|
} else {
|
|
last = i
|
|
}
|
|
}
|
|
newPipes = append(newPipes, &Pipe{
|
|
fromPos: pipe.toPos,
|
|
toPos: last,
|
|
fromSha: pipe.fromSha,
|
|
toSha: pipe.toSha,
|
|
kind: CONTINUES,
|
|
style: pipe.style,
|
|
})
|
|
traverse(pipe.toPos, last)
|
|
}
|
|
}
|
|
|
|
// not efficient but doing it for now: sorting my pipes by toPos, then by kind
|
|
slices.SortFunc(newPipes, func(a, b *Pipe) bool {
|
|
if a.toPos == b.toPos {
|
|
return a.kind < b.kind
|
|
}
|
|
return a.toPos < b.toPos
|
|
})
|
|
|
|
return newPipes
|
|
}
|
|
|
|
func renderPipeSet(
|
|
pipes []*Pipe,
|
|
selectedCommitSha string,
|
|
prevCommit *models.Commit,
|
|
) string {
|
|
maxPos := 0
|
|
commitPos := 0
|
|
startCount := 0
|
|
for _, pipe := range pipes {
|
|
if pipe.kind == STARTS {
|
|
startCount++
|
|
commitPos = pipe.fromPos
|
|
} else if pipe.kind == TERMINATES {
|
|
commitPos = pipe.toPos
|
|
}
|
|
|
|
if pipe.right() > maxPos {
|
|
maxPos = pipe.right()
|
|
}
|
|
}
|
|
isMerge := startCount > 1
|
|
|
|
cells := slices.Map(lo.Range(maxPos+1), func(i int) *Cell {
|
|
return &Cell{cellType: CONNECTION, style: style.FgDefault}
|
|
})
|
|
|
|
renderPipe := func(pipe *Pipe, style style.TextStyle, overrideRightStyle bool) {
|
|
left := pipe.left()
|
|
right := pipe.right()
|
|
|
|
if left != right {
|
|
for i := left + 1; i < right; i++ {
|
|
cells[i].setLeft(style).setRight(style, overrideRightStyle)
|
|
}
|
|
cells[left].setRight(style, overrideRightStyle)
|
|
cells[right].setLeft(style)
|
|
}
|
|
|
|
if pipe.kind == STARTS || pipe.kind == CONTINUES {
|
|
cells[pipe.toPos].setDown(style)
|
|
}
|
|
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
|
|
cells[pipe.fromPos].setUp(style)
|
|
}
|
|
}
|
|
|
|
// we don't want to highlight two commits if they're contiguous. We only want
|
|
// to highlight multiple things if there's an actual visible pipe involved.
|
|
highlight := true
|
|
if prevCommit != nil && equalHashes(prevCommit.Sha, selectedCommitSha) {
|
|
highlight = false
|
|
for _, pipe := range pipes {
|
|
if equalHashes(pipe.fromSha, selectedCommitSha) && (pipe.kind != TERMINATES || pipe.fromPos != pipe.toPos) {
|
|
highlight = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// so we have our commit pos again, now it's time to build the cells.
|
|
// we'll handle the one that's sourced from our selected commit last so that it can override the other cells.
|
|
selectedPipes, nonSelectedPipes := slices.Partition(pipes, func(pipe *Pipe) bool {
|
|
return highlight && equalHashes(pipe.fromSha, selectedCommitSha)
|
|
})
|
|
|
|
for _, pipe := range nonSelectedPipes {
|
|
if pipe.kind == STARTS {
|
|
renderPipe(pipe, pipe.style, true)
|
|
}
|
|
}
|
|
|
|
for _, pipe := range nonSelectedPipes {
|
|
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
|
|
renderPipe(pipe, pipe.style, false)
|
|
}
|
|
}
|
|
|
|
for _, pipe := range selectedPipes {
|
|
for i := pipe.left(); i <= pipe.right(); i++ {
|
|
cells[i].reset()
|
|
}
|
|
}
|
|
for _, pipe := range selectedPipes {
|
|
renderPipe(pipe, highlightStyle, true)
|
|
if pipe.toPos == commitPos {
|
|
cells[pipe.toPos].setStyle(highlightStyle)
|
|
}
|
|
}
|
|
|
|
cType := COMMIT
|
|
if isMerge {
|
|
cType = MERGE
|
|
}
|
|
|
|
cells[commitPos].setType(cType)
|
|
|
|
// using a string builder here for the sake of performance
|
|
writer := &strings.Builder{}
|
|
writer.Grow(len(cells) * 2)
|
|
for _, cell := range cells {
|
|
cell.render(writer)
|
|
}
|
|
return writer.String()
|
|
}
|
|
|
|
func equalHashes(a, b string) bool {
|
|
// if our selectedCommitSha is an empty string we treat that as meaning there is no selected commit sha
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
|
|
length := utils.Min(len(a), len(b))
|
|
// parent hashes are only stored up to 20 characters for some reason so we'll truncate to that for comparison
|
|
return a[:length] == b[:length]
|
|
}
|