From 2c1ae6c82d0b3fa62dda7e6a30fb91e27aba6e04 Mon Sep 17 00:00:00 2001 From: zeripath Date: Thu, 6 Aug 2020 09:04:08 +0100 Subject: [PATCH] Render the git graph on the server (#12333) Rendering the git graph on the server means that we can properly track flows and switch from the Canvas implementation to a SVG implementation. * This implementation provides a 16 limited color selection * The uniqued color numbers are also provided * And there is also a monochrome version *In addition is a hover highlight that allows users to highlight commits on the same flow. Closes #12209 Signed-off-by: Andrew Thornton art27@cantab.net Co-authored-by: silverwind --- .stylelintrc | 3 - modules/gitgraph/graph.go | 108 +--- modules/gitgraph/graph_models.go | 186 ++++++ modules/gitgraph/graph_test.go | 666 +++++++++++++++++++++- modules/gitgraph/parser.go | 338 +++++++++++ modules/templates/helper.go | 29 +- options/locale/locale_en-US.ini | 2 + public/img/svg/material-invert-colors.svg | 1 + public/img/svg/material-palette.svg | 1 + routers/repo/commit.go | 11 +- templates/repo/graph.tmpl | 45 +- web_src/js/features/gitgraph.js | 641 +++------------------ web_src/less/_repository.less | 8 - web_src/less/features/gitgraph.less | 256 +++++++++ web_src/less/index.less | 3 +- web_src/less/themes/theme-arc-green.less | 47 +- web_src/less/vendor/gitGraph.css | 15 - web_src/svg/material-invert-colors.svg | 1 + web_src/svg/material-palette.svg | 1 + 19 files changed, 1666 insertions(+), 696 deletions(-) create mode 100644 modules/gitgraph/graph_models.go create mode 100644 modules/gitgraph/parser.go create mode 100644 public/img/svg/material-invert-colors.svg create mode 100644 public/img/svg/material-palette.svg create mode 100644 web_src/less/features/gitgraph.less delete mode 100644 web_src/less/vendor/gitGraph.css create mode 100644 web_src/svg/material-invert-colors.svg create mode 100644 web_src/svg/material-palette.svg diff --git a/.stylelintrc b/.stylelintrc index 102b90f1fdaf..fcc33edeff98 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,8 +1,5 @@ extends: stylelint-config-standard -ignoreFiles: - - web_src/less/vendor/**/* - rules: at-rule-empty-line-before: null block-closing-brace-empty-line-before: null diff --git a/modules/gitgraph/graph.go b/modules/gitgraph/graph.go index 4ba110c70615..257e4f3af0de 100644 --- a/modules/gitgraph/graph.go +++ b/modules/gitgraph/graph.go @@ -16,26 +16,9 @@ import ( "code.gitea.io/gitea/modules/setting" ) -// GraphItem represent one commit, or one relation in timeline -type GraphItem struct { - GraphAcii string - Relation string - Branch string - Rev string - Date string - Author string - AuthorEmail string - ShortRev string - Subject string - OnlyRelation bool -} - -// GraphItems is a list of commits from all branches -type GraphItems []GraphItem - // GetCommitGraph return a list of commit (GraphItems) from all branches -func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) { - format := "DATA:|%d|%H|%ad|%an|%ae|%h|%s" +func GetCommitGraph(r *git.Repository, page int, maxAllowedColors int) (*Graph, error) { + format := "DATA:%d|%H|%ad|%an|%ae|%h|%s" if page == 0 { page = 1 @@ -51,7 +34,8 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) { "--date=iso", fmt.Sprintf("--pretty=format:%s", format), ) - commitGraph := make([]GraphItem, 0, 100) + graph := NewGraph() + stderr := new(strings.Builder) stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { @@ -64,86 +48,56 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) { if err := graphCmd.RunInDirTimeoutEnvFullPipelineFunc(nil, -1, r.Path, stdoutWriter, stderr, nil, func(ctx context.Context, cancel context.CancelFunc) error { _ = stdoutWriter.Close() defer stdoutReader.Close() + parser := &Parser{} + parser.firstInUse = -1 + parser.maxAllowedColors = maxAllowedColors + if maxAllowedColors > 0 { + parser.availableColors = make([]int, maxAllowedColors) + for i := range parser.availableColors { + parser.availableColors[i] = i + 1 + } + } else { + parser.availableColors = []int{1, 2} + } for commitsToSkip > 0 && scanner.Scan() { line := scanner.Bytes() dataIdx := bytes.Index(line, []byte("DATA:")) + if dataIdx < 0 { + dataIdx = len(line) + } starIdx := bytes.IndexByte(line, '*') if starIdx >= 0 && starIdx < dataIdx { commitsToSkip-- } + parser.ParseGlyphs(line[:dataIdx]) } + + row := 0 + // Skip initial non-commit lines for scanner.Scan() { - if bytes.IndexByte(scanner.Bytes(), '*') >= 0 { - line := scanner.Text() - graphItem, err := graphItemFromString(line, r) - if err != nil { + line := scanner.Bytes() + if bytes.IndexByte(line, '*') >= 0 { + if err := parser.AddLineToGraph(graph, row, line); err != nil { cancel() return err } - commitGraph = append(commitGraph, graphItem) break } + parser.ParseGlyphs(line) } for scanner.Scan() { - line := scanner.Text() - graphItem, err := graphItemFromString(line, r) - if err != nil { + row++ + line := scanner.Bytes() + if err := parser.AddLineToGraph(graph, row, line); err != nil { cancel() return err } - commitGraph = append(commitGraph, graphItem) } return scanner.Err() }); err != nil { - return commitGraph, err + return graph, 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 gi, nil + return graph, nil } diff --git a/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go new file mode 100644 index 000000000000..ea6ba960846d --- /dev/null +++ b/modules/gitgraph/graph_models.go @@ -0,0 +1,186 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package gitgraph + +import ( + "bytes" + "fmt" +) + +// NewGraph creates a basic graph +func NewGraph() *Graph { + graph := &Graph{} + graph.relationCommit = &Commit{ + Row: -1, + Column: -1, + } + graph.Flows = map[int64]*Flow{} + return graph +} + +// Graph represents a collection of flows +type Graph struct { + Flows map[int64]*Flow + Commits []*Commit + MinRow int + MinColumn int + MaxRow int + MaxColumn int + relationCommit *Commit +} + +// Width returns the width of the graph +func (graph *Graph) Width() int { + return graph.MaxColumn - graph.MinColumn + 1 +} + +// Height returns the height of the graph +func (graph *Graph) Height() int { + return graph.MaxRow - graph.MinRow + 1 +} + +// AddGlyph adds glyph to flows +func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) { + flow, ok := graph.Flows[flowID] + if !ok { + flow = NewFlow(flowID, color, row, column) + graph.Flows[flowID] = flow + } + flow.AddGlyph(row, column, glyph) + + if row < graph.MinRow { + graph.MinRow = row + } + if row > graph.MaxRow { + graph.MaxRow = row + } + if column < graph.MinColumn { + graph.MinColumn = column + } + if column > graph.MaxColumn { + graph.MaxColumn = column + } +} + +// AddCommit adds a commit at row, column on flowID with the provided data +func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error { + commit, err := NewCommit(row, column, data) + if err != nil { + return err + } + commit.Flow = flowID + graph.Commits = append(graph.Commits, commit) + + graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit) + return nil +} + +// NewFlow creates a new flow +func NewFlow(flowID int64, color, row, column int) *Flow { + return &Flow{ + ID: flowID, + ColorNumber: color, + MinRow: row, + MinColumn: column, + MaxRow: row, + MaxColumn: column, + } +} + +// Flow represents a series of glyphs +type Flow struct { + ID int64 + ColorNumber int + Glyphs []Glyph + Commits []*Commit + MinRow int + MinColumn int + MaxRow int + MaxColumn int +} + +// Color16 wraps the color numbers around mod 16 +func (flow *Flow) Color16() int { + return flow.ColorNumber % 16 +} + +// AddGlyph adds glyph at row and column +func (flow *Flow) AddGlyph(row, column int, glyph byte) { + if row < flow.MinRow { + flow.MinRow = row + } + if row > flow.MaxRow { + flow.MaxRow = row + } + if column < flow.MinColumn { + flow.MinColumn = column + } + if column > flow.MaxColumn { + flow.MaxColumn = column + } + + flow.Glyphs = append(flow.Glyphs, Glyph{ + row, + column, + glyph, + }) +} + +// Glyph represents a co-ordinate and glyph +type Glyph struct { + Row int + Column int + Glyph byte +} + +// RelationCommit represents an empty relation commit +var RelationCommit = &Commit{ + Row: -1, +} + +// NewCommit creates a new commit from a provided line +func NewCommit(row, column int, line []byte) (*Commit, error) { + data := bytes.SplitN(line, []byte("|"), 7) + if len(data) < 7 { + return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line)) + } + return &Commit{ + Row: row, + Column: column, + // 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1) + Branch: string(data[0]), + // 1 matches git log --pretty=format:%H => commit hash + Rev: string(data[1]), + // 2 matches git log --pretty=format:%ad => author date (format respects --date= option) + Date: string(data[2]), + // 3 matches git log --pretty=format:%an => author name + Author: string(data[3]), + // 4 matches git log --pretty=format:%ae => author email + AuthorEmail: string(data[4]), + // 5 matches git log --pretty=format:%h => abbreviated commit hash + ShortRev: string(data[5]), + // 6 matches git log --pretty=format:%s => subject + Subject: string(data[6]), + }, nil +} + +// Commit represents a commit at co-ordinate X, Y with the data +type Commit struct { + Flow int64 + Row int + Column int + Branch string + Rev string + Date string + Author string + AuthorEmail string + ShortRev string + Subject string +} + +// OnlyRelation returns whether this a relation only commit +func (c *Commit) OnlyRelation() bool { + return c.Row == -1 +} diff --git a/modules/gitgraph/graph_test.go b/modules/gitgraph/graph_test.go index a2c7f447b685..ca9d653ceed3 100644 --- a/modules/gitgraph/graph_test.go +++ b/modules/gitgraph/graph_test.go @@ -5,7 +5,9 @@ package gitgraph import ( + "bytes" "fmt" + "strings" "testing" "code.gitea.io/gitea/modules/git" @@ -14,40 +16,235 @@ import ( func BenchmarkGetCommitGraph(b *testing.B) { currentRepo, err := git.OpenRepository(".") - if err != nil { + if err != nil || currentRepo == nil { b.Error("Could not open repository") } defer currentRepo.Close() for i := 0; i < b.N; i++ { - graph, err := GetCommitGraph(currentRepo, 1) + graph, err := GetCommitGraph(currentRepo, 1, 0) if err != nil { b.Error("Could get commit graph") } - if len(graph) < 100 { + if len(graph.Commits) < 100 { b.Error("Should get 100 log lines.") } } } func BenchmarkParseCommitString(b *testing.B) { - testString := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph" + testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph" + parser := &Parser{} + parser.Reset() for i := 0; i < b.N; i++ { - graphItem, err := graphItemFromString(testString, nil) - if err != nil { + parser.Reset() + graph := NewGraph() + if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil { b.Error("could not parse teststring") } - - if graphItem.Author != "Kjell Kvinge" { + if graph.Flows[1].Commits[0].Author != "Kjell Kvinge" { b.Error("Did not get expected data") } } } +func BenchmarkParseGlyphs(b *testing.B) { + parser := &Parser{} + parser.Reset() + tgBytes := []byte(testglyphs) + tg := tgBytes + idx := bytes.Index(tg, []byte("\n")) + for i := 0; i < b.N; i++ { + parser.Reset() + tg = tgBytes + idx = bytes.Index(tg, []byte("\n")) + for idx > 0 { + parser.ParseGlyphs(tg[:idx]) + tg = tg[idx+1:] + idx = bytes.Index(tg, []byte("\n")) + } + } +} + +func TestReleaseUnusedColors(t *testing.T) { + testcases := []struct { + availableColors []int + oldColors []int + firstInUse int // these values have to be either be correct or suggest less is + firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it + }{ + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{1, 1, 1, 1, 1}, + firstAvailable: -1, + firstInUse: 1, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{1, 2, 3, 4}, + firstAvailable: 6, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0}, + firstAvailable: 6, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7}, + oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7}, + firstAvailable: -1, + firstInUse: 0, + }, + { + availableColors: []int{1, 2, 3, 4, 5, 6, 7}, + oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7}, + firstAvailable: -1, + firstInUse: 0, + }, + } + for _, testcase := range testcases { + parser := &Parser{} + parser.Reset() + parser.availableColors = append([]int{}, testcase.availableColors...) + parser.oldColors = append(parser.oldColors, testcase.oldColors...) + parser.firstAvailable = testcase.firstAvailable + parser.firstInUse = testcase.firstInUse + parser.releaseUnusedColors() + + if parser.firstAvailable == -1 { + // All in use + for _, color := range parser.availableColors { + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if !found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } else if parser.firstInUse != -1 { + // Some in use + for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) { + color := parser.availableColors[i] + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if !found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) { + color := parser.availableColors[i] + found := false + for _, oldColor := range parser.oldColors { + if oldColor == color { + found = true + break + } + } + if found { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } else { + // None in use + for _, color := range parser.oldColors { + if color != 0 { + t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is", + testcase.availableColors, + testcase.oldColors, + testcase.firstAvailable, + testcase.firstInUse, + parser.availableColors, + parser.oldColors, + parser.firstAvailable, + parser.firstInUse, + color) + } + } + } + } +} + +func TestParseGlyphs(t *testing.T) { + parser := &Parser{} + parser.Reset() + tgBytes := []byte(testglyphs) + tg := tgBytes + idx := bytes.Index(tg, []byte("\n")) + row := 0 + for idx > 0 { + parser.ParseGlyphs(tg[:idx]) + tg = tg[idx+1:] + idx = bytes.Index(tg, []byte("\n")) + if parser.flows[0] != 1 { + t.Errorf("First column flow should be 1 but was %d", parser.flows[0]) + } + colorToFlow := map[int]int64{} + flowToColor := map[int64]int{} + + for i, flow := range parser.flows { + if flow == 0 { + continue + } + color := parser.colors[i] + + if fColor, in := flowToColor[flow]; in && fColor != color { + t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor) + } + flowToColor[flow] = color + if cFlow, in := colorToFlow[color]; in && cFlow != flow { + t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow) + } + colorToFlow[color] = flow + } + row++ + } + if len(parser.availableColors) != 9 { + t.Errorf("Expected 9 colors but have %d", len(parser.availableColors)) + } +} + func TestCommitStringParsing(t *testing.T) { - dataFirstPart := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|" + dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|" tests := []struct { shouldPass bool testName string @@ -62,15 +259,460 @@ func TestCommitStringParsing(t *testing.T) { t.Run(test.testName, func(t *testing.T) { testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage) - graphItem, err := graphItemFromString(testString, nil) + idx := strings.Index(testString, "DATA:") + commit, err := NewCommit(0, 0, []byte(testString[idx+5:])) if err != nil && test.shouldPass { t.Errorf("Could not parse %s", testString) return } - if test.commitMessage != graphItem.Subject { - t.Errorf("%s does not match %s", test.commitMessage, graphItem.Subject) + if test.commitMessage != commit.Subject { + t.Errorf("%s does not match %s", test.commitMessage, commit.Subject) } }) } } + +var testglyphs = `* +* +* +* +* +* +* +* +|\ +* | +* | +* | +* | +* | +| * +* | +| * +| |\ +* | | +| | * +| | |\ +* | | \ +|\ \ \ \ +| * | | | +| |\| | | +* | | | | +|/ / / / +| | | * +| * | | +| * | | +| * | | +* | | | +* | | | +* | | | +* | | | +* | | | +|\ \ \ \ +| | * | | +| | |\| | +| | | * | +| | | | * +* | | | | +* | | | | +* | | | | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | |/ / / +| |/| | | +| | | | * +| * | | | +|/| | | | +| * | | | +|/| | | | +| | |/ / +| |/| | +| * | | +| * | | +| |\ \ \ +| | * | | +| |/| | | +| | | |/ +| | |/| +| * | | +| * | | +| * | | +| | * | +| | |\ \ +| | | * | +| | |/| | +| | | * | +| | | |\ \ +| | | | * | +| | | |/| | +| | * | | | +| | * | | | +| | |\ \ \ \ +| | | * | | | +| | |/| | | | +| | | | | * | +| | | | |/ / +* | | | / / +|/ / / / / +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| * | | | | +| * | | | | +| |\ \ \ \ \ +| | | * \ \ \ +| | | |\ \ \ \ +| | | | * | | | +| | | |/| | | | +| | | | | |/ / +| | | | |/| | +* | | | | | | +* | | | | | | +* | | | | | | +| | | | * | | +* | | | | | | +| | * | | | | +| |/| | | | | +* | | | | | | +| |/ / / / / +|/| | | | | +| | | | * | +| | | |/ / +| | |/| | +| * | | | +| | | | * +| | * | | +| | |\ \ \ +| | | * | | +| | |/| | | +| | | |/ / +| | | * | +| | * | | +| | |\ \ \ +| | | * | | +| | |/| | | +| | | |/ / +| | | * | +* | | | | +|\ \ \ \ \ +| * \ \ \ \ +| |\ \ \ \ \ +| | | |/ / / +| | |/| | | +| | | | * | +| | | | * | +* | | | | | +* | | | | | +|/ / / / / +| | | * | +* | | | | +* | | | | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | * | | | +| | |\ \ \ \ +| | | * | | | +| | |/| | | | +| |/| | |/ / +| | | |/| | +| | | | | * +| |_|_|_|/ +|/| | | | +| | * | | +| |/ / / +* | | | +* | | | +| | * | +* | | | +* | | | +| * | | +| | * | +| * | | +* | | | +|\ \ \ \ +| * | | | +|/| | | | +| |/ / / +| * | | +| |\ \ \ +| | * | | +| |/| | | +| | |/ / +| | * | +| | |\ \ +| | | * | +| | |/| | +* | | | | +* | | | | +|\ \ \ \ \ +| * | | | | +|/| | | | | +| | * | | | +| | * | | | +| | * | | | +| |/ / / / +| * | | | +| |\ \ \ \ +| | * | | | +| |/| | | | +* | | | | | +* | | | | | +* | | | | | +* | | | | | +* | | | | | +| | | | * | +* | | | | | +|\ \ \ \ \ \ +| * | | | | | +|/| | | | | | +| | | | | * | +| | | | |/ / +* | | | | | +|\ \ \ \ \ \ +* | | | | | | +* | | | | | | +| | | | * | | +* | | | | | | +* | | | | | | +|\ \ \ \ \ \ \ +| | |_|_|/ / / +| |/| | | | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | | * | | +| | | |/ / / +| | | * | | +| | | * | | +| | | * | | +| | |/| | | +| | | * | | +| | |/| | | +| | | |/ / +| | * | | +| |/| | | +| | | * | +| | |/ / +| | * | +| * | | +| |\ \ \ +| * | | | +| | * | | +| |/| | | +| | |/ / +| | * | +| | |\ \ +| | * | | +* | | | | +|\| | | | +| * | | | +| * | | | +| * | | | +| | * | | +| * | | | +| |\| | | +| * | | | +| | * | | +| | * | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +* | | | | +|\| | | | +| | * | | +| * | | | +| |\| | | +| | * | | +| | * | | +| | * | | +| | | * | +* | | | | +|\| | | | +| | * | | +| | |/ / +| * | | +| * | | +| |\| | +* | | | +|\| | | +| | * | +| | * | +| | * | +| * | | +| | * | +| * | | +| | * | +| | * | +| | * | +| * | | +| * | | +| * | | +| * | | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| |\| | +| | * | +| | |\ \ +* | | | | +|\| | | | +| * | | | +| |\| | | +| | * | | +| | | * | +| | |/ / +* | | | +* | | | +|\| | | +| * | | +| |\| | +| | * | +| | * | +| | * | +| | | * +* | | | +|\| | | +| * | | +| * | | +| | | * +| | | |\ +* | | | | +| |_|_|/ +|/| | | +| * | | +| |\| | +| | * | +| | * | +| | * | +| | * | +| | * | +| * | | +* | | | +|\| | | +| * | | +|/| | | +| |/ / +| * | +| |\ \ +| * | | +| * | | +* | | | +|\| | | +| | * | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| * | | +| | * | +| | |\ \ +| | |/ / +| |/| | +| * | | +* | | | +|\| | | +| * | | +* | | | +|\| | | +| * | | +| |\ \ \ +| * | | | +| * | | | +| | | * | +| * | | | +| * | | | +| | |/ / +| |/| | +| | * | +* | | | +|\| | | +| * | | +| * | | +| * | | +| * | | +| * | | +| |\ \ \ +* | | | | +|\| | | | +| * | | | +| * | | | +* | | | | +* | | | | +|\| | | | +| | | | * +| | | | |\ +| |_|_|_|/ +|/| | | | +| * | | | +* | | | | +* | | | | +|\| | | | +| * | | | +| |\ \ \ \ +| | | |/ / +| | |/| | +| * | | | +| * | | | +| * | | | +| * | | | +| | * | | +| | | * | +| | |/ / +| |/| | +* | | | +|\| | | +| * | | +| * | | +| * | | +| * | | +| * | | +* | | | +|\| | | +| * | | +| * | | +* | | | +| * | | +| * | | +| * | | +* | | | +* | | | +* | | | +|\| | | +| * | | +* | | | +* | | | +* | | | +* | | | +| | | * +* | | | +|\| | | +| * | | +| * | | +| * | | +` diff --git a/modules/gitgraph/parser.go b/modules/gitgraph/parser.go new file mode 100644 index 000000000000..62e05056520f --- /dev/null +++ b/modules/gitgraph/parser.go @@ -0,0 +1,338 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package gitgraph + +import ( + "bytes" + "fmt" +) + +// Parser represents a git graph parser. It is stateful containing the previous +// glyphs, detected flows and color assignments. +type Parser struct { + glyphs []byte + oldGlyphs []byte + flows []int64 + oldFlows []int64 + maxFlow int64 + colors []int + oldColors []int + availableColors []int + nextAvailable int + firstInUse int + firstAvailable int + maxAllowedColors int +} + +// Reset resets the internal parser state. +func (parser *Parser) Reset() { + parser.glyphs = parser.glyphs[0:0] + parser.oldGlyphs = parser.oldGlyphs[0:0] + parser.flows = parser.flows[0:0] + parser.oldFlows = parser.oldFlows[0:0] + parser.maxFlow = 0 + parser.colors = parser.colors[0:0] + parser.oldColors = parser.oldColors[0:0] + parser.availableColors = parser.availableColors[0:0] + parser.availableColors = append(parser.availableColors, 1, 2) + parser.nextAvailable = 0 + parser.firstInUse = -1 + parser.firstAvailable = 0 + parser.maxAllowedColors = 0 +} + +// AddLineToGraph adds the line as a row to the graph +func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { + idx := bytes.Index(line, []byte("DATA:")) + if idx < 0 { + parser.ParseGlyphs(line) + } else { + parser.ParseGlyphs(line[:idx]) + } + + var err error + commitDone := false + + for column, glyph := range parser.glyphs { + if glyph == ' ' { + continue + } + + flowID := parser.flows[column] + + graph.AddGlyph(row, column, flowID, parser.colors[column], glyph) + + if glyph == '*' { + if commitDone { + if err != nil { + err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err) + } else { + err = fmt.Errorf("double commit on line %d: %s", row, string(line)) + } + } + commitDone = true + if idx < 0 { + if err != nil { + err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err) + } else { + err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line)) + } + continue + } + err2 := graph.AddCommit(row, column, flowID, line[idx+5:]) + if err != nil && err2 != nil { + err = fmt.Errorf("%v %w", err2, err) + continue + } else if err2 != nil { + err = err2 + continue + } + } + } + if !commitDone { + graph.Commits = append(graph.Commits, RelationCommit) + } + return err +} + +func (parser *Parser) releaseUnusedColors() { + if parser.firstInUse > -1 { + // Here we step through the old colors, searching for them in the + // "in-use" section of availableColors (that is, the colors between + // firstInUse and firstAvailable) + // Ensure that the benchmarks are not worsened with proposed changes + stepstaken := 0 + position := parser.firstInUse + for _, color := range parser.oldColors { + if color == 0 { + continue + } + found := false + i := position + for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ { + colorToCheck := parser.availableColors[i] + if colorToCheck == color { + found = true + break + } + i = (i + 1) % len(parser.availableColors) + } + if !found { + // Duplicate color + continue + } + // Swap them around + parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position] + stepstaken++ + position = (parser.firstInUse + stepstaken) % len(parser.availableColors) + if position == parser.firstAvailable || stepstaken == len(parser.availableColors) { + break + } + } + if stepstaken == len(parser.availableColors) { + parser.firstAvailable = -1 + } else { + parser.firstAvailable = position + if parser.nextAvailable == -1 { + parser.nextAvailable = parser.firstAvailable + } + } + } +} + +// ParseGlyphs parses the provided glyphs and sets the internal state +func (parser *Parser) ParseGlyphs(glyphs []byte) { + + // Clean state for parsing this row + parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs + parser.glyphs = parser.glyphs[0:0] + parser.flows, parser.oldFlows = parser.oldFlows, parser.flows + parser.flows = parser.flows[0:0] + parser.colors, parser.oldColors = parser.oldColors, parser.colors + + // Ensure we have enough flows and colors + parser.colors = parser.colors[0:0] + for range glyphs { + parser.flows = append(parser.flows, 0) + parser.colors = append(parser.colors, 0) + } + + // Copy the provided glyphs in to state.glyphs for safekeeping + parser.glyphs = append(parser.glyphs, glyphs...) + + // release unused colors + parser.releaseUnusedColors() + + for i := len(glyphs) - 1; i >= 0; i-- { + glyph := glyphs[i] + switch glyph { + case '|': + fallthrough + case '*': + parser.setUpFlow(i) + case '/': + parser.setOutFlow(i) + case '\\': + parser.setInFlow(i) + case '_': + parser.setRightFlow(i) + case '.': + fallthrough + case '-': + parser.setLeftFlow(i) + case ' ': + // no-op + default: + parser.newFlow(i) + } + } +} + +func (parser *Parser) takePreviousFlow(i, j int) { + if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 { + parser.flows[i] = parser.oldFlows[j] + parser.oldFlows[j] = 0 + parser.colors[i] = parser.oldColors[j] + parser.oldColors[j] = 0 + } else { + parser.newFlow(i) + } +} + +func (parser *Parser) takeCurrentFlow(i, j int) { + if j < len(parser.flows) && parser.flows[j] > 0 { + parser.flows[i] = parser.flows[j] + parser.colors[i] = parser.colors[j] + } else { + parser.newFlow(i) + } +} + +func (parser *Parser) newFlow(i int) { + parser.maxFlow++ + parser.flows[i] = parser.maxFlow + + // Now give this flow a color + if parser.nextAvailable == -1 { + next := len(parser.availableColors) + if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors { + parser.nextAvailable = next + parser.firstAvailable = next + parser.availableColors = append(parser.availableColors, next+1) + } + } + parser.colors[i] = parser.availableColors[parser.nextAvailable] + if parser.firstInUse == -1 { + parser.firstInUse = parser.nextAvailable + } + parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable] + + parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors) + parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors) + + if parser.nextAvailable == parser.firstInUse { + parser.nextAvailable = parser.firstAvailable + } + if parser.nextAvailable == parser.firstInUse { + parser.nextAvailable = -1 + parser.firstAvailable = -1 + } +} + +// setUpFlow handles '|' or '*' +func (parser *Parser) setUpFlow(i int) { + // In preference order: + // + // Previous Row: '\? ' ' |' ' /' + // Current Row: ' | ' ' |' ' | ' + if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' { + parser.takePreviousFlow(i, i-1) + } else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') { + parser.takePreviousFlow(i, i) + } else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' { + parser.takePreviousFlow(i, i+1) + } else { + parser.newFlow(i) + } +} + +// setOutFlow handles '/' +func (parser *Parser) setOutFlow(i int) { + // In preference order: + // + // Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\' + // Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/' + if i+2 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') && + (parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') && + i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') { + parser.takePreviousFlow(i, i+2) + } else if i+1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' || + parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') { + parser.takePreviousFlow(i, i+1) + if parser.oldGlyphs[i+1] == '/' { + parser.glyphs[i] = '|' + } + } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' { + parser.takePreviousFlow(i, i) + } else { + parser.newFlow(i) + } +} + +// setInFlow handles '\' +func (parser *Parser) setInFlow(i int) { + // In preference order: + // + // Previous Row: '| ' '-. ' '| ' '\ ' '/' '---' + // Current Row: '|\' ' \' ' \' ' \' '\' ' \ ' + if i > 0 && i-1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') && + (parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') { + parser.newFlow(i) + } else if i > 0 && i-1 < len(parser.oldGlyphs) && + (parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' || + parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') { + parser.takePreviousFlow(i, i-1) + if parser.oldGlyphs[i-1] == '\\' { + parser.glyphs[i] = '|' + } + } else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' { + parser.takePreviousFlow(i, i) + } else { + parser.newFlow(i) + } +} + +// setRightFlow handles '_' +func (parser *Parser) setRightFlow(i int) { + // In preference order: + // + // Current Row: '__' '_/' '_|_' '_|/' + if i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') { + parser.takeCurrentFlow(i, i+1) + } else if i+2 < len(parser.glyphs) && + (parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') && + (parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') { + parser.takeCurrentFlow(i, i+2) + } else { + parser.newFlow(i) + } +} + +// setLeftFlow handles '----.' +func (parser *Parser) setLeftFlow(i int) { + if parser.glyphs[i] == '.' { + parser.newFlow(i) + } else if i+1 < len(parser.glyphs) && + (parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') { + parser.takeCurrentFlow(i, i+1) + } else { + parser.newFlow(i) + } +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 8f3fba618d3e..718fe8f2672e 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -99,8 +99,19 @@ func NewFuncMap() []template.FuncMap { "Subtract": base.Subtract, "EntryIcon": base.EntryIcon, "MigrationIcon": MigrationIcon, - "Add": func(a, b int) int { - return a + b + "Add": func(a ...int) int { + sum := 0 + for _, val := range a { + sum += val + } + return sum + }, + "Mul": func(a ...int) int { + sum := 1 + for _, val := range a { + sum *= val + } + return sum }, "ActionIcon": ActionIcon, "DateFmtLong": func(t time.Time) string { @@ -437,6 +448,20 @@ func NewTextFuncMap() []texttmpl.FuncMap { } return float32(n) * 100 / float32(sum) }, + "Add": func(a ...int) int { + sum := 0 + for _, val := range a { + sum += val + } + return sum + }, + "Mul": func(a ...int) int { + sum := 1 + for _, val := range a { + sum *= val + } + return sum + }, }} } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 751cce65835f..6dedae34227d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -775,6 +775,8 @@ audio_not_supported_in_browser = Your browser does not support the HTML5 'audio' stored_lfs = Stored with Git LFS symbolic_link = Symbolic link commit_graph = Commit Graph +commit_graph.monochrome = Mono +commit_graph.color = Color blame = Blame normal_view = Normal View line = line diff --git a/public/img/svg/material-invert-colors.svg b/public/img/svg/material-invert-colors.svg new file mode 100644 index 000000000000..018a693a02da --- /dev/null +++ b/public/img/svg/material-invert-colors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/svg/material-palette.svg b/public/img/svg/material-palette.svg new file mode 100644 index 000000000000..d257e65d3355 --- /dev/null +++ b/public/img/svg/material-palette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/repo/commit.go b/routers/repo/commit.go index 004d4915df8b..d9547cc51de6 100644 --- a/routers/repo/commit.go +++ b/routers/repo/commit.go @@ -90,6 +90,11 @@ func Commits(ctx *context.Context) { func Graph(ctx *context.Context) { ctx.Data["PageIsCommits"] = true ctx.Data["PageIsViewCode"] = true + mode := strings.ToLower(ctx.QueryTrim("mode")) + if mode != "monochrome" { + mode = "color" + } + ctx.Data["Mode"] = mode commitsCount, err := ctx.Repo.GetCommitsCount() if err != nil { @@ -105,7 +110,7 @@ func Graph(ctx *context.Context) { page := ctx.QueryInt("page") - graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page) + graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0) if err != nil { ctx.ServerError("GetCommitGraph", err) return @@ -116,7 +121,9 @@ func Graph(ctx *context.Context) { ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["CommitCount"] = commitsCount ctx.Data["Branch"] = ctx.Repo.BranchName - ctx.Data["Page"] = context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) + paginator := context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) + paginator.AddParam(ctx, "mode", "Mode") + ctx.Data["Page"] = paginator ctx.HTML(200, tplGraph) } diff --git a/templates/repo/graph.tmpl b/templates/repo/graph.tmpl index 493ac7a696c0..c644b24d577c 100644 --- a/templates/repo/graph.tmpl +++ b/templates/repo/graph.tmpl @@ -2,21 +2,44 @@
{{template "repo/header" .}}
-
-

{{.i18n.Tr "repo.commit_graph"}}

+
+

{{.i18n.Tr "repo.commit_graph"}} +
+
+ + +
+
+

+
- -
    - {{ range .Graph }} -
  • {{ .GraphAcii -}}
  • - {{ end }} -
-
+ + {{range $flowid, $flow := .Graph.Flows}} + + + {{range $flow.Commits}} + + {{end}} + + {{end}} +
    - {{ range .Graph }} -
  • + {{ range .Graph.Commits }} +
  • {{ if .OnlyRelation }} {{ else }} diff --git a/web_src/js/features/gitgraph.js b/web_src/js/features/gitgraph.js index 3e6b27436daf..655cfb77c222 100644 --- a/web_src/js/features/gitgraph.js +++ b/web_src/js/features/gitgraph.js @@ -1,568 +1,81 @@ -// Although inspired by the https://github.com/bluef/gitgraph.js/blob/master/gitgraph.js -// this has been completely rewritten with almost no remaining code - -// GitGraphCanvas is a canvas for drawing gitgraphs on to -class GitGraphCanvas { - constructor(canvas, widthUnits, heightUnits, config) { - this.ctx = canvas.getContext('2d'); - - const width = widthUnits * config.unitSize; - this.height = heightUnits * config.unitSize; - - const ratio = window.devicePixelRatio || 1; - - canvas.width = width * ratio; - canvas.height = this.height * ratio; - - canvas.style.width = `${width}px`; - canvas.style.height = `${this.height}px`; - - this.ctx.lineWidth = config.lineWidth; - this.ctx.lineJoin = 'round'; - this.ctx.lineCap = 'round'; - - this.ctx.scale(ratio, ratio); - this.config = config; - } - drawLine(moveX, moveY, lineX, lineY, color) { - this.ctx.strokeStyle = color; - this.ctx.beginPath(); - this.ctx.moveTo(moveX, moveY); - this.ctx.lineTo(lineX, lineY); - this.ctx.stroke(); - } - drawLineRight(x, y, color) { - this.drawLine( - x - 0.5 * this.config.unitSize, - y + this.config.unitSize / 2, - x + 0.5 * this.config.unitSize, - y + this.config.unitSize / 2, - color - ); - } - drawLineUp(x, y, color) { - this.drawLine( - x, - y + this.config.unitSize / 2, - x, - y - this.config.unitSize / 2, - color - ); - } - drawNode(x, y, color) { - this.ctx.strokeStyle = color; - - this.drawLineUp(x, y, color); - - this.ctx.beginPath(); - this.ctx.arc(x, y, this.config.nodeRadius, 0, Math.PI * 2, true); - this.ctx.fillStyle = color; - this.ctx.fill(); - } - drawLineIn(x, y, color) { - this.drawLine( - x + 0.5 * this.config.unitSize, - y + this.config.unitSize / 2, - x - 0.5 * this.config.unitSize, - y - this.config.unitSize / 2, - color - ); - } - drawLineOut(x, y, color) { - this.drawLine( - x - 0.5 * this.config.unitSize, - y + this.config.unitSize / 2, - x + 0.5 * this.config.unitSize, - y - this.config.unitSize / 2, - color - ); - } - drawSymbol(symbol, columnNumber, rowNumber, color) { - const y = this.height - this.config.unitSize * (rowNumber + 0.5); - const x = this.config.unitSize * 0.5 * (columnNumber + 1); - switch (symbol) { - case '-': - if (columnNumber % 2 === 1) { - this.drawLineRight(x, y, color); - } - break; - case '_': - this.drawLineRight(x, y, color); - break; - case '*': - this.drawNode(x, y, color); - break; - case '|': - this.drawLineUp(x, y, color); - break; - case '/': - this.drawLineOut(x, y, color); - break; - case '\\': - this.drawLineIn(x, y, color); - break; - case '.': - case ' ': - break; - default: - console.error('Unknown symbol', symbol, color); - } - } -} - -class GitGraph { - constructor(canvas, rawRows, config) { - this.rows = []; - let maxWidth = 0; - - for (let i = 0; i < rawRows.length; i++) { - const rowStr = rawRows[i]; - maxWidth = Math.max(rowStr.replace(/([_\s.-])/g, '').length, maxWidth); - - const rowArray = rowStr.split(''); - - this.rows.unshift(rowArray); - } - - this.currentFlows = []; - this.previousFlows = []; - - this.gitGraphCanvas = new GitGraphCanvas( - canvas, - maxWidth, - this.rows.length, - config - ); - } - - generateNewFlow(column) { - let newId; - - do { - newId = generateRandomColorString(); - } while (this.hasFlow(newId, column)); - - return {id: newId, color: `#${newId}`}; - } - - hasFlow(id, column) { - // We want to find the flow with the current ID - // Possible flows are those in the currentFlows - // Or flows in previousFlows[column-2:...] - for ( - let idx = column - 2 < 0 ? 0 : column - 2; - idx < this.previousFlows.length; - idx++ - ) { - if (this.previousFlows[idx] && this.previousFlows[idx].id === id) { - return true; - } - } - for (let idx = 0; idx < this.currentFlows.length; idx++) { - if (this.currentFlows[idx] && this.currentFlows[idx].id === id) { - return true; - } - } - return false; - } - - takePreviousFlow(column) { - if (column < this.previousFlows.length && this.previousFlows[column]) { - const flow = this.previousFlows[column]; - this.previousFlows[column] = null; - return flow; - } - return this.generateNewFlow(column); - } - - draw() { - if (this.rows.length === 0) { - return; - } - - this.currentFlows = new Array(this.rows[0].length); - - // Generate flows for the first row - I do not believe that this can contain '_', '-', '.' - for (let column = 0; column < this.rows[0].length; column++) { - if (this.rows[0][column] === ' ') { - continue; - } - this.currentFlows[column] = this.generateNewFlow(column); - } - - // Draw the first row - for (let column = 0; column < this.rows[0].length; column++) { - const symbol = this.rows[0][column]; - const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; - this.gitGraphCanvas.drawSymbol(symbol, column, 0, color); - } - - for (let row = 1; row < this.rows.length; row++) { - // Done previous row - step up the row - const currentRow = this.rows[row]; - const previousRow = this.rows[row - 1]; - - this.previousFlows = this.currentFlows; - this.currentFlows = new Array(currentRow.length); - - // Set flows for this row - for (let column = 0; column < currentRow.length; column++) { - column = this.setFlowFor(column, currentRow, previousRow); - } - - // Draw this row - for (let column = 0; column < currentRow.length; column++) { - const symbol = currentRow[column]; - const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; - this.gitGraphCanvas.drawSymbol(symbol, column, row, color); - } - } - } - - setFlowFor(column, currentRow, previousRow) { - const symbol = currentRow[column]; - switch (symbol) { - case '|': - case '*': - return this.setUpFlow(column, currentRow, previousRow); - case '/': - return this.setOutFlow(column, currentRow, previousRow); - case '\\': - return this.setInFlow(column, currentRow, previousRow); - case '_': - return this.setRightFlow(column, currentRow, previousRow); - case '-': - return this.setLeftFlow(column, currentRow, previousRow); - case ' ': - // In space no one can hear you flow ... (?) - return column; - default: - // Unexpected so let's generate a new flow and wait for bug-reports - this.currentFlows[column] = this.generateNewFlow(column); - return column; - } - } - - // setUpFlow handles '|' or '*' - returns the last column that was set - // generally we prefer to take the left most flow from the previous row - setUpFlow(column, currentRow, previousRow) { - // If ' |/' or ' |_' - // '/|' '/|' -> Take the '|' flow directly beneath us - if ( - column + 1 < currentRow.length && - (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && - column < previousRow.length && - (previousRow[column] === '|' || previousRow[column] === '*') && - previousRow[column - 1] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column); - return column; - } - - // If ' |/' or ' |_' - // '/ ' '/ ' -> Take the '/' flow from the preceding column - if ( - column + 1 < currentRow.length && - (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && - column - 1 < previousRow.length && - previousRow[column - 1] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 1); - return column; - } - - // If ' |' - // '/' -> Take the '/' flow - (we always prefer the left-most flow) - if ( - column > 0 && - column - 1 < previousRow.length && - previousRow[column - 1] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 1); - return column; - } - - // If '|' OR '|' take the '|' flow - // '|' '*' - if ( - column < previousRow.length && - (previousRow[column] === '|' || previousRow[column] === '*') - ) { - this.currentFlows[column] = this.takePreviousFlow(column); - return column; - } - - // If '| ' keep the '\' flow - // ' \' - if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { - this.currentFlows[column] = this.takePreviousFlow(column + 1); - return column; - } - - // Otherwise just create a new flow - probably this is an error... - this.currentFlows[column] = this.generateNewFlow(column); - return column; - } - - // setOutFlow handles '/' - returns the last column that was set - // generally we prefer to take the left most flow from the previous row - setOutFlow(column, currentRow, previousRow) { - // If '_/' -> keep the '_' flow - if (column > 0 && currentRow[column - 1] === '_') { - this.currentFlows[column] = this.currentFlows[column - 1]; - return column; - } - - // If '_|/' -> keep the '_' flow - if ( - column > 1 && - (currentRow[column - 1] === '|' || currentRow[column - 1] === '*') && - currentRow[column - 2] === '_' - ) { - this.currentFlows[column] = this.currentFlows[column - 2]; - return column; - } - - // If '|/' - // '/' -> take the '/' flow (if it is still available) - if ( - column > 1 && - currentRow[column - 1] === '|' && - column - 2 < previousRow.length && - previousRow[column - 2] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 2); - return column; - } - - // If ' /' - // '/' -> take the '/' flow, but transform the symbol to '|' due to our spacing - // This should only happen if there are 3 '/' - in a row so we don't need to be cleverer here - if ( - column > 0 && - currentRow[column - 1] === ' ' && - column - 1 < previousRow.length && - previousRow[column - 1] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 1); - currentRow[column] = '|'; - return column; - } - - // If ' /' - // '|' -> take the '|' flow - if ( - column > 0 && - currentRow[column - 1] === ' ' && - column - 1 < previousRow.length && - (previousRow[column - 1] === '|' || previousRow[column - 1] === '*') - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 1); - return column; - } - - // If '/' <- Not sure this ever happens... but take the '\' flow - // '\' - if (column < previousRow.length && previousRow[column] === '\\') { - this.currentFlows[column] = this.takePreviousFlow(column); - return column; - } - - // Otherwise just generate a new flow and wait for bug-reports... - this.currentFlows[column] = this.generateNewFlow(column); - return column; - } - - // setInFlow handles '\' - returns the last column that was set - // generally we prefer to take the left most flow from the previous row - setInFlow(column, currentRow, previousRow) { - // If '\?' - // '/?' -> take the '/' flow - if (column < previousRow.length && previousRow[column] === '/') { - this.currentFlows[column] = this.takePreviousFlow(column); - return column; - } - - // If '\?' - // ' \' -> take the '\' flow and reassign to '|' - // This should only happen if there are 3 '\' - in a row so we don't need to be cleverer here - if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { - this.currentFlows[column] = this.takePreviousFlow(column + 1); - currentRow[column] = '|'; - return column; - } - - // If '\?' - // ' |' -> take the '|' flow - if ( - column + 1 < previousRow.length && - (previousRow[column + 1] === '|' || previousRow[column + 1] === '*') - ) { - this.currentFlows[column] = this.takePreviousFlow(column + 1); - return column; - } - - // Otherwise just generate a new flow and wait for bug-reports if we're wrong... - this.currentFlows[column] = this.generateNewFlow(column); - return column; - } - - // setRightFlow handles '_' - returns the last column that was set - // generally we prefer to take the left most flow from the previous row - setRightFlow(column, currentRow, previousRow) { - // if '__' keep the '_' flow - if (column > 0 && currentRow[column - 1] === '_') { - this.currentFlows[column] = this.currentFlows[column - 1]; - return column; - } - - // if '_|_' -> keep the '_' flow - if ( - column > 1 && - currentRow[column - 1] === '|' && - currentRow[column - 2] === '_' - ) { - this.currentFlows[column] = this.currentFlows[column - 2]; - return column; - } - - // if ' _' -> take the '/' flow - // '/ ' - if ( - column > 0 && - column - 1 < previousRow.length && - previousRow[column - 1] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 1); - return column; - } - - // if ' |_' - // '/? ' -> take the '/' flow (this may cause generation...) - // we can do this because we know that git graph - // doesn't create compact graphs like: ' |_' - // '//' - if ( - column > 1 && - column - 2 < previousRow.length && - previousRow[column - 2] === '/' - ) { - this.currentFlows[column] = this.takePreviousFlow(column - 2); - return column; - } - - // There really shouldn't be another way of doing this - generate and wait for bug-reports... - - this.currentFlows[column] = this.generateNewFlow(column); - return column; - } - - // setLeftFlow handles '----.' - returns the last column that was set - // generally we prefer to take the left most flow from the previous row that terminates this left recursion - setLeftFlow(column, currentRow, previousRow) { - // This is: '----------.' or the like - // ' \ \ /|\' - - // Find the end of the '-' or nearest '/|\' in the previousRow : - let originalColumn = column; - let flow; - for (; column < currentRow.length && currentRow[column] === '-'; column++) { - if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { - flow = this.takePreviousFlow(column - 1); - break; - } else if (column < previousRow.length && previousRow[column] === '|') { - flow = this.takePreviousFlow(column); - break; - } else if ( - column + 1 < previousRow.length && - previousRow[column + 1] === '\\' - ) { - flow = this.takePreviousFlow(column + 1); - break; - } - } - - // if we have a flow then we found a '/|\' in the previousRow - if (flow) { - for (; originalColumn < column + 1; originalColumn++) { - this.currentFlows[originalColumn] = flow; - } - return column; - } - - // If the symbol in the column is not a '.' then there's likely an error - if (currentRow[column] !== '.') { - // It really should end in a '.' but this one doesn't... - // 1. Step back - we don't want to eat this column - column--; - // 2. Generate a new flow and await bug-reports... - this.currentFlows[column] = this.generateNewFlow(column); - - // 3. Assign all of the '-' to the same flow. - for (; originalColumn < column; originalColumn++) { - this.currentFlows[originalColumn] = this.currentFlows[column]; - } - return column; - } - - // We have a terminal '.' eg. the current row looks like '----.' - // the previous row should look like one of '/|\' eg. ' \' - if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { - flow = this.takePreviousFlow(column - 1); - } else if (column < previousRow.length && previousRow[column] === '|') { - flow = this.takePreviousFlow(column); - } else if ( - column + 1 < previousRow.length && - previousRow[column + 1] === '\\' - ) { - flow = this.takePreviousFlow(column + 1); - } 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; - } - - 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; + const graphContainer = document.getElementById('git-graph-container'); + if (!graphContainer) return; - // Grab the raw graphList - const graphList = []; - $('#graph-raw-list li span.node-relation').each(function () { - graphList.push($(this).text()); + $('#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 { + 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); + } + }); + $('#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'); }); - - // 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'); } diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 94b1d72a0b17..81a3282ee4e9 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -3082,11 +3082,3 @@ tbody.commit-list { } } } - -#git-graph-container { - display: none; -} - -#git-graph-container.in { - display: block; -} diff --git a/web_src/less/features/gitgraph.less b/web_src/less/features/gitgraph.less new file mode 100644 index 000000000000..8a9c4239a798 --- /dev/null +++ b/web_src/less/features/gitgraph.less @@ -0,0 +1,256 @@ +#git-graph-container { + float: left; + display: block; + overflow-x: auto; + width: 100%; + + .color-buttons { + margin-right: 0; + } + + .ui.header.dividing { + padding-bottom: 10px; + } + + li { + list-style-type: none; + height: 20px; + line-height: 20px; + white-space: nowrap; + + .node-relation { + font-family: "Bitstream Vera Sans Mono", "Courier", monospace; + } + + .author { + color: #666666; + } + + .time { + color: #999999; + font-size: 80%; + } + + a { + color: #000000; + } + + a:hover { + text-decoration: underline; + } + + a em { + color: #bb0000; + border-bottom: 1px dotted #bbbbbb; + text-decoration: none; + font-style: normal; + } + } + + #rel-container { + max-width: 30%; + overflow-x: auto; + float: left; + } + + #rev-container { + width: 100%; + } + + #rev-list { + margin: 0; + padding: 0 5px; + min-width: 95%; + + li.highlight, + li.hover { + background-color: rgba(0, 0, 0, .05); + } + + li.highlight.hover { + background-color: rgba(0, 0, 0, .1); + } + } + + #graph-raw-list { + margin: 0; + } + + &.monochrome #rel-container { + .flow-group { + stroke: grey; + fill: grey; + } + + .flow-group.highlight { + stroke: black; + fill: black; + } + } + + &:not(.monochrome) #rel-container { + .flow-group { + &.flow-color-16-1 { + stroke: #499a37; + fill: #499a37; + } + + &.flow-color-16-2 { + stroke: hsl(356, 58%, 54%); + fill: #ce4751; + } + + &.flow-color-16-3 { + stroke: #8f9121; + fill: #8f9121; + } + + &.flow-color-16-4 { + stroke: #ac32a6; + fill: #ac32a6; + } + + &.flow-color-16-5 { + stroke: #3d27aa; + fill: #3d27aa; + } + + &.flow-color-16-6 { + stroke: #c67d28; + fill: #c67d28; + } + + &.flow-color-16-7 { + stroke: #4db392; + fill: #4db392; + } + + &.flow-color-16-8 { + stroke: #aa4d30; + fill: #aa4d30; + } + + &.flow-color-16-9 { + stroke: #2a6f84; + fill: #2a6f84; + } + + &.flow-color-16-10 { + stroke: #c45327; + fill: #c45327; + } + + &.flow-color-16-11 { + stroke: #3d965c; + fill: #3d965c; + } + + &.flow-color-16-12 { + stroke: #792a93; + fill: #792a93; + } + + &.flow-color-16-13 { + stroke: #439d73; + fill: #439d73; + } + + &.flow-color-16-14 { + stroke: #103aad; + fill: #103aad; + } + + &.flow-color-16-15 { + stroke: #982e85; + fill: #982e85; + } + + &.flow-color-16-0 { + stroke: #7db233; + fill: #7db233; + } + } + + .flow-group.highlight { + &.flow-color-16-1 { + stroke: #5ac144; + fill: #5ac144; + } + + &.flow-color-16-2 { + stroke: #ed5a8b; + fill: #ed5a8b; + } + + &.flow-color-16-3 { + stroke: #ced049; + fill: #ced048; + } + + &.flow-color-16-4 { + stroke: #db61d7; + fill: #db62d6; + } + + &.flow-color-16-5 { + stroke: #4e33d1; + fill: #4f35d1; + } + + &.flow-color-16-6 { + stroke: #e6a151; + fill: #e6a151; + } + + &.flow-color-16-7 { + stroke: #44daaa; + fill: #44daaa; + } + + &.flow-color-16-8 { + stroke: #dd7a5c; + fill: #dd7a5c; + } + + &.flow-color-16-9 { + stroke: #38859c; + fill: #38859c; + } + + &.flow-color-16-10 { + stroke: #d95520; + fill: #d95520; + } + + &.flow-color-16-11 { + stroke: #42ae68; + fill: #42ae68; + } + + &.flow-color-16-12 { + stroke: #9126b5; + fill: #9126b5; + } + + &.flow-color-16-13 { + stroke: #4ab080; + fill: #4ab080; + } + + &.flow-color-16-14 { + stroke: #284fb8; + fill: #284fb8; + } + + &.flow-color-16-15 { + stroke: #971c80; + fill: #971c80; + } + + &.flow-color-16-0 { + stroke: #87ca28; + fill: #87ca28; + } + } + } +} diff --git a/web_src/less/index.less b/web_src/less/index.less index ef38f863cdf5..92b25e1db12a 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -1,5 +1,6 @@ @import "~font-awesome/css/font-awesome.css"; -@import "./vendor/gitGraph.css"; + +@import "./features/gitgraph.less"; @import "./features/animations.less"; @import "./markdown/mermaid.less"; diff --git a/web_src/less/themes/theme-arc-green.less b/web_src/less/themes/theme-arc-green.less index 8de66fd251c0..1e8c118675c6 100644 --- a/web_src/less/themes/theme-arc-green.less +++ b/web_src/less/themes/theme-arc-green.less @@ -919,11 +919,17 @@ a.ui.basic.green.label:hover { .ui.active.button:active, .ui.button:active, -.ui.button:focus { +.ui.button:focus, +.ui.active.button { background-color: #2e3e4e; color: #dbdbdb; } +.ui.active.button:hover { + background-color: #475e75; + color: #dbdbdb; +} + .ui.dropdown .menu .selected.item, .ui.dropdown.selected { color: #dbdbdb; @@ -1921,6 +1927,45 @@ footer .container .links > * { color: #2a2e3a; } +#git-graph-container.monochrome #rel-container .flow-group { + stroke: dimgrey; + fill: dimgrey; +} + +#git-graph-container.monochrome #rel-container .flow-group.highlight { + stroke: darkgrey; + fill: darkgrey; +} + +#git-graph-container:not(.monochrome) #rel-container .flow-group { + &.flow-color-16-5 { + stroke: #5543b1; + fill: #5543b1; + } +} + +#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight { + &.flow-color-16-5 { + stroke: #7058e6; + fill: #7058e6; + } +} + +#git-graph-container #rev-list li.highlight, +#git-graph-container #rev-list li.hover { + background-color: rgba(255, 255, 255, .05); +} + +#git-graph-container #rev-list li.highlight.hover { + background-color: rgba(255, 255, 255, .1); +} + +#git-graph-container .ui.buttons button#flow-color-monochrome.ui.button { + border-left-color: rgb(76, 80, 92); + border-left-style: solid; + border-left-width: 1px; +} + .mermaid-chart { filter: invert(84%) hue-rotate(180deg); } diff --git a/web_src/less/vendor/gitGraph.css b/web_src/less/vendor/gitGraph.css deleted file mode 100644 index bb7e708101b8..000000000000 --- a/web_src/less/vendor/gitGraph.css +++ /dev/null @@ -1,15 +0,0 @@ -/* This is a customized version of https://github.com/bluef/gitgraph.js/blob/master/gitgraph.css - Changes include the removal of `body` and `em` styles */ -#git-graph-container, #rel-container {float:left;} -#rel-container {max-width:30%; overflow-x:auto;} -#git-graph-container {overflow-x:auto; width:100%} -#git-graph-container li {list-style-type:none;height:20px;line-height:20px; white-space:nowrap;} -#git-graph-container li .node-relation {font-family:'Bitstream Vera Sans Mono', 'Courier', monospace;} -#git-graph-container li .author {color:#666666;} -#git-graph-container li .time {color:#999999;font-size:80%} -#git-graph-container li a {color:#000000;} -#git-graph-container li a:hover {text-decoration:underline;} -#git-graph-container li a em {color:#BB0000;border-bottom:1px dotted #BBBBBB;text-decoration:none;font-style:normal;} -#rev-container {width:100%} -#rev-list {margin:0;padding:0 5px 0 5px;min-width:95%} -#graph-raw-list {margin:0px;} diff --git a/web_src/svg/material-invert-colors.svg b/web_src/svg/material-invert-colors.svg new file mode 100644 index 000000000000..e6445ab230f1 --- /dev/null +++ b/web_src/svg/material-invert-colors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_src/svg/material-palette.svg b/web_src/svg/material-palette.svg new file mode 100644 index 000000000000..df0e1756ffd0 --- /dev/null +++ b/web_src/svg/material-palette.svg @@ -0,0 +1 @@ + \ No newline at end of file