commithgraph / timeline (#428)

* Add model and tests for graph

* Add route and router for graph

* Add assets for graph

* Add template for graph
This commit is contained in:
Kjell Kvinge 2016-12-29 00:44:32 +01:00 committed by Lunny Xiao
parent 35d9378e4e
commit 22e1bd31c6
10 changed files with 673 additions and 2 deletions

View file

@ -547,6 +547,7 @@ func runWeb(ctx *cli.Context) error {
m.Get("/src/*", repo.SetEditorconfigIfExists, repo.Home) m.Get("/src/*", repo.SetEditorconfigIfExists, repo.Home)
m.Get("/raw/*", repo.SingleDownload) m.Get("/raw/*", repo.SingleDownload)
m.Get("/commits/*", repo.RefCommits) 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("/commit/:sha([a-f0-9]{7,40})$", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.Diff)
m.Get("/forks", repo.Forks) m.Get("/forks", repo.Forks)
}, context.RepoRef()) }, context.RepoRef())

108
models/graph.go Normal file
View file

@ -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
}

41
models/graph_test.go Normal file
View file

@ -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")
}
}

15
public/css/gitgraph.css Normal file
View file

@ -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;}

17
public/js/draw.js Normal file
View file

@ -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);
}
})

399
public/js/libs/gitgraph.js Normal file
View file

@ -0,0 +1,399 @@
/*
* Copyright (c) 2011, Terrence Lee <kill889@gmail.com>
* 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 <organization> 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 <COPYRIGHT HOLDER> 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);
};

View file

@ -18,6 +18,7 @@ import (
const ( const (
tplCommits base.TplName = "repo/commits" tplCommits base.TplName = "repo/commits"
tplGraph base.TplName = "repo/graph"
tplDiff base.TplName = "repo/diff/page" tplDiff base.TplName = "repo/diff/page"
) )
@ -75,6 +76,32 @@ func Commits(ctx *context.Context) {
ctx.HTML(200, tplCommits) 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 // SearchCommits render commits filtered by keyword
func SearchCommits(ctx *context.Context) { func SearchCommits(ctx *context.Context) {
ctx.Data["PageIsCommits"] = true ctx.Data["PageIsCommits"] = true

View file

@ -31,6 +31,13 @@
</script> </script>
{{end}} {{end}}
{{if .RequireGitGraph}}
<!-- graph -->
<script src="{{AppSubUrl}}/js/libs/gitgraph.js"></script>
<script src="{{AppSubUrl}}/js/draw.js"></script>
<link rel="stylesheet" href="{{AppSubUrl}}/css/gitgraph.css">
{{end}}
<!-- Stylesheet --> <!-- Stylesheet -->
<link rel="stylesheet" href="{{AppSubUrl}}/css/semantic-2.2.1.min.css"> <link rel="stylesheet" href="{{AppSubUrl}}/css/semantic-2.2.1.min.css">
<link rel="stylesheet" href="{{AppSubUrl}}/css/index.css?v={{MD5 AppVer}}"> <link rel="stylesheet" href="{{AppSubUrl}}/css/index.css?v={{MD5 AppVer}}">

View file

@ -2,8 +2,20 @@
<div class="repository commits"> <div class="repository commits">
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "repo/branch_dropdown" .}} <div class="ui secondary menu">
{{template "repo/commits_table" .}} {{template "repo/branch_dropdown" .}}
<div class="fitted item">
<div class="ui breadcrumb">
<a href="{{.RepoLink}}/graph">
<span class="text">
<i class="octicon octicon-git-branch"></i>
</span>
commit graph
</a>
</div>
</div>
</div>
{{template "repo/commits_table" .}}
</div> </div>
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}

44
templates/repo/graph.tmpl Normal file
View file

@ -0,0 +1,44 @@
{{template "base/head" .}}
<div class="repository commits">
{{template "repo/header" .}}
<div class="ui container">
<div id="git-graph-container">
<div id="rel-container">
<canvas id="graph-canvas">
<ul id="graph-raw-list">
{{ range .Graph }}
<li><span class="node-relation">{{ .GraphAcii -}}</span></li>
{{ end }}
</ul>
</canvas>
</div>
<div id="rev-container">
<ul id="rev-list">
{{ range .Graph }}
<li>
{{ if .OnlyRelation }}
<span />
{{ else }}
<code id="{{.ShortRev}}">
<a href="{{AppSubUrl}}/{{$.Username}}/{{$.Reponame}}/commit/{{.Rev}}">{{ .ShortRev}}</a>
</code>
<strong> {{.Branch}}</strong>
<em>{{.Subject}}</em> by
<span class="author">
{{.Author}}
</span>
<span class="time">{{.Date}}</span>
{{ end }}
</li>
{{ end }}
</ul>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}