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

Start the commits tab.
dmitshur committed 6 years ago commit 88890b4519fcbbc04b6593790bdf4591d900e350
Collapse all
_data/change-commits.html.tmpl
@@ -0,0 +1,11 @@
<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 "Commits"}}
_data/change-files.html.tmpl
@@ -10,11 +10,11 @@
		<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>
	<header class="list-entry-header">{{.Title}}</header>
	<div class="list-entry-body">
		<pre class="highlight-diff">{{.Diff}}</pre>
	</div>
</div>
{{end}}
_data/comment.html.tmpl
@@ -3,19 +3,19 @@
<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;">
				<header 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>
				</header>
				<div class="list-entry-body">
					<div class="markdown-body">
						{{with .Body}}
							{{. | gfm}}
						{{else}}
_data/edit-comment.html.tmpl
@@ -1,17 +1,17 @@
{{/* 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;">
		<header 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>
		</header>
		<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>
_data/new-comment.html.tmpl
@@ -1,17 +1,17 @@
{{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;">
			<header 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>
			</header>
			<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>
_data/new-issue.html.tmpl
@@ -11,20 +11,20 @@

{{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">
		<header 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>
		</header>
		<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>
_data/style.css
@@ -25,36 +25,36 @@ div.list-entry-container {
}
div.list-entry-border {
	border: 1px solid #ddd;
	border-radius: 4px;
}
div.list-entry-header {
header.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 {
.hash-selected header.list-entry-header {
	background-color: #dbe5ff;
	border-bottom: 1px solid #8ca2d9;
}
div.list-entry-header.tabs {
header.list-entry-header.tabs {
	padding: 16px 10px 6px 10px;
}
div.list-entry-header.tabs-title {
header.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)) {
div.multilist-entry:not(:first-of-type) {
	border: 0px solid #ddd;
	border-top-width: 1px;
}

/* Needed in issuesapp only because of something in parent containers... */
@@ -149,20 +149,20 @@ textarea.comment-editor:focus {
	height: 32px;
	display: flex; justify-content: center; align-items: center;
	border-radius: 50%;
}

div.list-entry-header nav a {
header.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 {
header.list-entry-header nav a:hover,
header.list-entry-header nav a:active,
header.list-entry-header nav a:focus {
	color: #000;
}
div.list-entry-header nav .selected {
header.list-entry-header nav .selected {
	color: #000;
	font-weight: bold;
}

span.right-icon div.new-reaction {
@@ -345,10 +345,28 @@ div.rm-reactions-menu-signin {
}

/* 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}

.ellipsis-button {
	padding: 0 4px;
	vertical-align: middle;
	color: #444d56;
	background: #dfe2e5;
	border: 0;
	border-radius: 1px;
	margin-left: 6px;
	cursor: pointer;
}
.ellipsis-button:hover {
	background-color: #c6cbd1;
}
.ellipsis-button:active {
	color: #fff;
	background-color: #2188ff;
}

pre.highlight-diff {
	font-size: 12px;
	line-height: 16px;
	overflow-x: scroll;
}
cmd/changesdev/main.go
@@ -121,11 +121,11 @@ func main() {
		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.RepoSpecContextKey, "github.com/google/go-github"))
		req = req.WithContext(context.WithValue(req.Context(), changesapp.BaseURIContextKey, "/changes"))
		changesApp.ServeHTTP(w, req)
	})
	r.Path("/changes").Handler(issuesHandler)
	r.PathPrefix("/changes/").Handler(issuesHandler)
commits.go
@@ -0,0 +1,141 @@
package changesapp

import (
	"strings"

	"dmitri.shuralyov.com/changes"
	homecomponent "github.com/shurcooL/home/component"
	"github.com/shurcooL/htmlg"
	issuescomponent "github.com/shurcooL/issuesapp/component"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

type Commits struct {
	Commits []Commit
}

func (cs Commits) Render() []*html.Node {
	if len(cs.Commits) == 0 {
		// No commits. 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;"}},
			FirstChild: htmlg.Text("There are no commits."),
		}
		return []*html.Node{htmlg.DivClass("list-entry-border", div)}
	}

	var nodes []*html.Node
	for _, c := range cs.Commits {
		nodes = append(nodes, c.Render()...)
	}
	return []*html.Node{htmlg.DivClass("list-entry-border", nodes...)}
}

type Commit struct {
	changes.Commit
}

func (c Commit) Render() []*html.Node {
	div := &html.Node{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr: []html.Attribute{{Key: atom.Style.String(), Val: "display: flex;"}},
	}

	avatarDiv := &html.Node{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr: []html.Attribute{{Key: atom.Style.String(), Val: "margin-right: 6px;"}},
	}
	htmlg.AppendChildren(avatarDiv, issuescomponent.Avatar{User: c.Author, Size: 32}.Render()...)
	div.AppendChild(avatarDiv)

	titleAndByline := &html.Node{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr: []html.Attribute{{Key: atom.Style.String(), Val: "flex-grow: 1;"}},
	}
	{
		commitSubject, commitBody := splitCommitMessage(c.Message)

		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: "commit/" + c.SHA},
				},
				FirstChild: htmlg.Strong(commitSubject),
			},
		)
		if commitBody != "" {
			htmlg.AppendChildren(title, homecomponent.EllipsisButton{OnClick: "ToggleDetails(this);"}.Render()...)
		}
		titleAndByline.AppendChild(title)

		byline := htmlg.DivClass("gray tiny")
		byline.Attr = append(byline.Attr, html.Attribute{Key: atom.Style.String(), Val: "margin-top: 2px;"})
		htmlg.AppendChildren(byline, issuescomponent.User{User: c.Author}.Render()...)
		byline.AppendChild(htmlg.Text(" committed "))
		htmlg.AppendChildren(byline, issuescomponent.Time{Time: c.AuthorTime}.Render()...)
		titleAndByline.AppendChild(byline)

		if commitBody != "" {
			pre := &html.Node{
				Type: html.ElementNode, Data: atom.Pre.String(),
				Attr: []html.Attribute{
					{Key: atom.Class.String(), Val: "commit-details"},
					{Key: atom.Style.String(), Val: `font-size: 13px;
font-family: Go;
color: #444;
margin-top: 10px;
margin-bottom: 0;
display: none;`}},
				FirstChild: htmlg.Text(commitBody),
			}
			titleAndByline.AppendChild(pre)
		}
	}
	div.AppendChild(titleAndByline)

	commitID := commitID{SHA: c.SHA}
	htmlg.AppendChildren(div, commitID.Render()...)

	listEntryDiv := htmlg.DivClass("list-entry-body multilist-entry commit-container", div)
	return []*html.Node{listEntryDiv}
}

// commitID is a component that displays a linked commit ID. E.g., "c0de1234".
type commitID struct {
	SHA     string
	HTMLURL string // Optional.
}

func (c commitID) Render() []*html.Node {
	sha := &html.Node{
		Type: html.ElementNode, Data: atom.Code.String(),
		Attr: []html.Attribute{
			{Key: atom.Style.String(), Val: "width: 8ch; overflow: hidden; display: inline-grid; white-space: nowrap;"},
			{Key: atom.Title.String(), Val: c.SHA},
		},
		FirstChild: htmlg.Text(c.SHA),
	}
	if c.HTMLURL != "" {
		sha = &html.Node{
			Type: html.ElementNode, Data: atom.A.String(),
			Attr: []html.Attribute{
				{Key: atom.Href.String(), Val: c.HTMLURL},
			},
			FirstChild: sha,
		}
	}
	return []*html.Node{sha}
}

// splitCommitMessage splits commit message s into subject and body, if any.
func splitCommitMessage(s string) (subject, body string) {
	i := strings.Index(s, "\n\n")
	if i == -1 {
		return s, ""
	}
	return s[:i], s[i+2:]
}
component/tabs.go
@@ -20,17 +20,21 @@ type IssuesNav struct {
	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">
	// <header class="list-entry-header">
	// 	<nav>{{.Tabs}}</nav>
	// </div>
	// </header>
	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}
	header := &html.Node{
		Type: html.ElementNode, Data: atom.Header.String(),
		Attr:       []html.Attribute{{Key: atom.Class.String(), Val: "list-entry-header"}},
		FirstChild: nav,
	}
	return []*html.Node{header}
}

// tabs renders the HTML nodes for <nav> element with tab header links.
func (n IssuesNav) tabs() []*html.Node {
	selectedTabName := n.Query.Get(n.StateQueryKey)
frontend/main.go
@@ -49,10 +49,11 @@ func main() {
	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))
	js.Global.Set("ToggleDetails", jsutil.Wrap(ToggleDetails))

	switch readyState := document.ReadyState(); readyState {
	case "loading":
		document.AddEventListener("DOMContentLoaded", false, func(dom.Event) {
			go setup(f)
@@ -329,5 +330,17 @@ func switchWriteTab(container dom.Element, commentEditor *dom.HTMLTextAreaElemen
		commentPreview.Style().SetProperty("display", "none", "")
	}

	commentEditor.Focus()
}

func ToggleDetails(el dom.HTMLElement) {
	container := getAncestorByClassName(el, "commit-container").(dom.HTMLElement)
	details := container.QuerySelector("pre.commit-details").(dom.HTMLElement)

	switch details.Style().GetPropertyValue("display") {
	default:
		details.Style().SetProperty("display", "none", "")
	case "none":
		details.Style().SetProperty("display", "block", "")
	}
}
main.go
@@ -157,10 +157,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	switch {
	// "/{changeID}".
	case len(elems) == 1:
		return h.ChangeHandler(w, req, changeID)

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

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

	default:
@@ -320,10 +324,43 @@ func (h *handler) ChangeHandler(w http.ResponseWriter, req *http.Request, change
		return fmt.Errorf("t.ExecuteTemplate: %v", err)
	}
	return nil
}

func (h *handler) ChangeCommitsHandler(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.ListCommits(req.Context(), state.RepoSpec, state.IssueID)
	if err != nil {
		return err
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.static.ExecuteTemplate(w, "change-commits.html.tmpl", &state)
	if err != nil {
		return err
	}
	var commits []Commit
	for _, c := range cs {
		commits = append(commits, Commit{Commit: c})
	}
	err = htmlg.RenderComponents(w, Commits{Commits: commits})
	if err != nil {
		return err
	}
	_, err = io.WriteString(w, `</body></html>`)
	return err
}

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)