aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKjell Kvinge <kjell@kvinge.biz>2016-12-29 00:44:32 +0100
committerLunny Xiao <xiaolunwen@gmail.com>2016-12-29 07:44:32 +0800
commit22e1bd31c68586e963262db964d6a83f6115e56f (patch)
tree39d669cd4b982063512320e91ed359357a518f1f
parent35d9378e4e1b3b1c15db3a7e7237a55fa96919a1 (diff)
downloadgitea-22e1bd31c68586e963262db964d6a83f6115e56f.tar.gz
gitea-22e1bd31c68586e963262db964d6a83f6115e56f.zip
commithgraph / timeline (#428)
* Add model and tests for graph * Add route and router for graph * Add assets for graph * Add template for graph
-rw-r--r--cmd/web.go1
-rw-r--r--models/graph.go108
-rw-r--r--models/graph_test.go41
-rw-r--r--public/css/gitgraph.css15
-rw-r--r--public/js/draw.js17
-rw-r--r--public/js/libs/gitgraph.js399
-rw-r--r--routers/repo/commit.go27
-rw-r--r--templates/base/head.tmpl7
-rw-r--r--templates/repo/commits.tmpl16
-rw-r--r--templates/repo/graph.tmpl44
10 files changed, 673 insertions, 2 deletions
diff --git a/cmd/web.go b/cmd/web.go
index e6f6820a6e..45d198fdfb 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 0000000000..973476a746
--- /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 0000000000..23d8aa8492
--- /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 0000000000..930b15e2ef
--- /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 0000000000..fadb3330b3
--- /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 0000000000..e2f0026993
--- /dev/null
+++ b/public/js/libs/gitgraph.js
@@ -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);
+}; \ No newline at end of file
diff --git a/routers/repo/commit.go b/routers/repo/commit.go
index ff90cdf465..43db9e4480 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 2c45932a7e..a114b8dac5 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -31,6 +31,13 @@
</script>
{{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 -->
<link rel="stylesheet" href="{{AppSubUrl}}/css/semantic-2.2.1.min.css">
<link rel="stylesheet" href="{{AppSubUrl}}/css/index.css?v={{MD5 AppVer}}">
diff --git a/templates/repo/commits.tmpl b/templates/repo/commits.tmpl
index 88a87ef8ce..198f3cc08d 100644
--- a/templates/repo/commits.tmpl
+++ b/templates/repo/commits.tmpl
@@ -2,8 +2,20 @@
<div class="repository commits">
{{template "repo/header" .}}
<div class="ui container">
- {{template "repo/branch_dropdown" .}}
- {{template "repo/commits_table" .}}
+ <div class="ui secondary menu">
+ {{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>
{{template "base/footer" .}}
diff --git a/templates/repo/graph.tmpl b/templates/repo/graph.tmpl
new file mode 100644
index 0000000000..622234cdea
--- /dev/null
+++ b/templates/repo/graph.tmpl
@@ -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" .}}