summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.stylelintrc3
-rw-r--r--modules/gitgraph/graph.go108
-rw-r--r--modules/gitgraph/graph_models.go186
-rw-r--r--modules/gitgraph/graph_test.go666
-rw-r--r--modules/gitgraph/parser.go338
-rw-r--r--modules/templates/helper.go29
-rw-r--r--options/locale/locale_en-US.ini2
-rw-r--r--public/img/svg/material-invert-colors.svg1
-rw-r--r--public/img/svg/material-palette.svg1
-rw-r--r--routers/repo/commit.go11
-rw-r--r--templates/repo/graph.tmpl45
-rw-r--r--web_src/js/features/gitgraph.js641
-rw-r--r--web_src/less/_repository.less8
-rw-r--r--web_src/less/features/gitgraph.less256
-rw-r--r--web_src/less/index.less3
-rw-r--r--web_src/less/themes/theme-arc-green.less47
-rw-r--r--web_src/less/vendor/gitGraph.css15
-rw-r--r--web_src/svg/material-invert-colors.svg1
-rw-r--r--web_src/svg/material-palette.svg1
19 files changed, 1666 insertions, 696 deletions
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