@@ -0,0 +1,11 @@ <html> <head> {{template "head" .}} </head> <body> {{template "body-pre" .}} {{.BodyTop}} <h1>{{.Change.Title}} <span class="gray">#{{.Change.ID}}</span></h1> <div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div> {{.Tabnav "Commits"}}
@@ -10,11 +10,11 @@ <div id="change-state-badge" style="margin-bottom: 20px;">{{render (changeStateBadge .Change)}}</div> {{.Tabnav "Files"}} {{define "FileDiff"}} <div class="list-entry list-entry-border"> <div class="list-entry-header">{{.Title}}</div> <header class="list-entry-header">{{.Title}}</header> <div class="list-entry-body"> <pre class="highlight-diff">{{.Diff}}</pre> </div> </div> {{end}}
@@ -3,19 +3,19 @@ <div class="comment-edit-container"> <div> <div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div> <div id="comment-{{.ID}}" style="display: flex;" class="list-entry"> <div class="list-entry-container list-entry-border"> <div class="list-entry-header" style="display: flex;"> <header class="list-entry-header" style="display: flex;"> <span class="content">{{render (user .User)}} commented <a class="black" href="#comment-{{.ID}}" onclick="AnchorScroll(this, event);">{{render (time .CreatedAt)}}</a> {{with .Edited}} ยท <span style="cursor: default;" title="{{.By.Login}} edited this comment {{reltime .At}}.">edited{{if not (equalUsers $.User .By)}} by {{.By.Login}}{{end}}</span>{{end}} </span> {{if (not state.DisableReactions)}} <span class="right-icon">{{render (newReaction (reactableID .ID))}}</span> {{end}} {{if .Editable}}<span class="right-icon"><a href="javascript:" title="Edit" onclick="EditComment({{`edit` | json}}, this);">{{octicon "pencil"}}</a></span>{{end}} </div> </header> <div class="list-entry-body"> <div class="markdown-body"> {{with .Body}} {{. | gfm}} {{else}}
@@ -1,17 +1,17 @@ {{/* TODO: Dedup with new-comment, only buttons differ, so factor them out. */}} {{define "edit-comment"}} <div style="display: flex;" class="edit-container list-entry"> <div style="margin-right: 10px;">{{render (avatar .User)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <div class="list-entry-header tabs" style="display: flex;"> <header class="list-entry-header tabs" style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </div> </header> <div class="list-entry-body"> <textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" data-id="{{.ID}}" data-raw="{{.Body}}" tabindex=1></textarea> <div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button class="btn btn-success btn-small" onclick="EditComment({{`update` | json}}, this);" tabindex=1>Update comment</button>
@@ -1,17 +1,17 @@ {{define "new-comment"}} {{if .CurrentUser.ID}} <div id="new-comment-container" class="edit-container list-entry" style="display: flex;"> <div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <div class="list-entry-header tabs" style="display: flex;"> <header class="list-entry-header tabs" style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </div> </header> <div class="list-entry-body"> <textarea class="comment-editor" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);" tabindex=1></textarea> <div class="comment-preview markdown-body" style="padding: 11px 11px 10px 11px; min-height: 120px; box-sizing: border-box; border-bottom: 1px solid #eee; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button class="btn btn-success btn-small" onclick="PostComment();" tabindex=1>Comment</button>
@@ -11,20 +11,20 @@ {{define "new-issue"}} <div style="display: flex; margin-top: 20px;" class="edit-container list-entry"> <div style="margin-right: 10px;">{{render (avatar .CurrentUser)}}</div> <div class="list-entry-border" style="flex-grow: 1;"> <div class="list-entry-header tabs-title"> <header class="list-entry-header tabs-title"> <div><input id="title-editor" type="text" placeholder="Title" autofocus></div> <div style="display: flex;"> <span style="flex-grow: 1; font-size: 14px;"> <a class="write-tab-link black tab-link active" tabindex=-1 href="javascript:" onclick="SwitchWriteTab(this);">Write</a> <a class="preview-tab-link black tab-link" tabindex=-1 href="javascript:" onclick="MarkdownPreview(this);">Preview</a> </span> <span class="gray"><span style="margin-right: 6px;">{{octicon "markdown"}}</span>Markdown</span> </div> </div> </header> <div class="list-entry-body"> <textarea class="comment-editor" style="min-height: 200px;" placeholder="Leave a comment." onpaste="PasteHandler(event);" onkeydown="TabSupportKeyDownHandler(this, event);"></textarea> <div class="comment-preview markdown-body" style="padding: 10px; min-height: 200px; display: none;"></div> <div style="text-align: right; margin-top: 10px;"> <button id="create-issue-button" class="btn btn-success btn-small" disabled="disabled" onclick="CreateNewIssue();">Create Issue</button>
@@ -25,36 +25,36 @@ div.list-entry-container { } div.list-entry-border { border: 1px solid #ddd; border-radius: 4px; } div.list-entry-header { header.list-entry-header { font-size: 13px; background-color: #f8f8f8; padding: 10px; border-radius: 4px 4px 0 0; border-bottom: 1px solid #eee; } .hash-selected div.list-entry-border { border: 1px solid #8ca2d9; } .hash-selected div.list-entry-header { .hash-selected header.list-entry-header { background-color: #dbe5ff; border-bottom: 1px solid #8ca2d9; } div.list-entry-header.tabs { header.list-entry-header.tabs { padding: 16px 10px 6px 10px; } div.list-entry-header.tabs-title { header.list-entry-header.tabs-title { padding-bottom: 6px; background-color: #fff; } div.list-entry-body { padding: 10px; } div.multilist-entry:not(:nth-child(0n+2)) { div.multilist-entry:not(:first-of-type) { border: 0px solid #ddd; border-top-width: 1px; } /* Needed in issuesapp only because of something in parent containers... */ @@ -149,20 +149,20 @@ textarea.comment-editor:focus { height: 32px; display: flex; justify-content: center; align-items: center; border-radius: 50%; } div.list-entry-header nav a { header.list-entry-header nav a { color: #767676; text-decoration: none; } div.list-entry-header nav a:hover, div.list-entry-header nav a:active, div.list-entry-header nav a:focus { header.list-entry-header nav a:hover, header.list-entry-header nav a:active, header.list-entry-header nav a:focus { color: #000; } div.list-entry-header nav .selected { header.list-entry-header nav .selected { color: #000; font-weight: bold; } span.right-icon div.new-reaction { @@ -345,10 +345,28 @@ div.rm-reactions-menu-signin { } /* https://github.com/primer/primer-navigation */ .counter{display:inline-block;padding:2px 5px;font-size:12px;font-weight:600;line-height:1;color:#666;background-color:#eee;border-radius:20px}.menu{margin-bottom:15px;list-style:none;background-color:#fff;border:1px solid #d8d8d8;border-radius:3px}.menu-item{position:relative;display:block;padding:8px 10px;border-bottom:1px solid #eee}.menu-item:first-child{border-top:0;border-top-left-radius:2px;border-top-right-radius:2px}.menu-item:first-child::before{border-top-left-radius:2px}.menu-item:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.menu-item:last-child::before{border-bottom-left-radius:2px}.menu-item:hover{text-decoration:none;background-color:#f9f9f9}.menu-item.selected{font-weight:bold;color:#222;cursor:default;background-color:#fff}.menu-item.selected::before{position:absolute;top:0;bottom:0;left:0;width:2px;content:"";background-color:#d26911}.menu-item .octicon{width:16px;margin-right:5px;color:#333;text-align:center}.menu-item .counter{float:right;margin-left:5px}.menu-item .menu-warning{float:right;color:#d26911}.menu-item .avatar{float:left;margin-right:5px}.menu-item.alert .counter{color:#bd2c00}.menu-heading{display:block;padding:8px 10px;margin-top:0;margin-bottom:0;font-size:13px;font-weight:bold;line-height:20px;color:#555;background-color:#f7f7f7;border-bottom:1px solid #eee}.menu-heading:hover{text-decoration:none}.menu-heading:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.menu-heading:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.tabnav{margin-top:0;margin-bottom:15px;border-bottom:1px solid #ddd}.tabnav .counter{margin-left:5px}.tabnav-tabs{margin-bottom:-1px}.tabnav-tab{display:inline-block;padding:8px 12px;font-size:14px;line-height:20px;color:#666;text-decoration:none;background-color:transparent;border:1px solid transparent;border-bottom:0}.tabnav-tab.selected{color:#333;background-color:#fff;border-color:#ddd;border-radius:3px 3px 0 0}.tabnav-tab:hover,.tabnav-tab:focus{text-decoration:none}.tabnav-extra{display:inline-block;padding-top:10px;margin-left:10px;font-size:12px;color:#666}.tabnav-extra>.octicon{margin-right:2px}a.tabnav-extra:hover{color:#4078c0;text-decoration:none}.tabnav-btn{margin-left:10px}.filter-list{list-style-type:none}.filter-list.small .filter-item{padding:4px 10px;margin:0 0 2px;font-size:12px}.filter-list.pjax-active .filter-item{color:#767676;background-color:transparent}.filter-list.pjax-active .filter-item.pjax-active{color:#fff;background-color:#4078c0}.filter-item{position:relative;display:block;padding:8px 10px;margin-bottom:5px;overflow:hidden;font-size:14px;color:#767676;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;border-radius:3px}.filter-item:hover{text-decoration:none;background-color:#eee}.filter-item.selected{color:#fff;background-color:#4078c0}.filter-item .count{float:right;font-weight:bold}.filter-item .bar{position:absolute;top:2px;right:0;bottom:2px;z-index:-1;display:inline-block;background-color:#f1f1f1}.subnav{margin-bottom:20px}.subnav::before{display:table;content:""}.subnav::after{display:table;clear:both;content:""}.subnav-bordered{padding-bottom:20px;border-bottom:1px solid #eee}.subnav-flush{margin-bottom:0}.subnav-item{position:relative;float:left;padding:6px 14px;font-weight:600;line-height:20px;color:#666;border:1px solid #e5e5e5}.subnav-item+.subnav-item{margin-left:-1px}.subnav-item:hover,.subnav-item:focus{text-decoration:none;background-color:#f5f5f5}.subnav-item.selected,.subnav-item.selected:hover,.subnav-item.selected:focus{z-index:2;color:#fff;background-color:#4078c0;border-color:#4078c0}.subnav-item:first-child{border-top-left-radius:3px;border-bottom-left-radius:3px}.subnav-item:last-child{border-top-right-radius:3px;border-bottom-right-radius:3px}.subnav-search{position:relative;margin-left:10px}.subnav-search-input{width:320px;padding-left:30px;color:#767676;border-color:#d5d5d5}.subnav-search-input-wide{width:500px}.subnav-search-icon{position:absolute;top:9px;left:8px;display:block;color:#ccc;text-align:center;pointer-events:none}.subnav-search-context .btn{color:#555;border-top-right-radius:0;border-bottom-right-radius:0}.subnav-search-context .btn:hover,.subnav-search-context .btn:focus,.subnav-search-context .btn:active,.subnav-search-context .btn.selected{z-index:2}.subnav-search-context+.subnav-search{margin-left:-1px}.subnav-search-context+.subnav-search .subnav-search-input{border-top-left-radius:0;border-bottom-left-radius:0}.subnav-search-context .select-menu-modal-holder{z-index:30}.subnav-search-context .select-menu-modal{width:220px}.subnav-search-context .select-menu-item-icon{color:inherit}.subnav-spacer-right{padding-right:10px} .ellipsis-button { padding: 0 4px; vertical-align: middle; color: #444d56; background: #dfe2e5; border: 0; border-radius: 1px; margin-left: 6px; cursor: pointer; } .ellipsis-button:hover { background-color: #c6cbd1; } .ellipsis-button:active { color: #fff; background-color: #2188ff; } pre.highlight-diff { font-size: 12px; line-height: 16px; overflow-x: scroll; }
@@ -121,11 +121,11 @@ func main() { req.URL.Path = req.URL.Path[prefixLen:] if req.URL.Path == "" { req.URL.Path = "/" } //req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, "go.googlesource.com/go")) req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, "github.com/shurcooL/vfsgen")) req = req.WithContext(context.WithValue(req.Context(), changesapp.RepoSpecContextKey, "github.com/google/go-github")) req = req.WithContext(context.WithValue(req.Context(), changesapp.BaseURIContextKey, "/changes")) changesApp.ServeHTTP(w, req) }) r.Path("/changes").Handler(issuesHandler) r.PathPrefix("/changes/").Handler(issuesHandler)
@@ -0,0 +1,141 @@ package changesapp import ( "strings" "dmitri.shuralyov.com/changes" homecomponent "github.com/shurcooL/home/component" "github.com/shurcooL/htmlg" issuescomponent "github.com/shurcooL/issuesapp/component" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) type Commits struct { Commits []Commit } func (cs Commits) Render() []*html.Node { if len(cs.Commits) == 0 { // No commits. Let the user know via a blank slate. div := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: atom.Style.String(), Val: "text-align: center; margin-top: 80px; margin-bottom: 80px;"}}, FirstChild: htmlg.Text("There are no commits."), } return []*html.Node{htmlg.DivClass("list-entry-border", div)} } var nodes []*html.Node for _, c := range cs.Commits { nodes = append(nodes, c.Render()...) } return []*html.Node{htmlg.DivClass("list-entry-border", nodes...)} } type Commit struct { changes.Commit } func (c Commit) Render() []*html.Node { div := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: atom.Style.String(), Val: "display: flex;"}}, } avatarDiv := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: atom.Style.String(), Val: "margin-right: 6px;"}}, } htmlg.AppendChildren(avatarDiv, issuescomponent.Avatar{User: c.Author, Size: 32}.Render()...) div.AppendChild(avatarDiv) titleAndByline := &html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{{Key: atom.Style.String(), Val: "flex-grow: 1;"}}, } { commitSubject, commitBody := splitCommitMessage(c.Message) title := htmlg.Div( &html.Node{ Type: html.ElementNode, Data: atom.A.String(), Attr: []html.Attribute{ {Key: atom.Class.String(), Val: "black"}, {Key: atom.Href.String(), Val: "commit/" + c.SHA}, }, FirstChild: htmlg.Strong(commitSubject), }, ) if commitBody != "" { htmlg.AppendChildren(title, homecomponent.EllipsisButton{OnClick: "ToggleDetails(this);"}.Render()...) } titleAndByline.AppendChild(title) byline := htmlg.DivClass("gray tiny") byline.Attr = append(byline.Attr, html.Attribute{Key: atom.Style.String(), Val: "margin-top: 2px;"}) htmlg.AppendChildren(byline, issuescomponent.User{User: c.Author}.Render()...) byline.AppendChild(htmlg.Text(" committed ")) htmlg.AppendChildren(byline, issuescomponent.Time{Time: c.AuthorTime}.Render()...) titleAndByline.AppendChild(byline) if commitBody != "" { pre := &html.Node{ Type: html.ElementNode, Data: atom.Pre.String(), Attr: []html.Attribute{ {Key: atom.Class.String(), Val: "commit-details"}, {Key: atom.Style.String(), Val: `font-size: 13px; font-family: Go; color: #444; margin-top: 10px; margin-bottom: 0; display: none;`}}, FirstChild: htmlg.Text(commitBody), } titleAndByline.AppendChild(pre) } } div.AppendChild(titleAndByline) commitID := commitID{SHA: c.SHA} htmlg.AppendChildren(div, commitID.Render()...) listEntryDiv := htmlg.DivClass("list-entry-body multilist-entry commit-container", div) return []*html.Node{listEntryDiv} } // commitID is a component that displays a linked commit ID. E.g., "c0de1234". type commitID struct { SHA string HTMLURL string // Optional. } func (c commitID) Render() []*html.Node { sha := &html.Node{ Type: html.ElementNode, Data: atom.Code.String(), Attr: []html.Attribute{ {Key: atom.Style.String(), Val: "width: 8ch; overflow: hidden; display: inline-grid; white-space: nowrap;"}, {Key: atom.Title.String(), Val: c.SHA}, }, FirstChild: htmlg.Text(c.SHA), } if c.HTMLURL != "" { sha = &html.Node{ Type: html.ElementNode, Data: atom.A.String(), Attr: []html.Attribute{ {Key: atom.Href.String(), Val: c.HTMLURL}, }, FirstChild: sha, } } return []*html.Node{sha} } // splitCommitMessage splits commit message s into subject and body, if any. func splitCommitMessage(s string) (subject, body string) { i := strings.Index(s, "\n\n") if i == -1 { return s, "" } return s[:i], s[i+2:] }
@@ -20,17 +20,21 @@ type IssuesNav struct { StateQueryKey string // Name of query key for controlling issue state filter. Constant, but provided externally. } func (n IssuesNav) Render() []*html.Node { // TODO: Make this much nicer. // <div class="list-entry-header"> // <header class="list-entry-header"> // <nav>{{.Tabs}}</nav> // </div> // </header> nav := &html.Node{Type: html.ElementNode, Data: atom.Nav.String()} htmlg.AppendChildren(nav, n.tabs()...) div := htmlg.DivClass("list-entry-header", nav) return []*html.Node{div} header := &html.Node{ Type: html.ElementNode, Data: atom.Header.String(), Attr: []html.Attribute{{Key: atom.Class.String(), Val: "list-entry-header"}}, FirstChild: nav, } return []*html.Node{header} } // tabs renders the HTML nodes for <nav> element with tab header links. func (n IssuesNav) tabs() []*html.Node { selectedTabName := n.Query.Get(n.StateQueryKey)
@@ -49,10 +49,11 @@ func main() { js.Global.Set("CreateNewIssue", CreateNewIssue) js.Global.Set("ToggleIssueState", ToggleIssueState) js.Global.Set("PostComment", PostComment) js.Global.Set("EditComment", jsutil.Wrap(f.EditComment)) js.Global.Set("TabSupportKeyDownHandler", jsutil.Wrap(tabsupport.KeyDownHandler)) js.Global.Set("ToggleDetails", jsutil.Wrap(ToggleDetails)) switch readyState := document.ReadyState(); readyState { case "loading": document.AddEventListener("DOMContentLoaded", false, func(dom.Event) { go setup(f) @@ -329,5 +330,17 @@ func switchWriteTab(container dom.Element, commentEditor *dom.HTMLTextAreaElemen commentPreview.Style().SetProperty("display", "none", "") } commentEditor.Focus() } func ToggleDetails(el dom.HTMLElement) { container := getAncestorByClassName(el, "commit-container").(dom.HTMLElement) details := container.QuerySelector("pre.commit-details").(dom.HTMLElement) switch details.Style().GetPropertyValue("display") { default: details.Style().SetProperty("display", "none", "") case "none": details.Style().SetProperty("display", "block", "") } }
@@ -157,10 +157,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error { switch { // "/{changeID}". case len(elems) == 1: return h.ChangeHandler(w, req, changeID) // "/{changeID}/commits". case len(elems) == 2 && elems[1] == "commits": return h.ChangeCommitsHandler(w, req, changeID) // "/{changeID}/files". case len(elems) == 2 && elems[1] == "files": return h.ChangeFilesHandler(w, req, changeID) default: @@ -320,10 +324,43 @@ func (h *handler) ChangeHandler(w http.ResponseWriter, req *http.Request, change return fmt.Errorf("t.ExecuteTemplate: %v", err) } return nil } func (h *handler) ChangeCommitsHandler(w http.ResponseWriter, req *http.Request, changeID uint64) error { if req.Method != http.MethodGet { return httperror.Method{Allowed: []string{http.MethodGet}} } state, err := h.state(req, changeID) if err != nil { return err } state.Change, err = h.is.Get(req.Context(), state.RepoSpec, state.IssueID) if err != nil { return err } cs, err := h.is.ListCommits(req.Context(), state.RepoSpec, state.IssueID) if err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") err = h.static.ExecuteTemplate(w, "change-commits.html.tmpl", &state) if err != nil { return err } var commits []Commit for _, c := range cs { commits = append(commits, Commit{Commit: c}) } err = htmlg.RenderComponents(w, Commits{Commits: commits}) if err != nil { return err } _, err = io.WriteString(w, `</body></html>`) return err } func (h *handler) ChangeFilesHandler(w http.ResponseWriter, req *http.Request, changeID uint64) error { if req.Method != http.MethodGet { return httperror.Method{Allowed: []string{http.MethodGet}} } state, err := h.state(req, changeID)