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>tags/v1.13.0-rc1
@@ -1,8 +1,5 @@ | |||||
extends: stylelint-config-standard | extends: stylelint-config-standard | ||||
ignoreFiles: | |||||
- web_src/less/vendor/**/* | |||||
rules: | rules: | ||||
at-rule-empty-line-before: null | at-rule-empty-line-before: null | ||||
block-closing-brace-empty-line-before: null | block-closing-brace-empty-line-before: null | ||||
@@ -16,26 +16,9 @@ import ( | |||||
"code.gitea.io/gitea/modules/setting" | "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 | // 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 { | if page == 0 { | ||||
page = 1 | page = 1 | ||||
@@ -51,7 +34,8 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) { | |||||
"--date=iso", | "--date=iso", | ||||
fmt.Sprintf("--pretty=format:%s", format), | fmt.Sprintf("--pretty=format:%s", format), | ||||
) | ) | ||||
commitGraph := make([]GraphItem, 0, 100) | |||||
graph := NewGraph() | |||||
stderr := new(strings.Builder) | stderr := new(strings.Builder) | ||||
stdoutReader, stdoutWriter, err := os.Pipe() | stdoutReader, stdoutWriter, err := os.Pipe() | ||||
if err != nil { | 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 { | if err := graphCmd.RunInDirTimeoutEnvFullPipelineFunc(nil, -1, r.Path, stdoutWriter, stderr, nil, func(ctx context.Context, cancel context.CancelFunc) error { | ||||
_ = stdoutWriter.Close() | _ = stdoutWriter.Close() | ||||
defer stdoutReader.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() { | for commitsToSkip > 0 && scanner.Scan() { | ||||
line := scanner.Bytes() | line := scanner.Bytes() | ||||
dataIdx := bytes.Index(line, []byte("DATA:")) | dataIdx := bytes.Index(line, []byte("DATA:")) | ||||
if dataIdx < 0 { | |||||
dataIdx = len(line) | |||||
} | |||||
starIdx := bytes.IndexByte(line, '*') | starIdx := bytes.IndexByte(line, '*') | ||||
if starIdx >= 0 && starIdx < dataIdx { | if starIdx >= 0 && starIdx < dataIdx { | ||||
commitsToSkip-- | commitsToSkip-- | ||||
} | } | ||||
parser.ParseGlyphs(line[:dataIdx]) | |||||
} | } | ||||
row := 0 | |||||
// Skip initial non-commit lines | // Skip initial non-commit lines | ||||
for scanner.Scan() { | 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() | cancel() | ||||
return err | return err | ||||
} | } | ||||
commitGraph = append(commitGraph, graphItem) | |||||
break | break | ||||
} | } | ||||
parser.ParseGlyphs(line) | |||||
} | } | ||||
for scanner.Scan() { | 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() | cancel() | ||||
return err | return err | ||||
} | } | ||||
commitGraph = append(commitGraph, graphItem) | |||||
} | } | ||||
return scanner.Err() | return scanner.Err() | ||||
}); err != nil { | }); 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 | |||||
} | } |
@@ -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 | |||||
} |
@@ -5,7 +5,9 @@ | |||||
package gitgraph | package gitgraph | ||||
import ( | import ( | ||||
"bytes" | |||||
"fmt" | "fmt" | ||||
"strings" | |||||
"testing" | "testing" | ||||
"code.gitea.io/gitea/modules/git" | "code.gitea.io/gitea/modules/git" | ||||
@@ -14,40 +16,235 @@ import ( | |||||
func BenchmarkGetCommitGraph(b *testing.B) { | func BenchmarkGetCommitGraph(b *testing.B) { | ||||
currentRepo, err := git.OpenRepository(".") | currentRepo, err := git.OpenRepository(".") | ||||
if err != nil { | |||||
if err != nil || currentRepo == nil { | |||||
b.Error("Could not open repository") | b.Error("Could not open repository") | ||||
} | } | ||||
defer currentRepo.Close() | defer currentRepo.Close() | ||||
for i := 0; i < b.N; i++ { | for i := 0; i < b.N; i++ { | ||||
graph, err := GetCommitGraph(currentRepo, 1) | |||||
graph, err := GetCommitGraph(currentRepo, 1, 0) | |||||
if err != nil { | if err != nil { | ||||
b.Error("Could get commit graph") | b.Error("Could get commit graph") | ||||
} | } | ||||
if len(graph) < 100 { | |||||
if len(graph.Commits) < 100 { | |||||
b.Error("Should get 100 log lines.") | b.Error("Should get 100 log lines.") | ||||
} | } | ||||
} | } | ||||
} | } | ||||
func BenchmarkParseCommitString(b *testing.B) { | 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++ { | 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") | 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") | 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) { | 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 { | tests := []struct { | ||||
shouldPass bool | shouldPass bool | ||||
testName string | testName string | ||||
@@ -62,15 +259,460 @@ func TestCommitStringParsing(t *testing.T) { | |||||
t.Run(test.testName, func(t *testing.T) { | t.Run(test.testName, func(t *testing.T) { | ||||
testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage) | 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 { | if err != nil && test.shouldPass { | ||||
t.Errorf("Could not parse %s", testString) | t.Errorf("Could not parse %s", testString) | ||||
return | 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 = `* | |||||
* | |||||
* | |||||
* | |||||
* | |||||
* | |||||
* | |||||
* | |||||
|\ | |||||
* | | |||||
* | | |||||
* | | |||||
* | | |||||
* | | |||||
| * | |||||
* | | |||||
| * | |||||
| |\ | |||||
* | | | |||||
| | * | |||||
| | |\ | |||||
* | | \ | |||||
|\ \ \ \ | |||||
| * | | | | |||||
| |\| | | | |||||
* | | | | | |||||
|/ / / / | |||||
| | | * | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
|\ \ \ \ | |||||
| | * | | | |||||
| | |\| | | |||||
| | | * | | |||||
| | | | * | |||||
* | | | | | |||||
* | | | | | |||||
* | | | | | |||||
* | | | | | |||||
* | | | | | |||||
|\ \ \ \ \ | |||||
| * | | | | | |||||
|/| | | | | | |||||
| | |/ / / | |||||
| |/| | | | |||||
| | | | * | |||||
| * | | | | |||||
|/| | | | | |||||
| * | | | | |||||
|/| | | | | |||||
| | |/ / | |||||
| |/| | | |||||
| * | | | |||||
| * | | | |||||
| |\ \ \ | |||||
| | * | | | |||||
| |/| | | | |||||
| | | |/ | |||||
| | |/| | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| | * | | |||||
| | |\ \ | |||||
| | | * | | |||||
| | |/| | | |||||
| | | * | | |||||
| | | |\ \ | |||||
| | | | * | | |||||
| | | |/| | | |||||
| | * | | | | |||||
| | * | | | | |||||
| | |\ \ \ \ | |||||
| | | * | | | | |||||
| | |/| | | | | |||||
| | | | | * | | |||||
| | | | |/ / | |||||
* | | | / / | |||||
|/ / / / / | |||||
* | | | | | |||||
|\ \ \ \ \ | |||||
| * | | | | | |||||
|/| | | | | | |||||
| * | | | | | |||||
| * | | | | | |||||
| |\ \ \ \ \ | |||||
| | | * \ \ \ | |||||
| | | |\ \ \ \ | |||||
| | | | * | | | | |||||
| | | |/| | | | | |||||
| | | | | |/ / | |||||
| | | | |/| | | |||||
* | | | | | | | |||||
* | | | | | | | |||||
* | | | | | | | |||||
| | | | * | | | |||||
* | | | | | | | |||||
| | * | | | | | |||||
| |/| | | | | | |||||
* | | | | | | | |||||
| |/ / / / / | |||||
|/| | | | | | |||||
| | | | * | | |||||
| | | |/ / | |||||
| | |/| | | |||||
| * | | | | |||||
| | | | * | |||||
| | * | | | |||||
| | |\ \ \ | |||||
| | | * | | | |||||
| | |/| | | | |||||
| | | |/ / | |||||
| | | * | | |||||
| | * | | | |||||
| | |\ \ \ | |||||
| | | * | | | |||||
| | |/| | | | |||||
| | | |/ / | |||||
| | | * | | |||||
* | | | | | |||||
|\ \ \ \ \ | |||||
| * \ \ \ \ | |||||
| |\ \ \ \ \ | |||||
| | | |/ / / | |||||
| | |/| | | | |||||
| | | | * | | |||||
| | | | * | | |||||
* | | | | | | |||||
* | | | | | | |||||
|/ / / / / | |||||
| | | * | | |||||
* | | | | | |||||
* | | | | | |||||
* | | | | | |||||
* | | | | | |||||
|\ \ \ \ \ | |||||
| * | | | | | |||||
|/| | | | | | |||||
| | * | | | | |||||
| | |\ \ \ \ | |||||
| | | * | | | | |||||
| | |/| | | | | |||||
| |/| | |/ / | |||||
| | | |/| | | |||||
| | | | | * | |||||
| |_|_|_|/ | |||||
|/| | | | | |||||
| | * | | | |||||
| |/ / / | |||||
* | | | | |||||
* | | | | |||||
| | * | | |||||
* | | | | |||||
* | | | | |||||
| * | | | |||||
| | * | | |||||
| * | | | |||||
* | | | | |||||
|\ \ \ \ | |||||
| * | | | | |||||
|/| | | | | |||||
| |/ / / | |||||
| * | | | |||||
| |\ \ \ | |||||
| | * | | | |||||
| |/| | | | |||||
| | |/ / | |||||
| | * | | |||||
| | |\ \ | |||||
| | | * | | |||||
| | |/| | | |||||
* | | | | | |||||
* | | | | | |||||
|\ \ \ \ \ | |||||
| * | | | | | |||||
|/| | | | | | |||||
| | * | | | | |||||
| | * | | | | |||||
| | * | | | | |||||
| |/ / / / | |||||
| * | | | | |||||
| |\ \ \ \ | |||||
| | * | | | | |||||
| |/| | | | | |||||
* | | | | | | |||||
* | | | | | | |||||
* | | | | | | |||||
* | | | | | | |||||
* | | | | | | |||||
| | | | * | | |||||
* | | | | | | |||||
|\ \ \ \ \ \ | |||||
| * | | | | | | |||||
|/| | | | | | | |||||
| | | | | * | | |||||
| | | | |/ / | |||||
* | | | | | | |||||
|\ \ \ \ \ \ | |||||
* | | | | | | | |||||
* | | | | | | | |||||
| | | | * | | | |||||
* | | | | | | | |||||
* | | | | | | | |||||
|\ \ \ \ \ \ \ | |||||
| | |_|_|/ / / | |||||
| |/| | | | | | |||||
| | | | * | | | |||||
| | | | * | | | |||||
| | | | * | | | |||||
| | | | * | | | |||||
| | | | * | | | |||||
| | | | * | | | |||||
| | | |/ / / | |||||
| | | * | | | |||||
| | | * | | | |||||
| | | * | | | |||||
| | |/| | | | |||||
| | | * | | | |||||
| | |/| | | | |||||
| | | |/ / | |||||
| | * | | | |||||
| |/| | | | |||||
| | | * | | |||||
| | |/ / | |||||
| | * | | |||||
| * | | | |||||
| |\ \ \ | |||||
| * | | | | |||||
| | * | | | |||||
| |/| | | | |||||
| | |/ / | |||||
| | * | | |||||
| | |\ \ | |||||
| | * | | | |||||
* | | | | | |||||
|\| | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| | * | | | |||||
| * | | | | |||||
| |\| | | | |||||
| * | | | | |||||
| | * | | | |||||
| | * | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| | * | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| | * | | | |||||
* | | | | | |||||
|\| | | | | |||||
| | * | | | |||||
| * | | | | |||||
| |\| | | | |||||
| | * | | | |||||
| | * | | | |||||
| | * | | | |||||
| | | * | | |||||
* | | | | | |||||
|\| | | | | |||||
| | * | | | |||||
| | |/ / | |||||
| * | | | |||||
| * | | | |||||
| |\| | | |||||
* | | | | |||||
|\| | | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| * | | | |||||
| | * | | |||||
| * | | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| |\| | | |||||
| | * | | |||||
| | |\ \ | |||||
* | | | | | |||||
|\| | | | | |||||
| * | | | | |||||
| |\| | | | |||||
| | * | | | |||||
| | | * | | |||||
| | |/ / | |||||
* | | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| |\| | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| | | * | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
| | | * | |||||
| | | |\ | |||||
* | | | | | |||||
| |_|_|/ | |||||
|/| | | | |||||
| * | | | |||||
| |\| | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| | * | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
|/| | | | |||||
| |/ / | |||||
| * | | |||||
| |\ \ | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| | * | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
| | * | | |||||
| | |\ \ | |||||
| | |/ / | |||||
| |/| | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| |\ \ \ | |||||
| * | | | | |||||
| * | | | | |||||
| | | * | | |||||
| * | | | | |||||
| * | | | | |||||
| | |/ / | |||||
| |/| | | |||||
| | * | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| |\ \ \ | |||||
* | | | | | |||||
|\| | | | | |||||
| * | | | | |||||
| * | | | | |||||
* | | | | | |||||
* | | | | | |||||
|\| | | | | |||||
| | | | * | |||||
| | | | |\ | |||||
| |_|_|_|/ | |||||
|/| | | | | |||||
| * | | | | |||||
* | | | | | |||||
* | | | | | |||||
|\| | | | | |||||
| * | | | | |||||
| |\ \ \ \ | |||||
| | | |/ / | |||||
| | |/| | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| * | | | | |||||
| | * | | | |||||
| | | * | | |||||
| | |/ / | |||||
| |/| | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
* | | | | |||||
| | | * | |||||
* | | | | |||||
|\| | | | |||||
| * | | | |||||
| * | | | |||||
| * | | | |||||
` |
@@ -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) | |||||
} | |||||
} |
@@ -99,8 +99,19 @@ func NewFuncMap() []template.FuncMap { | |||||
"Subtract": base.Subtract, | "Subtract": base.Subtract, | ||||
"EntryIcon": base.EntryIcon, | "EntryIcon": base.EntryIcon, | ||||
"MigrationIcon": MigrationIcon, | "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, | "ActionIcon": ActionIcon, | ||||
"DateFmtLong": func(t time.Time) string { | "DateFmtLong": func(t time.Time) string { | ||||
@@ -437,6 +448,20 @@ func NewTextFuncMap() []texttmpl.FuncMap { | |||||
} | } | ||||
return float32(n) * 100 / float32(sum) | 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 | |||||
}, | |||||
}} | }} | ||||
} | } | ||||
@@ -775,6 +775,8 @@ audio_not_supported_in_browser = Your browser does not support the HTML5 'audio' | |||||
stored_lfs = Stored with Git LFS | stored_lfs = Stored with Git LFS | ||||
symbolic_link = Symbolic link | symbolic_link = Symbolic link | ||||
commit_graph = Commit Graph | commit_graph = Commit Graph | ||||
commit_graph.monochrome = Mono | |||||
commit_graph.color = Color | |||||
blame = Blame | blame = Blame | ||||
normal_view = Normal View | normal_view = Normal View | ||||
line = line | line = line | ||||
@@ -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> |
@@ -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> |
@@ -90,6 +90,11 @@ func Commits(ctx *context.Context) { | |||||
func Graph(ctx *context.Context) { | func Graph(ctx *context.Context) { | ||||
ctx.Data["PageIsCommits"] = true | ctx.Data["PageIsCommits"] = true | ||||
ctx.Data["PageIsViewCode"] = 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() | commitsCount, err := ctx.Repo.GetCommitsCount() | ||||
if err != nil { | if err != nil { | ||||
@@ -105,7 +110,7 @@ func Graph(ctx *context.Context) { | |||||
page := ctx.QueryInt("page") | page := ctx.QueryInt("page") | ||||
graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page) | |||||
graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("GetCommitGraph", err) | ctx.ServerError("GetCommitGraph", err) | ||||
return | return | ||||
@@ -116,7 +121,9 @@ func Graph(ctx *context.Context) { | |||||
ctx.Data["Reponame"] = ctx.Repo.Repository.Name | ctx.Data["Reponame"] = ctx.Repo.Repository.Name | ||||
ctx.Data["CommitCount"] = commitsCount | ctx.Data["CommitCount"] = commitsCount | ||||
ctx.Data["Branch"] = ctx.Repo.BranchName | 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) | ctx.HTML(200, tplGraph) | ||||
} | } | ||||
@@ -2,21 +2,44 @@ | |||||
<div class="repository commits"> | <div class="repository commits"> | ||||
{{template "repo/header" .}} | {{template "repo/header" .}} | ||||
<div class="ui container"> | <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"> | <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> | ||||
<div id="rev-container"> | <div id="rev-container"> | ||||
<ul id="rev-list"> | <ul id="rev-list"> | ||||
{{ range .Graph }} | |||||
<li> | |||||
{{ range .Graph.Commits }} | |||||
<li id="commit-{{.Rev}}" data-flow="{{.Flow}}"> | |||||
{{ if .OnlyRelation }} | {{ if .OnlyRelation }} | ||||
<span /> | <span /> | ||||
{{ else }} | {{ else }} | ||||
@@ -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 { | } 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'); | |||||
}); | |||||
} | } |
@@ -3082,11 +3082,3 @@ tbody.commit-list { | |||||
} | } | ||||
} | } | ||||
} | } | ||||
#git-graph-container { | |||||
display: none; | |||||
} | |||||
#git-graph-container.in { | |||||
display: block; | |||||
} |
@@ -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; | |||||
} | |||||
} | |||||
} | |||||
} |
@@ -1,5 +1,6 @@ | |||||
@import "~font-awesome/css/font-awesome.css"; | @import "~font-awesome/css/font-awesome.css"; | ||||
@import "./vendor/gitGraph.css"; | |||||
@import "./features/gitgraph.less"; | |||||
@import "./features/animations.less"; | @import "./features/animations.less"; | ||||
@import "./markdown/mermaid.less"; | @import "./markdown/mermaid.less"; | ||||
@@ -919,11 +919,17 @@ a.ui.basic.green.label:hover { | |||||
.ui.active.button:active, | .ui.active.button:active, | ||||
.ui.button:active, | .ui.button:active, | ||||
.ui.button:focus { | |||||
.ui.button:focus, | |||||
.ui.active.button { | |||||
background-color: #2e3e4e; | background-color: #2e3e4e; | ||||
color: #dbdbdb; | color: #dbdbdb; | ||||
} | } | ||||
.ui.active.button:hover { | |||||
background-color: #475e75; | |||||
color: #dbdbdb; | |||||
} | |||||
.ui.dropdown .menu .selected.item, | .ui.dropdown .menu .selected.item, | ||||
.ui.dropdown.selected { | .ui.dropdown.selected { | ||||
color: #dbdbdb; | color: #dbdbdb; | ||||
@@ -1921,6 +1927,45 @@ footer .container .links > * { | |||||
color: #2a2e3a; | 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 { | .mermaid-chart { | ||||
filter: invert(84%) hue-rotate(180deg); | filter: invert(84%) hue-rotate(180deg); | ||||
} | } | ||||
@@ -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;} |
@@ -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> |
@@ -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> |