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

Refactor issues.{Comment,Event} to changes.{Comment,TimelineItem}.
dmitshur committed 7 years ago commit 0f8a29f593306d1df80edb1bf7ae65434320eea4
Collapse all
_data/comment.html.tmpl
@@ -1,6 +1,6 @@
{{/* Dot is an issues.Comment. */}}
{{/* Dot is an changes.Comment. */}}
{{define "comment"}}
<div>
	<div>
		<div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div>
		<div id="comment-{{.ID}}" style="display: flex;" class="list-entry">
_data/style.css
@@ -83,11 +83,11 @@ div.commit-message.list-entry-border {
code {
	font-family: "Go Mono";
	font-size: 12px;
}

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

/* GFM style overrides. */
assets/assets.go
@@ -9,11 +9,11 @@ import (

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

// Assets contains assets for issuesapp.
// Assets contains assets for changesapp.
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")),
})

assets/doc.go
@@ -1,4 +1,4 @@
//go:generate vfsgendev -source="dmitri.shuralyov.com/changes/app/assets".Assets

// Package assets contains assets for issuesapp.
// Package assets contains assets for changesapp.
package assets
commits.go
@@ -2,13 +2,13 @@ package changesapp

import (
	"strings"

	"dmitri.shuralyov.com/changes"
	"dmitri.shuralyov.com/changes/app/component"
	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 {
@@ -45,11 +45,11 @@ func (c Commit) Render() []*html.Node {

	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()...)
	htmlg.AppendChildren(avatarDiv, component.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;"}},
@@ -72,13 +72,13 @@ func (c Commit) Render() []*html.Node {
		}
		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()...)
		htmlg.AppendChildren(byline, component.User{User: c.Author}.Render()...)
		byline.AppendChild(htmlg.Text(" committed "))
		htmlg.AppendChildren(byline, issuescomponent.Time{Time: c.AuthorTime}.Render()...)
		htmlg.AppendChildren(byline, component.Time{Time: c.AuthorTime}.Render()...)
		titleAndByline.AppendChild(byline)

		if commitBody != "" {
			pre := &html.Node{
				Type: html.ElementNode, Data: atom.Pre.String(),
component/changes.go
@@ -3,10 +3,11 @@ package component
import (
	"fmt"

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

@@ -104,11 +105,11 @@ func (i ChangeEntry) Render() []*html.Node {
		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()...)
			htmlg.AppendChildren(span, issuescomponent.Label{Label: l}.Render()...)
			title.AppendChild(span)
		}
		titleAndByline.AppendChild(title)

		byline := htmlg.DivClass("gray tiny")
component/component.go
@@ -1,26 +1,25 @@
// 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"
	issuescomponent "github.com/shurcooL/issuesapp/component"
	"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
	Event changes.TimelineItem
}

func (e Event) Render() []*html.Node {
	// TODO: Make this much nicer.
	// <div class="list-entry event event-{{.Type}}">
@@ -29,18 +28,18 @@ func (e Event) Render() []*html.Node {
	// 		{{render (avatar .Actor)}} {{render (user .Actor)}} {{.Text}} {{render (time .CreatedAt)}}
	// 	</div>
	// </div>

	div := htmlg.DivClass("event-header")
	htmlg.AppendChildren(div, Avatar{User: e.Event.Actor, Size: 16, Inline: true}.Render()...)
	htmlg.AppendChildren(div, Avatar{User: e.Event.Actor, Size: 16, inline: true}.Render()...)
	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),
	outerDiv := htmlg.DivClass("list-entry event",
		e.icon(),
		div,
	)
	return []*html.Node{outerDiv}
}
@@ -49,31 +48,31 @@ 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:
	switch e.Event.Payload.(type) {
	case changes.ClosedEvent:
		icon = octiconssvg.CircleSlash()
		color, backgroundColor = "#fff", "#bd2c00"
	case issues.Renamed:
	case changes.ReopenedEvent:
		icon = octiconssvg.PrimitiveDot()
		color, backgroundColor = "#fff", "#6cc644"
	case changes.RenamedEvent:
		icon = octiconssvg.Pencil()
	case issues.Labeled, issues.Unlabeled:
	case changes.LabeledEvent, changes.UnlabeledEvent:
		icon = octiconssvg.Tag()
	case issues.CommentDeleted:
	case changes.CommentDeletedEvent:
		icon = octiconssvg.X()
	case "ReviewRequestedEvent":
	case changes.ReviewRequestedEvent:
		icon = octiconssvg.Eye()
	case "ReviewRequestRemovedEvent":
	case changes.ReviewRequestRemovedEvent:
		icon = octiconssvg.X()
	case "MergedEvent":
	case changes.MergedEvent:
		icon = octiconssvg.GitMerge()
		color, backgroundColor = "#fff", "#6f42c1"
	case "ApprovedEvent":
	case changes.ApprovedEvent:
		icon = octiconssvg.Check()
		color, backgroundColor = "#fff", "#6cc644"
	default:
		icon = octiconssvg.PrimitiveDot()
	}
@@ -86,50 +85,52 @@ func (e Event) icon() *html.Node {
		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:
	switch p := e.Event.Payload.(type) {
	case changes.ClosedEvent:
		return []*html.Node{htmlg.Text("closed this")}
	case changes.ReopenedEvent:
		return []*html.Node{htmlg.Text("reopened this")}
	case changes.RenamedEvent:
		return []*html.Node{htmlg.Text("changed the title from "), htmlg.Strong(p.From), htmlg.Text(" to "), htmlg.Strong(p.To)}
	case changes.LabeledEvent:
		var ns []*html.Node
		ns = append(ns, htmlg.Text("added the "))
		ns = append(ns, Label{Label: *e.Event.Label}.Render()...)
		ns = append(ns, issuescomponent.Label{Label: p.Label}.Render()...)
		ns = append(ns, htmlg.Text(" label"))
		return ns
	case issues.Unlabeled:
	case changes.UnlabeledEvent:
		var ns []*html.Node
		ns = append(ns, htmlg.Text("removed the "))
		ns = append(ns, Label{Label: *e.Event.Label}.Render()...)
		ns = append(ns, issuescomponent.Label{Label: p.Label}.Render()...)
		ns = append(ns, htmlg.Text(" label"))
		return ns
	case issues.CommentDeleted:
	case changes.CommentDeletedEvent:
		return []*html.Node{htmlg.Text("deleted a comment")}
	case "ReviewRequestedEvent":
	case changes.ReviewRequestedEvent:
		ns := []*html.Node{htmlg.Text("requested a review from ")}
		ns = append(ns, Avatar{User: e.Event.RequestedReviewer, Size: 16, Inline: true}.Render()...)
		ns = append(ns, User{e.Event.RequestedReviewer}.Render()...)
		ns = append(ns, Avatar{User: p.RequestedReviewer, Size: 16, inline: true}.Render()...)
		ns = append(ns, User{p.RequestedReviewer}.Render()...)
		return ns
	case "ReviewRequestRemovedEvent":
	case changes.ReviewRequestRemovedEvent:
		ns := []*html.Node{htmlg.Text("removed the review request from ")}
		ns = append(ns, Avatar{User: e.Event.RequestedReviewer, Size: 16, Inline: true}.Render()...)
		ns = append(ns, User{e.Event.RequestedReviewer}.Render()...)
		ns = append(ns, Avatar{User: p.RequestedReviewer, Size: 16, inline: true}.Render()...)
		ns = append(ns, User{p.RequestedReviewer}.Render()...)
		return ns
	case "MergedEvent":
	case changes.MergedEvent:
		var ns []*html.Node
		ns = append(ns, htmlg.Text("merged commit "))
		ns = append(ns, htmlg.Strong("d34db33f")) // TODO: e.MergedEvent.CommitID.
		ns = append(ns, htmlg.Strong(p.CommitID)) // TODO: Code{}, use CommitHTMLURL.
		ns = append(ns, htmlg.Text(" into "))
		ns = append(ns, htmlg.Strong("master")) // TODO: e.MergedEvent.RefName.
		ns = append(ns, htmlg.Strong(p.RefName)) // TODO: Code{}.
		return ns
	case "ApprovedEvent":
	case changes.ApprovedEvent:
		return []*html.Node{htmlg.Text("approved these changes")}
	default:
		return []*html.Node{htmlg.Text(string(e.Event.Type))}
		return []*html.Node{htmlg.Text("unknown event")} // TODO: See if this is optimal.
	}
}

// ChangeStateBadge is a component that displays the state of a change
// with a badge, who opened it, and when it was opened.
@@ -237,48 +238,10 @@ 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
}

@@ -297,21 +260,21 @@ func (u User) Render() []*html.Node {
}

// Avatar is an avatar component.
type Avatar struct {
	User   users.User
	Size   int // In pixels, e.g., 48.
	Inline bool
	Size   int  // In pixels, e.g., 48.
	inline bool // inline is experimental; so keep it contained to this package only for now.
}

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>
	imgStyle := "border-radius: 3px;"
	if a.Inline {
	if a.inline {
		imgStyle += " vertical-align: middle; margin-right: 4px;"
	}
	return []*html.Node{{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
display.go
@@ -1,47 +1,59 @@
package changesapp

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

	"github.com/shurcooL/issues"
	"dmitri.shuralyov.com/changes"
	"dmitri.shuralyov.com/changes/app/component"
	"github.com/shurcooL/highlight_diff"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/users"
	"github.com/sourcegraph/annotate"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
	"sourcegraph.com/sourcegraph/go-diff/diff"
)

// timelineItem represents a timeline item for display purposes.
type timelineItem struct {
	// TimelineItem can be one of issues.Comment, issues.Event.
	// TimelineItem can be one of changes.Comment, changes.TimelineItem.
	TimelineItem interface{}
}

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

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

func (i timelineItem) ID() uint64 {
	switch i := i.TimelineItem.(type) {
	case issues.Comment:
	case changes.Comment:
		return i.ID
	case issues.Event:
	case changes.TimelineItem:
		return i.ID
	default:
		panic(fmt.Errorf("unknown item type %T", i))
	}
}
@@ -56,5 +68,214 @@ func (s byCreatedAtID) Less(i, j int) bool {
		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] }

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

type contentCounter struct {
	Content htmlg.Component
	Count   int
}

func (cc contentCounter) Render() []*html.Node {
	var ns []*html.Node
	ns = append(ns, cc.Content.Render()...)
	ns = append(ns, htmlg.SpanClass("counter", htmlg.Text(fmt.Sprint(cc.Count))))
	return ns
}

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

// commitMessage ...
type commitMessage struct {
	CommitHash string
	Subject    string
	Body       string
	Author     users.User
	AuthorTime time.Time

	PrevSHA, NextSHA string // Empty if none.
}

func (c commitMessage) Avatar() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(component.Avatar{User: c.Author, Size: 24}))
}

func (c commitMessage) User() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(component.User{User: c.Author}))
}

func (c commitMessage) Time() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(component.Time{Time: c.AuthorTime}))
}

// 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
}
frontend/main.go
@@ -1,6 +1,6 @@
// frontend script for issuesapp.
// frontend script for changesapp.
//
// It's a Go package meant to be compiled with GOARCH=js
// and executed in a browser, where the DOM is available.
package main

main.go
@@ -25,11 +25,10 @@ import (
	"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"
@@ -571,11 +570,11 @@ func loadTemplates(state common.State, bodyPre string) (*template.Template, erro
		},

		"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} },
		"event":            func(e changes.TimelineItem) 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} },
	})
xxx.go
@@ -1,228 +0,0 @@
package changesapp

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

	"github.com/shurcooL/highlight_diff"
	"github.com/shurcooL/htmlg"
	issuescomponent "github.com/shurcooL/issuesapp/component"
	"github.com/shurcooL/users"
	"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}
}

type contentCounter struct {
	Content htmlg.Component
	Count   int
}

func (cc contentCounter) Render() []*html.Node {
	var ns []*html.Node
	ns = append(ns, cc.Content.Render()...)
	ns = append(ns, htmlg.SpanClass("counter", htmlg.Text(fmt.Sprint(cc.Count))))
	return ns
}

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

// commitMessage ...
type commitMessage struct {
	CommitHash string
	Subject    string
	Body       string
	Author     users.User
	AuthorTime time.Time

	PrevSHA, NextSHA string // Empty if none.
}

func (c commitMessage) Avatar() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(issuescomponent.Avatar{User: c.Author, Size: 24}))
}

func (c commitMessage) User() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(issuescomponent.User{User: c.Author}))
}

func (c commitMessage) Time() template.HTML {
	return template.HTML(htmlg.RenderComponentsString(issuescomponent.Time{Time: c.AuthorTime}))
}

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