Browse Source
Render the git graph on the server (#12333)
Render the git graph on the server (#12333)
Rendering the git graph on the server means that we can properly track flows and switch from the Canvas implementation to a SVG implementation. * This implementation provides a 16 limited color selection * The uniqued color numbers are also provided * And there is also a monochrome version *In addition is a hover highlight that allows users to highlight commits on the same flow. Closes #12209 Signed-off-by: Andrew Thornton art27@cantab.net Co-authored-by: silverwind <me@silverwind.io>mj-v1.14.3
committed by
GitHub
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1666 additions and 696 deletions
-
3.stylelintrc
-
108modules/gitgraph/graph.go
-
186modules/gitgraph/graph_models.go
-
666modules/gitgraph/graph_test.go
-
338modules/gitgraph/parser.go
-
29modules/templates/helper.go
-
2options/locale/locale_en-US.ini
-
1public/img/svg/material-invert-colors.svg
-
1public/img/svg/material-palette.svg
-
11routers/repo/commit.go
-
45templates/repo/graph.tmpl
-
641web_src/js/features/gitgraph.js
-
8web_src/less/_repository.less
-
256web_src/less/features/gitgraph.less
-
3web_src/less/index.less
-
47web_src/less/themes/theme-arc-green.less
-
15web_src/less/vendor/gitGraph.css
-
1web_src/svg/material-invert-colors.svg
-
1web_src/svg/material-palette.svg
@ -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 |
|||
} |
@ -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) |
|||
} |
|||
} |
@ -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> |