Commit ce4ca463ba

ce4ca463ba12dff63b70b324dc23edb4615e8f52

parent: ab515bab17

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 03:02 UTC

web: diff lines, files and review threads can be linked to

Diff rows carried no id, so a review comment or a line under
discussion could not be pointed at, and a file's #L anchors had no
highlight. Each diff file is #f<n>, each row #f<n>-n<line> (or
-o<line> for a deleted line), each thread #thread-<id>, and a targeted
row, file line or thread is highlighted.

Ref #118

Layout: unified · split

internal/web/static/style.css +4
@@ -773,6 +773,10 @@ button.linklike:hover { text-decoration: underline; filter: none; }
773 773
774/* rendered markdown / org / html */ 774/* rendered markdown / org / html */
775.rendered { max-width: 100%; overflow-wrap: anywhere; } 775.rendered { max-width: 100%; overflow-wrap: anywhere; }
776/* A linked line: #f2-n17 on a diff, #L17 on a file (#118). */
777.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); }
778.difftable tr:target td.ln, .code .line:has(a:target) .ln { border-left: 3px solid var(--mark); }
779.thread:target { outline: 2px solid var(--mark); outline-offset: 2px; }
776/* GFM task lists: the box sits in the line, sized to the prose. */ 780/* GFM task lists: the box sits in the line, sized to the prose. */
777.rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; accent-color: var(--accent); } 781.rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; accent-color: var(--accent); }
778.rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; } 782.rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; }
internal/web/templates/layout.html +3 −3
@@ -119,7 +119,7 @@
119 endpoint and Viewer the signed-in account; the commit page passes 119 endpoint and Viewer the signed-in account; the commit page passes
120 neither and gets the same diff without review controls. */}} 120 neither and gets the same diff without review controls. */}}
121{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}} 121{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}}
122{{range .Files}}<details class="difffold"{{if .Open}} open{{end}}> 122{{range $fi, $f := .Files}}<details class="difffold" id="f{{$fi}}"{{if .Open}} open{{end}}>
123 <summary> 123 <summary>
124 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span> 124 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
125 {{if ne .Status "modified"}}<span class="chip">{{.Status}}</span>{{end}} 125 {{if ne .Status "modified"}}<span class="chip">{{.Status}}</span>{{end}}
@@ -129,7 +129,7 @@
129 {{else}}<div class="tablewrap"><table class="difftable"> 129 {{else}}<div class="tablewrap"><table class="difftable">
130 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 130 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
131 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 131 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
132 {{else}}<tr class="{{.Class}}"><td class="ln">{{if .OldLine}}{{if eq .Class "del"}}{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer}}{{else}}{{.OldLine}}{{end}}{{end}}</td><td class="ln">{{if .NewLine}}{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer}}{{end}}</td><td class="src chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr> 132 {{else}}<tr class="{{.Class}}" id="f{{$fi}}-{{if eq .Class "del"}}o{{.OldLine}}{{else}}n{{.NewLine}}{{end}}"><td class="ln">{{if .OldLine}}{{if eq .Class "del"}}{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer}}{{else}}{{.OldLine}}{{end}}{{end}}</td><td class="ln">{{if .NewLine}}{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer}}{{end}}</td><td class="src chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr>
133 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing"> 133 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
134 <input type="hidden" name="path" value="{{.Path}}"> 134 <input type="hidden" name="path" value="{{.Path}}">
135 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}"> 135 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
@@ -149,7 +149,7 @@
149 149
150{{/* thread renders one review thread with its reply and resolve controls. 150{{/* thread renders one review thread with its reply and resolve controls.
151 Class carries "stale" for threads whose anchor is gone. */}} 151 Class carries "stale" for threads whose anchor is gone. */}}
152{{define "thread"}}{{$t := .T}}<div class="thread{{if $t.Resolved}} resolved{{end}}{{if .Class}} {{.Class}}{{end}}"> 152{{define "thread"}}{{$t := .T}}<div class="thread{{if $t.Resolved}} resolved{{end}}{{if .Class}} {{.Class}}{{end}}" id="thread-{{$t.ID}}">
153{{if or $t.Resolved (and .Class $t.Stale)}}<p class="threadstate">{{if and .Class $t.Stale}}stale{{end}}{{if $t.Resolved}}{{if and .Class $t.Stale}} · {{end}}resolved by {{$t.Resolved}}{{end}}</p>{{end}} 153{{if or $t.Resolved (and .Class $t.Stale)}}<p class="threadstate">{{if and .Class $t.Stale}}stale{{end}}{{if $t.Resolved}}{{if and .Class $t.Stale}} · {{end}}resolved by {{$t.Resolved}}{{end}}</p>{{end}}
154{{range $t.Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}} 154{{range $t.Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}
155{{if .Viewer}}<details class="threadreply"><summary>Reply</summary> 155{{if .Viewer}}<details class="threadreply"><summary>Reply</summary>