From 2c1ae6c82d0b3fa62dda7e6a30fb91e27aba6e04 Mon Sep 17 00:00:00 2001
From: zeripath <art27@cantab.net>
Date: Thu, 6 Aug 2020 09:04:08 +0100
Subject: [PATCH] Render the git graph on the server (#12333)

Rendering the git graph on the server means that we can properly track flows and switch from the Canvas implementation to a SVG implementation.

* This implementation provides a 16 limited color selection
* The uniqued color numbers are also provided
* And there is also a monochrome version
*In addition is a hover highlight that allows users to highlight commits on the same flow.

Closes #12209

Signed-off-by: Andrew Thornton art27@cantab.net
Co-authored-by: silverwind <me@silverwind.io>
---
 .stylelintrc                              |   3 -
 modules/gitgraph/graph.go                 | 108 +---
 modules/gitgraph/graph_models.go          | 186 ++++++
 modules/gitgraph/graph_test.go            | 666 +++++++++++++++++++++-
 modules/gitgraph/parser.go                | 338 +++++++++++
 modules/templates/helper.go               |  29 +-
 options/locale/locale_en-US.ini           |   2 +
 public/img/svg/material-invert-colors.svg |   1 +
 public/img/svg/material-palette.svg       |   1 +
 routers/repo/commit.go                    |  11 +-
 templates/repo/graph.tmpl                 |  45 +-
 web_src/js/features/gitgraph.js           | 641 +++------------------
 web_src/less/_repository.less             |   8 -
 web_src/less/features/gitgraph.less       | 256 +++++++++
 web_src/less/index.less                   |   3 +-
 web_src/less/themes/theme-arc-green.less  |  47 +-
 web_src/less/vendor/gitGraph.css          |  15 -
 web_src/svg/material-invert-colors.svg    |   1 +
 web_src/svg/material-palette.svg          |   1 +
 19 files changed, 1666 insertions(+), 696 deletions(-)
 create mode 100644 modules/gitgraph/graph_models.go
 create mode 100644 modules/gitgraph/parser.go
 create mode 100644 public/img/svg/material-invert-colors.svg
 create mode 100644 public/img/svg/material-palette.svg
 create mode 100644 web_src/less/features/gitgraph.less
 delete mode 100644 web_src/less/vendor/gitGraph.css
 create mode 100644 web_src/svg/material-invert-colors.svg
 create mode 100644 web_src/svg/material-palette.svg

diff --git a/.stylelintrc b/.stylelintrc
index 102b90f1fd..fcc33edeff 100644
--- a/.stylelintrc
+++ b/.stylelintrc
@@ -1,8 +1,5 @@
 extends: stylelint-config-standard
 
-ignoreFiles:
-  - web_src/less/vendor/**/*
-
 rules:
   at-rule-empty-line-before: null
   block-closing-brace-empty-line-before: null
diff --git a/modules/gitgraph/graph.go b/modules/gitgraph/graph.go
index 4ba110c706..257e4f3af0 100644
--- a/modules/gitgraph/graph.go
+++ b/modules/gitgraph/graph.go
@@ -16,26 +16,9 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 )
 
-// GraphItem represent one commit, or one relation in timeline
-type GraphItem struct {
-	GraphAcii    string
-	Relation     string
-	Branch       string
-	Rev          string
-	Date         string
-	Author       string
-	AuthorEmail  string
-	ShortRev     string
-	Subject      string
-	OnlyRelation bool
-}
-
-// GraphItems is a list of commits from all branches
-type GraphItems []GraphItem
-
 // GetCommitGraph return a list of commit (GraphItems) from all branches
-func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
-	format := "DATA:|%d|%H|%ad|%an|%ae|%h|%s"
+func GetCommitGraph(r *git.Repository, page int, maxAllowedColors int) (*Graph, error) {
+	format := "DATA:%d|%H|%ad|%an|%ae|%h|%s"
 
 	if page == 0 {
 		page = 1
@@ -51,7 +34,8 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
 		"--date=iso",
 		fmt.Sprintf("--pretty=format:%s", format),
 	)
-	commitGraph := make([]GraphItem, 0, 100)
+	graph := NewGraph()
+
 	stderr := new(strings.Builder)
 	stdoutReader, stdoutWriter, err := os.Pipe()
 	if err != nil {
@@ -64,86 +48,56 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
 	if err := graphCmd.RunInDirTimeoutEnvFullPipelineFunc(nil, -1, r.Path, stdoutWriter, stderr, nil, func(ctx context.Context, cancel context.CancelFunc) error {
 		_ = stdoutWriter.Close()
 		defer stdoutReader.Close()
+		parser := &Parser{}
+		parser.firstInUse = -1
+		parser.maxAllowedColors = maxAllowedColors
+		if maxAllowedColors > 0 {
+			parser.availableColors = make([]int, maxAllowedColors)
+			for i := range parser.availableColors {
+				parser.availableColors[i] = i + 1
+			}
+		} else {
+			parser.availableColors = []int{1, 2}
+		}
 		for commitsToSkip > 0 && scanner.Scan() {
 			line := scanner.Bytes()
 			dataIdx := bytes.Index(line, []byte("DATA:"))
+			if dataIdx < 0 {
+				dataIdx = len(line)
+			}
 			starIdx := bytes.IndexByte(line, '*')
 			if starIdx >= 0 && starIdx < dataIdx {
 				commitsToSkip--
 			}
+			parser.ParseGlyphs(line[:dataIdx])
 		}
+
+		row := 0
+
 		// Skip initial non-commit lines
 		for scanner.Scan() {
-			if bytes.IndexByte(scanner.Bytes(), '*') >= 0 {
-				line := scanner.Text()
-				graphItem, err := graphItemFromString(line, r)
-				if err != nil {
+			line := scanner.Bytes()
+			if bytes.IndexByte(line, '*') >= 0 {
+				if err := parser.AddLineToGraph(graph, row, line); err != nil {
 					cancel()
 					return err
 				}
-				commitGraph = append(commitGraph, graphItem)
 				break
 			}
+			parser.ParseGlyphs(line)
 		}
 
 		for scanner.Scan() {
-			line := scanner.Text()
-			graphItem, err := graphItemFromString(line, r)
-			if err != nil {
+			row++
+			line := scanner.Bytes()
+			if err := parser.AddLineToGraph(graph, row, line); err != nil {
 				cancel()
 				return err
 			}
-			commitGraph = append(commitGraph, graphItem)
 		}
 		return scanner.Err()
 	}); err != nil {
-		return commitGraph, err
-	}
-
-	return commitGraph, nil
-}
-
-func graphItemFromString(s string, r *git.Repository) (GraphItem, error) {
-
-	var ascii string
-	var data = "|||||||"
-	lines := strings.SplitN(s, "DATA:", 2)
-
-	switch len(lines) {
-	case 1:
-		ascii = lines[0]
-	case 2:
-		ascii = lines[0]
-		data = lines[1]
-	default:
-		return GraphItem{}, fmt.Errorf("Failed parsing grap line:%s. Expect 1 or two fields", s)
-	}
-
-	rows := strings.SplitN(data, "|", 8)
-	if len(rows) < 8 {
-		return GraphItem{}, fmt.Errorf("Failed parsing grap line:%s - Should containt 8 datafields", s)
-	}
-
-	/* // see format in getCommitGraph()
-	   0	Relation string
-	   1	Branch string
-	   2	Rev string
-	   3	Date string
-	   4	Author string
-	   5	AuthorEmail string
-	   6	ShortRev string
-	   7	Subject string
-	*/
-	gi := GraphItem{ascii,
-		rows[0],
-		rows[1],
-		rows[2],
-		rows[3],
-		rows[4],
-		rows[5],
-		rows[6],
-		rows[7],
-		len(rows[2]) == 0, // no commits referred to, only relation in current line.
+		return graph, err
 	}
-	return gi, nil
+	return graph, nil
 }
diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go
new file mode 100644
index 0000000000..ea6ba96084
--- /dev/null
+++ b/modules/gitgraph/graph_models.go
@@ -0,0 +1,186 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitgraph
+
+import (
+	"bytes"
+	"fmt"
+)
+
+// NewGraph creates a basic graph
+func NewGraph() *Graph {
+	graph := &Graph{}
+	graph.relationCommit = &Commit{
+		Row:    -1,
+		Column: -1,
+	}
+	graph.Flows = map[int64]*Flow{}
+	return graph
+}
+
+// Graph represents a collection of flows
+type Graph struct {
+	Flows          map[int64]*Flow
+	Commits        []*Commit
+	MinRow         int
+	MinColumn      int
+	MaxRow         int
+	MaxColumn      int
+	relationCommit *Commit
+}
+
+// Width returns the width of the graph
+func (graph *Graph) Width() int {
+	return graph.MaxColumn - graph.MinColumn + 1
+}
+
+// Height returns the height of the graph
+func (graph *Graph) Height() int {
+	return graph.MaxRow - graph.MinRow + 1
+}
+
+// AddGlyph adds glyph to flows
+func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) {
+	flow, ok := graph.Flows[flowID]
+	if !ok {
+		flow = NewFlow(flowID, color, row, column)
+		graph.Flows[flowID] = flow
+	}
+	flow.AddGlyph(row, column, glyph)
+
+	if row < graph.MinRow {
+		graph.MinRow = row
+	}
+	if row > graph.MaxRow {
+		graph.MaxRow = row
+	}
+	if column < graph.MinColumn {
+		graph.MinColumn = column
+	}
+	if column > graph.MaxColumn {
+		graph.MaxColumn = column
+	}
+}
+
+// AddCommit adds a commit at row, column on flowID with the provided data
+func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error {
+	commit, err := NewCommit(row, column, data)
+	if err != nil {
+		return err
+	}
+	commit.Flow = flowID
+	graph.Commits = append(graph.Commits, commit)
+
+	graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit)
+	return nil
+}
+
+// NewFlow creates a new flow
+func NewFlow(flowID int64, color, row, column int) *Flow {
+	return &Flow{
+		ID:          flowID,
+		ColorNumber: color,
+		MinRow:      row,
+		MinColumn:   column,
+		MaxRow:      row,
+		MaxColumn:   column,
+	}
+}
+
+// Flow represents a series of glyphs
+type Flow struct {
+	ID          int64
+	ColorNumber int
+	Glyphs      []Glyph
+	Commits     []*Commit
+	MinRow      int
+	MinColumn   int
+	MaxRow      int
+	MaxColumn   int
+}
+
+// Color16 wraps the color numbers around mod 16
+func (flow *Flow) Color16() int {
+	return flow.ColorNumber % 16
+}
+
+// AddGlyph adds glyph at row and column
+func (flow *Flow) AddGlyph(row, column int, glyph byte) {
+	if row < flow.MinRow {
+		flow.MinRow = row
+	}
+	if row > flow.MaxRow {
+		flow.MaxRow = row
+	}
+	if column < flow.MinColumn {
+		flow.MinColumn = column
+	}
+	if column > flow.MaxColumn {
+		flow.MaxColumn = column
+	}
+
+	flow.Glyphs = append(flow.Glyphs, Glyph{
+		row,
+		column,
+		glyph,
+	})
+}
+
+// Glyph represents a co-ordinate and glyph
+type Glyph struct {
+	Row    int
+	Column int
+	Glyph  byte
+}
+
+// RelationCommit represents an empty relation commit
+var RelationCommit = &Commit{
+	Row: -1,
+}
+
+// NewCommit creates a new commit from a provided line
+func NewCommit(row, column int, line []byte) (*Commit, error) {
+	data := bytes.SplitN(line, []byte("|"), 7)
+	if len(data) < 7 {
+		return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
+	}
+	return &Commit{
+		Row:    row,
+		Column: column,
+		// 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1)
+		Branch: string(data[0]),
+		// 1 matches git log --pretty=format:%H => commit hash
+		Rev: string(data[1]),
+		// 2 matches git log --pretty=format:%ad => author date (format respects --date= option)
+		Date: string(data[2]),
+		// 3 matches git log --pretty=format:%an => author name
+		Author: string(data[3]),
+		// 4 matches git log --pretty=format:%ae => author email
+		AuthorEmail: string(data[4]),
+		// 5 matches git log --pretty=format:%h => abbreviated commit hash
+		ShortRev: string(data[5]),
+		// 6 matches git log --pretty=format:%s => subject
+		Subject: string(data[6]),
+	}, nil
+}
+
+// Commit represents a commit at co-ordinate X, Y with the data
+type Commit struct {
+	Flow        int64
+	Row         int
+	Column      int
+	Branch      string
+	Rev         string
+	Date        string
+	Author      string
+	AuthorEmail string
+	ShortRev    string
+	Subject     string
+}
+
+// OnlyRelation returns whether this a relation only commit
+func (c *Commit) OnlyRelation() bool {
+	return c.Row == -1
+}
diff --git a/modules/gitgraph/graph_test.go b/modules/gitgraph/graph_test.go
index a2c7f447b6..ca9d653cee 100644
--- a/modules/gitgraph/graph_test.go
+++ b/modules/gitgraph/graph_test.go
@@ -5,7 +5,9 @@
 package gitgraph
 
 import (
+	"bytes"
 	"fmt"
+	"strings"
 	"testing"
 
 	"code.gitea.io/gitea/modules/git"
@@ -14,40 +16,235 @@ import (
 func BenchmarkGetCommitGraph(b *testing.B) {
 
 	currentRepo, err := git.OpenRepository(".")
-	if err != nil {
+	if err != nil || currentRepo == nil {
 		b.Error("Could not open repository")
 	}
 	defer currentRepo.Close()
 
 	for i := 0; i < b.N; i++ {
-		graph, err := GetCommitGraph(currentRepo, 1)
+		graph, err := GetCommitGraph(currentRepo, 1, 0)
 		if err != nil {
 			b.Error("Could get commit graph")
 		}
 
-		if len(graph) < 100 {
+		if len(graph.Commits) < 100 {
 			b.Error("Should get 100 log lines.")
 		}
 	}
 }
 
 func BenchmarkParseCommitString(b *testing.B) {
-	testString := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph"
+	testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph"
 
+	parser := &Parser{}
+	parser.Reset()
 	for i := 0; i < b.N; i++ {
-		graphItem, err := graphItemFromString(testString, nil)
-		if err != nil {
+		parser.Reset()
+		graph := NewGraph()
+		if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil {
 			b.Error("could not parse teststring")
 		}
-
-		if graphItem.Author != "Kjell Kvinge" {
+		if graph.Flows[1].Commits[0].Author != "Kjell Kvinge" {
 			b.Error("Did not get expected data")
 		}
 	}
 }
 
+func BenchmarkParseGlyphs(b *testing.B) {
+	parser := &Parser{}
+	parser.Reset()
+	tgBytes := []byte(testglyphs)
+	tg := tgBytes
+	idx := bytes.Index(tg, []byte("\n"))
+	for i := 0; i < b.N; i++ {
+		parser.Reset()
+		tg = tgBytes
+		idx = bytes.Index(tg, []byte("\n"))
+		for idx > 0 {
+			parser.ParseGlyphs(tg[:idx])
+			tg = tg[idx+1:]
+			idx = bytes.Index(tg, []byte("\n"))
+		}
+	}
+}
+
+func TestReleaseUnusedColors(t *testing.T) {
+	testcases := []struct {
+		availableColors []int
+		oldColors       []int
+		firstInUse      int // these values have to be either be correct or suggest less is
+		firstAvailable  int // available than possibly is - i.e. you cannot say 10 is available when it
+	}{
+		{
+			availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+			oldColors:       []int{1, 1, 1, 1, 1},
+			firstAvailable:  -1,
+			firstInUse:      1,
+		},
+		{
+			availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+			oldColors:       []int{1, 2, 3, 4},
+			firstAvailable:  6,
+			firstInUse:      0,
+		},
+		{
+			availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+			oldColors:       []int{6, 0, 3, 5, 3, 4, 0, 0},
+			firstAvailable:  6,
+			firstInUse:      0,
+		},
+		{
+			availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+			oldColors:       []int{6, 1, 3, 5, 3, 4, 2, 7},
+			firstAvailable:  -1,
+			firstInUse:      0,
+		},
+		{
+			availableColors: []int{1, 2, 3, 4, 5, 6, 7},
+			oldColors:       []int{6, 0, 3, 5, 3, 4, 2, 7},
+			firstAvailable:  -1,
+			firstInUse:      0,
+		},
+	}
+	for _, testcase := range testcases {
+		parser := &Parser{}
+		parser.Reset()
+		parser.availableColors = append([]int{}, testcase.availableColors...)
+		parser.oldColors = append(parser.oldColors, testcase.oldColors...)
+		parser.firstAvailable = testcase.firstAvailable
+		parser.firstInUse = testcase.firstInUse
+		parser.releaseUnusedColors()
+
+		if parser.firstAvailable == -1 {
+			// All in use
+			for _, color := range parser.availableColors {
+				found := false
+				for _, oldColor := range parser.oldColors {
+					if oldColor == color {
+						found = true
+						break
+					}
+				}
+				if !found {
+					t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+						testcase.availableColors,
+						testcase.oldColors,
+						testcase.firstAvailable,
+						testcase.firstInUse,
+						parser.availableColors,
+						parser.oldColors,
+						parser.firstAvailable,
+						parser.firstInUse,
+						color)
+				}
+			}
+		} else if parser.firstInUse != -1 {
+			// Some in use
+			for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) {
+				color := parser.availableColors[i]
+				found := false
+				for _, oldColor := range parser.oldColors {
+					if oldColor == color {
+						found = true
+						break
+					}
+				}
+				if !found {
+					t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
+						testcase.availableColors,
+						testcase.oldColors,
+						testcase.firstAvailable,
+						testcase.firstInUse,
+						parser.availableColors,
+						parser.oldColors,
+						parser.firstAvailable,
+						parser.firstInUse,
+						color)
+				}
+			}
+			for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) {
+				color := parser.availableColors[i]
+				found := false
+				for _, oldColor := range parser.oldColors {
+					if oldColor == color {
+						found = true
+						break
+					}
+				}
+				if found {
+					t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+						testcase.availableColors,
+						testcase.oldColors,
+						testcase.firstAvailable,
+						testcase.firstInUse,
+						parser.availableColors,
+						parser.oldColors,
+						parser.firstAvailable,
+						parser.firstInUse,
+						color)
+				}
+			}
+		} else {
+			// None in use
+			for _, color := range parser.oldColors {
+				if color != 0 {
+					t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
+						testcase.availableColors,
+						testcase.oldColors,
+						testcase.firstAvailable,
+						testcase.firstInUse,
+						parser.availableColors,
+						parser.oldColors,
+						parser.firstAvailable,
+						parser.firstInUse,
+						color)
+				}
+			}
+		}
+	}
+}
+
+func TestParseGlyphs(t *testing.T) {
+	parser := &Parser{}
+	parser.Reset()
+	tgBytes := []byte(testglyphs)
+	tg := tgBytes
+	idx := bytes.Index(tg, []byte("\n"))
+	row := 0
+	for idx > 0 {
+		parser.ParseGlyphs(tg[:idx])
+		tg = tg[idx+1:]
+		idx = bytes.Index(tg, []byte("\n"))
+		if parser.flows[0] != 1 {
+			t.Errorf("First column flow should be 1 but was %d", parser.flows[0])
+		}
+		colorToFlow := map[int]int64{}
+		flowToColor := map[int64]int{}
+
+		for i, flow := range parser.flows {
+			if flow == 0 {
+				continue
+			}
+			color := parser.colors[i]
+
+			if fColor, in := flowToColor[flow]; in && fColor != color {
+				t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor)
+			}
+			flowToColor[flow] = color
+			if cFlow, in := colorToFlow[color]; in && cFlow != flow {
+				t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow)
+			}
+			colorToFlow[color] = flow
+		}
+		row++
+	}
+	if len(parser.availableColors) != 9 {
+		t.Errorf("Expected 9 colors but have %d", len(parser.availableColors))
+	}
+}
+
 func TestCommitStringParsing(t *testing.T) {
-	dataFirstPart := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|"
+	dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|"
 	tests := []struct {
 		shouldPass    bool
 		testName      string
@@ -62,15 +259,460 @@ func TestCommitStringParsing(t *testing.T) {
 
 		t.Run(test.testName, func(t *testing.T) {
 			testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
-			graphItem, err := graphItemFromString(testString, nil)
+			idx := strings.Index(testString, "DATA:")
+			commit, err := NewCommit(0, 0, []byte(testString[idx+5:]))
 			if err != nil && test.shouldPass {
 				t.Errorf("Could not parse %s", testString)
 				return
 			}
 
-			if test.commitMessage != graphItem.Subject {
-				t.Errorf("%s does not match %s", test.commitMessage, graphItem.Subject)
+			if test.commitMessage != commit.Subject {
+				t.Errorf("%s does not match %s", test.commitMessage, commit.Subject)
 			}
 		})
 	}
 }
+
+var testglyphs = `* 
+* 
+* 
+* 
+* 
+* 
+* 
+*   
+|\  
+* | 
+* | 
+* | 
+* | 
+* | 
+| * 
+* | 
+| *   
+| |\  
+* | | 
+| | *   
+| | |\  
+* | | \   
+|\ \ \ \  
+| * | | | 
+| |\| | | 
+* | | | | 
+|/ / / /  
+| | | * 
+| * | | 
+| * | | 
+| * | | 
+* | | | 
+* | | | 
+* | | | 
+* | | | 
+* | | |   
+|\ \ \ \  
+| | * | | 
+| | |\| | 
+| | | * | 
+| | | | * 
+* | | | | 
+* | | | | 
+* | | | | 
+* | | | | 
+* | | | |   
+|\ \ \ \ \  
+| * | | | | 
+|/| | | | | 
+| | |/ / /  
+| |/| | |   
+| | | | * 
+| * | | | 
+|/| | | | 
+| * | | | 
+|/| | | | 
+| | |/ /  
+| |/| |   
+| * | | 
+| * | |   
+| |\ \ \  
+| | * | | 
+| |/| | | 
+| | | |/  
+| | |/|   
+| * | | 
+| * | | 
+| * | | 
+| | * |   
+| | |\ \  
+| | | * | 
+| | |/| | 
+| | | * |   
+| | | |\ \  
+| | | | * | 
+| | | |/| | 
+| | * | | | 
+| | * | | |   
+| | |\ \ \ \  
+| | | * | | | 
+| | |/| | | | 
+| | | | | * | 
+| | | | |/ /  
+* | | | / / 
+|/ / / / /  
+* | | | |   
+|\ \ \ \ \  
+| * | | | | 
+|/| | | | | 
+| * | | | | 
+| * | | | |   
+| |\ \ \ \ \  
+| | | * \ \ \   
+| | | |\ \ \ \  
+| | | | * | | | 
+| | | |/| | | | 
+| | | | | |/ /  
+| | | | |/| |   
+* | | | | | | 
+* | | | | | | 
+* | | | | | | 
+| | | | * | | 
+* | | | | | | 
+| | * | | | | 
+| |/| | | | | 
+* | | | | | | 
+| |/ / / / /  
+|/| | | | |   
+| | | | * | 
+| | | |/ /  
+| | |/| |   
+| * | | | 
+| | | | * 
+| | * | |   
+| | |\ \ \  
+| | | * | | 
+| | |/| | | 
+| | | |/ /  
+| | | * | 
+| | * | |   
+| | |\ \ \  
+| | | * | | 
+| | |/| | | 
+| | | |/ /  
+| | | * | 
+* | | | |   
+|\ \ \ \ \  
+| * \ \ \ \   
+| |\ \ \ \ \  
+| | | |/ / /  
+| | |/| | |   
+| | | | * | 
+| | | | * | 
+* | | | | | 
+* | | | | | 
+|/ / / / /  
+| | | * | 
+* | | | | 
+* | | | | 
+* | | | | 
+* | | | |   
+|\ \ \ \ \  
+| * | | | | 
+|/| | | | | 
+| | * | | |   
+| | |\ \ \ \  
+| | | * | | | 
+| | |/| | | | 
+| |/| | |/ /  
+| | | |/| |   
+| | | | | * 
+| |_|_|_|/  
+|/| | | |   
+| | * | | 
+| |/ / /  
+* | | | 
+* | | | 
+| | * | 
+* | | | 
+* | | | 
+| * | | 
+| | * | 
+| * | | 
+* | | |   
+|\ \ \ \  
+| * | | | 
+|/| | | | 
+| |/ / /  
+| * | |   
+| |\ \ \  
+| | * | | 
+| |/| | | 
+| | |/ /  
+| | * |   
+| | |\ \  
+| | | * | 
+| | |/| | 
+* | | | | 
+* | | | |   
+|\ \ \ \ \  
+| * | | | | 
+|/| | | | | 
+| | * | | | 
+| | * | | | 
+| | * | | | 
+| |/ / / /  
+| * | | |   
+| |\ \ \ \  
+| | * | | | 
+| |/| | | | 
+* | | | | | 
+* | | | | | 
+* | | | | | 
+* | | | | | 
+* | | | | | 
+| | | | * | 
+* | | | | |   
+|\ \ \ \ \ \  
+| * | | | | | 
+|/| | | | | | 
+| | | | | * | 
+| | | | |/ /  
+* | | | | |   
+|\ \ \ \ \ \  
+* | | | | | | 
+* | | | | | | 
+| | | | * | | 
+* | | | | | | 
+* | | | | | |   
+|\ \ \ \ \ \ \  
+| | |_|_|/ / /  
+| |/| | | | |   
+| | | | * | | 
+| | | | * | | 
+| | | | * | | 
+| | | | * | | 
+| | | | * | | 
+| | | | * | | 
+| | | |/ / /  
+| | | * | | 
+| | | * | | 
+| | | * | | 
+| | |/| | | 
+| | | * | | 
+| | |/| | | 
+| | | |/ /  
+| | * | | 
+| |/| | | 
+| | | * | 
+| | |/ /  
+| | * | 
+| * | |   
+| |\ \ \  
+| * | | | 
+| | * | | 
+| |/| | | 
+| | |/ /  
+| | * |   
+| | |\ \  
+| | * | | 
+* | | | | 
+|\| | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| | * | | 
+| * | | | 
+| |\| | | 
+| * | | | 
+| | * | | 
+| | * | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| | * | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| | * | | 
+* | | | | 
+|\| | | | 
+| | * | | 
+| * | | | 
+| |\| | | 
+| | * | | 
+| | * | | 
+| | * | | 
+| | | * | 
+* | | | | 
+|\| | | | 
+| | * | | 
+| | |/ /  
+| * | | 
+| * | | 
+| |\| | 
+* | | | 
+|\| | | 
+| | * | 
+| | * | 
+| | * | 
+| * | | 
+| | * | 
+| * | | 
+| | * | 
+| | * | 
+| | * | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+* | | | 
+|\| | | 
+| * | | 
+| |\| | 
+| | * |   
+| | |\ \  
+* | | | | 
+|\| | | | 
+| * | | | 
+| |\| | | 
+| | * | | 
+| | | * | 
+| | |/ /  
+* | | | 
+* | | | 
+|\| | | 
+| * | | 
+| |\| | 
+| | * | 
+| | * | 
+| | * | 
+| | | * 
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+| | | *   
+| | | |\  
+* | | | | 
+| |_|_|/  
+|/| | |   
+| * | | 
+| |\| | 
+| | * | 
+| | * | 
+| | * | 
+| | * | 
+| | * | 
+| * | | 
+* | | | 
+|\| | | 
+| * | | 
+|/| | | 
+| |/ /  
+| * |   
+| |\ \  
+| * | | 
+| * | | 
+* | | | 
+|\| | | 
+| | * | 
+| * | | 
+| * | | 
+| * | | 
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+| | * |   
+| | |\ \  
+| | |/ /  
+| |/| |   
+| * | | 
+* | | | 
+|\| | | 
+| * | | 
+* | | | 
+|\| | | 
+| * | |   
+| |\ \ \  
+| * | | | 
+| * | | | 
+| | | * | 
+| * | | | 
+| * | | | 
+| | |/ /  
+| |/| |   
+| | * | 
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+| * | |   
+| |\ \ \  
+* | | | | 
+|\| | | | 
+| * | | | 
+| * | | | 
+* | | | | 
+* | | | | 
+|\| | | | 
+| | | | *   
+| | | | |\  
+| |_|_|_|/  
+|/| | | |   
+| * | | | 
+* | | | | 
+* | | | | 
+|\| | | | 
+| * | | |   
+| |\ \ \ \  
+| | | |/ /  
+| | |/| |   
+| * | | | 
+| * | | | 
+| * | | | 
+| * | | | 
+| | * | | 
+| | | * | 
+| | |/ /  
+| |/| |   
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+| * | | 
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+* | | | 
+| * | | 
+| * | | 
+| * | | 
+* | | | 
+* | | | 
+* | | | 
+|\| | | 
+| * | | 
+* | | | 
+* | | | 
+* | | | 
+* | | | 
+| | | * 
+* | | | 
+|\| | | 
+| * | | 
+| * | | 
+| * | | 
+`
diff --git a/modules/gitgraph/parser.go b/modules/gitgraph/parser.go
new file mode 100644
index 0000000000..62e0505652
--- /dev/null
+++ b/modules/gitgraph/parser.go
@@ -0,0 +1,338 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package gitgraph
+
+import (
+	"bytes"
+	"fmt"
+)
+
+// Parser represents a git graph parser. It is stateful containing the previous
+// glyphs, detected flows and color assignments.
+type Parser struct {
+	glyphs           []byte
+	oldGlyphs        []byte
+	flows            []int64
+	oldFlows         []int64
+	maxFlow          int64
+	colors           []int
+	oldColors        []int
+	availableColors  []int
+	nextAvailable    int
+	firstInUse       int
+	firstAvailable   int
+	maxAllowedColors int
+}
+
+// Reset resets the internal parser state.
+func (parser *Parser) Reset() {
+	parser.glyphs = parser.glyphs[0:0]
+	parser.oldGlyphs = parser.oldGlyphs[0:0]
+	parser.flows = parser.flows[0:0]
+	parser.oldFlows = parser.oldFlows[0:0]
+	parser.maxFlow = 0
+	parser.colors = parser.colors[0:0]
+	parser.oldColors = parser.oldColors[0:0]
+	parser.availableColors = parser.availableColors[0:0]
+	parser.availableColors = append(parser.availableColors, 1, 2)
+	parser.nextAvailable = 0
+	parser.firstInUse = -1
+	parser.firstAvailable = 0
+	parser.maxAllowedColors = 0
+}
+
+// AddLineToGraph adds the line as a row to the graph
+func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
+	idx := bytes.Index(line, []byte("DATA:"))
+	if idx < 0 {
+		parser.ParseGlyphs(line)
+	} else {
+		parser.ParseGlyphs(line[:idx])
+	}
+
+	var err error
+	commitDone := false
+
+	for column, glyph := range parser.glyphs {
+		if glyph == ' ' {
+			continue
+		}
+
+		flowID := parser.flows[column]
+
+		graph.AddGlyph(row, column, flowID, parser.colors[column], glyph)
+
+		if glyph == '*' {
+			if commitDone {
+				if err != nil {
+					err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err)
+				} else {
+					err = fmt.Errorf("double commit on line %d: %s", row, string(line))
+				}
+			}
+			commitDone = true
+			if idx < 0 {
+				if err != nil {
+					err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err)
+				} else {
+					err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line))
+				}
+				continue
+			}
+			err2 := graph.AddCommit(row, column, flowID, line[idx+5:])
+			if err != nil && err2 != nil {
+				err = fmt.Errorf("%v %w", err2, err)
+				continue
+			} else if err2 != nil {
+				err = err2
+				continue
+			}
+		}
+	}
+	if !commitDone {
+		graph.Commits = append(graph.Commits, RelationCommit)
+	}
+	return err
+}
+
+func (parser *Parser) releaseUnusedColors() {
+	if parser.firstInUse > -1 {
+		// Here we step through the old colors, searching for them in the
+		// "in-use" section of availableColors (that is, the colors between
+		// firstInUse and firstAvailable)
+		// Ensure that the benchmarks are not worsened with proposed changes
+		stepstaken := 0
+		position := parser.firstInUse
+		for _, color := range parser.oldColors {
+			if color == 0 {
+				continue
+			}
+			found := false
+			i := position
+			for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ {
+				colorToCheck := parser.availableColors[i]
+				if colorToCheck == color {
+					found = true
+					break
+				}
+				i = (i + 1) % len(parser.availableColors)
+			}
+			if !found {
+				// Duplicate color
+				continue
+			}
+			// Swap them around
+			parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position]
+			stepstaken++
+			position = (parser.firstInUse + stepstaken) % len(parser.availableColors)
+			if position == parser.firstAvailable || stepstaken == len(parser.availableColors) {
+				break
+			}
+		}
+		if stepstaken == len(parser.availableColors) {
+			parser.firstAvailable = -1
+		} else {
+			parser.firstAvailable = position
+			if parser.nextAvailable == -1 {
+				parser.nextAvailable = parser.firstAvailable
+			}
+		}
+	}
+}
+
+// ParseGlyphs parses the provided glyphs and sets the internal state
+func (parser *Parser) ParseGlyphs(glyphs []byte) {
+
+	// Clean state for parsing this row
+	parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs
+	parser.glyphs = parser.glyphs[0:0]
+	parser.flows, parser.oldFlows = parser.oldFlows, parser.flows
+	parser.flows = parser.flows[0:0]
+	parser.colors, parser.oldColors = parser.oldColors, parser.colors
+
+	// Ensure we have enough flows and colors
+	parser.colors = parser.colors[0:0]
+	for range glyphs {
+		parser.flows = append(parser.flows, 0)
+		parser.colors = append(parser.colors, 0)
+	}
+
+	// Copy the provided glyphs in to state.glyphs for safekeeping
+	parser.glyphs = append(parser.glyphs, glyphs...)
+
+	// release unused colors
+	parser.releaseUnusedColors()
+
+	for i := len(glyphs) - 1; i >= 0; i-- {
+		glyph := glyphs[i]
+		switch glyph {
+		case '|':
+			fallthrough
+		case '*':
+			parser.setUpFlow(i)
+		case '/':
+			parser.setOutFlow(i)
+		case '\\':
+			parser.setInFlow(i)
+		case '_':
+			parser.setRightFlow(i)
+		case '.':
+			fallthrough
+		case '-':
+			parser.setLeftFlow(i)
+		case ' ':
+			// no-op
+		default:
+			parser.newFlow(i)
+		}
+	}
+}
+
+func (parser *Parser) takePreviousFlow(i, j int) {
+	if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 {
+		parser.flows[i] = parser.oldFlows[j]
+		parser.oldFlows[j] = 0
+		parser.colors[i] = parser.oldColors[j]
+		parser.oldColors[j] = 0
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+func (parser *Parser) takeCurrentFlow(i, j int) {
+	if j < len(parser.flows) && parser.flows[j] > 0 {
+		parser.flows[i] = parser.flows[j]
+		parser.colors[i] = parser.colors[j]
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+func (parser *Parser) newFlow(i int) {
+	parser.maxFlow++
+	parser.flows[i] = parser.maxFlow
+
+	// Now give this flow a color
+	if parser.nextAvailable == -1 {
+		next := len(parser.availableColors)
+		if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors {
+			parser.nextAvailable = next
+			parser.firstAvailable = next
+			parser.availableColors = append(parser.availableColors, next+1)
+		}
+	}
+	parser.colors[i] = parser.availableColors[parser.nextAvailable]
+	if parser.firstInUse == -1 {
+		parser.firstInUse = parser.nextAvailable
+	}
+	parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable]
+
+	parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors)
+	parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors)
+
+	if parser.nextAvailable == parser.firstInUse {
+		parser.nextAvailable = parser.firstAvailable
+	}
+	if parser.nextAvailable == parser.firstInUse {
+		parser.nextAvailable = -1
+		parser.firstAvailable = -1
+	}
+}
+
+// setUpFlow handles '|' or '*'
+func (parser *Parser) setUpFlow(i int) {
+	// In preference order:
+	//
+	// Previous Row: '\? ' ' |' '  /'
+	// Current Row:  ' | ' ' |' ' | '
+	if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' {
+		parser.takePreviousFlow(i, i-1)
+	} else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') {
+		parser.takePreviousFlow(i, i)
+	} else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' {
+		parser.takePreviousFlow(i, i+1)
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+// setOutFlow handles '/'
+func (parser *Parser) setOutFlow(i int) {
+	// In preference order:
+	//
+	// Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\'
+	// Current Row:  '/| ' '/| ' '/ ' '/ ' '/ ' '/'
+	if i+2 < len(parser.oldGlyphs) &&
+		(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') &&
+		(parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') &&
+		i+1 < len(parser.glyphs) &&
+		(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') {
+		parser.takePreviousFlow(i, i+2)
+	} else if i+1 < len(parser.oldGlyphs) &&
+		(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' ||
+			parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') {
+		parser.takePreviousFlow(i, i+1)
+		if parser.oldGlyphs[i+1] == '/' {
+			parser.glyphs[i] = '|'
+		}
+	} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' {
+		parser.takePreviousFlow(i, i)
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+// setInFlow handles '\'
+func (parser *Parser) setInFlow(i int) {
+	// In preference order:
+	//
+	// Previous Row: '| ' '-. ' '| ' '\ ' '/' '---'
+	// Current Row:  '|\' '  \' ' \' ' \' '\' ' \ '
+	if i > 0 && i-1 < len(parser.oldGlyphs) &&
+		(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') &&
+		(parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') {
+		parser.newFlow(i)
+	} else if i > 0 && i-1 < len(parser.oldGlyphs) &&
+		(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' ||
+			parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') {
+		parser.takePreviousFlow(i, i-1)
+		if parser.oldGlyphs[i-1] == '\\' {
+			parser.glyphs[i] = '|'
+		}
+	} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' {
+		parser.takePreviousFlow(i, i)
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+// setRightFlow handles '_'
+func (parser *Parser) setRightFlow(i int) {
+	// In preference order:
+	//
+	// Current Row:  '__' '_/' '_|_' '_|/'
+	if i+1 < len(parser.glyphs) &&
+		(parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') {
+		parser.takeCurrentFlow(i, i+1)
+	} else if i+2 < len(parser.glyphs) &&
+		(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') &&
+		(parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') {
+		parser.takeCurrentFlow(i, i+2)
+	} else {
+		parser.newFlow(i)
+	}
+}
+
+// setLeftFlow handles '----.'
+func (parser *Parser) setLeftFlow(i int) {
+	if parser.glyphs[i] == '.' {
+		parser.newFlow(i)
+	} else if i+1 < len(parser.glyphs) &&
+		(parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') {
+		parser.takeCurrentFlow(i, i+1)
+	} else {
+		parser.newFlow(i)
+	}
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 8f3fba618d..718fe8f267 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -99,8 +99,19 @@ func NewFuncMap() []template.FuncMap {
 		"Subtract":      base.Subtract,
 		"EntryIcon":     base.EntryIcon,
 		"MigrationIcon": MigrationIcon,
-		"Add": func(a, b int) int {
-			return a + b
+		"Add": func(a ...int) int {
+			sum := 0
+			for _, val := range a {
+				sum += val
+			}
+			return sum
+		},
+		"Mul": func(a ...int) int {
+			sum := 1
+			for _, val := range a {
+				sum *= val
+			}
+			return sum
 		},
 		"ActionIcon": ActionIcon,
 		"DateFmtLong": func(t time.Time) string {
@@ -437,6 +448,20 @@ func NewTextFuncMap() []texttmpl.FuncMap {
 			}
 			return float32(n) * 100 / float32(sum)
 		},
+		"Add": func(a ...int) int {
+			sum := 0
+			for _, val := range a {
+				sum += val
+			}
+			return sum
+		},
+		"Mul": func(a ...int) int {
+			sum := 1
+			for _, val := range a {
+				sum *= val
+			}
+			return sum
+		},
 	}}
 }
 
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 751cce6583..6dedae3422 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -775,6 +775,8 @@ audio_not_supported_in_browser = Your browser does not support the HTML5 'audio'
 stored_lfs = Stored with Git LFS
 symbolic_link = Symbolic link
 commit_graph = Commit Graph
+commit_graph.monochrome = Mono
+commit_graph.color = Color
 blame = Blame
 normal_view = Normal View
 line = line
diff --git a/public/img/svg/material-invert-colors.svg b/public/img/svg/material-invert-colors.svg
new file mode 100644
index 0000000000..018a693a02
--- /dev/null
+++ b/public/img/svg/material-invert-colors.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 768 768" class="svg material-invert-colors" width="16" height="16" aria-hidden="true"><path d="M384 627V163.5l-135 135c-36 36-57 85.5-57 136.5 0 103.19 88.8 192 192 192zm181.5-373.5C666 354 666 514.5 565.5 615 516 664.5 450 690 384 690s-132-25.5-181.5-75C102 514.5 102 354 202.5 253.5L384 72z"/></svg>
\ No newline at end of file
diff --git a/public/img/svg/material-palette.svg b/public/img/svg/material-palette.svg
new file mode 100644
index 0000000000..d257e65d33
--- /dev/null
+++ b/public/img/svg/material-palette.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 768 768" class="svg material-palette" width="16" height="16" aria-hidden="true"><path d="M559.5 384c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96-127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-159 0c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96 127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM384 96c159 0 288 115.5 288 256.5 0 88.5-72 159-160.5 159H456c-27 0-48 21-48 48 0 12 4.5 22.5 12 31.5s12 21 12 33c0 27-21 48-48 48-159 0-288-129-288-288S225 96 384 96z"/></svg>
\ No newline at end of file
diff --git a/routers/repo/commit.go b/routers/repo/commit.go
index 004d4915df..d9547cc51d 100644
--- a/routers/repo/commit.go
+++ b/routers/repo/commit.go
@@ -90,6 +90,11 @@ func Commits(ctx *context.Context) {
 func Graph(ctx *context.Context) {
 	ctx.Data["PageIsCommits"] = true
 	ctx.Data["PageIsViewCode"] = true
+	mode := strings.ToLower(ctx.QueryTrim("mode"))
+	if mode != "monochrome" {
+		mode = "color"
+	}
+	ctx.Data["Mode"] = mode
 
 	commitsCount, err := ctx.Repo.GetCommitsCount()
 	if err != nil {
@@ -105,7 +110,7 @@ func Graph(ctx *context.Context) {
 
 	page := ctx.QueryInt("page")
 
-	graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page)
+	graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0)
 	if err != nil {
 		ctx.ServerError("GetCommitGraph", err)
 		return
@@ -116,7 +121,9 @@ func Graph(ctx *context.Context) {
 	ctx.Data["Reponame"] = ctx.Repo.Repository.Name
 	ctx.Data["CommitCount"] = commitsCount
 	ctx.Data["Branch"] = ctx.Repo.BranchName
-	ctx.Data["Page"] = context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5)
+	paginator := context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5)
+	paginator.AddParam(ctx, "mode", "Mode")
+	ctx.Data["Page"] = paginator
 	ctx.HTML(200, tplGraph)
 }
 
diff --git a/templates/repo/graph.tmpl b/templates/repo/graph.tmpl
index 493ac7a696..c644b24d57 100644
--- a/templates/repo/graph.tmpl
+++ b/templates/repo/graph.tmpl
@@ -2,21 +2,44 @@
 <div class="repository commits">
 	{{template "repo/header" .}}
 	<div class="ui container">
-		<div id="git-graph-container" class="ui segment">
-			<h1>{{.i18n.Tr "repo.commit_graph"}}</h1>
+		<div id="git-graph-container" class="ui segment{{if eq .Mode "monochrome"}} monochrome{{end}}">
+			<h2 class="ui header dividing">{{.i18n.Tr "repo.commit_graph"}}
+				<div class="ui right">
+					<div class="ui icon buttons tiny color-buttons">
+						<button id="flow-color-monochrome" class="ui labelled icon button{{if eq .Mode "monochrome"}} active{{end}}" title="{{.i18n.Tr "repo.commit_graph.monochrome"}}"><span class="emoji">{{svg "material-invert-colors" 16}}</span> {{.i18n.Tr "repo.commit_graph.monochrome"}}</button>
+						<button id="flow-color-colored" class="ui labelled icon button{{if ne .Mode "monochrome"}} active{{end}}" title="{{.i18n.Tr "repo.commit_graph.color"}}"><span class="emoji">{{svg "material-palette" 16}}</span> {{.i18n.Tr "repo.commit_graph.color"}}</button>
+					</div>
+				</div>
+			</h2>
+			<div class="ui dividing"></div>
 			<div id="rel-container">
-				<canvas id="graph-canvas">
-					<ul id="graph-raw-list">
-						{{ range .Graph }}
-							<li><span class="node-relation">{{ .GraphAcii -}}</span></li>
-						{{ end }}
-					</ul>
-				</canvas>
+				<svg viewbox="{{Mul .Graph.MinColumn 5}} {{Mul .Graph.MinRow 10}} {{Add (Mul .Graph.Width 5) 5}} {{Mul .Graph.Height 10}}" width="{{Add (Mul .Graph.Width 10) 10}}px">
+					{{range $flowid, $flow := .Graph.Flows}}
+						<g id="flow-{{$flow.ID}}" class="flow-group flow-color-{{$flow.ColorNumber}} flow-color-16-{{$flow.Color16}}" data-flow="{{$flow.ID}}" data-color="{{$flow.ColorNumber}}">
+							<path d="{{range $i, $glyph := $flow.Glyphs -}}
+								{{- if or (eq $glyph.Glyph '*') (eq $glyph.Glyph '|') -}}
+									M {{Add (Mul $glyph.Column 5) 5}} {{Add (Mul $glyph.Row 10) 0}} v 10 {{/* */ -}}
+								{{- else if eq $glyph.Glyph '/' -}}
+									M {{Add (Mul $glyph.Column 5) 10}} {{Add (Mul $glyph.Row 10) 0}} l -10 10 {{/* */ -}}
+								{{- else if eq $glyph.Glyph '\\' -}}
+									M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 0}} l 10 10 {{/* */ -}}
+								{{- else if or (eq $glyph.Glyph '-') (eq $glyph.Glyph '.') -}}
+									M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 10}} h 5 {{/* */ -}}
+								{{- else if eq $glyph.Glyph '_' -}}
+									M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 10}} h 10 {{/* */ -}}
+								{{- end -}}
+							{{- end}}" stroke-width="1" fill="none" id="flow-{{$flow.ID}}-path" stroke-linecap="round"/>
+							{{range $flow.Commits}}
+								<circle class="flow-commit" cx="{{Add (Mul .Column 5) 5}}" cy="{{Add (Mul .Row 10) 5}}" r="2.5" stroke="none" id="flow-commit-{{.Rev}}" data-rev="{{.Rev}}"/>
+							{{end}}
+						</g>
+					{{end}}
+				</svg>
 			</div>
 			<div id="rev-container">
 				<ul id="rev-list">
-					{{ range .Graph }}
-						<li>
+					{{ range .Graph.Commits }}
+						<li id="commit-{{.Rev}}" data-flow="{{.Flow}}">
 							{{ if .OnlyRelation }}
 								<span />
 							{{ else }}
diff --git a/web_src/js/features/gitgraph.js b/web_src/js/features/gitgraph.js
index 3e6b27436d..655cfb77c2 100644
--- a/web_src/js/features/gitgraph.js
+++ b/web_src/js/features/gitgraph.js
@@ -1,568 +1,81 @@
-// Although inspired by the https://github.com/bluef/gitgraph.js/blob/master/gitgraph.js
-// this has been completely rewritten with almost no remaining code
-
-// GitGraphCanvas is a canvas for drawing gitgraphs on to
-class GitGraphCanvas {
-  constructor(canvas, widthUnits, heightUnits, config) {
-    this.ctx = canvas.getContext('2d');
-
-    const width = widthUnits * config.unitSize;
-    this.height = heightUnits * config.unitSize;
-
-    const ratio = window.devicePixelRatio || 1;
-
-    canvas.width = width * ratio;
-    canvas.height = this.height * ratio;
-
-    canvas.style.width = `${width}px`;
-    canvas.style.height = `${this.height}px`;
-
-    this.ctx.lineWidth = config.lineWidth;
-    this.ctx.lineJoin = 'round';
-    this.ctx.lineCap = 'round';
-
-    this.ctx.scale(ratio, ratio);
-    this.config = config;
-  }
-  drawLine(moveX, moveY, lineX, lineY, color) {
-    this.ctx.strokeStyle = color;
-    this.ctx.beginPath();
-    this.ctx.moveTo(moveX, moveY);
-    this.ctx.lineTo(lineX, lineY);
-    this.ctx.stroke();
-  }
-  drawLineRight(x, y, color) {
-    this.drawLine(
-      x - 0.5 * this.config.unitSize,
-      y + this.config.unitSize / 2,
-      x + 0.5 * this.config.unitSize,
-      y + this.config.unitSize / 2,
-      color
-    );
-  }
-  drawLineUp(x, y, color) {
-    this.drawLine(
-      x,
-      y + this.config.unitSize / 2,
-      x,
-      y - this.config.unitSize / 2,
-      color
-    );
-  }
-  drawNode(x, y, color) {
-    this.ctx.strokeStyle = color;
-
-    this.drawLineUp(x, y, color);
-
-    this.ctx.beginPath();
-    this.ctx.arc(x, y, this.config.nodeRadius, 0, Math.PI * 2, true);
-    this.ctx.fillStyle = color;
-    this.ctx.fill();
-  }
-  drawLineIn(x, y, color) {
-    this.drawLine(
-      x + 0.5 * this.config.unitSize,
-      y + this.config.unitSize / 2,
-      x - 0.5 * this.config.unitSize,
-      y - this.config.unitSize / 2,
-      color
-    );
-  }
-  drawLineOut(x, y, color) {
-    this.drawLine(
-      x - 0.5 * this.config.unitSize,
-      y + this.config.unitSize / 2,
-      x + 0.5 * this.config.unitSize,
-      y - this.config.unitSize / 2,
-      color
-    );
-  }
-  drawSymbol(symbol, columnNumber, rowNumber, color) {
-    const y = this.height - this.config.unitSize * (rowNumber + 0.5);
-    const x = this.config.unitSize * 0.5 * (columnNumber + 1);
-    switch (symbol) {
-      case '-':
-        if (columnNumber % 2 === 1) {
-          this.drawLineRight(x, y, color);
-        }
-        break;
-      case '_':
-        this.drawLineRight(x, y, color);
-        break;
-      case '*':
-        this.drawNode(x, y, color);
-        break;
-      case '|':
-        this.drawLineUp(x, y, color);
-        break;
-      case '/':
-        this.drawLineOut(x, y, color);
-        break;
-      case '\\':
-        this.drawLineIn(x, y, color);
-        break;
-      case '.':
-      case ' ':
-        break;
-      default:
-        console.error('Unknown symbol', symbol, color);
-    }
-  }
-}
-
-class GitGraph {
-  constructor(canvas, rawRows, config) {
-    this.rows = [];
-    let maxWidth = 0;
-
-    for (let i = 0; i < rawRows.length; i++) {
-      const rowStr = rawRows[i];
-      maxWidth = Math.max(rowStr.replace(/([_\s.-])/g, '').length, maxWidth);
-
-      const rowArray = rowStr.split('');
-
-      this.rows.unshift(rowArray);
-    }
-
-    this.currentFlows = [];
-    this.previousFlows = [];
-
-    this.gitGraphCanvas = new GitGraphCanvas(
-      canvas,
-      maxWidth,
-      this.rows.length,
-      config
-    );
-  }
-
-  generateNewFlow(column) {
-    let newId;
-
-    do {
-      newId = generateRandomColorString();
-    } while (this.hasFlow(newId, column));
-
-    return {id: newId, color: `#${newId}`};
-  }
-
-  hasFlow(id, column) {
-    // We want to find the flow with the current ID
-    // Possible flows are those in the currentFlows
-    // Or flows in previousFlows[column-2:...]
-    for (
-      let idx = column - 2 < 0 ? 0 : column - 2;
-      idx < this.previousFlows.length;
-      idx++
-    ) {
-      if (this.previousFlows[idx] && this.previousFlows[idx].id === id) {
-        return true;
-      }
-    }
-    for (let idx = 0; idx < this.currentFlows.length; idx++) {
-      if (this.currentFlows[idx] && this.currentFlows[idx].id === id) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  takePreviousFlow(column) {
-    if (column < this.previousFlows.length && this.previousFlows[column]) {
-      const flow = this.previousFlows[column];
-      this.previousFlows[column] = null;
-      return flow;
-    }
-    return this.generateNewFlow(column);
-  }
-
-  draw() {
-    if (this.rows.length === 0) {
-      return;
-    }
-
-    this.currentFlows = new Array(this.rows[0].length);
-
-    // Generate flows for the first row - I do not believe that this can contain '_', '-', '.'
-    for (let column = 0; column < this.rows[0].length; column++) {
-      if (this.rows[0][column] === ' ') {
-        continue;
-      }
-      this.currentFlows[column] = this.generateNewFlow(column);
-    }
-
-    // Draw the first row
-    for (let column = 0; column < this.rows[0].length; column++) {
-      const symbol = this.rows[0][column];
-      const color = this.currentFlows[column] ? this.currentFlows[column].color : '';
-      this.gitGraphCanvas.drawSymbol(symbol, column, 0, color);
-    }
-
-    for (let row = 1; row < this.rows.length; row++) {
-      // Done previous row - step up the row
-      const currentRow = this.rows[row];
-      const previousRow = this.rows[row - 1];
-
-      this.previousFlows = this.currentFlows;
-      this.currentFlows = new Array(currentRow.length);
-
-      // Set flows for this row
-      for (let column = 0; column < currentRow.length; column++) {
-        column = this.setFlowFor(column, currentRow, previousRow);
-      }
-
-      // Draw this row
-      for (let column = 0; column < currentRow.length; column++) {
-        const symbol = currentRow[column];
-        const color = this.currentFlows[column] ? this.currentFlows[column].color : '';
-        this.gitGraphCanvas.drawSymbol(symbol, column, row, color);
-      }
-    }
-  }
-
-  setFlowFor(column, currentRow, previousRow) {
-    const symbol = currentRow[column];
-    switch (symbol) {
-      case '|':
-      case '*':
-        return this.setUpFlow(column, currentRow, previousRow);
-      case '/':
-        return this.setOutFlow(column, currentRow, previousRow);
-      case '\\':
-        return this.setInFlow(column, currentRow, previousRow);
-      case '_':
-        return this.setRightFlow(column, currentRow, previousRow);
-      case '-':
-        return this.setLeftFlow(column, currentRow, previousRow);
-      case ' ':
-        // In space no one can hear you flow ... (?)
-        return column;
-      default:
-        // Unexpected so let's generate a new flow and wait for bug-reports
-        this.currentFlows[column] = this.generateNewFlow(column);
-        return column;
-    }
-  }
-
-  // setUpFlow handles '|' or '*' - returns the last column that was set
-  // generally we prefer to take the left most flow from the previous row
-  setUpFlow(column, currentRow, previousRow) {
-    // If ' |/' or ' |_'
-    //    '/|'     '/|'  -> Take the '|' flow directly beneath us
-    if (
-      column + 1 < currentRow.length &&
-      (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') &&
-      column < previousRow.length &&
-      (previousRow[column] === '|' || previousRow[column] === '*') &&
-      previousRow[column - 1] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column);
-      return column;
-    }
-
-    // If ' |/' or ' |_'
-    //    '/ '     '/ '  -> Take the '/' flow from the preceding column
-    if (
-      column + 1 < currentRow.length &&
-      (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') &&
-      column - 1 < previousRow.length &&
-      previousRow[column - 1] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 1);
-      return column;
-    }
-
-    // If ' |'
-    //    '/'   ->  Take the '/' flow - (we always prefer the left-most flow)
-    if (
-      column > 0 &&
-      column - 1 < previousRow.length &&
-      previousRow[column - 1] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 1);
-      return column;
-    }
-
-    // If '|' OR '|' take the '|' flow
-    //    '|'    '*'
-    if (
-      column < previousRow.length &&
-      (previousRow[column] === '|' || previousRow[column] === '*')
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column);
-      return column;
-    }
-
-    // If '| ' keep the '\' flow
-    //    ' \'
-    if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') {
-      this.currentFlows[column] = this.takePreviousFlow(column + 1);
-      return column;
-    }
-
-    // Otherwise just create a new flow - probably this is an error...
-    this.currentFlows[column] = this.generateNewFlow(column);
-    return column;
-  }
-
-  // setOutFlow handles '/' - returns the last column that was set
-  // generally we prefer to take the left most flow from the previous row
-  setOutFlow(column, currentRow, previousRow) {
-    // If  '_/' -> keep the '_' flow
-    if (column > 0 && currentRow[column - 1] === '_') {
-      this.currentFlows[column] = this.currentFlows[column - 1];
-      return column;
-    }
-
-    // If '_|/' -> keep the '_' flow
-    if (
-      column > 1 &&
-      (currentRow[column - 1] === '|' || currentRow[column - 1] === '*') &&
-      currentRow[column - 2] === '_'
-    ) {
-      this.currentFlows[column] = this.currentFlows[column - 2];
-      return column;
-    }
-
-    // If  '|/'
-    //    '/'   -> take the '/' flow (if it is still available)
-    if (
-      column > 1 &&
-      currentRow[column - 1] === '|' &&
-      column - 2 < previousRow.length &&
-      previousRow[column - 2] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 2);
-      return column;
-    }
-
-    // If ' /'
-    //    '/'  -> take the '/' flow, but transform the symbol to '|' due to our spacing
-    // This should only happen if there are 3 '/' - in a row so we don't need to be cleverer here
-    if (
-      column > 0 &&
-      currentRow[column - 1] === ' ' &&
-      column - 1 < previousRow.length &&
-      previousRow[column - 1] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 1);
-      currentRow[column] = '|';
-      return column;
-    }
-
-    // If ' /'
-    //    '|'  -> take the '|' flow
-    if (
-      column > 0 &&
-      currentRow[column - 1] === ' ' &&
-      column - 1 < previousRow.length &&
-      (previousRow[column - 1] === '|' || previousRow[column - 1] === '*')
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 1);
-      return column;
-    }
-
-    // If '/' <- Not sure this ever happens... but take the '\' flow
-    //    '\'
-    if (column < previousRow.length && previousRow[column] === '\\') {
-      this.currentFlows[column] = this.takePreviousFlow(column);
-      return column;
-    }
-
-    // Otherwise just generate a new flow and wait for bug-reports...
-    this.currentFlows[column] = this.generateNewFlow(column);
-    return column;
-  }
-
-  // setInFlow handles '\' - returns the last column that was set
-  // generally we prefer to take the left most flow from the previous row
-  setInFlow(column, currentRow, previousRow) {
-    // If '\?'
-    //    '/?' -> take the '/' flow
-    if (column < previousRow.length && previousRow[column] === '/') {
-      this.currentFlows[column] = this.takePreviousFlow(column);
-      return column;
-    }
-
-    // If '\?'
-    //    ' \' -> take the '\' flow and reassign to '|'
-    // This should only happen if there are 3 '\' - in a row so we don't need to be cleverer here
-    if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') {
-      this.currentFlows[column] = this.takePreviousFlow(column + 1);
-      currentRow[column] = '|';
-      return column;
-    }
-
-    // If '\?'
-    //    ' |' -> take the '|' flow
-    if (
-      column + 1 < previousRow.length &&
-      (previousRow[column + 1] === '|' || previousRow[column + 1] === '*')
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column + 1);
-      return column;
-    }
-
-    // Otherwise just generate a new flow and wait for bug-reports if we're wrong...
-    this.currentFlows[column] = this.generateNewFlow(column);
-    return column;
-  }
-
-  // setRightFlow handles '_' - returns the last column that was set
-  // generally we prefer to take the left most flow from the previous row
-  setRightFlow(column, currentRow, previousRow) {
-    // if '__' keep the '_' flow
-    if (column > 0 && currentRow[column - 1] === '_') {
-      this.currentFlows[column] = this.currentFlows[column - 1];
-      return column;
-    }
-
-    // if '_|_' -> keep the '_' flow
-    if (
-      column > 1 &&
-      currentRow[column - 1] === '|' &&
-      currentRow[column - 2] === '_'
-    ) {
-      this.currentFlows[column] = this.currentFlows[column - 2];
-      return column;
-    }
-
-    // if ' _' -> take the '/' flow
-    //    '/ '
-    if (
-      column > 0 &&
-      column - 1 < previousRow.length &&
-      previousRow[column - 1] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 1);
-      return column;
-    }
-
-    // if ' |_'
-    //    '/? ' -> take the '/' flow (this may cause generation...)
-    //             we can do this because we know that git graph
-    //             doesn't create compact graphs like: ' |_'
-    //                                                 '//'
-    if (
-      column > 1 &&
-      column - 2 < previousRow.length &&
-      previousRow[column - 2] === '/'
-    ) {
-      this.currentFlows[column] = this.takePreviousFlow(column - 2);
-      return column;
-    }
-
-    // There really shouldn't be another way of doing this - generate and wait for bug-reports...
-
-    this.currentFlows[column] = this.generateNewFlow(column);
-    return column;
-  }
-
-  // setLeftFlow handles '----.' - returns the last column that was set
-  // generally we prefer to take the left most flow from the previous row that terminates this left recursion
-  setLeftFlow(column, currentRow, previousRow) {
-    // This is: '----------.' or the like
-    //          '   \  \  /|\'
-
-    // Find the end of the '-' or nearest '/|\' in the previousRow :
-    let originalColumn = column;
-    let flow;
-    for (; column < currentRow.length && currentRow[column] === '-'; column++) {
-      if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') {
-        flow = this.takePreviousFlow(column - 1);
-        break;
-      } else if (column < previousRow.length && previousRow[column] === '|') {
-        flow = this.takePreviousFlow(column);
-        break;
-      } else if (
-        column + 1 < previousRow.length &&
-        previousRow[column + 1] === '\\'
-      ) {
-        flow = this.takePreviousFlow(column + 1);
-        break;
-      }
-    }
-
-    // if we have a flow then we found a '/|\' in the previousRow
-    if (flow) {
-      for (; originalColumn < column + 1; originalColumn++) {
-        this.currentFlows[originalColumn] = flow;
-      }
-      return column;
-    }
-
-    // If the symbol in the column is not a '.' then there's likely an error
-    if (currentRow[column] !== '.') {
-      // It really should end in a '.' but this one doesn't...
-      // 1. Step back - we don't want to eat this column
-      column--;
-      // 2. Generate a new flow and await bug-reports...
-      this.currentFlows[column] = this.generateNewFlow(column);
-
-      // 3. Assign all of the '-' to the same flow.
-      for (; originalColumn < column; originalColumn++) {
-        this.currentFlows[originalColumn] = this.currentFlows[column];
-      }
-      return column;
-    }
-
-    // We have a terminal '.' eg. the current row looks like '----.'
-    // the previous row should look like one of '/|\' eg.    '     \'
-    if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') {
-      flow = this.takePreviousFlow(column - 1);
-    } else if (column < previousRow.length && previousRow[column] === '|') {
-      flow = this.takePreviousFlow(column);
-    } else if (
-      column + 1 < previousRow.length &&
-      previousRow[column + 1] === '\\'
-    ) {
-      flow = this.takePreviousFlow(column + 1);
+export default async function initGitGraph() {
+  const graphContainer = document.getElementById('git-graph-container');
+  if (!graphContainer) return;
+
+  $('#flow-color-monochrome').on('click', () => {
+    $('#flow-color-monochrome').addClass('active');
+    $('#flow-color-colored').removeClass('active');
+    $('#git-graph-container').removeClass('colored').addClass('monochrome');
+    const params = new URLSearchParams(window.location.search);
+    params.set('mode', 'monochrome');
+    const queryString = params.toString();
+    if (queryString) {
+      window.history.replaceState({}, '', `?${queryString}`);
     } else {
-      // Again unexpected so let's generate and wait the bug-report
-      flow = this.generateNewFlow(column);
-    }
-
-    // Assign all of the rest of the ----. to this flow.
-    for (; originalColumn < column + 1; originalColumn++) {
-      this.currentFlows[originalColumn] = flow;
+      window.history.replaceState({}, '', window.location.pathname);
+    }
+    $('.pagination a').each((_, that) => {
+      const href = $(that).attr('href');
+      if (!href) return;
+      const url = new URL(href, window.location);
+      const params = url.searchParams;
+      params.set('mode', 'monochrome');
+      url.search = `?${params.toString()}`;
+      $(that).attr('href', url.href);
+    });
+  });
+  $('#flow-color-colored').on('click', () => {
+    $('#flow-color-colored').addClass('active');
+    $('#flow-color-monochrome').removeClass('active');
+    $('#git-graph-container').addClass('colored').removeClass('monochrome');
+    $('.pagination a').each((_, that) => {
+      const href = $(that).attr('href');
+      if (!href) return;
+      const url = new URL(href, window.location);
+      const params = url.searchParams;
+      params.delete('mode');
+      url.search = `?${params.toString()}`;
+      $(that).attr('href', url.href);
+    });
+    const params = new URLSearchParams(window.location.search);
+    params.delete('mode');
+    const queryString = params.toString();
+    if (queryString) {
+      window.history.replaceState({}, '', `?${queryString}`);
+    } else {
+      window.history.replaceState({}, '', window.location.pathname);
     }
-
-    return column;
-  }
-}
-
-function generateRandomColorString() {
-  const chars = '0123456789ABCDEF';
-  const stringLength = 6;
-  let randomString = '',
-    rnum,
-    i;
-  for (i = 0; i < stringLength; i++) {
-    rnum = Math.floor(Math.random() * chars.length);
-    randomString += chars.substring(rnum, rnum + 1);
-  }
-
-  return randomString;
-}
-
-export default async function initGitGraph() {
-  const graphCanvas = document.getElementById('graph-canvas');
-  if (!graphCanvas || !graphCanvas.getContext) return;
-
-  // Grab the raw graphList
-  const graphList = [];
-  $('#graph-raw-list li span.node-relation').each(function () {
-    graphList.push($(this).text());
   });
-
-  // Define some drawing parameters
-  const config = {
-    unitSize: 20,
-    lineWidth: 3,
-    nodeRadius: 4
-  };
-
-
-  const gitGraph = new GitGraph(graphCanvas, graphList, config);
-  gitGraph.draw();
-  graphCanvas.closest('#git-graph-container').classList.add('in');
+  $('#git-graph-container').on('mouseenter', '#rev-list li', (e) => {
+    const flow = $(e.currentTarget).data('flow');
+    if (flow === 0) return;
+    $(`#flow-${flow}`).addClass('highlight');
+    $(e.currentTarget).addClass('hover');
+    $(`#rev-list li[data-flow='${flow}']`).addClass('highlight');
+  });
+  $('#git-graph-container').on('mouseleave', '#rev-list li', (e) => {
+    const flow = $(e.currentTarget).data('flow');
+    if (flow === 0) return;
+    $(`#flow-${flow}`).removeClass('highlight');
+    $(e.currentTarget).removeClass('hover');
+    $(`#rev-list li[data-flow='${flow}']`).removeClass('highlight');
+  });
+  $('#git-graph-container').on('mouseenter', '#rel-container .flow-group', (e) => {
+    $(e.currentTarget).addClass('highlight');
+    const flow = $(e.currentTarget).data('flow');
+    $(`#rev-list li[data-flow='${flow}']`).addClass('highlight');
+  });
+  $('#git-graph-container').on('mouseleave', '#rel-container .flow-group', (e) => {
+    $(e.currentTarget).removeClass('highlight');
+    const flow = $(e.currentTarget).data('flow');
+    $(`#rev-list li[data-flow='${flow}']`).removeClass('highlight');
+  });
+  $('#git-graph-container').on('mouseenter', '#rel-container .flow-commit', (e) => {
+    const rev = $(e.currentTarget).data('rev');
+    $(`#rev-list li#commit-${rev}`).addClass('hover');
+  });
+  $('#git-graph-container').on('mouseleave', '#rel-container .flow-commit', (e) => {
+    const rev = $(e.currentTarget).data('rev');
+    $(`#rev-list li#commit-${rev}`).removeClass('hover');
+  });
 }
diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less
index 94b1d72a0b..81a3282ee4 100644
--- a/web_src/less/_repository.less
+++ b/web_src/less/_repository.less
@@ -3082,11 +3082,3 @@ tbody.commit-list {
         }
     }
 }
-
-#git-graph-container {
-    display: none;
-}
-
-#git-graph-container.in {
-    display: block;
-}
diff --git a/web_src/less/features/gitgraph.less b/web_src/less/features/gitgraph.less
new file mode 100644
index 0000000000..8a9c4239a7
--- /dev/null
+++ b/web_src/less/features/gitgraph.less
@@ -0,0 +1,256 @@
+#git-graph-container {
+    float: left;
+    display: block;
+    overflow-x: auto;
+    width: 100%;
+
+    .color-buttons {
+        margin-right: 0;
+    }
+
+    .ui.header.dividing {
+        padding-bottom: 10px;
+    }
+
+    li {
+        list-style-type: none;
+        height: 20px;
+        line-height: 20px;
+        white-space: nowrap;
+
+        .node-relation {
+            font-family: "Bitstream Vera Sans Mono", "Courier", monospace;
+        }
+
+        .author {
+            color: #666666;
+        }
+
+        .time {
+            color: #999999;
+            font-size: 80%;
+        }
+
+        a {
+            color: #000000;
+        }
+
+        a:hover {
+            text-decoration: underline;
+        }
+
+        a em {
+            color: #bb0000;
+            border-bottom: 1px dotted #bbbbbb;
+            text-decoration: none;
+            font-style: normal;
+        }
+    }
+
+    #rel-container {
+        max-width: 30%;
+        overflow-x: auto;
+        float: left;
+    }
+
+    #rev-container {
+        width: 100%;
+    }
+
+    #rev-list {
+        margin: 0;
+        padding: 0 5px;
+        min-width: 95%;
+
+        li.highlight,
+        li.hover {
+            background-color: rgba(0, 0, 0, .05);
+        }
+
+        li.highlight.hover {
+            background-color: rgba(0, 0, 0, .1);
+        }
+    }
+
+    #graph-raw-list {
+        margin: 0;
+    }
+
+    &.monochrome #rel-container {
+        .flow-group {
+            stroke: grey;
+            fill: grey;
+        }
+
+        .flow-group.highlight {
+            stroke: black;
+            fill: black;
+        }
+    }
+
+    &:not(.monochrome) #rel-container {
+        .flow-group {
+            &.flow-color-16-1 {
+                stroke: #499a37;
+                fill: #499a37;
+            }
+
+            &.flow-color-16-2 {
+                stroke: hsl(356, 58%, 54%);
+                fill: #ce4751;
+            }
+
+            &.flow-color-16-3 {
+                stroke: #8f9121;
+                fill: #8f9121;
+            }
+
+            &.flow-color-16-4 {
+                stroke: #ac32a6;
+                fill: #ac32a6;
+            }
+
+            &.flow-color-16-5 {
+                stroke: #3d27aa;
+                fill: #3d27aa;
+            }
+
+            &.flow-color-16-6 {
+                stroke: #c67d28;
+                fill: #c67d28;
+            }
+
+            &.flow-color-16-7 {
+                stroke: #4db392;
+                fill: #4db392;
+            }
+
+            &.flow-color-16-8 {
+                stroke: #aa4d30;
+                fill: #aa4d30;
+            }
+
+            &.flow-color-16-9 {
+                stroke: #2a6f84;
+                fill: #2a6f84;
+            }
+
+            &.flow-color-16-10 {
+                stroke: #c45327;
+                fill: #c45327;
+            }
+
+            &.flow-color-16-11 {
+                stroke: #3d965c;
+                fill: #3d965c;
+            }
+
+            &.flow-color-16-12 {
+                stroke: #792a93;
+                fill: #792a93;
+            }
+
+            &.flow-color-16-13 {
+                stroke: #439d73;
+                fill: #439d73;
+            }
+
+            &.flow-color-16-14 {
+                stroke: #103aad;
+                fill: #103aad;
+            }
+
+            &.flow-color-16-15 {
+                stroke: #982e85;
+                fill: #982e85;
+            }
+
+            &.flow-color-16-0 {
+                stroke: #7db233;
+                fill: #7db233;
+            }
+        }
+
+        .flow-group.highlight {
+            &.flow-color-16-1 {
+                stroke: #5ac144;
+                fill: #5ac144;
+            }
+
+            &.flow-color-16-2 {
+                stroke: #ed5a8b;
+                fill: #ed5a8b;
+            }
+
+            &.flow-color-16-3 {
+                stroke: #ced049;
+                fill: #ced048;
+            }
+
+            &.flow-color-16-4 {
+                stroke: #db61d7;
+                fill: #db62d6;
+            }
+
+            &.flow-color-16-5 {
+                stroke: #4e33d1;
+                fill: #4f35d1;
+            }
+
+            &.flow-color-16-6 {
+                stroke: #e6a151;
+                fill: #e6a151;
+            }
+
+            &.flow-color-16-7 {
+                stroke: #44daaa;
+                fill: #44daaa;
+            }
+
+            &.flow-color-16-8 {
+                stroke: #dd7a5c;
+                fill: #dd7a5c;
+            }
+
+            &.flow-color-16-9 {
+                stroke: #38859c;
+                fill: #38859c;
+            }
+
+            &.flow-color-16-10 {
+                stroke: #d95520;
+                fill: #d95520;
+            }
+
+            &.flow-color-16-11 {
+                stroke: #42ae68;
+                fill: #42ae68;
+            }
+
+            &.flow-color-16-12 {
+                stroke: #9126b5;
+                fill: #9126b5;
+            }
+
+            &.flow-color-16-13 {
+                stroke: #4ab080;
+                fill: #4ab080;
+            }
+
+            &.flow-color-16-14 {
+                stroke: #284fb8;
+                fill: #284fb8;
+            }
+
+            &.flow-color-16-15 {
+                stroke: #971c80;
+                fill: #971c80;
+            }
+
+            &.flow-color-16-0 {
+                stroke: #87ca28;
+                fill: #87ca28;
+            }
+        }
+    }
+}
diff --git a/web_src/less/index.less b/web_src/less/index.less
index ef38f863cd..92b25e1db1 100644
--- a/web_src/less/index.less
+++ b/web_src/less/index.less
@@ -1,5 +1,6 @@
 @import "~font-awesome/css/font-awesome.css";
-@import "./vendor/gitGraph.css";
+
+@import "./features/gitgraph.less";
 @import "./features/animations.less";
 @import "./markdown/mermaid.less";
 
diff --git a/web_src/less/themes/theme-arc-green.less b/web_src/less/themes/theme-arc-green.less
index 8de66fd251..1e8c118675 100644
--- a/web_src/less/themes/theme-arc-green.less
+++ b/web_src/less/themes/theme-arc-green.less
@@ -919,11 +919,17 @@ a.ui.basic.green.label:hover {
 
 .ui.active.button:active,
 .ui.button:active,
-.ui.button:focus {
+.ui.button:focus,
+.ui.active.button {
     background-color: #2e3e4e;
     color: #dbdbdb;
 }
 
+.ui.active.button:hover {
+    background-color: #475e75;
+    color: #dbdbdb;
+}
+
 .ui.dropdown .menu .selected.item,
 .ui.dropdown.selected {
     color: #dbdbdb;
@@ -1921,6 +1927,45 @@ footer .container .links > * {
     color: #2a2e3a;
 }
 
+#git-graph-container.monochrome #rel-container .flow-group {
+    stroke: dimgrey;
+    fill: dimgrey;
+}
+
+#git-graph-container.monochrome #rel-container .flow-group.highlight {
+    stroke: darkgrey;
+    fill: darkgrey;
+}
+
+#git-graph-container:not(.monochrome) #rel-container .flow-group {
+    &.flow-color-16-5 {
+        stroke: #5543b1;
+        fill: #5543b1;
+    }
+}
+
+#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight {
+    &.flow-color-16-5 {
+        stroke: #7058e6;
+        fill: #7058e6;
+    }
+}
+
+#git-graph-container #rev-list li.highlight,
+#git-graph-container #rev-list li.hover {
+    background-color: rgba(255, 255, 255, .05);
+}
+
+#git-graph-container #rev-list li.highlight.hover {
+    background-color: rgba(255, 255, 255, .1);
+}
+
+#git-graph-container .ui.buttons button#flow-color-monochrome.ui.button {
+    border-left-color: rgb(76, 80, 92);
+    border-left-style: solid;
+    border-left-width: 1px;
+}
+
 .mermaid-chart {
     filter: invert(84%) hue-rotate(180deg);
 }
diff --git a/web_src/less/vendor/gitGraph.css b/web_src/less/vendor/gitGraph.css
deleted file mode 100644
index bb7e708101..0000000000
--- a/web_src/less/vendor/gitGraph.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* This is a customized version of https://github.com/bluef/gitgraph.js/blob/master/gitgraph.css
-   Changes include the removal of `body` and `em` styles */
-#git-graph-container, #rel-container {float:left;}
-#rel-container {max-width:30%; overflow-x:auto;}
-#git-graph-container {overflow-x:auto; width:100%}
-#git-graph-container li {list-style-type:none;height:20px;line-height:20px; white-space:nowrap;}
-#git-graph-container li .node-relation {font-family:'Bitstream Vera Sans Mono', 'Courier', monospace;}
-#git-graph-container li .author {color:#666666;}
-#git-graph-container li .time {color:#999999;font-size:80%}
-#git-graph-container li a {color:#000000;}
-#git-graph-container li a:hover {text-decoration:underline;}
-#git-graph-container li a em {color:#BB0000;border-bottom:1px dotted #BBBBBB;text-decoration:none;font-style:normal;}
-#rev-container {width:100%}
-#rev-list {margin:0;padding:0 5px 0 5px;min-width:95%}
-#graph-raw-list {margin:0px;}
diff --git a/web_src/svg/material-invert-colors.svg b/web_src/svg/material-invert-colors.svg
new file mode 100644
index 0000000000..e6445ab230
--- /dev/null
+++ b/web_src/svg/material-invert-colors.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 768 768"><path d="M384 627V163.5l-135 135c-36 36-57 85.5-57 136.5 0 103.19 88.8 192 192 192zm181.5-373.5C666 354 666 514.5 565.5 615 516 664.5 450 690 384 690s-132-25.5-181.5-75C102 514.5 102 354 202.5 253.5L384 72z"/></svg>
\ No newline at end of file
diff --git a/web_src/svg/material-palette.svg b/web_src/svg/material-palette.svg
new file mode 100644
index 0000000000..df0e1756ff
--- /dev/null
+++ b/web_src/svg/material-palette.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 768 768"><path d="M559.5 384c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96-127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-159 0c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96 127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM384 96c159 0 288 115.5 288 256.5 0 88.5-72 159-160.5 159H456c-27 0-48 21-48 48 0 12 4.5 22.5 12 31.5s12 21 12 33c0 27-21 48-48 48-159 0-288-129-288-288S225 96 384 96z"/></svg>
\ No newline at end of file