@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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; }
@@ -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 }
@@ -0,0 +1,4 @@ //go:generate vfsgendev -source="dmitri.shuralyov.com/changes/app/assets".Assets // Package assets contains assets for issuesapp. package assets
@@ -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 )
@@ -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) }
@@ -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 }
@@ -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} }
@@ -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} }
@@ -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} }
@@ -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] }
@@ -0,0 +1,2 @@ // Package changesapp is a web frontend for a changes service. package changesapp
@@ -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) }
@@ -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 }
@@ -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() }
@@ -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") }
@@ -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 }()
@@ -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 }
@@ -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: ¬ifications.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 }
@@ -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, §ionSegments, [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 }