mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
Improve the rendering of the commit graph for terminals that support it (#6077)
Lazygit uses box drawing characters to render the commit graph. This is problematic, because not all possible graph topologies can be represented by them. In the example below, the first merge commit is the parent of two more commits, a1 and b1, but you can't see this in the left image; it looks like a1's parent was x1. Improve this by using kitty's branch drawing characters that were added to kitty in https://github.com/kovidgoyal/kitty/pull/7681. Ghostty, Contour and VS Code's builtin terminal support them too; WezTerm supports them in a nightly build. Lazygit uses them automatically when it can tell that the terminal supports them, which is the case for Ghostty and kitty, but not VS Code or Contour; if you are using a terminal that supports them but isn't recognized (or if you happen to use a font that contains them), you can opt in by setting the new `gui.commitGraphStyle` config to "detailed". The little circles that represent commits look nicer too. | Before | After | | ------ | ----- | | <img width="191" height="114" alt="before" src="https://github.com/user-attachments/assets/f00e1e91-67f5-4026-81ed-9a5f8b2fdef1" /> | <img width="191" height="114" alt="after" src="https://github.com/user-attachments/assets/bf60ac91-b761-458f-a13a-8eb5ea915e14" /> |
This commit is contained in:
@@ -301,6 +301,18 @@ gui:
|
||||
# NerdFontsVersion is not empty.
|
||||
showFileIcons: true
|
||||
|
||||
# How the commit graph is drawn.
|
||||
# One of: 'auto' (default) | 'classic' | '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.
|
||||
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
|
||||
# these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
commitGraphStyle: auto
|
||||
|
||||
# Length of author name in (non-expanded) commits view. 2 means show initials
|
||||
# only.
|
||||
commitAuthorShortLength: 2
|
||||
|
||||
@@ -179,6 +179,11 @@ 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: 'auto' (default) | 'classic' | '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.
|
||||
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,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 +927,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
ShowIcons: false,
|
||||
NerdFontsVersion: "",
|
||||
ShowFileIcons: true,
|
||||
CommitGraphStyle: "auto",
|
||||
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{"auto", "classic", "detailed"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
|
||||
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -733,6 +733,12 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// Terminal returns the name and version that the terminal reported at
|
||||
// startup. Both are empty if it didn't report them.
|
||||
func (g *Gui) Terminal() (string, string) {
|
||||
return g.screen.Terminal()
|
||||
}
|
||||
|
||||
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
|
||||
// the terminal's colors change after startup.
|
||||
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
|
||||
|
||||
@@ -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,18 @@ func shouldShowGraph(c *ContextCommon) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
|
||||
switch c.UserConfig().Gui.CommitGraphStyle {
|
||||
case "detailed":
|
||||
return graph.BranchDrawingSymbols
|
||||
case "auto":
|
||||
if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -934,6 +934,8 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
g.ErrorHandler = gui.PopupHandler.ErrorHandler
|
||||
|
||||
terminalName, terminalVersion := g.Terminal()
|
||||
gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion)
|
||||
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
|
||||
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
|
||||
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
|
||||
|
||||
@@ -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,132 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// The terminals that draw the branch drawing symbols themselves, with the
|
||||
// first version that draws all of them
|
||||
var terminalsWithBranchDrawingSymbols = map[string][]int{
|
||||
"kitty": {0, 36, 2},
|
||||
"ghostty": {1, 0, 0},
|
||||
}
|
||||
|
||||
// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw
|
||||
// the branch drawing symbols itself, given the name and version it reports
|
||||
func TerminalDrawsBranchDrawingSymbols(name, version string) bool {
|
||||
minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)]
|
||||
return ok && slices.Compare(versionNumbers(version), minVersion) >= 0
|
||||
}
|
||||
|
||||
// The numbers at the start of the dot-separated parts of a version, e.g.
|
||||
// [1 3 0] for "1.3.0-dev+abc"
|
||||
func versionNumbers(version string) []int {
|
||||
var numbers []int
|
||||
for part := range strings.SplitSeq(version, ".") {
|
||||
digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))]
|
||||
number, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{name: "kitty", version: "0.36.1", expected: false},
|
||||
{name: "kitty", version: "0.36.2", expected: true},
|
||||
{name: "kitty", version: "0.44.0", expected: true},
|
||||
{name: "ghostty", version: "0.9.0", expected: false},
|
||||
{name: "ghostty", version: "1.0.0", expected: true},
|
||||
{name: "ghostty", version: "1.3.0-main+0123abcd", expected: true},
|
||||
{name: "WezTerm", version: "20250601-102030-89abcdef", expected: false},
|
||||
{name: "tmux", version: "3.5a", expected: false},
|
||||
{name: "iTerm2", version: "3.6.4", expected: false},
|
||||
{name: "kitty", version: "", expected: false},
|
||||
{name: "", version: "", expected: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+" "+test.version, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -21,25 +29,40 @@ const (
|
||||
MERGE
|
||||
)
|
||||
|
||||
// How a line that touches the top or bottom edge of a cell runs within it
|
||||
type verticalLine uint8
|
||||
|
||||
const (
|
||||
noLine verticalLine = iota
|
||||
// On to the opposite edge, or into the commit symbol
|
||||
straightLine
|
||||
// Bends towards the left edge
|
||||
lineToLeft
|
||||
// Bends towards the right edge
|
||||
lineToRight
|
||||
)
|
||||
|
||||
type Cell struct {
|
||||
up, down, left, right bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
// The lines that touch the top and bottom edges
|
||||
up, down verticalLine
|
||||
// Whether lines touch the left and right edges
|
||||
left, right bool
|
||||
// Whether a line passes through from the left edge to the right edge
|
||||
horizontal bool
|
||||
// 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) {
|
||||
up, down, left, right := cell.up, cell.down, cell.left, cell.right
|
||||
|
||||
first, second := getBoxDrawingChars(up, down, left, right)
|
||||
var adjustedFirst string
|
||||
switch cell.cellType {
|
||||
case CONNECTION:
|
||||
adjustedFirst = first
|
||||
case COMMIT:
|
||||
adjustedFirst = string(CommitSymbol)
|
||||
case MERGE:
|
||||
adjustedFirst = string(MergeSymbol)
|
||||
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
|
||||
@@ -59,10 +82,22 @@ func (cell *Cell) render(writer io.StringWriter) {
|
||||
styledSecondChar = cachedSprint(*rightStyle, second)
|
||||
}
|
||||
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst))
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, first))
|
||||
_, _ = writer.WriteString(styledSecondChar)
|
||||
}
|
||||
|
||||
func (cell *Cell) boxDrawingChars() (string, string) {
|
||||
first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
switch cell.cellType {
|
||||
case COMMIT:
|
||||
return string(CommitSymbol), second
|
||||
case MERGE:
|
||||
return string(MergeSymbol), second
|
||||
default:
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
|
||||
type rgbCacheKey struct {
|
||||
*color.RGBStyle
|
||||
str string
|
||||
@@ -98,34 +133,35 @@ func cachedSprint(style style.TextStyle, str string) string {
|
||||
}
|
||||
|
||||
func (cell *Cell) reset() {
|
||||
cell.up = false
|
||||
cell.down = false
|
||||
cell.up = noLine
|
||||
cell.down = noLine
|
||||
cell.left = false
|
||||
cell.right = false
|
||||
cell.horizontal = false
|
||||
cell.horizontalOnTop = false
|
||||
}
|
||||
|
||||
func (cell *Cell) setUp(style *style.TextStyle) *Cell {
|
||||
cell.up = true
|
||||
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.up = line
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setDown(style *style.TextStyle) *Cell {
|
||||
cell.down = true
|
||||
func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.down = line
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setLeft(style *style.TextStyle) *Cell {
|
||||
cell.left = true
|
||||
if !cell.up && !cell.down {
|
||||
if cell.up == noLine && cell.down == noLine {
|
||||
// vertical trumps left
|
||||
cell.style = style
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
//nolint:unparam
|
||||
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
cell.right = true
|
||||
if cell.rightStyle == nil || override {
|
||||
@@ -134,6 +170,11 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell {
|
||||
cell.horizontal = true
|
||||
return cell.setLeft(style).setRight(style, overrideRightStyle)
|
||||
}
|
||||
|
||||
func (cell *Cell) setStyle(style *style.TextStyle) *Cell {
|
||||
cell.style = style
|
||||
return 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
|
||||
@@ -107,19 +107,18 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
|
||||
}
|
||||
|
||||
func getNextPipes(prevPipes []Pipe, commit *models.Commit, getStyle func(c *models.Commit) *style.TextStyle) []Pipe {
|
||||
maxPos := int16(0)
|
||||
for _, pipe := range prevPipes {
|
||||
if pipe.toPos > maxPos {
|
||||
maxPos = pipe.toPos
|
||||
}
|
||||
}
|
||||
|
||||
// a pipe that terminated in the previous line has no bearing on the current line
|
||||
// so we'll filter those out
|
||||
// so we'll filter those out. The same goes for the pipe from a root commit to
|
||||
// the empty tree, because no commit comes after a root commit.
|
||||
currentPipes := lo.Filter(prevPipes, func(pipe Pipe, _ int) bool {
|
||||
return pipe.kind != TERMINATES
|
||||
return pipe.kind != TERMINATES && !equalHashes(pipe.toHash, &EmptyTreeCommitHash)
|
||||
})
|
||||
|
||||
maxPos := int16(-1)
|
||||
for _, pipe := range currentPipes {
|
||||
maxPos = max(maxPos, pipe.toPos)
|
||||
}
|
||||
|
||||
newPipes := make([]Pipe, 0, len(currentPipes)+len(commit.ParentPtrs()))
|
||||
// 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.
|
||||
@@ -276,6 +275,7 @@ func renderPipeSet(
|
||||
pipes []Pipe,
|
||||
selectedCommitHashPtr *string,
|
||||
prevCommit *models.Commit,
|
||||
symbolSet SymbolSet,
|
||||
) string {
|
||||
maxPos := int16(0)
|
||||
commitPos := int16(0)
|
||||
@@ -304,17 +304,17 @@ func renderPipeSet(
|
||||
|
||||
if left != right {
|
||||
for i := left + 1; i < right; i++ {
|
||||
cells[i].setLeft(style).setRight(style, overrideRightStyle)
|
||||
cells[i].setHorizontal(style, overrideRightStyle)
|
||||
}
|
||||
cells[left].setRight(style, overrideRightStyle)
|
||||
cells[right].setLeft(style)
|
||||
}
|
||||
|
||||
if pipe.kind == STARTS || pipe.kind == CONTINUES {
|
||||
cells[pipe.toPos].setDown(style)
|
||||
cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos))
|
||||
}
|
||||
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
|
||||
cells[pipe.fromPos].setUp(style)
|
||||
cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,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 {
|
||||
@@ -360,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
|
||||
@@ -371,11 +412,24 @@ func renderPipeSet(
|
||||
writer := &strings.Builder{}
|
||||
writer.Grow(len(cells) * 2)
|
||||
for _, cell := range cells {
|
||||
cell.render(writer)
|
||||
cell.render(writer, symbolSet)
|
||||
}
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
// The course of a line through the top or bottom edge of the cell at pos,
|
||||
// given the column of the pipe's other end
|
||||
func lineTowards(pos, otherPos int16) verticalLine {
|
||||
switch {
|
||||
case otherPos < pos:
|
||||
return lineToLeft
|
||||
case otherPos > pos:
|
||||
return lineToRight
|
||||
default:
|
||||
return straightLine
|
||||
}
|
||||
}
|
||||
|
||||
func equalHashes(a, b *string) bool {
|
||||
// if our selectedCommitHashPtr is nil, there is no selected commit
|
||||
if a == nil || b == nil {
|
||||
|
||||
@@ -94,6 +94,34 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
5 ○───╯ │
|
||||
6 ○ ╭───╯`,
|
||||
},
|
||||
{
|
||||
name: "with a root commit followed by an unrelated history",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2"}},
|
||||
{Hash: "2"},
|
||||
{Hash: "A", Parents: []string{"B"}},
|
||||
{Hash: "B"},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ○
|
||||
2 ○
|
||||
A ○
|
||||
B ○`,
|
||||
},
|
||||
{
|
||||
name: "with a merge of an unrelated history",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "A"}},
|
||||
{Hash: "2", Parents: []string{"3"}},
|
||||
{Hash: "A"},
|
||||
{Hash: "3"},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
2 ○ │
|
||||
A │ ○
|
||||
3 ○`,
|
||||
},
|
||||
{
|
||||
name: "with a path that has room to move to the left and continues",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
@@ -224,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") {
|
||||
@@ -247,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
|
||||
@@ -462,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 := ""
|
||||
@@ -528,7 +895,7 @@ func TestGetNextPipes(t *testing.T) {
|
||||
Parents: []string{},
|
||||
}),
|
||||
expected: []Pipe{
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -540,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:")
|
||||
@@ -562,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,16 @@
|
||||
"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": [
|
||||
"auto",
|
||||
"classic",
|
||||
"detailed"
|
||||
],
|
||||
"description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | '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.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.",
|
||||
"default": "auto"
|
||||
},
|
||||
"commitAuthorShortLength": {
|
||||
"type": "integer",
|
||||
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
|
||||
|
||||
@@ -12,6 +12,9 @@ gui:
|
||||
- black
|
||||
# Not important in tests but it creates clutter in demos
|
||||
showRandomTip: false
|
||||
# The expected output of the tests draws the commit graph with box drawing
|
||||
# characters, whichever terminal a test runs in
|
||||
commitGraphStyle: classic
|
||||
animateExplosion: false # takes too long
|
||||
git:
|
||||
# We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.
|
||||
|
||||
Reference in New Issue
Block a user