From 22e1bd31c68586e963262db964d6a83f6115e56f Mon Sep 17 00:00:00 2001
From: Kjell Kvinge <kjell@kvinge.biz>
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 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" .}}