dmitri.shuralyov.com/app/changes/...

Move in initial version of changes web app.
dmitshur committed 6 years ago commit 520276ebdfde7516cd418b47e2f67e360916ea6b
Collapse all
_data/change-files.html.tmpl
@@ -0,0 +1,20 @@
<html>
	<head>
		{{template "head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}

		<h1>{{.Change.Title}} <span class="gray">#{{.Change.ID}}</span></h1>
		<div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div>
		{{.Tabnav "Files"}}

{{define "FileDiff"}}
<div class="list-entry list-entry-border">
	<div class="list-entry-header">{{.Title}}</div>
	<div class="list-entry-body">
		<pre class="highlight-diff">{{.Diff}}</pre>
	</div>
</div>
{{end}}
_data/comment.html.tmpl
@@ -0,0 +1,36 @@
{{/* Dot is an issues.Comment. */}}
{{define "comment"}}
<div class="comment-edit-container">
	<div>
		<div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div>
		<div id="comment-{{.ID}}" style="display: flex;" class="list-entry">
			<div class="list-entry-container list-entry-border">
				<div class="list-entry-header" style="display: flex;">
					<span class="content">{{render (user .User)}} commented <a class="black" href="#comment-{{.ID}}" onclick="AnchorScroll(this, event);">{{render (time .CreatedAt)}}</a>
						{{with .Edited}} ยท <span style="cursor: default;" title="{{.By.Login}} edited this comment {{reltime .At}}.">edited{{if not (equalUsers $.User .By)}} by {{.By.Login}}{{end}}</span>{{end}}
					</span>
					{{if (not state.DisableReactions)}}
						<span class="right-icon">{{render (newReaction (reactableID .ID))}}</span>
					{{end}}
					{{if .Editable}}<span class="right-icon"><a href="javascript:" title="Edit" onclick="EditComment({{`edit` | json}}, this);">{{octicon "pencil"}}</a></span>{{end}}
				</div>
				<div class="list-entry-body">
					<div class="markdown-body">
						{{with .Body}}
							{{. | gfm}}
						{{else}}
							<i class="gray">No description.</i>
						{{end}}
					</div>
				</div>
			</div>
		</div>
	</div>
	<div style="display: none;">
		{{template "edit-comment" .}}
	</div>
	{{if (not state.DisableReactions)}}
		{{render (reactionsBar .Reactions (reactableID .ID))}}
	{{end}}
</div>
{{end}}
_data/edit-comment.html.tmpl
@@ -0,0 +1,23 @@
{{/* TODO: Dedup with new-comment, only buttons differ, so factor them out. */}}
{{define "edit-comment"}}
<div style="display: flex;" class="edit-container list-entry">
	<div style="margin-right: 10px;">{{render (avatar .User)}}</div>
	<div class="list-entry-border" style="flex-grow: 1;">
		<div class="list-entry-header tabs" style="display: flex;">
			<span style="flex-grow: 1; font-size: 14px;">
				<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
				<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
			</span>
			<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
		</div>
		<div class="list-entry-body">
			<textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" data-id="{{.ID}}" data-raw="{{.Body}}" tabindex=1></textarea>
			<div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div>
			<div style="text-align: right; margin-top: 10px;">
				<button class="btn btn-success btn-small" onclick="EditComment({{`update` | json}}, this);" tabindex=1>Update comment</button>
				<button class="btn btn-danger btn-small" onclick="EditComment({{`cancel` | json}}, this);" tabindex=1>Cancel</button>
			</div>
		</div>
	</div>
</div>
{{end}}
_data/icon-text.html.tmpl
@@ -0,0 +1,18 @@
{{/* TODO: Try to use issues.OpenState and issues.ClosedState constants. */}}
{{define "toggle-button"}}
	{{if eq . "open"}}
		{{template "close-button"}}
	{{else if eq . "closed"}}
		{{template "reopen-button"}}
	{{else}}
		{{.}}
	{{end}}
{{end}}

{{define "close-button"}}
<button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Close Issue" data-2-actions="Comment and close" onclick="ToggleIssueState('closed');" tabindex=1>Close Issue</button>
{{end}}

{{define "reopen-button"}}
<button id="issue-toggle-button" class="btn btn-neutral btn-small" data-1-action="Reopen Issue" data-2-actions="Reopen and comment" onclick="ToggleIssueState('open');" tabindex=1>Reopen Issue</button>
{{end}}
_data/issue.html.tmpl
@@ -0,0 +1,29 @@
<html>
	<head>
		{{template "head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "issue" .}}
	</body>
</html>

{{define "issue"}}
	<h1>{{.Change.Title}} <span class="gray">#{{.Change.ID}}</span></h1>
	<div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div>
	{{.Tabnav "Discussion"}}
	{{range .Items}}
		{{template "issue-item" .}}
	{{end}}
	<div id="new-item-marker"></div>
	{{template "new-comment" .}}
{{end}}

{{define "issue-item"}}
	{{if eq .TemplateName "comment"}}
		{{template "comment" .IssueItem}}
	{{else if eq .TemplateName "event"}}
		{{render (event .IssueItem)}}
	{{end}}
{{end}}
_data/issues.html.tmpl
@@ -0,0 +1,33 @@
<html>
	<head>
		{{template "scriptless-head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "create-issue" .}}
		{{render .Changes}}
	</body>
</html>

{{define "scriptless-head"}}
	{{.HeadPre}}
	<link href="{{.BaseURI}}/assets/gfm/gfm.css" rel="stylesheet" type="text/css" />
	<link href="{{.BaseURI}}/assets/style.css" rel="stylesheet" type="text/css" />
	{{.HeadPost}}
{{end}}

{{define "head"}}
	{{template "scriptless-head" .}}
	<script type="text/javascript">
		var State = {{.State | jsonfmt}};
		{{/*var State = {{.State}};*/}}
	</script>
	<script src="{{.BaseURI}}/assets/script.js" type="text/javascript"></script>
{{end}}

{{define "create-issue"}}
	{{if not .DisableUsers}}
		<div style="text-align: right;"><button class="btn btn-success btn-small" onclick="window.location = '{{.BaseURI}}/new';">Create Issue</button></div>
	{{end}}
{{end}}
_data/new-comment.html.tmpl
@@ -0,0 +1,28 @@
{{define "new-comment"}}
{{if .CurrentUser.ID}}
	<div id="new-comment-container" class="edit-container list-entry" style="display: flex;">
		<div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div>
		<div class="list-entry-border" style="flex-grow: 1;">
			<div class="list-entry-header tabs" style="display: flex;">
				<span style="flex-grow: 1; font-size: 14px;">
					<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
					<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
				</span>
				<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
			</div>
			<div class="list-entry-body">
				<textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" tabindex=1></textarea>
				<div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div>
				<div style="text-align: right; margin-top: 10px;">
					<button class="btn btn-success btn-small" onclick="PostComment();" tabindex=1>Comment</button>
					{{if .Issue.Editable}}{{template "toggle-button" (print .Issue.State)}}{{end}}
				</div>
			</div>
		</div>
	</div>
{{else}}
	<div class="event" style="margin-top: 20px; margin-bottom: 20px;">
		<form method="post" action="/login/github" style="display: inline-block; margin-bottom: 0;"><input class="btn" type="submit" value="Sign in via GitHub"><input type="hidden" name="return" value="{{.BaseURI}}{{.ReqPath}}"></form> to comment.
	</div>
{{end}}
{{end}}
_data/new-issue.html.tmpl
@@ -0,0 +1,35 @@
<html>
	<head>
		{{template "head" .}}
	</head>
	<body>
		{{template "body-pre" .}}
		{{.BodyTop}}
		{{template "new-issue" .}}
	</body>
</html>

{{define "new-issue"}}
<div style="display: flex; margin-top: 20px;" class="edit-container list-entry">
	<div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div>
	<div class="list-entry-border" style="flex-grow: 1;">
		<div class="list-entry-header tabs-title">
			<div><input id="title-editor" type="text" placeholder="Title" autofocus></div>
			<div style="display: flex;">
				<span style="flex-grow: 1; font-size: 14px;">
					<a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a>
					<a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a>
				</span>
				<span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span>
			</div>
		</div>
		<div class="list-entry-body">
			<textarea class="comment-editor" style="min-height: 200px;" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);"></textarea>
			<div class="comment-preview markdown-body" style="padding: 10px; min-height: 200px; display: none;"></div>
			<div style="text-align: right; margin-top: 10px;">
				<button id="create-issue-button" class="btn btn-success btn-small" disabled="disabled" onclick="CreateNewIssue();">Create Issue</button>
			</div>
		</div>
	</div>
</div>
{{end}}
_data/style.css
@@ -0,0 +1,362 @@
a.black {
	color: black;
	text-decoration: none;
}
a.black:hover {
	text-decoration: underline;
}

.gray {
	color: #888;
}
.lightgray {
	color: #ddd;
}
.tiny {
	font-size: 12px;
}

div.list-entry {
	margin-top: 12px;
}
div.list-entry-container {
	width: 100%;
	box-sizing: border-box;
}
div.list-entry-border {
	border: 1px solid #ddd;
	border-radius: 4px;
}
div.list-entry-header {
	font-size: 13px;
	background-color: #f8f8f8;
	padding: 10px;
	border-radius: 4px 4px 0 0;
	border-bottom: 1px solid #eee;
}
.hash-selected div.list-entry-border {
	border: 1px solid #8ca2d9;
}
.hash-selected div.list-entry-header {
	background-color: #dbe5ff;
	border-bottom: 1px solid #8ca2d9;
}
div.list-entry-header.tabs {
	padding: 16px 10px 6px 10px;
}
div.list-entry-header.tabs-title {
	padding-bottom: 6px;
	background-color: #fff;
}
div.list-entry-body {
	padding: 10px;
}

div.multilist-entry:not(:nth-child(0n+2)) {
	border: 0px solid #ddd;
	border-top-width: 1px;
}

/* Needed in issuesapp only because of something in parent containers... */
.markdown-body {
	word-break: break-word;
}

/* GFM style overrides. */
.markdown-body {
	font-size: 14px;
	line-height: 1.2;
}

span.content {
	display: table-cell;
	width: 100%;
}
span.right-icon {
	display: table-cell;
	padding-left: 12px;
	padding-right: 4px;
}
span.right-icon a {
	color: #bbb;
}
span.right-icon a:hover {
	color: black;
}

img.topbar-avatar {
	border-radius: 2px;
	width: 18px;
	height: 18px;
	vertical-align: top;
}
a.topbar-avatar {
	margin-right: 6px;
}

input#title-editor {
	font-family: inherit;
	font-size: 14px;
	background-color: #fafafa;
	display: block;
	padding: 6px;
	width: 100%;
	border: 1px solid #ddd;
	margin-bottom: 16px;
}
input#title-editor:focus {
	background-color: #fff;
}

textarea.comment-editor {
	font-family: inherit;
	font-size: 14px;
	background-color: #fafafa;
	display: block;
	padding: 10px;
	width: 100%;
	resize: vertical;
	min-height: 120px;
	max-height: 500px;
	border: 1px solid #ddd;
}
textarea.comment-editor:focus {
	background-color: #fff;
}

.tab-link {
	padding: 9px 13px 8px 13px;
}
.tab-link.active {
	padding: 8px 12px 8px 12px;
	background-color: #fff;
	border: 1px solid #ddd;
	border-bottom-width: 0;
}

.event {
	margin-left: 58px;
}
.event-header {
	padding-top: 6px;
	padding-bottom: 6px;
	line-height: 20px;
}
.event-icon {
	float: left;
	margin-right: 10px;
	width: 32px;
	height: 32px;
	display: flex; justify-content: center; align-items: center;
	border-radius: 50%;
}

div.list-entry-header nav a {
	color: #767676;
	text-decoration: none;
}
div.list-entry-header nav a:hover,
div.list-entry-header nav a:active,
div.list-entry-header nav a:focus {
	color: #000;
}
div.list-entry-header nav .selected {
	color: #000;
	font-weight: bold;
}

span.right-icon div.new-reaction {
	width: 22px;
}
span.right-icon div.new-reaction .smiley {
	width: 16px;
	height: 16px;
}
span.right-icon div.new-reaction .plus {
	top: -4px;
	width: 6px;
	position: relative;
}

div.reactable-container {
	vertical-align: top;
	margin-left: 58px;
}
div.reactable-container:hover div.new-reaction {
	display: inline-block;
}

span.emoji-inner {
	display: inline-block;
	overflow: hidden;
	width: 100%;
	height: 100%;
	background: url(/emojis/emojis.png);
	background-size: 4100% !important;
	vertical-align: middle;
}
span.emoji-outer {
	display: inline-block;
	height: 1em;
	width: 1em;
	font-size: 22px;
	margin-right: 4px;
}
.reaction strong {
	font-family: inherit;
	font-size: 14px;
}

div.reaction {
	cursor: pointer;
	display: inline-block;
	box-sizing: border-box;
	padding: 3px 5px;
	height: 30px;
	background-color: #f6fafd;
	color: #8ec0e6;
	border: 1px solid #92def9;
	border-radius: 4px;
}
a.reaction {
	display: inline-block;
	margin-right: 6px;
	margin-top: 4px;
}

div.others {
	background-color: #fff;
	color: #aaa;
	border: 1px solid #e0e0e0;
}
div.others:hover {
	border: 1px solid #92def9;
}

div.reactable-container div.new-reaction {
	cursor: pointer;
	display: none;
	box-sizing: border-box;
	padding: 5px 4px;
	height: 30px;
	color: #aaa;
	border: 1px solid #e0e0e0;
	border-radius: 4px;
	margin-top: 4px;
	vertical-align: top;
}
div.reactable-container div.new-reaction .smiley {
	width: 18px;
	height: 18px;
}
div.reactable-container div.new-reaction .plus {
	top: -4px;
	width: 6px;
	position: relative;
}
div.reactable-container div.new-reaction:hover {
	color: #eda000;
	border: 1px solid #92def9;
}

span.rm-emoji {
	display: inline-block;
	width: 22px;
	height: 22px;
	background: url(/emojis/emojis.png);
	background-size: 4100%;
}
span.rm-large {
	width: 44px;
	height: 44px;
}

div.rm-reaction {
	display: inline-block;
	padding: 4px;
	border-radius: 4px;
}
div.rm-reaction:hover {
	background-color: #92def9;
}

div#rm-reactions-menu {
	position: absolute;
	display: none;
	z-index: 1000;

	background-color: white;
	border: 1px solid lightgray;
	box-shadow: 0 5px 10px rgba(0, 0, 0, .12);
	border-radius: 6px;
	padding: 8px;
	width: 270px;
	box-sizing: content-box;
}
input.rm-reactions-filter {
	width: 100%;
	height: 30px;
	font-family: inherit;
	font-size: 14px;
	padding: 4px 16px;
	outline: 0;
	border: 1px solid #ccc;
	border-radius: 15px;
	margin-bottom: 8px;
	box-sizing: border-box;
}
div.rm-reactions-results {
	overflow-y: scroll;
	height: 300px;
	cursor: pointer;
}
div.rm-reactions-preview {
	height: 44px;
	margin-top: 10px;
	/*background-color: #eeeeee;*/
}
span#rm-reactions-preview-emoji {
}
span#rm-reactions-preview-label {
	margin-left: 10px;
	vertical-align: top;
	font-family: inherit;
	font-size: 14px;
	font-weight: bold;
}

div.rm-reactions-menu-container {
	position: relative;
}
div.rm-reactions-menu-disabled {
	position: absolute;
	width: 100%;
	height: 100%;
	background-color: rgba(255, 255, 255, 0.75);
}
div.rm-reactions-menu-signin {
	font-family: inherit;
	margin-top: 185px;
	margin-left: auto;
	margin-right: auto;
	display: table;
	background-color: rgba(255, 255, 255, 0.7);
	box-shadow: 0 0 50px 10px rgba(255, 255, 255, 1.0);
}

/* https://github.com/primer/primer-navigation */
.counter{display:inline-block;padding:2px 5px;font-size:12px;font-weight:600;line-height:1;color:#666;background-color:#eee;border-radius:20px}.menu{margin-bottom:15px;list-style:none;background-color:#fff;border:1px solid #d8d8d8;border-radius:3px}.menu-item{position:relative;display:block;padding:8px 10px;border-bottom:1px solid #eee}.menu-item:first-child{border-top:0;border-top-left-radius:2px;border-top-right-radius:2px}.menu-item:first-child::before{border-top-left-radius:2px}.menu-item:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.menu-item:last-child::before{border-bottom-left-radius:2px}.menu-item:hover{text-decoration:none;background-color:#f9f9f9}.menu-item.selected{font-weight:bold;color:#222;cursor:default;background-color:#fff}.menu-item.selected::before{position:absolute;top:0;bottom:0;left:0;width:2px;content:"";background-color:#d26911}.menu-item .octicon{width:16px;margin-right:5px;color:#333;text-align:center}.menu-item .counter{float:right;margin-left:5px}.menu-item .menu-warning{float:right;color:#d26911}.menu-item .avatar{float:left;margin-right:5px}.menu-item.alert .counter{color:#bd2c00}.menu-heading{display:block;padding:8px 10px;margin-top:0;margin-bottom:0;font-size:13px;font-weight:bold;line-height:20px;color:#555;background-color:#f7f7f7;border-bottom:1px solid #eee}.menu-heading:hover{text-decoration:none}.menu-heading:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.menu-heading:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.tabnav{margin-top:0;margin-bottom:15px;border-bottom:1px solid #ddd}.tabnav .counter{margin-left:5px}.tabnav-tabs{margin-bottom:-1px}.tabnav-tab{display:inline-block;padding:8px 12px;font-size:14px;line-height:20px;color:#666;text-decoration:none;background-color:transparent;border:1px solid transparent;border-bottom:0}.tabnav-tab.selected{color:#333;background-color:#fff;border-color:#ddd;border-radius:3px 3px 0 0}.tabnav-tab:hover,.tabnav-tab:focus{text-decoration:none}.tabnav-extra{display:inline-block;padding-top:10px;margin-left:10px;font-size:12px;color:#666}.tabnav-extra>.octicon{margin-right:2px}a.tabnav-extra:hover{color:#4078c0;text-decoration:none}.tabnav-btn{margin-left:10px}.filter-list{list-style-type:none}.filter-list.small .filter-item{padding:4px 10px;margin:0 0 2px;font-size:12px}.filter-list.pjax-active .filter-item{color:#767676;background-color:transparent}.filter-list.pjax-active .filter-item.pjax-active{color:#fff;background-color:#4078c0}.filter-item{position:relative;display:block;padding:8px 10px;margin-bottom:5px;overflow:hidden;font-size:14px;color:#767676;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;border-radius:3px}.filter-item:hover{text-decoration:none;background-color:#eee}.filter-item.selected{color:#fff;background-color:#4078c0}.filter-item .count{float:right;font-weight:bold}.filter-item .bar{position:absolute;top:2px;right:0;bottom:2px;z-index:-1;display:inline-block;background-color:#f1f1f1}.subnav{margin-bottom:20px}.subnav::before{display:table;content:""}.subnav::after{display:table;clear:both;content:""}.subnav-bordered{padding-bottom:20px;border-bottom:1px solid #eee}.subnav-flush{margin-bottom:0}.subnav-item{position:relative;float:left;padding:6px 14px;font-weight:600;line-height:20px;color:#666;border:1px solid #e5e5e5}.subnav-item+.subnav-item{margin-left:-1px}.subnav-item:hover,.subnav-item:focus{text-decoration:none;background-color:#f5f5f5}.subnav-item.selected,.subnav-item.selected:hover,.subnav-item.selected:focus{z-index:2;color:#fff;background-color:#4078c0;border-color:#4078c0}.subnav-item:first-child{border-top-left-radius:3px;border-bottom-left-radius:3px}.subnav-item:last-child{border-top-right-radius:3px;border-bottom-right-radius:3px}.subnav-search{position:relative;margin-left:10px}.subnav-search-input{width:320px;padding-left:30px;color:#767676;border-color:#d5d5d5}.subnav-search-input-wide{width:500px}.subnav-search-icon{position:absolute;top:9px;left:8px;display:block;color:#ccc;text-align:center;pointer-events:none}.subnav-search-context .btn{color:#555;border-top-right-radius:0;border-bottom-right-radius:0}.subnav-search-context .btn:hover,.subnav-search-context .btn:focus,.subnav-search-context .btn:active,.subnav-search-context .btn.selected{z-index:2}.subnav-search-context+.subnav-search{margin-left:-1px}.subnav-search-context+.subnav-search .subnav-search-input{border-top-left-radius:0;border-bottom-left-radius:0}.subnav-search-context .select-menu-modal-holder{z-index:30}.subnav-search-context .select-menu-modal{width:220px}.subnav-search-context .select-menu-item-icon{color:inherit}.subnav-spacer-right{padding-right:10px}

pre.highlight-diff {
	font-size: 12px;
	line-height: 16px;
	overflow-x: scroll;
}

.highlight-diff .input-block { display: block; width: 100%; }
.highlight-diff .gi { color: #000; background-color: #dfd; }
.highlight-diff .gi .x { color: #000; background-color: #afa; }
.highlight-diff .gd { color: #000; background-color: #fdd; }
.highlight-diff .gd .x { color: #000; background-color: #faa; }
.highlight-diff .gu { color: #800080; font-weight: bold; }
.highlight-diff .gh { color: #999; }
assets/assets.go
@@ -0,0 +1,26 @@
// +build dev

package assets

import (
	"go/build"
	"log"
	"net/http"

	"github.com/shurcooL/go/gopherjs_http"
	"github.com/shurcooL/httpfs/union"
)

// Assets contains assets for issuesapp.
var Assets = union.New(map[string]http.FileSystem{
	"/script.js": gopherjs_http.Package("dmitri.shuralyov.com/changes/app/frontend"),
	"/assets":    http.Dir(importPathToDir("dmitri.shuralyov.com/changes/app/_data")),
})

func importPathToDir(importPath string) string {
	p, err := build.Import(importPath, "", build.FindOnly)
	if err != nil {
		log.Fatalln(err)
	}
	return p.Dir
}
assets/doc.go
@@ -0,0 +1,4 @@
//go:generate vfsgendev -source="dmitri.shuralyov.com/changes/app/assets".Assets

// Package assets contains assets for issuesapp.
package assets
assets/external.go
@@ -0,0 +1,10 @@
package assets

import (
	"github.com/shurcooL/github_flavored_markdown/gfmstyle"
)

var (
	// GFMStyle contains CSS styles for rendering GitHub Flavored Markdown.
	GFMStyle = gfmstyle.Assets
)
cmd/changesdev/main.go
@@ -0,0 +1,154 @@
// changesdev is a sample program that serves changes.
//
// E.g., try http://localhost:8080/changes/33158.
package main

/*
Notes:

https://godoc.org/github.com/andygrunwald/go-gerrit
https://gerrit-review.googlesource.com/Documentation/rest-api.html
https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
https://gerrit-review.googlesource.com/Documentation/user-search.html#_search_operators
https://review.openstack.org/Documentation/config-hooks.html#_comment_added
*/

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"

	"dmitri.shuralyov.com/changes"
	"dmitri.shuralyov.com/changes/app"
	"dmitri.shuralyov.com/changes/gerritapi"
	"dmitri.shuralyov.com/changes/githubapi"
	"dmitri.shuralyov.com/changes/maintner"
	"github.com/andygrunwald/go-gerrit"
	"github.com/google/go-github/github"
	"github.com/gorilla/mux"
	"github.com/gregjones/httpcache"
	"github.com/shurcooL/githubql"
	"github.com/shurcooL/httpgzip"
	"github.com/shurcooL/reactions/emojis"
	ghusers "github.com/shurcooL/users/githubapi"
	"golang.org/x/build/maintner/godata"
	"golang.org/x/oauth2"
)

var httpFlag = flag.String("http", ":8080", "Listen for HTTP connections on this address.")

func main() {
	flag.Parse()

	var service changes.Service
	switch 2 {
	case 0:
		cacheTransport := httpcache.NewMemoryCacheTransport()
		gerrit, err := gerrit.NewClient("https://go-review.googlesource.com/", &http.Client{Transport: cacheTransport})
		if err != nil {
			log.Fatalln(err)
		}

		service = gerritapi.NewService(gerrit)

	case 1:
		corpus, err := godata.Get(context.Background())
		if err != nil {
			log.Fatalln(err)
		}

		service = maintner.NewService(corpus)

	case 2:
		// Perform GitHub API authentication with provided token.
		token := os.Getenv("CHANGES_GITHUB_TOKEN")
		if token == "" {
			log.Fatalln("CHANGES_GITHUB_TOKEN env var is empty")
		}
		cacheTransport := &httpcache.Transport{
			Transport: &oauth2.Transport{
				Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}),
			},
			Cache:               httpcache.NewMemoryCache(),
			MarkCachedResponses: true,
		}
		httpClient := &http.Client{Transport: cacheTransport}
		ghV3 := github.NewClient(httpClient)
		ghV4 := githubql.NewClient(httpClient)

		usersService := ghusers.NewService(ghV3)
		service = githubapi.NewService(ghV3, ghV4, nil, usersService)
	}

	changesOpt := changesapp.Options{
		HeadPre: `<style type="text/css">
	body {
		margin: 20px;
		font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
		font-size: 14px;
		line-height: initial;
		color: #373a3c;
	}
	.btn {
		font-size: 11px;
		line-height: 11px;
		border-radius: 4px;
		border: solid #d2d2d2 1px;
		background-color: #fff;
		box-shadow: 0 1px 1px rgba(0, 0, 0, .05);
	}
</style>`,
		DisableReactions: true,
	}
	changesApp := changesapp.New(service, nil, changesOpt)

	r := mux.NewRouter()

	issuesHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		prefixLen := len("/changes")
		if prefix := req.URL.Path[:prefixLen]; req.URL.Path == prefix+"/" {
			baseURL := prefix
			if req.URL.RawQuery != "" {
				baseURL += "?" + req.URL.RawQuery
			}
			http.Redirect(w, req, baseURL, http.StatusFound)
			return
		}
		req.URL.Path = req.URL.Path[prefixLen:]
		if req.URL.Path == "" {
			req.URL.Path = "/"
		}
		//req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, "go.googlesource.com/go"))
		req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, "github.com/shurcooL/vfsgen"))
		req = req.WithContext(context.WithValue(req.Context(), changesapp.BaseURIContextKey, "/changes"))
		changesApp.ServeHTTP(w, req)
	})
	r.Path("/changes").Handler(issuesHandler)
	r.PathPrefix("/changes/").Handler(issuesHandler)

	r.HandleFunc("/login/github", func(w http.ResponseWriter, req *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		fmt.Fprintln(w, "Sorry, this is just a demo instance and it doesn't support signing in.")
	})

	emojisHandler := httpgzip.FileServer(emojis.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed})
	r.PathPrefix("/emojis/").Handler(http.StripPrefix("/emojis", emojisHandler))

	printServingAt(*httpFlag)
	err := http.ListenAndServe(*httpFlag, r)
	if err != nil {
		log.Fatalln("ListenAndServe:", err)
	}
}

func printServingAt(addr string) {
	hostPort := addr
	if strings.HasPrefix(hostPort, ":") {
		hostPort = "localhost" + hostPort
	}
	fmt.Printf("serving at http://%s/\n", hostPort)
}
common/common.go
@@ -0,0 +1,16 @@
// Package common contains common code for backend and frontend.
package common

import (
	"github.com/shurcooL/users"
)

type State struct {
	BaseURI          string
	ReqPath          string
	RepoSpec         string
	IssueID          uint64 `json:",omitempty"` // IssueID is the current issue ID, or 0 if not applicable (e.g., current page is /new).
	CurrentUser      users.User
	DisableReactions bool
	DisableUsers     bool
}
component/component.go
@@ -0,0 +1,325 @@
// Package component contains individual components that can render themselves as HTML.
package component

import (
	"fmt"
	"image/color"
	"time"

	"dmitri.shuralyov.com/changes"
	"github.com/dustin/go-humanize"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/octiconssvg"
	"github.com/shurcooL/users"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// Event is an event component.
type Event struct {
	Event issues.Event
}

func (e Event) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry event event-{{.Type}}">
	// 	{{.Icon}}
	// 	<div class="event-header">
	// 		<img class="inline-avatar" width="16" height="16" src="{{.Actor.AvatarURL}}">
	// 		{{render (user .Actor)}} {{.Text}} {{render (time .CreatedAt)}}
	// 	</div>
	// </div>

	div := htmlg.DivClass("event-header")
	image := &html.Node{
		Type: html.ElementNode, Data: atom.Img.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "width: 16px; height: 16px; border-radius: 2px; vertical-align: middle; margin-right: 4px;"},
			{Key: atom.Src.String(), Val: e.Event.Actor.AvatarURL},
		},
	}
	div.AppendChild(image)
	htmlg.AppendChildren(div, User{e.Event.Actor}.Render()...)
	div.AppendChild(htmlg.Text(" "))
	htmlg.AppendChildren(div, e.text()...)
	div.AppendChild(htmlg.Text(" "))
	htmlg.AppendChildren(div, Time{e.Event.CreatedAt}.Render()...)

	outerDiv := htmlg.DivClass(fmt.Sprintf("list-entry event event-%s", e.Event.Type),
		e.icon(),
		div,
	)
	return []*html.Node{outerDiv}
}

func (e Event) icon() *html.Node {
	var (
		icon            *html.Node
		color           = "#767676"
		backgroundColor = "#f3f3f3"
	)
	switch e.Event.Type {
	case issues.Reopened:
		icon = octiconssvg.PrimitiveDot()
		color, backgroundColor = "#fff", "#6cc644"
	case issues.Closed:
		icon = octiconssvg.CircleSlash()
		color, backgroundColor = "#fff", "#bd2c00"
	case issues.Renamed:
		icon = octiconssvg.Pencil()
	case issues.Labeled, issues.Unlabeled:
		icon = octiconssvg.Tag()
	case issues.CommentDeleted:
		icon = octiconssvg.X()
	default:
		icon = octiconssvg.PrimitiveDot()
	}
	return &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Class.String(), Val: "event-icon"},
			{Key: atom.Style.String(), Val: fmt.Sprintf("color: %s; background-color: %s;", color, backgroundColor)},
		},
		FirstChild: icon,
	}
}

func (e Event) text() []*html.Node {
	switch e.Event.Type {
	case issues.Reopened, issues.Closed:
		return []*html.Node{htmlg.Text(fmt.Sprintf("%s this", e.Event.Type))}
	case issues.Renamed:
		return []*html.Node{htmlg.Text("changed the title from "), htmlg.Strong(e.Event.Rename.From), htmlg.Text(" to "), htmlg.Strong(e.Event.Rename.To)}
	case issues.Labeled:
		var ns []*html.Node
		ns = append(ns, htmlg.Text("added the "))
		ns = append(ns, Label{Label: *e.Event.Label}.Render()...)
		ns = append(ns, htmlg.Text(" label"))
		return ns
	case issues.Unlabeled:
		var ns []*html.Node
		ns = append(ns, htmlg.Text("removed the "))
		ns = append(ns, Label{Label: *e.Event.Label}.Render()...)
		ns = append(ns, htmlg.Text(" label"))
		return ns
	case issues.CommentDeleted:
		return []*html.Node{htmlg.Text("deleted a comment")}
	default:
		return []*html.Node{htmlg.Text(string(e.Event.Type))}
	}
}

// ChangeStateBadge is a component that displays the state of a change
// with a badge, who opened it, and when it was opened.
type ChangeStateBadge struct {
	Change changes.Change
}

func (i ChangeStateBadge) Render() []*html.Node {
	var ns []*html.Node
	ns = append(ns, ChangeBadge{State: i.Change.State}.Render()...)
	span := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "margin-left: 4px;"},
		},
	}
	htmlg.AppendChildren(span, User{i.Change.Author}.Render()...)
	span.AppendChild(htmlg.Text(" opened this change "))
	htmlg.AppendChildren(span, Time{i.Change.CreatedAt}.Render()...)
	ns = append(ns, span)
	return ns
}

// ChangeBadge is a change badge, displaying the change's state.
type ChangeBadge struct {
	State changes.State
}

func (cb ChangeBadge) Render() []*html.Node {
	var (
		icon  *html.Node
		text  string
		color string
	)
	switch cb.State {
	case changes.OpenState:
		icon = octiconssvg.IssueOpened()
		text = "Open"
		color = "#6cc644"
	case changes.ClosedState:
		icon = octiconssvg.IssueClosed()
		text = "Closed"
		color = "#bd2c00"
	case changes.MergedState:
		icon = octiconssvg.GitMerge()
		text = "Merged"
		color = "#6f42c1"
	default:
		return []*html.Node{htmlg.Text(string(cb.State))}
	}
	span := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{{
			Key: atom.Style.String(),
			Val: `display: inline-block;
padding: 4px 6px 4px 6px;
margin: 4px;
color: #fff;
background-color: ` + color + `;`,
		}},
	}
	span.AppendChild(&html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-right: 6px;"}},
		FirstChild: icon,
	})
	span.AppendChild(htmlg.Text(text))
	return []*html.Node{span}
}

// ChangeIcon is a change icon, displaying the change's state.
type ChangeIcon struct {
	State changes.State
}

func (ii ChangeIcon) Render() []*html.Node {
	// TODO: Make this much nicer.
	// {{if eq . "open"}}
	// 	<span style="margin-right: 6px; color: #6cc644;" class="octicon octicon-issue-opened"></span>
	// {{else if eq . "closed"}}
	// 	<span style="margin-right: 6px; color: #bd2c00;" class="octicon octicon-issue-closed"></span>
	// {{end}}
	var (
		icon  *html.Node
		color string
	)
	switch ii.State {
	case changes.OpenState:
		icon = octiconssvg.IssueOpened()
		color = "#6cc644"
	case changes.ClosedState:
		icon = octiconssvg.IssueClosed()
		color = "#bd2c00"
	case changes.MergedState:
		icon = octiconssvg.GitMerge()
		color = "#bd2c00" // TODO.
	}
	span := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{{
			Key: atom.Style.String(),
			Val: `margin-right: 6px;
color: ` + color + `;`,
		}},
		FirstChild: icon,
	}
	return []*html.Node{span}
}

// Label is a label component.
type Label struct {
	Label issues.Label
}

func (l Label) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <span style="...; color: {{.fontColor}}; background-color: {{.Color.HexString}};">{{.Name}}</span>
	span := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{{
			Key: atom.Style.String(),
			Val: `display: inline-block;
font-size: 12px;
line-height: 1.2;
padding: 0px 3px 0px 3px;
border-radius: 2px;
color: ` + l.fontColor() + `;
background-color: ` + l.Label.Color.HexString() + `;`,
		}},
	}
	span.AppendChild(htmlg.Text(l.Label.Name))
	return []*html.Node{span}
}

// fontColor returns one of "#fff" or "#000", whichever is a better fit for
// the font color given the label color.
func (l Label) fontColor() string {
	// Convert label color to 8-bit grayscale, and make a decision based on that.
	switch y := color.GrayModel.Convert(l.Label.Color).(color.Gray).Y; {
	case y < 128:
		return "#fff"
	case y >= 128:
		return "#000"
	}
	panic("unreachable")
}

// User is a user component.
type User struct {
	User users.User
}

func (u User) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <a class="black" href="{{.HTMLURL}}"><strong>{{.Login}}</strong></a>
	a := &html.Node{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
			{Key: atom.Class.String(), Val: "black"},
			{Key: atom.Href.String(), Val: u.User.HTMLURL},
		},
		FirstChild: htmlg.Strong(u.User.Login),
	}
	return []*html.Node{a}
}

// Avatar is an avatar component.
type Avatar struct {
	User users.User
	Size int // In pixels, e.g., 48.
}

func (a Avatar) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <a style="..." href="{{.User.HTMLURL}}" tabindex=-1>
	// 	<img style="..." width="{{.Size}}" height="{{.Size}}" src="{{.User.AvatarURL}}">
	// </a>
	return []*html.Node{{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "display: inline-block;"},
			{Key: atom.Href.String(), Val: a.User.HTMLURL},
			{Key: atom.Tabindex.String(), Val: "-1"},
		},
		FirstChild: &html.Node{
			Type: html.ElementNode, Data: atom.Img.String(),
			Attr: []html.Attribute{
				{Key: atom.Style.String(), Val: "border-radius: 3px;"},
				{Key: atom.Width.String(), Val: fmt.Sprint(a.Size)},
				{Key: atom.Height.String(), Val: fmt.Sprint(a.Size)},
				{Key: atom.Src.String(), Val: a.User.AvatarURL},
			},
		},
	}}
}

// Time component that displays human friendly relative time (e.g., "2 hours ago", "yesterday"),
// but also contains a tooltip with the full absolute time (e.g., "Jan 2, 2006, 3:04 PM MST").
//
// TODO: Factor out, it's the same as in notificationsapp.
type Time struct {
	Time time.Time
}

func (t Time) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <abbr title="{{.Format "Jan 2, 2006, 3:04 PM MST"}}">{{reltime .}}</abbr>
	abbr := &html.Node{
		Type: html.ElementNode, Data: atom.Abbr.String(),
		Attr:       []html.Attribute{{Key: atom.Title.String(), Val: t.Time.Format("Jan 2, 2006, 3:04 PM MST")}},
		FirstChild: htmlg.Text(humanize.Time(t.Time)),
	}
	return []*html.Node{abbr}
}
component/issues.go
@@ -0,0 +1,146 @@
package component

import (
	"fmt"

	"dmitri.shuralyov.com/changes"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// Issues is a component that displays a page of changes,
// with a navigation bar on top.
type Issues struct {
	IssuesNav IssuesNav
	Filter    changes.StateFilter
	Entries   []ChangeEntry
}

func (i Issues) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry list-entry-border">
	// 	{{render .IssuesNav}}
	// 	{{with .Issues}}{{range .}}
	// 		{{render .}}
	// 	{{end}}{{else}}
	// 		<div style="text-align: center; margin-top: 80px; margin-bottom: 80px;">There are no {{.Filter}} changes.</div>
	// 	{{end}}
	// </div>

	var ns []*html.Node
	ns = append(ns, i.IssuesNav.Render()...)
	for _, e := range i.Entries {
		ns = append(ns, e.Render()...)
	}
	if len(i.Entries) == 0 {
		// No changes with this filter. Let the user know via a blank slate.
		div := &html.Node{
			Type: html.ElementNode, Data: atom.Div.String(),
			Attr: []html.Attribute{{Key: atom.Style.String(), Val: "text-align: center; margin-top: 80px; margin-bottom: 80px;"}},
		}
		switch i.Filter {
		default:
			div.AppendChild(htmlg.Text(fmt.Sprintf("There are no %s changes.", i.Filter)))
		case changes.AllStates:
			div.AppendChild(htmlg.Text("There are no changes."))
		}
		ns = append(ns, div)
	}

	div := htmlg.DivClass("list-entry list-entry-border", ns...)
	return []*html.Node{div}
}

// ChangeEntry is an entry within the list of changes.
type ChangeEntry struct {
	Change changes.Change
	Unread bool // Unread indicates whether the change contains unread notifications for authenticated user.

	// TODO, THINK: This is router details, can it be factored out or cleaned up?
	BaseURI string
}

func (i ChangeEntry) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry-body multilist-entry"{{if .Unread}} style="box-shadow: 2px 0 0 #4183c4 inset;"{{end}}>
	// 	<div style="display: flex;">
	// 		{{render (issueIcon .State)}}
	// 		<div style="flex-grow: 1;">
	// 			<div>
	// 				<a class="black" href="{{state.BaseURI}}/{{.ID}}"><strong>{{.Title}}</strong></a>
	// 				{{range .Labels}}{{render (label .)}}{{end}}
	// 			</div>
	// 			<div class="gray tiny">#{{.ID}} opened {{render (time .CreatedAt)}} by {{.User.Login}}</div>
	// 		</div>
	// 		<span title="{{.Replies}} replies" class="tiny {{if .Replies}}gray{{else}}lightgray{{end}}">{{octicon "comment"}} {{.Replies}}</span>
	// 	</div>
	// </div>

	div := &html.Node{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr: []html.Attribute{{Key: atom.Style.String(), Val: "display: flex;"}},
	}
	htmlg.AppendChildren(div, ChangeIcon{State: i.Change.State}.Render()...)

	titleAndByline := &html.Node{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr: []html.Attribute{{Key: atom.Style.String(), Val: "flex-grow: 1;"}},
	}
	{
		title := htmlg.Div(
			&html.Node{
				Type: html.ElementNode, Data: atom.A.String(),
				Attr: []html.Attribute{
					{Key: atom.Class.String(), Val: "black"},
					{Key: atom.Href.String(), Val: fmt.Sprintf("%s/%d", i.BaseURI, i.Change.ID)},
				},
				FirstChild: htmlg.Strong(i.Change.Title),
			},
		)
		for _, l := range i.Change.Labels {
			span := &html.Node{
				Type: html.ElementNode, Data: atom.Span.String(),
				Attr: []html.Attribute{{Key: atom.Style.String(), Val: "margin-left: 4px;"}},
			}
			htmlg.AppendChildren(span, Label{Label: l}.Render()...)
			title.AppendChild(span)
		}
		titleAndByline.AppendChild(title)

		byline := htmlg.DivClass("gray tiny")
		byline.Attr = append(byline.Attr, html.Attribute{Key: atom.Style.String(), Val: "margin-top: 2px;"})
		byline.AppendChild(htmlg.Text(fmt.Sprintf("#%d opened ", i.Change.ID)))
		htmlg.AppendChildren(byline, Time{Time: i.Change.CreatedAt}.Render()...)
		byline.AppendChild(htmlg.Text(fmt.Sprintf(" by %s", i.Change.Author.Login)))
		titleAndByline.AppendChild(byline)
	}
	div.AppendChild(titleAndByline)

	spanClass := "tiny"
	switch i.Change.Replies {
	default:
		spanClass += " gray"
	case 0:
		spanClass += " lightgray"
	}
	span := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Title.String(), Val: fmt.Sprintf("%d replies", i.Change.Replies)},
			{Key: atom.Class.String(), Val: spanClass},
		},
	}
	span.AppendChild(octiconssvg.Comment())
	span.AppendChild(htmlg.Text(fmt.Sprintf(" %d", i.Change.Replies)))
	div.AppendChild(span)

	listEntryDiv := htmlg.DivClass("list-entry-body multilist-entry", div)
	if i.Unread {
		listEntryDiv.Attr = append(listEntryDiv.Attr,
			html.Attribute{Key: atom.Style.String(), Val: "box-shadow: 2px 0 0 #4183c4 inset;"},
		)
	}
	return []*html.Node{listEntryDiv}
}
component/tabs.go
@@ -0,0 +1,118 @@
package component

import (
	"fmt"
	"net/url"

	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// IssuesNav is a navigation component for displaying a header for a list of issues.
// It contains tabs to switch between viewing open and closed issues.
type IssuesNav struct {
	OpenCount     uint64     // Open issues count.
	ClosedCount   uint64     // Closed issues count.
	Path          string     // URL path of current page (needed to generate correct links).
	Query         url.Values // URL query of current page (needed to generate correct links).
	StateQueryKey string     // Name of query key for controlling issue state filter. Constant, but provided externally.
}

func (n IssuesNav) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry-header">
	// 	<nav>{{.Tabs}}</nav>
	// </div>
	nav := &html.Node{Type: html.ElementNode, Data: atom.Nav.String()}
	htmlg.AppendChildren(nav, n.tabs()...)
	div := htmlg.DivClass("list-entry-header", nav)
	return []*html.Node{div}
}

// tabs renders the HTML nodes for <nav> element with tab header links.
func (n IssuesNav) tabs() []*html.Node {
	selectedTabName := n.Query.Get(n.StateQueryKey)
	var ns []*html.Node
	for i, tab := range []struct {
		Name      string // Tab name corresponds to its state filter query value.
		Component htmlg.Component
	}{
		// Note: The routing logic (i.e., exact tab Name values) is duplicated with tabStateFilter.
		//       Might want to try to factor it out into a common location (e.g., a route package or so).
		{Name: "", Component: OpenIssuesTab{Count: n.OpenCount}},
		{Name: "closed", Component: ClosedIssuesTab{Count: n.ClosedCount}},
	} {
		tabURL := (&url.URL{
			Path:     n.Path,
			RawQuery: n.rawQuery(tab.Name),
		}).String()
		a := &html.Node{
			Type: html.ElementNode, Data: atom.A.String(),
			Attr: []html.Attribute{
				{Key: atom.Href.String(), Val: tabURL},
			},
		}
		if tab.Name == selectedTabName {
			a.Attr = append(a.Attr, html.Attribute{Key: atom.Class.String(), Val: "selected"})
		}
		if i > 0 {
			a.Attr = append(a.Attr, html.Attribute{Key: atom.Style.String(), Val: "margin-left: 12px;"})
		}
		htmlg.AppendChildren(a, tab.Component.Render()...)
		ns = append(ns, a)
	}
	return ns
}

// rawQuery returns the raw query for a link pointing to tabName.
func (n IssuesNav) rawQuery(tabName string) string {
	q := n.Query
	if tabName == "" {
		q.Del(n.StateQueryKey)
		return q.Encode()
	}
	q.Set(n.StateQueryKey, tabName)
	return q.Encode()
}

// OpenIssuesTab is an "Open Issues Tab" component.
type OpenIssuesTab struct {
	Count uint64 // Count of open issues.
}

func (t OpenIssuesTab) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <span style="margin-right: 4px;">{{octicon "issue-opened"}}</span>
	// {{.Count}} Open
	icon := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "margin-right: 4px;"},
		},
		FirstChild: octiconssvg.IssueOpened(),
	}
	text := htmlg.Text(fmt.Sprintf("%d Open", t.Count))
	return []*html.Node{icon, text}
}

// ClosedIssuesTab is a "Closed Issues Tab" component.
type ClosedIssuesTab struct {
	Count uint64 // Count of closed issues.
}

func (t ClosedIssuesTab) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <span style="margin-right: 4px;">{{octicon "check"}}</span>
	// {{.Count}} Closed
	icon := &html.Node{
		Type: html.ElementNode, Data: atom.Span.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "margin-right: 4px;"},
		},
		FirstChild: octiconssvg.Check(),
	}
	text := htmlg.Text(fmt.Sprintf("%d Closed", t.Count))
	return []*html.Node{icon, text}
}
display.go
@@ -0,0 +1,60 @@
package changesapp

import (
	"fmt"
	"time"

	"github.com/shurcooL/issues"
)

// issueItem represents an issue item for display purposes.
type issueItem struct {
	// IssueItem can be one of issues.Comment, issues.Event.
	IssueItem interface{}
}

func (i issueItem) TemplateName() string {
	switch i.IssueItem.(type) {
	case issues.Comment:
		return "comment"
	case issues.Event:
		return "event"
	default:
		panic(fmt.Errorf("unknown item type %T", i.IssueItem))
	}
}

func (i issueItem) CreatedAt() time.Time {
	switch i := i.IssueItem.(type) {
	case issues.Comment:
		return i.CreatedAt
	case issues.Event:
		return i.CreatedAt
	default:
		panic(fmt.Errorf("unknown item type %T", i))
	}
}

func (i issueItem) ID() uint64 {
	switch i := i.IssueItem.(type) {
	case issues.Comment:
		return i.ID
	case issues.Event:
		return i.ID
	default:
		panic(fmt.Errorf("unknown item type %T", i))
	}
}

// byCreatedAtID implements sort.Interface.
type byCreatedAtID []issueItem

func (s byCreatedAtID) Len() int { return len(s) }
func (s byCreatedAtID) Less(i, j int) bool {
	if s[i].CreatedAt().Equal(s[j].CreatedAt()) {
		// If CreatedAt time is equal, fall back to ID as a tiebreaker.
		return s[i].ID() < s[j].ID()
	}
	return s[i].CreatedAt().Before(s[j].CreatedAt())
}
func (s byCreatedAtID) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
doc.go
@@ -0,0 +1,2 @@
// Package changesapp is a web frontend for a changes service.
package changesapp
errorhandler.go
@@ -0,0 +1,109 @@
package changesapp

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/shurcooL/httperror"
	"github.com/shurcooL/users"
)

// errorHandler factors error handling out of the HTTP handler.
type errorHandler struct {
	handler func(w http.ResponseWriter, req *http.Request) error
	users   interface {
		GetAuthenticated(context.Context) (users.User, error)
	} // May be nil if there's no users service.
}

func (h *errorHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	rw := &responseWriter{ResponseWriter: w}
	err := h.handler(rw, req)
	if err == nil {
		// Do nothing.
		return
	}
	if err != nil && rw.WroteHeader {
		// The header has already been written, so it's too late to send
		// a different status code. Just log the error and move on.
		log.Println(err)
		return
	}
	if err, ok := httperror.IsMethod(err); ok {
		httperror.HandleMethod(w, err)
		return
	}
	if err, ok := httperror.IsRedirect(err); ok {
		if req.Method == http.MethodGet { // Workaround for https://groups.google.com/forum/#!topic/golang-nuts/9AVyMP9C8Ac.
			w.Header().Set("Content-Type", "text/html; charset=utf-8")
		}
		http.Redirect(w, req, err.URL, http.StatusSeeOther)
		return
	}
	if err, ok := httperror.IsBadRequest(err); ok {
		httperror.HandleBadRequest(w, err)
		return
	}
	if err, ok := httperror.IsHTTP(err); ok {
		code := err.Code
		error := fmt.Sprintf("%d %s", code, http.StatusText(code))
		if user, e := h.getAuthenticated(req.Context()); e == nil && user.SiteAdmin {
			error += "\n\n" + err.Error()
		}
		http.Error(w, error, code)
		return
	}
	if os.IsNotExist(err) {
		log.Println(err)
		error := "404 Not Found"
		if user, e := h.getAuthenticated(req.Context()); e == nil && user.SiteAdmin {
			error += "\n\n" + err.Error()
		}
		http.Error(w, error, http.StatusNotFound)
		return
	}
	if os.IsPermission(err) {
		log.Println(err)
		error := "403 Forbidden"
		if user, e := h.getAuthenticated(req.Context()); e == nil && user.SiteAdmin {
			error += "\n\n" + err.Error()
		}
		http.Error(w, error, http.StatusForbidden)
		return
	}

	log.Println(err)
	error := "500 Internal Server Error"
	if user, e := h.getAuthenticated(req.Context()); e == nil && user.SiteAdmin {
		error += "\n\n" + err.Error()
	}
	http.Error(w, error, http.StatusInternalServerError)
}

func (h *errorHandler) getAuthenticated(ctx context.Context) (users.User, error) {
	if h.users == nil {
		return users.User{}, errors.New("no users service")
	}
	return h.users.GetAuthenticated(ctx)
}

// responseWriter wraps a real http.ResponseWriter and captures
// whether or not the header has been written.
type responseWriter struct {
	http.ResponseWriter

	WroteHeader bool // Write or WriteHeader was called.
}

func (rw *responseWriter) Write(p []byte) (n int, err error) {
	rw.WroteHeader = true
	return rw.ResponseWriter.Write(p)
}
func (rw *responseWriter) WriteHeader(code int) {
	rw.WroteHeader = true
	rw.ResponseWriter.WriteHeader(code)
}
frontend/edit.go
@@ -0,0 +1,86 @@
package main

import (
	"bytes"
	"context"
	"log"
	"strconv"

	"github.com/shurcooL/github_flavored_markdown"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/markdownfmt/markdown"
	"honnef.co/go/js/dom"
)

func (f *frontend) EditComment(action string, this dom.HTMLElement) {
	container := getAncestorByClassName(this, "comment-edit-container")
	// HACK: Currently the child nodes are [text, div, text, div, text], but that isn't reliable.
	commentView := container.ChildNodes()[1].(dom.HTMLElement)
	editView := container.ChildNodes()[3].(dom.HTMLElement)
	commentEditor := editView.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)

	switch action {
	case "edit":
		commentEditor.Value = commentEditor.GetAttribute("data-raw")

		commentView.Style().SetProperty("display", "none", "")
		editView.Style().SetProperty("display", "block", "")

		commentEditor.Focus()
	case "cancel", "update":
		switch action {
		case "cancel":
			if commentEditor.Value != commentEditor.GetAttribute("data-raw") {
				if !dom.GetWindow().Confirm("Are you sure you want to discard your unsaved changes?") {
					return
				}
			}
			commentEditor.Value = commentEditor.GetAttribute("data-raw")
		case "update":
			if commentEditor.Value != commentEditor.GetAttribute("data-raw") {
				fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
				fmted = bytes.TrimSpace(fmted)
				if len(fmted) == 0 {
					// Empty body isn't allowed.
					// TODO: Unless it's an issue description (initial comment).
					// TODO: Display error? Disable "Update comment" button?
					return
				}
				commentID, err := strconv.ParseUint(commentEditor.GetAttribute("data-id"), 10, 64)
				if err != nil {
					panic(err)
				}

				go func() {
					body := string(fmted)
					cr := issues.CommentRequest{
						ID:   commentID,
						Body: &body,
					}
					_, err := f.is.EditComment(context.Background(), issues.RepoSpec{URI: state.RepoSpec}, state.IssueID, cr)
					if err != nil {
						// TODO: Handle failure more visibly in the UI.
						log.Println("EditComment:", err)
					}
				}()

				commentEditor.SetAttribute("data-raw", string(fmted))
				markdownBody := commentView.QuerySelector(".markdown-body").(*dom.HTMLDivElement)
				markdownBody.SetInnerHTML(string(github_flavored_markdown.Markdown(fmted)))
			}
		}

		commentView.Style().SetProperty("display", "block", "")
		editView.Style().SetProperty("display", "none", "")

		// TODO: switchWriteTab() (maybe without commentEditor.Focus() part).
		// TODO: Maybe without commentEditor.Focus() part?
		switchWriteTab(container, commentEditor)
	}
}

func getAncestorByClassName(el dom.Element, class string) dom.Element {
	for ; el != nil && !el.Class().Contains(class); el = el.ParentElement() {
	}
	return el
}
frontend/main.go
@@ -0,0 +1,333 @@
// frontend script for issuesapp.
//
// It's a Go package meant to be compiled with GOARCH=js
// and executed in a browser, where the DOM is available.
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strings"

	"dmitri.shuralyov.com/changes/app/common"
	"github.com/gopherjs/gopherjs/js"
	"github.com/shurcooL/frontend/reactionsmenu"
	"github.com/shurcooL/frontend/tabsupport"
	"github.com/shurcooL/github_flavored_markdown"
	"github.com/shurcooL/go/gopherjs_http/jsutil"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/httpclient"
	"github.com/shurcooL/markdownfmt/markdown"
	"golang.org/x/oauth2"
	"honnef.co/go/js/dom"
)

var document = dom.GetWindow().Document().(dom.HTMLDocument)

var state common.State

func main() {
	stateJSON := js.Global.Get("State").String()
	err := json.Unmarshal([]byte(stateJSON), &state)
	if err != nil {
		panic(err)
	}

	httpClient := httpClient()

	f := &frontend{is: httpclient.NewIssues(httpClient, "", "")}

	js.Global.Set("MarkdownPreview", jsutil.Wrap(MarkdownPreview))
	js.Global.Set("SwitchWriteTab", jsutil.Wrap(SwitchWriteTab))
	js.Global.Set("PasteHandler", jsutil.Wrap(PasteHandler))
	js.Global.Set("CreateNewIssue", CreateNewIssue)
	js.Global.Set("ToggleIssueState", ToggleIssueState)
	js.Global.Set("PostComment", PostComment)
	js.Global.Set("EditComment", jsutil.Wrap(f.EditComment))
	js.Global.Set("TabSupportKeyDownHandler", jsutil.Wrap(tabsupport.KeyDownHandler))

	switch readyState := document.ReadyState(); readyState {
	case "loading":
		document.AddEventListener("DOMContentLoaded", false, func(dom.Event) {
			go setup(f)
		})
	case "interactive", "complete":
		setup(f)
	default:
		panic(fmt.Errorf("internal error: unexpected document.ReadyState value: %v", readyState))
	}
}

func setup(f *frontend) {
	setupIssueToggleButton()

	if createIssueButton, ok := document.GetElementByID("create-issue-button").(dom.HTMLElement); ok {
		titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement)
		titleEditor.AddEventListener("input", false, func(_ dom.Event) {
			if strings.TrimSpace(titleEditor.Value) == "" {
				createIssueButton.SetAttribute("disabled", "disabled")
			} else {
				createIssueButton.RemoveAttribute("disabled")
			}
		})
	}

	if !state.DisableReactions {
		reactionsService := IssuesReactions{Issues: f.is}
		reactionsmenu.Setup(state.RepoSpec, reactionsService, state.CurrentUser)
	}
}

// httpClient gives an *http.Client for making API requests.
func httpClient() *http.Client {
	cookies := &http.Request{Header: http.Header{"Cookie": {document.Cookie()}}}
	if accessToken, err := cookies.Cookie("accessToken"); err == nil {
		// Authenticated client.
		src := oauth2.StaticTokenSource(
			&oauth2.Token{AccessToken: accessToken.Value},
		)
		return oauth2.NewClient(context.Background(), src)
	}
	// Not authenticated client.
	return http.DefaultClient
}

type frontend struct {
	is issues.Service
}

func setupIssueToggleButton() {
	if issueToggleButton := document.GetElementByID("issue-toggle-button"); issueToggleButton != nil {
		commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement)
		commentEditor.AddEventListener("input", false, func(_ dom.Event) {
			if strings.TrimSpace(commentEditor.Value) == "" {
				issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-1-action"))
			} else {
				issueToggleButton.SetTextContent(issueToggleButton.GetAttribute("data-2-actions"))
			}
		})
	}
}

func postJSON(url string, v interface{}) (*http.Response, error) {
	b, err := json.Marshal(v)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest("POST", url, bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	return http.DefaultClient.Do(req)
}

func CreateNewIssue() {
	titleEditor := document.GetElementByID("title-editor").(*dom.HTMLInputElement)
	commentEditor := document.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)

	title := strings.TrimSpace(titleEditor.Value)
	if title == "" {
		log.Println("cannot create issue with empty title")
		return
	}
	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	newIssue := issues.Issue{
		Title: title,
		Comment: issues.Comment{
			Body: string(bytes.TrimSpace(fmted)),
		},
	}

	go func() {
		resp, err := postJSON("new", newIssue)
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			log.Println(err)
			return
		}

		fmt.Printf("got reply: %v\n%q\n", resp.Status, string(body))

		switch resp.StatusCode {
		case http.StatusOK:
			// Redirect.
			dom.GetWindow().Location().Href = string(body)
		}
	}()
}

func ToggleIssueState(issueState issues.State) {
	go func() {
		// Post comment first if there's text entered, and we're closing.
		if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" &&
			issueState == issues.ClosedState {
			err := postComment()
			if err != nil {
				log.Println(err)
				return
			}
		}

		ir := issues.IssueRequest{
			State: &issueState,
		}
		value, err := json.Marshal(ir)
		if err != nil {
			panic(err)
		}

		resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/edit", url.Values{"value": {string(value)}})
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			log.Println(err)
			return
		}

		data, err := url.ParseQuery(string(body))
		if err != nil {
			log.Println(err)
			return
		}

		switch resp.StatusCode {
		case http.StatusOK:
			changeStateBadge := document.GetElementByID("change-state-badge")
			changeStateBadge.SetInnerHTML(data.Get("change-state-badge"))

			issueToggleButton := document.GetElementByID("issue-toggle-button")
			issueToggleButton.SetOuterHTML(data.Get("issue-toggle-button"))
			setupIssueToggleButton()

			for _, newEventData := range data["new-event"] {
				// Create event.
				newEvent := document.CreateElement("div").(*dom.HTMLDivElement)
				newItemMarker := document.GetElementByID("new-item-marker")
				newItemMarker.ParentNode().InsertBefore(newEvent, newItemMarker)
				newEvent.SetOuterHTML(newEventData)
			}
		}

		// Post comment after if there's text entered, and we're reopening.
		if strings.TrimSpace(document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement).Value) != "" &&
			issueState == issues.OpenState {
			err := postComment()
			if err != nil {
				log.Println(err)
				return
			}
		}
	}()
}

func PostComment() {
	go func() {
		err := postComment()
		if err != nil {
			log.Println(err)
		}
	}()
}

// postComment posts the comment to the remote API.
func postComment() error {
	commentEditor := document.QuerySelector("#new-comment-container .comment-editor").(*dom.HTMLTextAreaElement)

	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	if len(fmted) == 0 {
		return fmt.Errorf("cannot post empty comment")
	}
	value := string(bytes.TrimSpace(fmted))

	resp, err := http.PostForm(state.BaseURI+state.ReqPath+"/comment", url.Values{"value": {value}})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	fmt.Printf("got reply: %v\n", resp.Status)

	switch resp.StatusCode {
	case http.StatusOK:
		// Create comment.
		newComment := document.CreateElement("div").(*dom.HTMLDivElement)

		newItemMarker := document.GetElementByID("new-item-marker")
		newItemMarker.ParentNode().InsertBefore(newComment, newItemMarker)

		newComment.SetOuterHTML(string(body))

		// Reset new-comment component.
		commentEditor.Value = ""
		commentEditor.Underlying().Call("dispatchEvent", js.Global.Get("CustomEvent").New("input")) // Trigger "input" event listeners.
		switchWriteTab(document.GetElementByID("new-comment-container"), commentEditor)

		return nil
	default:
		return fmt.Errorf("did not get acceptable status code: %v", resp.Status)
	}
}

func MarkdownPreview(this dom.HTMLElement) {
	container := getAncestorByClassName(this, "edit-container")

	if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") {
		return
	}

	commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)
	commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement)

	fmted, _ := markdown.Process("", []byte(commentEditor.Value), nil)
	value := bytes.TrimSpace(fmted)

	if len(value) != 0 {
		commentPreview.SetInnerHTML(string(github_flavored_markdown.Markdown(value)))
	} else {
		commentPreview.SetInnerHTML(`<i class="gray">Nothing to preview.</i>`)
	}

	container.QuerySelector(".write-tab-link").(dom.Element).Class().Remove("active")
	container.QuerySelector(".preview-tab-link").(dom.Element).Class().Add("active")
	commentEditor.Style().SetProperty("display", "none", "")
	commentPreview.Style().SetProperty("display", "block", "")
}

func SwitchWriteTab(this dom.HTMLElement) {
	container := getAncestorByClassName(this, "edit-container")
	commentEditor := container.QuerySelector(".comment-editor").(*dom.HTMLTextAreaElement)
	switchWriteTab(container, commentEditor)
}

func switchWriteTab(container dom.Element, commentEditor *dom.HTMLTextAreaElement) {
	if container.QuerySelector(".preview-tab-link").(dom.Element).Class().Contains("active") {
		commentPreview := container.QuerySelector(".comment-preview").(*dom.HTMLDivElement)

		container.QuerySelector(".write-tab-link").(dom.Element).Class().Add("active")
		container.QuerySelector(".preview-tab-link").(dom.Element).Class().Remove("active")
		commentEditor.Style().SetProperty("display", "block", "")
		commentPreview.Style().SetProperty("display", "none", "")
	}

	commentEditor.Focus()
}
frontend/reactions.go
@@ -0,0 +1,44 @@
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/shurcooL/issues"
	"github.com/shurcooL/reactions"
)

// IssuesReactions implements reactions.Service on top of issues.Service,
// specifically for use by issuesapp.
//
// The format of ID is "{{.issueID}}/{{.commentID}}".
type IssuesReactions struct {
	Issues issues.Service
}

// Toggle toggles an issue.
// id is "{{.issueID}}/{{.commentID}}".
func (ir IssuesReactions) Toggle(ctx context.Context, uri string, id string, tr reactions.ToggleRequest) ([]reactions.Reaction, error) {
	var issueID, commentID uint64
	_, err := fmt.Sscanf(id, "%d/%d", &issueID, &commentID)
	if err != nil {
		return nil, err
	}
	comment, err := ir.Issues.EditComment(ctx, issues.RepoSpec{URI: uri}, issueID, issues.CommentRequest{
		ID:       commentID,
		Reaction: &tr.Reaction,
	})
	if err != nil {
		return nil, err
	}
	return comment.Reactions, nil
}

func (ir IssuesReactions) Get(_ context.Context, uri string, id string) ([]reactions.Reaction, error) {
	return nil, errors.New("IssuesReactions.Get: not implemented")
}

func (ir IssuesReactions) List(_ context.Context, uri string) (map[string][]reactions.Reaction, error) {
	return nil, errors.New("IssuesReactions.List: not implemented")
}
frontend/scroll.go
@@ -0,0 +1,134 @@
package main

import (
	"fmt"
	"net/url"
	"strings"

	"github.com/gopherjs/gopherjs/js"
	"github.com/shurcooL/go/gopherjs_http/jsutil"
	"honnef.co/go/js/dom"
)

func init() {
	js.Global.Set("AnchorScroll", jsutil.Wrap(AnchorScroll))

	processHashSet := func() {
		// Scroll to hash target.
		targetID := strings.TrimPrefix(dom.GetWindow().Location().Hash, "#")
		target, ok := document.GetElementByID(targetID).(dom.HTMLElement)
		if ok {
			centerWindowOn(target)
		}

		processHash(target)
	}
	// Jump to desired hash after page finishes loading (and override browser's default hash jumping).
	document.AddEventListener("DOMContentLoaded", false, func(_ dom.Event) {
		go func() {
			// This needs to be in a goroutine or else it "happens too early".
			// TODO: See if there's a better event than DOMContentLoaded.
			processHashSet()
		}()
	})
	// Start watching for hashchange events.
	dom.GetWindow().AddEventListener("hashchange", false, func(event dom.Event) {
		processHashSet()

		event.PreventDefault()
	})

	document.AddEventListener("keydown", false, func(event dom.Event) {
		if event.DefaultPrevented() {
			return
		}
		// Ignore when some element other than body has focus (it means the user is typing elsewhere).
		if !event.Target().IsEqualNode(document.Body()) {
			return
		}

		switch ke := event.(*dom.KeyboardEvent); {
		// Escape.
		case ke.KeyCode == 27 && !ke.Repeat && !ke.CtrlKey && !ke.AltKey && !ke.MetaKey && !ke.ShiftKey:
			if strings.TrimPrefix(dom.GetWindow().Location().Hash, "#") == "" {
				return
			}

			setFragment("")

			processHashSet()

			ke.PreventDefault()
		}
	})
}

// AnchorScroll scrolls window to target that is pointed by fragment of href of given anchor element.
// It must point to a valid target.
func AnchorScroll(anchor dom.HTMLElement, e dom.Event) {
	url, err := url.Parse(anchor.(*dom.HTMLAnchorElement).Href)
	if err != nil {
		// Should never happen if AnchorScroll is used correctly.
		panic(fmt.Errorf("AnchorScroll: url.Parse: %v", err))
	}
	targetID := url.Fragment
	target := document.GetElementByID(targetID).(dom.HTMLElement)

	setFragment(targetID)

	// TODO: Decide if it's better to do this or not to.
	centerWindowOn(target)

	processHash(target)

	e.PreventDefault()
}

// processHash highlights the selected element by giving it a "hash-selected" class.
// target can be nil if there isn't a valid target.
func processHash(target dom.HTMLElement) {
	// Clear everything.
	for _, e := range document.GetElementsByClassName("hash-selected") {
		e.Class().Remove("hash-selected")
	}

	if target != nil {
		target.Class().Add("hash-selected")
	}
}

// centerWindowOn scrolls window so that (the middle of) target is in the middle of window.
func centerWindowOn(target dom.HTMLElement) {
	windowHalfHeight := dom.GetWindow().InnerHeight() / 2
	targetHalfHeight := target.OffsetHeight() / 2
	if targetHalfHeight > float64(windowHalfHeight)*0.8 { // Prevent top of target from being offscreen.
		targetHalfHeight = float64(windowHalfHeight) * 0.8
	}
	dom.GetWindow().ScrollTo(dom.GetWindow().ScrollX(), int(offsetTopRoot(target)+targetHalfHeight)-windowHalfHeight)
}

// offsetTopRoot returns the offset top of element e relative to root element.
func offsetTopRoot(e dom.HTMLElement) float64 {
	var offsetTopRoot float64
	for ; e != nil; e = e.OffsetParent() {
		offsetTopRoot += e.OffsetTop()
	}
	return offsetTopRoot
}

// setFragment sets current page URL fragment to hash. The leading '#' shouldn't be included.
func setFragment(hash string) {
	url := windowLocation
	url.Fragment = hash
	// TODO: dom.GetWindow().History().ReplaceState(...), blocked on https://github.com/dominikh/go-js-dom/issues/41.
	js.Global.Get("window").Get("history").Call("replaceState", nil, nil, url.String())
}

var windowLocation = func() url.URL {
	url, err := url.Parse(dom.GetWindow().Location().Href)
	if err != nil {
		// We don't expect this can ever happen, so treat it as an internal error if it does.
		panic(fmt.Errorf("internal error: parsing window.location.href as URL failed: %v", err))
	}
	return *url
}()
frontend/upload.go
@@ -0,0 +1,104 @@
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/gopherjs/gopherjs/js"
	"honnef.co/go/js/dom"
)

func PasteHandler(e dom.Event) {
	ce := e.(*dom.ClipboardEvent)

	items := ce.Get("clipboardData").Get("items")
	file, err := imagePNGFile(items)
	if err != nil {
		// No image to paste.
		return
	}

	// From this point, we're taking on the responsibility to handle this clipboard event.
	ce.PreventDefault()

	nameFunc, err := plainTextString(items)
	if err != nil {
		nameFunc = func() string { return "Image" }
	}

	go func() {
		b := blobToBytes(file)
		name := nameFunc()

		resp, err := http.Post("/api/usercontent", "image/png", bytes.NewReader(b))
		if err != nil {
			log.Println(err)
			return
		}
		defer resp.Body.Close()
		var uploadResponse struct {
			URL   string
			Error string
		}
		err = json.NewDecoder(resp.Body).Decode(&uploadResponse)
		if err != nil {
			log.Println(err)
			return
		}
		if uploadResponse.Error != "" {
			log.Println(uploadResponse.Error)
			return
		}

		insertText(ce.Target().(*dom.HTMLTextAreaElement), fmt.Sprintf("![%s](%s)\n\n", name, uploadResponse.URL))
	}()
}

func insertText(t *dom.HTMLTextAreaElement, inserted string) {
	value, start, end := t.Value, t.SelectionStart, t.SelectionEnd
	t.Value = value[:start] + inserted + value[end:]
	t.SelectionStart, t.SelectionEnd = start+len(inserted), start+len(inserted)
}

// imagePNGFile tries to get an "image/png" file from items.
func imagePNGFile(items *js.Object) (file *js.Object, err error) {
	for i := 0; i < items.Length(); i++ {
		item := items.Index(i)
		if item.Get("kind").String() != "file" || item.Get("type").String() != "image/png" {
			continue
		}
		return item.Call("getAsFile"), nil
	}
	return nil, fmt.Errorf("not found")
}

// plainTextString tries to get a "text/plain" string from items.
// The returned func blocks until the string is available.
func plainTextString(items *js.Object) (func() string, error) {
	for i := 0; i < items.Length(); i++ {
		item := items.Index(i)
		if item.Get("kind").String() != "string" || item.Get("type").String() != "text/plain" {
			continue
		}
		s := make(chan string)
		item.Call("getAsString", func(o *js.Object) {
			go func() { s <- o.String() }()
		})
		return func() string { return <-s }, nil
	}
	return nil, fmt.Errorf("not found")
}

// blobToBytes converts a Blob to []byte.
func blobToBytes(blob *js.Object) []byte {
	b := make(chan []byte)
	fileReader := js.Global.Get("FileReader").New()
	fileReader.Set("onload", func() {
		b <- js.Global.Get("Uint8Array").New(fileReader.Get("result")).Interface().([]byte)
	})
	fileReader.Call("readAsArrayBuffer", blob)
	return <-b
}
main.go
@@ -0,0 +1,525 @@
package changesapp

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"dmitri.shuralyov.com/changes"
	"dmitri.shuralyov.com/changes/app/assets"
	"dmitri.shuralyov.com/changes/app/common"
	"dmitri.shuralyov.com/changes/app/component"
	"github.com/dustin/go-humanize"
	"github.com/shurcooL/github_flavored_markdown"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/httpfs/html/vfstemplate"
	"github.com/shurcooL/httpgzip"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/notifications"
	"github.com/shurcooL/octiconssvg"
	"github.com/shurcooL/reactions"
	reactionscomponent "github.com/shurcooL/reactions/component"
	"github.com/shurcooL/users"
	"golang.org/x/net/html"
	"sourcegraph.com/sourcegraph/go-diff/diff"
)

// TODO: Find a better way for changesapp to be able to ensure registration of a top-level route:
//
// 	emojisHandler := httpgzip.FileServer(emojis.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed})
// 	http.Handle("/emojis/", http.StripPrefix("/emojis", emojisHandler))
//
// So that it can depend on it.

// New returns a changes app http.Handler using given services and options.
// If users is nil, then there is no way to have an authenticated user.
// Emojis image data is expected to be available at /emojis/emojis.png, unless
// opt.DisableReactions is true.
//
// In order to serve HTTP requests, the returned http.Handler expects each incoming
// request to have 2 parameters provided to it via RepoSpecContextKey and BaseURIContextKey
// context keys. For example:
//
// 	changesApp := changesapp.New(...)
//
// 	http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
// 		req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, string(...)))
// 		req = req.WithContext(context.WithValue(req.Context(), changesapp.BaseURIContextKey, string(...)))
// 		changesApp.ServeHTTP(w, req)
// 	})
func New(service changes.Service, users users.Service, opt Options) http.Handler {
	static, err := loadTemplates(common.State{}, opt.BodyPre)
	if err != nil {
		log.Fatalln("loadTemplates failed:", err)
	}
	h := handler{
		is:               service,
		us:               users,
		static:           static,
		assetsFileServer: httpgzip.FileServer(assets.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		gfmFileServer:    httpgzip.FileServer(assets.GFMStyle, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		Options:          opt,
	}
	return &errorHandler{
		handler: h.ServeHTTP,
		users:   users,
	}
}

// RepoSpecContextKey is a context key for the request's issues.RepoSpec.
// That value specifies which repo the issues are to be displayed for.
// The associated value will be of type string.
var RepoSpecContextKey = &contextKey{"RepoSpec"}

// BaseURIContextKey is a context key for the request's base URI.
// That value specifies the base URI prefix to use for all absolute URLs.
// The associated value will be of type string.
var BaseURIContextKey = &contextKey{"BaseURI"}

// Options for configuring issues app.
type Options struct {
	Notifications    notifications.Service // If not nil, issues containing unread notifications are highlighted.
	DisableReactions bool                  // Disable all support for displaying and toggling reactions.

	HeadPre, HeadPost template.HTML
	BodyPre           string // An html/template definition of "body-pre" template.

	// BodyTop provides components to include on top of <body> of page rendered for req. It can be nil.
	BodyTop func(req *http.Request) ([]htmlg.Component, error)
}

// handler handles all requests to changesapp. It acts like a request multiplexer,
// choosing from various endpoints and parsing the repository ID from URL.
type handler struct {
	is changes.Service
	us users.Service // May be nil if there's no users service.

	assetsFileServer http.Handler
	gfmFileServer    http.Handler

	// static is loaded once in New, and is only for rendering templates that don't use state.
	static *template.Template

	Options
}

func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	if _, ok := req.Context().Value(RepoSpecContextKey).(string); !ok {
		return fmt.Errorf("request to %v doesn't have changesapp.RepoSpecContextKey context key set", req.URL.Path)
	}
	if _, ok := req.Context().Value(BaseURIContextKey).(string); !ok {
		return fmt.Errorf("request to %v doesn't have changesapp.BaseURIContextKey context key set", req.URL.Path)
	}

	// Handle "/assets/gfm/...".
	if strings.HasPrefix(req.URL.Path, "/assets/gfm/") {
		req = stripPrefix(req, len("/assets/gfm"))
		h.gfmFileServer.ServeHTTP(w, req)
		return nil
	}

	// Handle "/assets/script.js".
	if req.URL.Path == "/assets/script.js" {
		req = stripPrefix(req, len("/assets"))
		h.assetsFileServer.ServeHTTP(w, req)
		return nil
	}

	// Handle (the rest of) "/assets/...".
	if strings.HasPrefix(req.URL.Path, "/assets/") {
		h.assetsFileServer.ServeHTTP(w, req)
		return nil
	}

	// Handle "/".
	if req.URL.Path == "/" {
		return h.IssuesHandler(w, req)
	}

	// Handle "/{changeID}" and "/{changeID}/...".
	elems := strings.SplitN(req.URL.Path[1:], "/", 3)
	changeID, err := strconv.ParseUint(elems[0], 10, 64)
	if err != nil {
		return httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid issue ID %q: %v", elems[0], err)}
	}
	switch {
	// "/{changeID}".
	case len(elems) == 1:
		return h.ChangeHandler(w, req, changeID)

	// "/{changeID}/files".
	case len(elems) == 2 && elems[1] == "files":
		return h.ChangeFilesHandler(w, req, changeID)

	default:
		return httperror.HTTP{Code: http.StatusNotFound, Err: errors.New("no route")}
	}
}

func (h *handler) IssuesHandler(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	state, err := h.state(req, 0)
	if err != nil {
		return err
	}
	filter, err := stateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}
	is, err := h.is.List(req.Context(), state.RepoSpec, changes.ListOptions{State: filter})
	if err != nil {
		return err
	}
	openCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.OpenState)})
	if err != nil {
		return fmt.Errorf("issues.Count(open): %v", err)
	}
	closedCount, err := h.is.Count(req.Context(), state.RepoSpec, changes.ListOptions{State: changes.StateFilter(changes.ClosedState)})
	if err != nil {
		return fmt.Errorf("issues.Count(closed): %v", err)
	}
	var es []component.ChangeEntry
	for _, i := range is {
		es = append(es, component.ChangeEntry{Change: i, BaseURI: state.BaseURI})
	}
	es = state.augmentUnread(req.Context(), es, h.is, h.Notifications)
	state.Changes = component.Issues{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          state.BaseURI + state.ReqPath,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.static.ExecuteTemplate(w, "issues.html.tmpl", &state)
	if err != nil {
		return fmt.Errorf("h.static.ExecuteTemplate: %v", err)
	}
	return nil
}

const (
	// stateQueryKey is name of query key for controlling issue state filter.
	stateQueryKey = "state"
)

// stateFilter parses the issue state filter from query,
// returning an error if the value is unsupported.
func stateFilter(query url.Values) (changes.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return changes.StateFilter(changes.OpenState), nil
	case "closed":
		return changes.StateFilter(changes.ClosedState), nil
	case "all":
		return changes.AllStates, nil
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}

func (s state) augmentUnread(ctx context.Context, es []component.ChangeEntry, is changes.Service, notificationsService notifications.Service) []component.ChangeEntry {
	if notificationsService == nil {
		return es
	}

	tt, ok := is.(interface {
		ThreadType() string
	})
	if !ok {
		log.Println("augmentUnread: changes service doesn't implement ThreadType")
		return es
	}
	threadType := tt.ThreadType()

	if s.CurrentUser.ID == 0 {
		// Unauthenticated user cannot have any unread changes.
		return es
	}

	// TODO: Consider starting to do this in background in parallel with is.List.
	ns, err := notificationsService.List(ctx, notifications.ListOptions{
		Repo: &notifications.RepoSpec{URI: s.RepoSpec},
	})
	if err != nil {
		log.Println("augmentUnread: failed to notifications.List:", err)
		return es
	}

	unreadThreads := make(map[uint64]struct{}) // Set of unread thread IDs.
	for _, n := range ns {
		if n.ThreadType != threadType { // Assumes RepoSpec matches because we filtered via notifications.ListOptions.
			continue
		}
		unreadThreads[n.ThreadID] = struct{}{}
	}

	for i, e := range es {
		_, unread := unreadThreads[e.Change.ID]
		es[i].Unread = unread
	}
	return es
}

func (h *handler) ChangeHandler(w http.ResponseWriter, req *http.Request, changeID uint64) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	state, err := h.state(req, changeID)
	if err != nil {
		return err
	}
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID)
	if err != nil {
		return err
	}
	cs, err := h.is.ListComments(req.Context(), state.RepoSpec, state.IssueID, nil)
	if err != nil {
		return fmt.Errorf("changes.ListComments: %v", err)
	}
	es, err := h.is.ListEvents(req.Context(), state.RepoSpec, state.IssueID, nil)
	if err != nil {
		return fmt.Errorf("changes.ListEvents: %v", err)
	}
	var items []issueItem
	for _, comment := range cs {
		items = append(items, issueItem{comment})
	}
	for _, event := range es {
		items = append(items, issueItem{event})
	}
	sort.Sort(byCreatedAtID(items))
	state.Items = items
	// Call loadTemplates to set updated reactionsBar, reactableID, etc., template functions.
	t, err := loadTemplates(state.State, h.Options.BodyPre)
	if err != nil {
		return fmt.Errorf("loadTemplates: %v", err)
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = t.ExecuteTemplate(w, "issue.html.tmpl", &state)
	if err != nil {
		return fmt.Errorf("t.ExecuteTemplate: %v", err)
	}
	return nil
}

func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, changeID uint64) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	state, err := h.state(req, changeID)
	if err != nil {
		return err
	}
	state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID)
	if err != nil {
		return err
	}
	rawDiff, err := h.is.GetDiff(req.Context(), state.RepoSpec, state.IssueID)
	if err != nil {
		return err
	}
	fileDiffs, err := diff.ParseMultiFileDiff(rawDiff)
	if err != nil {
		return err
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.static.ExecuteTemplate(w, "change-files.html.tmpl", &state)
	if err != nil {
		return err
	}
	for _, f := range fileDiffs {
		err = h.static.ExecuteTemplate(w, "FileDiff", fileDiff{FileDiff: f})
		if err != nil {
			return err
		}
	}
	_, err = io.WriteString(w, `</body></html>`)
	return err
}

func (h *handler) state(req *http.Request, changeID uint64) (state, error) {
	// TODO: Caller still does a lot of work outside to calculate req.URL.Path by
	//       subtracting BaseURI from full original req.URL.Path. We should be able
	//       to compute it here internally by using req.RequestURI and BaseURI.
	reqPath := req.URL.Path
	if reqPath == "/" {
		reqPath = "" // This is needed so that absolute URL for root view, i.e., /issues, is "/issues" and not "/issues/" because of "/issues" + "/".
	}
	b := state{
		State: common.State{
			BaseURI:  req.Context().Value(BaseURIContextKey).(string),
			ReqPath:  reqPath,
			RepoSpec: req.Context().Value(RepoSpecContextKey).(string),
			IssueID:  changeID,
		},
	}
	b.HeadPre = h.HeadPre
	b.HeadPost = h.HeadPost
	if h.BodyTop != nil {
		c, err := h.BodyTop(req)
		if err != nil {
			return state{}, err
		}
		var buf bytes.Buffer
		err = htmlg.RenderComponents(&buf, c...)
		if err != nil {
			return state{}, fmt.Errorf("htmlg.RenderComponents: %v", err)
		}
		b.BodyTop = template.HTML(buf.String())
	}

	b.DisableReactions = h.Options.DisableReactions
	b.DisableUsers = h.us == nil

	if h.us == nil {
		// No user service provided, so there can never be an authenticated user.
		b.CurrentUser = users.User{}
	} else if user, err := h.us.GetAuthenticated(req.Context()); err == nil {
		b.CurrentUser = user
	} else {
		return state{}, fmt.Errorf("h.us.GetAuthenticated: %v", err)
	}

	return b, nil
}

type state struct {
	HeadPre, HeadPost template.HTML
	BodyTop           template.HTML

	common.State

	Changes component.Issues
	Change  changes.Change
	Items   []issueItem
}

func (s state) Tabnav(selected string) template.HTML {
	// Render the tabnav.
	return template.HTML(htmlg.RenderComponentsString(tabnav{
		Tabs: []tab{
			{
				Content:  iconText{Icon: octiconssvg.CommentDiscussion, Text: "Discussion"},
				URL:      fmt.Sprintf("%s/%d", s.BaseURI, s.IssueID),
				Selected: selected == "Discussion",
			},
			{
				Content:  iconText{Icon: octiconssvg.GitCommit, Text: "Commits"},
				URL:      fmt.Sprintf("%s/%d/commits", s.BaseURI, s.IssueID),
				Selected: selected == "Commits",
			},
			{
				Content:  iconText{Icon: octiconssvg.Diff, Text: "Files"},
				URL:      fmt.Sprintf("%s/%d/files", s.BaseURI, s.IssueID),
				Selected: selected == "Files",
			},
		},
	}))
}

func loadTemplates(state common.State, bodyPre string) (*template.Template, error) {
	t := template.New("").Funcs(template.FuncMap{
		"json": func(v interface{}) (string, error) {
			b, err := json.Marshal(v)
			return string(b), err
		},
		"jsonfmt": func(v interface{}) (string, error) {
			b, err := json.MarshalIndent(v, "", "\t")
			return string(b), err
		},
		"reltime":          humanize.Time,
		"gfm":              func(s string) template.HTML { return template.HTML(github_flavored_markdown.Markdown([]byte(s))) },
		"reactionPosition": func(emojiID reactions.EmojiID) string { return reactions.Position(":" + string(emojiID) + ":") },
		"equalUsers": func(a, b users.User) bool {
			return a.UserSpec == b.UserSpec
		},
		"reactableID": func(commentID uint64) string {
			return fmt.Sprintf("%d/%d", state.IssueID, commentID)
		},
		"reactionsBar": func(reactions []reactions.Reaction, reactableID string) htmlg.Component {
			return reactionscomponent.ReactionsBar{
				Reactions:   reactions,
				CurrentUser: state.CurrentUser,
				ID:          reactableID,
			}
		},
		"newReaction": func(reactableID string) htmlg.Component {
			return reactionscomponent.NewReaction{
				ReactableID: reactableID,
			}
		},
		"state": func() common.State { return state },

		"octicon": func(name string) (template.HTML, error) {
			icon := octiconssvg.Icon(name)
			if icon == nil {
				return "", fmt.Errorf("%q is not a valid Octicon symbol name", name)
			}
			var buf bytes.Buffer
			err := html.Render(&buf, icon)
			if err != nil {
				return "", err
			}
			return template.HTML(buf.String()), nil
		},

		"render": func(c htmlg.Component) template.HTML {
			return template.HTML(htmlg.Render(c.Render()...))
		},
		"event":            func(e issues.Event) htmlg.Component { return component.Event{Event: e} },
		"changeStateBadge": func(c changes.Change) htmlg.Component { return component.ChangeStateBadge{Change: c} },
		"time":             func(t time.Time) htmlg.Component { return component.Time{Time: t} },
		"user":             func(u users.User) htmlg.Component { return component.User{User: u} },
		"avatar":           func(u users.User) htmlg.Component { return component.Avatar{User: u, Size: 48} },
	})
	t, err := vfstemplate.ParseGlob(assets.Assets, t, "/assets/*.tmpl")
	if err != nil {
		return nil, err
	}
	return t.New("body-pre").Parse(bodyPre)
}

// contextKey is a value for use with context.WithValue. It's used as
// a pointer so it fits in an interface{} without allocation.
type contextKey struct {
	name string
}

func (k *contextKey) String() string {
	return "dmitri.shuralyov.com/changes/app context value " + k.name
}

// stripPrefix returns request r with prefix of length prefixLen stripped from r.URL.Path.
// prefixLen must not be longer than len(r.URL.Path), otherwise stripPrefix panics.
// If r.URL.Path is empty after the prefix is stripped, the path is changed to "/".
func stripPrefix(r *http.Request, prefixLen int) *http.Request {
	r2 := new(http.Request)
	*r2 = *r
	r2.URL = new(url.URL)
	*r2.URL = *r.URL
	r2.URL.Path = r.URL.Path[prefixLen:]
	if r2.URL.Path == "" {
		r2.URL.Path = "/"
	}
	return r2
}
xxx.go
@@ -0,0 +1,190 @@
package changesapp

import (
	"bytes"
	"fmt"
	"html/template"
	"sort"
	"strings"

	"github.com/shurcooL/highlight_diff"
	"github.com/shurcooL/htmlg"
	"github.com/sourcegraph/annotate"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
	"sourcegraph.com/sourcegraph/go-diff/diff"
)

// TODO: Dedup.

// tabnav is a left-aligned horizontal row of tabs Primer CSS component.
//
// http://primercss.io/nav/#tabnav
type tabnav struct {
	Tabs []tab
}

func (t tabnav) Render() []*html.Node {
	nav := &html.Node{
		Type: html.ElementNode, Data: atom.Nav.String(),
		Attr: []html.Attribute{{Key: atom.Class.String(), Val: "tabnav-tabs"}},
	}
	for _, t := range t.Tabs {
		htmlg.AppendChildren(nav, t.Render()...)
	}
	return []*html.Node{htmlg.DivClass("tabnav", nav)}
}

// tab is a single tab entry within a tabnav.
type tab struct {
	Content  htmlg.Component
	URL      string
	Selected bool
}

func (t tab) Render() []*html.Node {
	aClass := "tabnav-tab"
	if t.Selected {
		aClass += " selected"
	}
	a := &html.Node{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
			{Key: atom.Href.String(), Val: t.URL},
			{Key: atom.Class.String(), Val: aClass},
		},
	}
	htmlg.AppendChildren(a, t.Content.Render()...)
	return []*html.Node{a}
}

// iconText is an icon with text on the right.
// Icon must be not nil.
type iconText struct {
	Icon func() *html.Node // Must be not nil.
	Text string
}

func (it iconText) Render() []*html.Node {
	icon := htmlg.Span(it.Icon())
	icon.Attr = append(icon.Attr, html.Attribute{
		Key: atom.Style.String(), Val: "margin-right: 4px;",
	})
	text := htmlg.Text(it.Text)
	return []*html.Node{icon, text}
}

// fileDiff represents a file diff for display purposes.
type fileDiff struct {
	*diff.FileDiff
}

func (f fileDiff) Title() (template.HTML, error) {
	old := strings.TrimPrefix(f.OrigName, "a/")
	new := strings.TrimPrefix(f.NewName, "b/")
	switch {
	case old != "/dev/null" && new != "/dev/null" && old == new: // Modified.
		return template.HTML(html.EscapeString(new)), nil
	case old != "/dev/null" && new != "/dev/null" && old != new: // Renamed.
		return template.HTML(html.EscapeString(old + " -> " + new)), nil
	case old == "/dev/null" && new != "/dev/null": // Added.
		return template.HTML(html.EscapeString(new)), nil
	case old != "/dev/null" && new == "/dev/null": // Removed.
		return template.HTML("<strikethrough>" + html.EscapeString(old) + "</strikethrough>"), nil
	default:
		return "", fmt.Errorf("unexpected *diff.FileDiff: %+v", f)
	}
}

func (f fileDiff) Diff() (template.HTML, error) {
	hunks, err := diff.PrintHunks(f.Hunks)
	if err != nil {
		return "", err
	}
	diff, err := highlightDiff(hunks)
	if err != nil {
		return "", err
	}
	return template.HTML(diff), nil
}

// highlightDiff highlights the src diff, returning the annotated HTML.
func highlightDiff(src []byte) ([]byte, error) {
	anns, err := highlight_diff.Annotate(src)
	if err != nil {
		return nil, err
	}

	lines := bytes.Split(src, []byte("\n"))
	lineStarts := make([]int, len(lines))
	var offset int
	for lineIndex := 0; lineIndex < len(lines); lineIndex++ {
		lineStarts[lineIndex] = offset
		offset += len(lines[lineIndex]) + 1
	}

	lastDel, lastIns := -1, -1
	for lineIndex := 0; lineIndex < len(lines); lineIndex++ {
		var lineFirstChar byte
		if len(lines[lineIndex]) > 0 {
			lineFirstChar = lines[lineIndex][0]
		}
		switch lineFirstChar {
		case '+':
			if lastIns == -1 {
				lastIns = lineIndex
			}
		case '-':
			if lastDel == -1 {
				lastDel = lineIndex
			}
		default:
			if lastDel != -1 || lastIns != -1 {
				if lastDel == -1 {
					lastDel = lastIns
				} else if lastIns == -1 {
					lastIns = lineIndex
				}

				beginOffsetLeft := lineStarts[lastDel]
				endOffsetLeft := lineStarts[lastIns]
				beginOffsetRight := lineStarts[lastIns]
				endOffsetRight := lineStarts[lineIndex]

				anns = append(anns, &annotate.Annotation{Start: beginOffsetLeft, End: endOffsetLeft, Left: []byte(`<span class="gd input-block">`), Right: []byte(`</span>`), WantInner: 0})
				anns = append(anns, &annotate.Annotation{Start: beginOffsetRight, End: endOffsetRight, Left: []byte(`<span class="gi input-block">`), Right: []byte(`</span>`), WantInner: 0})

				if '@' != lineFirstChar {
					//leftContent := string(src[beginOffsetLeft:endOffsetLeft])
					//rightContent := string(src[beginOffsetRight:endOffsetRight])
					// This is needed to filter out the "-" and "+" at the beginning of each line from being highlighted.
					// TODO: Still not completely filtered out.
					leftContent := ""
					for line := lastDel; line < lastIns; line++ {
						leftContent += "\x00" + string(lines[line][1:]) + "\n"
					}
					rightContent := ""
					for line := lastIns; line < lineIndex; line++ {
						rightContent += "\x00" + string(lines[line][1:]) + "\n"
					}

					var sectionSegments [2][]*annotate.Annotation
					highlight_diff.HighlightedDiffFunc(leftContent, rightContent, &sectionSegments, [2]int{beginOffsetLeft, beginOffsetRight})

					anns = append(anns, sectionSegments[0]...)
					anns = append(anns, sectionSegments[1]...)
				}
			}
			lastDel, lastIns = -1, -1
		}
	}

	sort.Sort(anns)

	out, err := annotate.Annotate(src, anns, template.HTMLEscape)
	if err != nil {
		return nil, err
	}

	return out, nil
}