From 22e1bd31c68586e963262db964d6a83f6115e56f Mon Sep 17 00:00:00 2001 From: Kjell Kvinge Date: Thu, 29 Dec 2016 00:44:32 +0100 Subject: [PATCH] commithgraph / timeline (#428) * Add model and tests for graph * Add route and router for graph * Add assets for graph * Add template for graph --- cmd/web.go | 1 + models/graph.go | 108 ++++++++++ models/graph_test.go | 41 ++++ public/css/gitgraph.css | 15 ++ public/js/draw.js | 17 ++ public/js/libs/gitgraph.js | 399 ++++++++++++++++++++++++++++++++++++ routers/repo/commit.go | 27 +++ templates/base/head.tmpl | 7 + templates/repo/commits.tmpl | 16 +- templates/repo/graph.tmpl | 44 ++++ 10 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 models/graph.go create mode 100644 models/graph_test.go create mode 100644 public/css/gitgraph.css create mode 100644 public/js/draw.js create mode 100644 public/js/libs/gitgraph.js create mode 100644 templates/repo/graph.tmpl diff --git a/cmd/web.go b/cmd/web.go index e6f6820a6..45d198fdf 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -547,6 +547,7 @@ func runWeb(ctx *cli.Context) error { m.Get("/src/*", repo.SetEditorconfigIfExists, repo.Home) m.Get("/raw/*", repo.SingleDownload) m.Get("/commits/*", repo.RefCommits) + m.Get("/graph", repo.Graph) m.Get("/commit/:sha([a-f0-9]{7,40})$", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.Diff) m.Get("/forks", repo.Forks) }, context.RepoRef()) diff --git a/models/graph.go b/models/graph.go new file mode 100644 index 000000000..973476a74 --- /dev/null +++ b/models/graph.go @@ -0,0 +1,108 @@ +// Copyright 2016 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 models + +import ( + "fmt" + "strings" + + "code.gitea.io/git" +) + +// 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) (GraphItems, error) { + + var Commitgraph []GraphItem + + format := "DATA:|%d|%H|%ad|%an|%ae|%h|%s" + + graphCmd := git.NewCommand("log") + graphCmd.AddArguments("--graph", + "--date-order", + "--all", + "-C", + "-M", + "-n 100", + "--date=iso", + fmt.Sprintf("--pretty=format:%s", format), + ) + graph, err := graphCmd.RunInDir(r.Path) + if err != nil { + return Commitgraph, err + } + + Commitgraph = make([]GraphItem, 0, 100) + for _, s := range strings.Split(graph, "\n") { + GraphItem, err := graphItemFromString(s, r) + if err != nil { + return Commitgraph, err + } + Commitgraph = append(Commitgraph, GraphItem) + } + + return Commitgraph, nil +} + +func graphItemFromString(s string, r *git.Repository) (GraphItem, error) { + + var ascii string + var data = "|||||||" + lines := strings.Split(s, "DATA:") + + 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.Split(data, "|") + 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 refered to, only relation in current line. + } + return gi, nil +} diff --git a/models/graph_test.go b/models/graph_test.go new file mode 100644 index 000000000..23d8aa849 --- /dev/null +++ b/models/graph_test.go @@ -0,0 +1,41 @@ +// Copyright 2016 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 models + +import ( + "testing" + + "code.gitea.io/git" +) + +func BenchmarkGetCommitGraph(b *testing.B) { + + currentRepo, err := git.OpenRepository(".") + if err != nil { + b.Error("Could not open repository") + } + + graph, err := GetCommitGraph(currentRepo) + if err != nil { + b.Error("Could get commit graph") + } + + if len(graph) < 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" + + graphItem, err := graphItemFromString(testString, nil) + if err != nil { + b.Error("could not parse teststring") + } + + if graphItem.Author != "Kjell Kvinge" { + b.Error("Did not get expected data") + } +} diff --git a/public/css/gitgraph.css b/public/css/gitgraph.css new file mode 100644 index 000000000..930b15e2e --- /dev/null +++ b/public/css/gitgraph.css @@ -0,0 +1,15 @@ +body {font:13.34px/1.4 helvetica,arial,freesans,clean,sans-serif;} +em {font-style:normal;} + +#git-graph-container, #rel-container {float:left;} +#git-graph-container {} +#git-graph-container li {list-style-type:none;height:20px;line-height:20px;overflow:hidden;} +#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:80%} +#rev-list {margin:0;padding:0 5px 0 0;width:80%} +#graph-raw-list {margin:0px;} \ No newline at end of file diff --git a/public/js/draw.js b/public/js/draw.js new file mode 100644 index 000000000..fadb3330b --- /dev/null +++ b/public/js/draw.js @@ -0,0 +1,17 @@ +$(document).ready(function () { + var graphList = []; + + if (!document.getElementById('graph-canvas')) { + return; + } + + $("#graph-raw-list li span.node-relation").each(function () { + graphList.push($(this).text()); + }) + + gitGraph(document.getElementById('graph-canvas'), graphList); + + if ($("#rev-container")) { + $("#rev-container").css("width", document.body.clientWidth - document.getElementById('graph-canvas').width); + } +}) diff --git a/public/js/libs/gitgraph.js b/public/js/libs/gitgraph.js new file mode 100644 index 000000000..e2f002699 --- /dev/null +++ b/public/js/libs/gitgraph.js @@ -0,0 +1,399 @@ +/* + * Copyright (c) 2011, Terrence Lee + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of the nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +var gitGraph = function (canvas, rawGraphList, config) { + if (!canvas.getContext) { + return; + } + + if (typeof config === "undefined") { + config = { + unitSize: 20, + lineWidth: 3, + nodeRadius: 4 + }; + } + + var flows = []; + var graphList = []; + + var ctx = canvas.getContext("2d"); + + var init = function () { + var maxWidth = 0; + var i; + var l = rawGraphList.length; + var row; + var midStr; + + for (i = 0; i < l; i++) { + midStr = rawGraphList[i].replace(/\s+/g, " ").replace(/^\s+|\s+$/g, ""); + + maxWidth = Math.max(midStr.replace(/(\_|\s)/g, "").length, maxWidth); + + row = midStr.split(""); + + graphList.unshift(row); + } + + canvas.width = maxWidth * config.unitSize; + canvas.height = graphList.length * config.unitSize; + + ctx.lineWidth = config.lineWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + }; + + var genRandomStr = function () { + var chars = "0123456789ABCDEF"; + var stringLength = 6; + var randomString = '', rnum, i; + for (i = 0; i < stringLength; i++) { + rnum = Math.floor(Math.random() * chars.length); + randomString += chars.substring(rnum, rnum + 1); + } + + return randomString; + }; + + var findFlow = function (id) { + var i = flows.length; + + while (i-- && flows[i].id !== id) {} + + return i; + }; + + var findColomn = function (symbol, row) { + var i = row.length; + + while (i-- && row[i] !== symbol) {} + + return i; + }; + + var findBranchOut = function (row) { + if (!row) { + return -1 + } + + var i = row.length; + + while (i-- && + !(row[i - 1] && row[i] === "/" && row[i - 1] === "|") && + !(row[i - 2] && row[i] === "_" && row[i - 2] === "|")) {} + + return i; + } + + var genNewFlow = function () { + var newId; + + do { + newId = genRandomStr(); + } while (findFlow(newId) !== -1); + + return {id:newId, color:"#" + newId}; + }; + + //draw method + var drawLineRight = function (x, y, color) { + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y + config.unitSize / 2); + ctx.lineTo(x + config.unitSize, y + config.unitSize / 2); + ctx.stroke(); + }; + + var drawLineUp = function (x, y, color) { + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y + config.unitSize / 2); + ctx.lineTo(x, y - config.unitSize / 2); + ctx.stroke(); + }; + + var drawNode = function (x, y, color) { + ctx.strokeStyle = color; + + drawLineUp(x, y, color); + + ctx.beginPath(); + ctx.arc(x, y, config.nodeRadius, 0, Math.PI * 2, true); + ctx.fill(); + }; + + var drawLineIn = function (x, y, color) { + ctx.strokeStyle = color; + + ctx.beginPath(); + ctx.moveTo(x + config.unitSize, y + config.unitSize / 2); + ctx.lineTo(x, y - config.unitSize / 2); + ctx.stroke(); + }; + + var drawLineOut = function (x, y, color) { + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y + config.unitSize / 2); + ctx.lineTo(x + config.unitSize, y - config.unitSize / 2); + ctx.stroke(); + }; + + var draw = function (graphList) { + var colomn, colomnIndex, prevColomn, condenseIndex; + var x, y; + var color; + var nodePos, outPos; + var tempFlow; + var prevRowLength = 0; + var flowSwapPos = -1; + var lastLinePos; + var i, k, l; + var condenseCurrentLength, condensePrevLength = 0, condenseNextLength = 0; + + var inlineIntersect = false; + + //initiate for first row + for (i = 0, l = graphList[0].length; i < l; i++) { + if (graphList[0][i] !== "_" && graphList[0][i] !== " ") { + flows.push(genNewFlow()); + } + } + + y = canvas.height - config.unitSize * 0.5; + + //iterate + for (i = 0, l = graphList.length; i < l; i++) { + x = config.unitSize * 0.5; + + currentRow = graphList[i]; + nextRow = graphList[i + 1]; + prevRow = graphList[i - 1]; + + flowSwapPos = -1; + + condenseCurrentLength = currentRow.filter(function (val) { + return (val !== " " && val !== "_") + }).length; + + if (nextRow) { + condenseNextLength = nextRow.filter(function (val) { + return (val !== " " && val !== "_") + }).length; + } else { + condenseNextLength = 0; + } + + //pre process begin + //use last row for analysing + if (prevRow) { + if (!inlineIntersect) { + //intersect might happen + for (colomnIndex = 0; colomnIndex < prevRowLength; colomnIndex++) { + if (prevRow[colomnIndex + 1] && + (prevRow[colomnIndex] === "/" && prevRow[colomnIndex + 1] === "|") || + ((prevRow[colomnIndex] === "_" && prevRow[colomnIndex + 1] === "|") && + (prevRow[colomnIndex + 2] === "/"))) { + + flowSwapPos = colomnIndex; + + //swap two flow + tempFlow = {id:flows[flowSwapPos].id, color:flows[flowSwapPos].color}; + + flows[flowSwapPos].id = flows[flowSwapPos + 1].id; + flows[flowSwapPos].color = flows[flowSwapPos + 1].color; + + flows[flowSwapPos + 1].id = tempFlow.id; + flows[flowSwapPos + 1].color = tempFlow.color; + } + } + } + + if (condensePrevLength < condenseCurrentLength && + ((nodePos = findColomn("*", currentRow)) !== -1 && + (findColomn("_", currentRow) === -1))) { + + flows.splice(nodePos - 1, 0, genNewFlow()); + } + + if (prevRowLength > currentRow.length && + (nodePos = findColomn("*", prevRow)) !== -1) { + + if (findColomn("_", currentRow) === -1 && + findColomn("/", currentRow) === -1 && + findColomn("\\", currentRow) === -1) { + + flows.splice(nodePos + 1, 1); + } + } + } //done with the previous row + + prevRowLength = currentRow.length; //store for next round + colomnIndex = 0; //reset index + condenseIndex = 0; + condensePrevLength = 0; + while (colomnIndex < currentRow.length) { + colomn = currentRow[colomnIndex]; + + if (colomn !== " " && colomn !== "_") { + ++condensePrevLength; + } + + if (colomn === " " && + currentRow[colomnIndex + 1] && + currentRow[colomnIndex + 1] === "_" && + currentRow[colomnIndex - 1] && + currentRow[colomnIndex - 1] === "|") { + + currentRow.splice(colomnIndex, 1); + + currentRow[colomnIndex] = "/"; + colomn = "/"; + } + + //create new flow only when no intersetc happened + if (flowSwapPos === -1 && + colomn === "/" && + currentRow[colomnIndex - 1] && + currentRow[colomnIndex - 1] === "|") { + + flows.splice(condenseIndex, 0, genNewFlow()); + } + + //change \ and / to | when it's in the last position of the whole row + if (colomn === "/" || colomn === "\\") { + if (!(colomn === "/" && findBranchOut(nextRow) === -1)) { + if ((lastLinePos = Math.max(findColomn("|", currentRow), + findColomn("*", currentRow))) !== -1 && + (lastLinePos < colomnIndex - 1)) { + + while (currentRow[++lastLinePos] === " ") {} + + if (lastLinePos === colomnIndex) { + currentRow[colomnIndex] = "|"; + } + } + } + } + + if (colomn === "*" && + prevRow && + prevRow[condenseIndex + 1] === "\\") { + flows.splice(condenseIndex + 1, 1); + } + + if (colomn !== " ") { + ++condenseIndex; + } + + ++colomnIndex; + } + + condenseCurrentLength = currentRow.filter(function (val) { + return (val !== " " && val !== "_") + }).length; + + //do some clean up + if (flows.length > condenseCurrentLength) { + flows.splice(condenseCurrentLength, flows.length - condenseCurrentLength); + } + + colomnIndex = 0; + + //a little inline analysis and draw process + while (colomnIndex < currentRow.length) { + colomn = currentRow[colomnIndex]; + prevColomn = currentRow[colomnIndex - 1]; + + if (currentRow[colomnIndex] === " ") { + currentRow.splice(colomnIndex, 1); + x += config.unitSize; + + continue; + } + + //inline interset + if ((colomn === "_" || colomn === "/") && + currentRow[colomnIndex - 1] === "|" && + currentRow[colomnIndex - 2] === "_") { + + inlineIntersect = true; + + tempFlow = flows.splice(colomnIndex - 2, 1)[0]; + flows.splice(colomnIndex - 1, 0, tempFlow); + currentRow.splice(colomnIndex - 2, 1); + + colomnIndex = colomnIndex - 1; + } else { + inlineIntersect = false; + } + + color = flows[colomnIndex].color; + + switch (colomn) { + case "_" : + drawLineRight(x, y, color); + + x += config.unitSize; + break; + + case "*" : + drawNode(x, y, color); + break; + + case "|" : + drawLineUp(x, y, color); + break; + + case "/" : + if (prevColomn && + (prevColomn === "/" || + prevColomn === " ")) { + x -= config.unitSize; + } + + drawLineOut(x, y, color); + + x += config.unitSize; + break; + + case "\\" : + drawLineIn(x, y, color); + break; + } + + ++colomnIndex; + } + + y -= config.unitSize; + } + }; + + init(); + draw(graphList); +}; \ No newline at end of file diff --git a/routers/repo/commit.go b/routers/repo/commit.go index ff90cdf46..43db9e448 100644 --- a/routers/repo/commit.go +++ b/routers/repo/commit.go @@ -18,6 +18,7 @@ import ( const ( tplCommits base.TplName = "repo/commits" + tplGraph base.TplName = "repo/graph" tplDiff base.TplName = "repo/diff/page" ) @@ -75,6 +76,32 @@ func Commits(ctx *context.Context) { ctx.HTML(200, tplCommits) } +// Graph render commit graph - show commits from all branches. +func Graph(ctx *context.Context) { + ctx.Data["PageIsCommits"] = true + + commitsCount, err := ctx.Repo.Commit.CommitsCount() + if err != nil { + ctx.Handle(500, "GetCommitsCount", err) + return + } + + graph, err := models.GetCommitGraph(ctx.Repo.GitRepo) + if err != nil { + ctx.Handle(500, "GetCommitGraph", err) + return + } + + ctx.Data["Graph"] = graph + ctx.Data["Username"] = ctx.Repo.Owner.Name + ctx.Data["Reponame"] = ctx.Repo.Repository.Name + ctx.Data["CommitCount"] = commitsCount + ctx.Data["Branch"] = ctx.Repo.BranchName + ctx.Data["RequireGitGraph"] = true + ctx.HTML(200, tplGraph) + +} + // SearchCommits render commits filtered by keyword func SearchCommits(ctx *context.Context) { ctx.Data["PageIsCommits"] = true diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index 2c45932a7..a114b8dac 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -31,6 +31,13 @@ {{end}} + {{if .RequireGitGraph}} + + + + + {{end}} + diff --git a/templates/repo/commits.tmpl b/templates/repo/commits.tmpl index 88a87ef8c..198f3cc08 100644 --- a/templates/repo/commits.tmpl +++ b/templates/repo/commits.tmpl @@ -2,8 +2,20 @@
{{template "repo/header" .}}
- {{template "repo/branch_dropdown" .}} - {{template "repo/commits_table" .}} + + {{template "repo/commits_table" .}}
{{template "base/footer" .}} diff --git a/templates/repo/graph.tmpl b/templates/repo/graph.tmpl new file mode 100644 index 000000000..622234cde --- /dev/null +++ b/templates/repo/graph.tmpl @@ -0,0 +1,44 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ + +
+
+ +
    + {{ range .Graph }} +
  • {{ .GraphAcii -}}
  • + {{ end }} +
+
+
+
+
    + {{ range .Graph }} +
  • + {{ if .OnlyRelation }} + + {{ else }} + + {{ .ShortRev}} + + {{.Branch}} + {{.Subject}} by + + {{.Author}} + + {{.Date}} + {{ end }} +
  • + {{ end }} +
+
+
+ + + +
+
+{{template "base/footer" .}}