mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
Add an option to draw the commit graph with branch drawing symbols
If a branch forks off a merge commit, the graph can't show both lines in the merge commit's row. The line from the branch's first commit ends in the same cell in which the merge's line to its second parent starts. The box drawing characters have no symbol for a line from above that bends to the left combined with a line from the left that bends down, so one of the two gets lost. Today the cell shows a plain vertical line, and the branch looks as if it sat on the merge's second parent (#5497). The branch drawing symbols in the Unicode Private Use Area (U+F5D0 to U+F60D) have a symbol for every way in which lines meet in a cell. kitty introduced them for git graph viewers like vim-flog, and Ghostty, Contour, the terminal of VS Code and nightly builds of WezTerm render them too. Other terminals need a font that contains them. Add the gui.commitGraphStyle config. Its 'detailed' style draws the graph with these symbols, and the commit symbols of the set connect to their lines. Commits are drawn as hollow circles and merge commits as filled ones. The default stays the 'classic' style with the box drawing characters, because not every terminal can display the others. A cell has only one colour. Where two lines meet, the symbol takes the colour of the line from above. With the box drawing characters, highlighting the lines of the selected commit clears the cells they run through. The branch drawing symbols keep the other lines. A cell in which a highlighted line bends is highlighted as a whole. Where a highlighted line crosses the vertical line of another commit, it is drawn over that line, so that it reads as one line. In a cell in which another line bends, the symbol keeps the colour of that line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
74e7572d7a
commit
da5b9b54eb
@@ -301,6 +301,16 @@ gui:
|
||||
# NerdFontsVersion is not empty.
|
||||
showFileIcons: true
|
||||
|
||||
# How the commit graph is drawn.
|
||||
# One of: 'classic' (default) | 'detailed'
|
||||
# 'detailed' connects the lines to the commit circles, and shows exactly where
|
||||
# branches fork off and merge. It draws the graph with the git branch drawing
|
||||
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
|
||||
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
|
||||
# GPU acceleration. Other terminals need a font that contains them, such as
|
||||
# https://github.com/rbong/flog-symbols.
|
||||
commitGraphStyle: classic
|
||||
|
||||
# Length of author name in (non-expanded) commits view. 2 means show initials
|
||||
# only.
|
||||
commitAuthorShortLength: 2
|
||||
|
||||
@@ -179,6 +179,10 @@ type GuiConfig struct {
|
||||
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
|
||||
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
|
||||
ShowFileIcons bool `yaml:"showFileIcons"`
|
||||
// How the commit graph is drawn.
|
||||
// One of: 'classic' (default) | 'detailed'
|
||||
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
|
||||
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=classic,enum=detailed"`
|
||||
// Length of author name in (non-expanded) commits view. 2 means show initials only.
|
||||
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
|
||||
// Length of author name in expanded commits view. 2 means show initials only.
|
||||
@@ -922,6 +926,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
ShowIcons: false,
|
||||
NerdFontsVersion: "",
|
||||
ShowFileIcons: true,
|
||||
CommitGraphStyle: "classic",
|
||||
CommitAuthorShortLength: 2,
|
||||
CommitAuthorLongLength: 17,
|
||||
CommitHashLength: 8,
|
||||
|
||||
@@ -30,6 +30,10 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
|
||||
[]string{"classic", "detailed"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
|
||||
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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/presentation/graph"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
@@ -73,6 +74,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
c.Model().BisectInfo,
|
||||
)
|
||||
}
|
||||
@@ -349,6 +351,13 @@ func shouldShowGraph(c *ContextCommon) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
|
||||
if c.UserConfig().Gui.CommitGraphStyle == "detailed" {
|
||||
return graph.BranchDrawingSymbols
|
||||
}
|
||||
return graph.BoxDrawingSymbols
|
||||
}
|
||||
|
||||
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
|
||||
modelToViewIndex func(int) int, searchStr string,
|
||||
) []gocui.SearchPosition {
|
||||
|
||||
@@ -75,6 +75,7 @@ func NewSubCommitsContext(
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
git_commands.NewNullBisectInfo(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func GetCommitListDisplayStrings(
|
||||
startIdx int,
|
||||
endIdx int,
|
||||
showGraph bool,
|
||||
graphSymbolSet graph.SymbolSet,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) [][]string {
|
||||
mutex.Lock()
|
||||
@@ -107,6 +108,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -124,6 +126,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -145,6 +148,7 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
getGraphLine = func(idx int) string {
|
||||
if idx >= graphOffset {
|
||||
|
||||
@@ -611,6 +611,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
s.startIdx,
|
||||
s.endIdx,
|
||||
s.showGraph,
|
||||
graph.BoxDrawingSymbols,
|
||||
s.bisectInfo,
|
||||
)
|
||||
|
||||
@@ -635,7 +636,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
|
||||
}
|
||||
renderGraph := func() string {
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n")
|
||||
}
|
||||
|
||||
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package graph
|
||||
|
||||
// The branch drawing symbols are characters in the Unicode Private Use Area
|
||||
// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in
|
||||
// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing
|
||||
// characters, they can show how the lines in a cell connect. For example, there
|
||||
// is a symbol for a cell in which a line from above bends to the left and a
|
||||
// line from the left bends down.
|
||||
|
||||
const branchDrawingHorizontal = "\uf5d0"
|
||||
|
||||
// The lines in a cell that isn't a commit
|
||||
type lineCourses struct {
|
||||
up, down verticalLine
|
||||
horizontal bool
|
||||
}
|
||||
|
||||
var branchDrawingConnections = map[lineCourses]string{
|
||||
{}: " ",
|
||||
{horizontal: true}: branchDrawingHorizontal,
|
||||
// A horizontal line passing behind a vertical one only shows in the
|
||||
// neighbouring cells
|
||||
{up: straightLine, down: straightLine}: "\uf5d1", // │
|
||||
{up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │
|
||||
{up: lineToLeft}: "\uf5d9", // ╯
|
||||
{up: lineToRight}: "\uf5d8", // ╰
|
||||
{down: lineToLeft}: "\uf5d7", // ╮
|
||||
{down: lineToRight}: "\uf5d6", // ╭
|
||||
{up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─
|
||||
{up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─
|
||||
{down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─
|
||||
{down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─
|
||||
{up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮
|
||||
{up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭
|
||||
{up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─
|
||||
{up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─
|
||||
{up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─
|
||||
{up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─
|
||||
}
|
||||
|
||||
// The edges of a commit's cell that its lines touch
|
||||
type commitEdges struct {
|
||||
up, down, left, right bool
|
||||
}
|
||||
|
||||
// Commits are drawn as hollow circles, merge commits as filled ones. Both
|
||||
// connect to the lines at the given edges.
|
||||
var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{
|
||||
{}: {"\uf5ef", "\uf5ee"},
|
||||
{right: true}: {"\uf5f1", "\uf5f0"},
|
||||
{left: true}: {"\uf5f3", "\uf5f2"},
|
||||
{left: true, right: true}: {"\uf5f5", "\uf5f4"},
|
||||
{down: true}: {"\uf5f7", "\uf5f6"},
|
||||
{up: true}: {"\uf5f9", "\uf5f8"},
|
||||
{up: true, down: true}: {"\uf5fb", "\uf5fa"},
|
||||
{down: true, right: true}: {"\uf5fd", "\uf5fc"},
|
||||
{down: true, left: true}: {"\uf5ff", "\uf5fe"},
|
||||
{up: true, right: true}: {"\uf601", "\uf600"},
|
||||
{up: true, left: true}: {"\uf603", "\uf602"},
|
||||
{up: true, down: true, right: true}: {"\uf605", "\uf604"},
|
||||
{up: true, down: true, left: true}: {"\uf607", "\uf606"},
|
||||
{down: true, left: true, right: true}: {"\uf609", "\uf608"},
|
||||
{up: true, left: true, right: true}: {"\uf60b", "\uf60a"},
|
||||
{up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"},
|
||||
}
|
||||
|
||||
func (cell *Cell) branchDrawingChars() (string, string) {
|
||||
second := " "
|
||||
if cell.right {
|
||||
second = branchDrawingHorizontal
|
||||
}
|
||||
|
||||
switch cell.cellType {
|
||||
case COMMIT, MERGE:
|
||||
symbols := branchDrawingCommitSymbols[commitEdges{
|
||||
up: cell.up != noLine,
|
||||
down: cell.down != noLine,
|
||||
left: cell.left,
|
||||
right: cell.right,
|
||||
}]
|
||||
if cell.cellType == MERGE {
|
||||
return symbols.merge, second
|
||||
}
|
||||
return symbols.commit, second
|
||||
default:
|
||||
if cell.horizontalOnTop {
|
||||
return branchDrawingHorizontal, second
|
||||
}
|
||||
if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok {
|
||||
return first, second
|
||||
}
|
||||
// There is no symbol for lines that the layout of the graph never
|
||||
// produces, such as a lone half of a line
|
||||
first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ const (
|
||||
CommitSymbol = '○'
|
||||
)
|
||||
|
||||
// The characters that the commit graph is drawn with
|
||||
type SymbolSet uint8
|
||||
|
||||
const (
|
||||
BoxDrawingSymbols SymbolSet = iota
|
||||
BranchDrawingSymbols
|
||||
)
|
||||
|
||||
type cellType int
|
||||
|
||||
const (
|
||||
@@ -41,13 +49,21 @@ type Cell struct {
|
||||
left, right bool
|
||||
// Whether a line passes through from the left edge to the right edge
|
||||
horizontal bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
// Whether that line is drawn over the vertical line that it crosses
|
||||
horizontalOnTop bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
}
|
||||
|
||||
func (cell *Cell) render(writer io.StringWriter) {
|
||||
first, second := cell.boxDrawingChars()
|
||||
func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
|
||||
var first, second string
|
||||
switch symbolSet {
|
||||
case BoxDrawingSymbols:
|
||||
first, second = cell.boxDrawingChars()
|
||||
case BranchDrawingSymbols:
|
||||
first, second = cell.branchDrawingChars()
|
||||
}
|
||||
|
||||
var rightStyle *style.TextStyle
|
||||
if cell.rightStyle == nil {
|
||||
@@ -122,6 +138,7 @@ func (cell *Cell) reset() {
|
||||
cell.left = false
|
||||
cell.right = false
|
||||
cell.horizontal = false
|
||||
cell.horizontalOnTop = false
|
||||
}
|
||||
|
||||
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
|
||||
|
||||
@@ -45,13 +45,13 @@ func (self Pipe) right() int16 {
|
||||
return max(self.fromPos, self.toPos)
|
||||
}
|
||||
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string {
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string {
|
||||
pipeSets := GetPipeSets(commits, getStyle)
|
||||
if len(pipeSets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr)
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl
|
||||
})
|
||||
}
|
||||
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string {
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string {
|
||||
maxProcs := runtime.GOMAXPROCS(0)
|
||||
|
||||
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
|
||||
@@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
|
||||
if k > 0 {
|
||||
prevCommit = commits[k-1]
|
||||
}
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit)
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet)
|
||||
innerLines = append(innerLines, line)
|
||||
}
|
||||
chunks[i] = innerLines
|
||||
@@ -275,6 +275,7 @@ func renderPipeSet(
|
||||
pipes []Pipe,
|
||||
selectedCommitHashPtr *string,
|
||||
prevCommit *models.Commit,
|
||||
symbolSet SymbolSet,
|
||||
) string {
|
||||
maxPos := int16(0)
|
||||
commitPos := int16(0)
|
||||
@@ -342,14 +343,31 @@ func renderPipeSet(
|
||||
}
|
||||
|
||||
for _, pipe := range nonSelectedPipes {
|
||||
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
if pipe.kind == STARTS {
|
||||
continue
|
||||
}
|
||||
if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos {
|
||||
// Record this line without drawing it, so that the commit symbol
|
||||
// keeps the style of the commit itself. The branch drawing commit
|
||||
// symbols connect to it. The pipe from above the first commit
|
||||
// doesn't come from a commit, so it has no line.
|
||||
if !equalHashes(pipe.fromHash, &StartCommitHash) {
|
||||
cells[commitPos].up = straightLine
|
||||
}
|
||||
continue
|
||||
}
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
}
|
||||
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
// Box drawing characters can't show every combination of lines in a cell,
|
||||
// so the cells that the selected commit's lines run through show only
|
||||
// those lines. The branch drawing symbols can, so they keep the other
|
||||
// lines too.
|
||||
if symbolSet == BoxDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pipe := range selectedPipes {
|
||||
@@ -359,6 +377,30 @@ func renderPipeSet(
|
||||
}
|
||||
}
|
||||
|
||||
// The pipe from a root commit to the empty tree gives the commit symbol the
|
||||
// style of the commit, but there is no line below a root commit
|
||||
for _, pipe := range pipes {
|
||||
if pipe.kind == STARTS && equalHashes(pipe.toHash, &EmptyTreeCommitHash) {
|
||||
cells[pipe.toPos].down = noLine
|
||||
}
|
||||
}
|
||||
|
||||
// Where a line of the selected commit crosses the vertical line of another
|
||||
// commit, draw it over that line, so that it reads as one line. The
|
||||
// vertical line visibly continues above and below. A vertical line in a
|
||||
// cell that doesn't have the highlight style belongs to another commit.
|
||||
if symbolSet == BranchDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left() + 1; i < pipe.right(); i++ {
|
||||
cell := cells[i]
|
||||
if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle {
|
||||
cell.horizontalOnTop = true
|
||||
cell.style = &highlightStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cType := COMMIT
|
||||
if isMerge {
|
||||
cType = MERGE
|
||||
@@ -370,7 +412,7 @@ func renderPipeSet(
|
||||
writer := &strings.Builder{}
|
||||
writer.Grow(len(cells) * 2)
|
||||
for _, cell := range cells {
|
||||
cell.render(writer)
|
||||
cell.render(writer, symbolSet)
|
||||
}
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle)
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
@@ -275,6 +275,345 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Box drawing look-alikes for the branch drawing symbols, so that the expected
|
||||
// output in tests is readable. The box drawing characters of the graph never
|
||||
// use ┤ and ┼; here they stand for the symbols that have two bends. Commit
|
||||
// symbols are shown as ○ and ◎, whichever lines they connect to.
|
||||
var branchDrawingLookAlikes = func() map[rune]rune {
|
||||
lookAlikes := map[rune]rune{
|
||||
'\uf5d0': '─',
|
||||
'\uf5d1': '│',
|
||||
'\uf5d6': '╭',
|
||||
'\uf5d7': '╮',
|
||||
'\uf5d8': '╰',
|
||||
'\uf5d9': '╯',
|
||||
'\uf5e0': '┬', // ╮ on ─
|
||||
'\uf5e3': '┴', // ╯ on ─
|
||||
'\uf5df': '┤', // ╯ and ╮
|
||||
'\uf5e8': '┼', // ╯ and ╮ on ─
|
||||
}
|
||||
for _, symbols := range branchDrawingCommitSymbols {
|
||||
lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol
|
||||
lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol
|
||||
}
|
||||
return lookAlikes
|
||||
}()
|
||||
|
||||
func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commitOpts []models.NewCommitOpts
|
||||
selectedHash string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "branch forked off a merge commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "5",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit above it selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "2",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎───╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with no line passing through",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"4", "5"}},
|
||||
{Hash: "4", Parents: []string{"6", "7"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─┤
|
||||
4 ◎─│─╮
|
||||
6 ○ │ │`,
|
||||
},
|
||||
{
|
||||
name: "several lines ending in a commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"3", "4"}},
|
||||
{Hash: "3", Parents: []string{"5", "4"}},
|
||||
{Hash: "5", Parents: []string{"7", "8"}},
|
||||
{Hash: "7", Parents: []string{"4", "A"}},
|
||||
{Hash: "4", Parents: []string{"B"}},
|
||||
{Hash: "B", Parents: []string{"C"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ○
|
||||
2 ◎─╮
|
||||
3 ◎─│─╮
|
||||
5 ◎─│─│─╮
|
||||
7 ◎─│─│─│─╮
|
||||
4 ○─┴─╯ │ │
|
||||
B ○ ╭───╯ │`,
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
trimmedExpectedOutput += strings.TrimSpace(line) + "\n"
|
||||
}
|
||||
|
||||
output := ""
|
||||
for i, line := range lines {
|
||||
lookAlikes := strings.Map(func(r rune) rune {
|
||||
return lo.ValueOr(branchDrawingLookAlikes, r, r)
|
||||
}, utils.Decolorise(line))
|
||||
output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n"
|
||||
}
|
||||
|
||||
assert.Equal(t, trimmedExpectedOutput, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
green := style.FgGreen
|
||||
yellow := style.FgYellow
|
||||
magenta := style.FgMagenta
|
||||
nothing := style.Nothing
|
||||
|
||||
hashPool := &utils.StringPool{}
|
||||
pool := func(s string) *string { return hashPool.Add(s) }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pipes []Pipe
|
||||
prevCommit *models.Commit
|
||||
expectedStr string
|
||||
expectedStyles []style.TextStyle
|
||||
}{
|
||||
{
|
||||
name: "commit with lines above and below",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "first commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
expectedStr: "\uf5f7", // ○ with a line down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "root commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: &EmptyTreeCommitHash, kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5f9", // ○ with a line up
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "selected root commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: &EmptyTreeCommitHash, kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5f9", // ○ with a line up
|
||||
expectedStyles: []style.TextStyle{highlightStyle},
|
||||
},
|
||||
{
|
||||
name: "selected commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{highlightStyle},
|
||||
},
|
||||
{
|
||||
name: "commit whose previous commit is selected and is a merge commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}),
|
||||
expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, nothing, highlightStyle,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
green, green, magenta, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off, with the branch's first commit selected",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit crossing another line",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta},
|
||||
{fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green},
|
||||
{fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit passing through a cell in which other lines bend",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols)
|
||||
if len([]rune(test.expectedStr)) != len(test.expectedStyles) {
|
||||
t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles))
|
||||
}
|
||||
expectedStr := ""
|
||||
for i, char := range []rune(test.expectedStr) {
|
||||
expectedStr += test.expectedStyles[i].Sprint(string(char))
|
||||
}
|
||||
expectedStr += " "
|
||||
|
||||
assert.Equal(t, expectedStr, actualStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commits := generateCommits(hashPool, 1000)
|
||||
getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' }
|
||||
|
||||
for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} {
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols)
|
||||
for i, line := range lines {
|
||||
line = utils.Decolorise(line)
|
||||
assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar),
|
||||
"row %d falls back to box drawing characters: %q", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSet(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
@@ -490,7 +829,7 @@ func TestRenderPipeSet(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit)
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols)
|
||||
t.Log("actual cells:")
|
||||
t.Log(actualStr)
|
||||
expectedStr := ""
|
||||
@@ -568,8 +907,8 @@ func TestGetNextPipes(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
|
||||
// rendering cells so that it's easier to see what went wrong
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil)
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols)
|
||||
t.Log("expected cells:")
|
||||
t.Log(expectedStr)
|
||||
t.Log("actual cells:")
|
||||
@@ -590,7 +929,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle)
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1221,6 +1221,15 @@
|
||||
"description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.",
|
||||
"default": true
|
||||
},
|
||||
"commitGraphStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"classic",
|
||||
"detailed"
|
||||
],
|
||||
"description": "How the commit graph is drawn.\nOne of: 'classic' (default) | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.",
|
||||
"default": "classic"
|
||||
},
|
||||
"commitAuthorShortLength": {
|
||||
"type": "integer",
|
||||
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
|
||||
|
||||
Reference in New Issue
Block a user