Commit c9357581fe

c9357581fe162c196251e4ef866d21dfd82b531b

parent: 9cf7a688b5

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

cmc <hello@cleberg.net> · 2026-09-29 05:18 UTC

web: keep no-newline markers in split rows

A marker between the - and + runs no longer breaks pairing; it shows
under its own side. Empty split cells are aria-hidden and the layout
picker is hidden where the split falls back.

Closes #290

Layout: unified · split

internal/httpd/diff.go +31
@@ -316,6 +316,8 @@ type splitRow struct {
316316 Old *diffLine
317317 New *diffLine
318318 Threads []diffThread
319 OldNote string // "\ No newline" marker belonging to the old side
320 NewNote string // and to the new side
319321 Compose *diffLine // the line whose new-thread form opens under this row
320322}
321323
@@ -338,6 +340,14 @@ func splitFiles(files []diffFile) {
338340 ln := &lines[i]
339341 switch ln.Class {
340342 case "hunk", "meta":
343 if ln.Class == "meta" && ln.Path == "" && len(rows) > 0 && strings.HasPrefix(ln.Text, `\`) {
344 // a marker after a context line: neither side ends in a newline
345 if last := &rows[len(rows)-1]; last.Old != nil && last.Old == last.New {
346 last.OldNote, last.NewNote = ln.Text, ln.Text
347 i++
348 continue
349 }
350 }
341351 rows = append(rows, splitRow{Kind: ln.Class, Text: ln.Text})
342352 i++
343353 case "ctx":
@@ -349,18 +359,33 @@ func splitFiles(files []diffFile) {
349359 i++
350360 default:
351361 var dels, adds []*diffLine
362 marker := func() string {
363 if i < len(lines) && lines[i].Class == "meta" && strings.HasPrefix(lines[i].Text, `\`) {
364 i++
365 return lines[i-1].Text
366 }
367 return ""
368 }
369 var oldNote, newNote string
352370 for i < len(lines) && lines[i].Class == "del" {
353371 dels = append(dels, &lines[i])
354372 i++
355373 }
374 if len(dels) > 0 {
375 oldNote = marker()
376 }
356377 for i < len(lines) && lines[i].Class == "add" {
357378 adds = append(adds, &lines[i])
358379 i++
359380 }
381 if len(adds) > 0 {
382 newNote = marker()
383 }
360384 if len(dels)+len(adds) == 0 {
361385 i++ // an unknown class: skip rather than loop
362386 continue
363387 }
388 first := len(rows)
364389 for k := 0; k < len(dels) || k < len(adds); k++ {
365390 r := splitRow{Kind: "pair"}
366391 for _, l := range []*diffLine{pick(dels, k), pick(adds, k)} {
@@ -379,6 +404,12 @@ func splitFiles(files []diffFile) {
379404 }
380405 rows = append(rows, r)
381406 }
407 if len(dels) > 0 {
408 rows[first+len(dels)-1].OldNote = oldNote
409 }
410 if len(adds) > 0 {
411 rows[first+len(adds)-1].NewNote = newNote
412 }
382413 }
383414 }
384415 files[f].Rows = rows
internal/httpd/difflayout_test.go +40
@@ -160,3 +160,43 @@ func TestDiffLayoutForQueryOverridesAccount(t *testing.T) {
160160 t.Error("?layout=split ignored for a signed-out reader")
161161 }
162162}
163
164func splitPairs(patch string) []splitRow {
165 files := parseDiff(patch)
166 splitFiles(files)
167 var out []splitRow
168 for _, r := range files[0].Rows {
169 out = append(out, r)
170 }
171 return out
172}
173
174const noNL = `\ No newline at end of file`
175
176func TestSplitNoNewlineMarkerPairs(t *testing.T) {
177 head := "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n"
178 cases := map[string]struct {
179 patch, oldNote, newNote string
180 }{
181 "newline added": {head + "-foo\n" + noNL + "\n+foo\n", noNL, ""},
182 "newline removed": {head + "-foo\n+foo\n" + noNL + "\n", "", noNL},
183 "both lack": {head + "-foo\n" + noNL + "\n+bar\n" + noNL + "\n", noNL, noNL},
184 }
185 for name, c := range cases {
186 rows := splitPairs(c.patch)
187 if len(rows) != 2 || rows[1].Kind != "pair" || rows[1].Old == nil || rows[1].New == nil {
188 t.Errorf("%s: want a hunk and one paired row, got %+v", name, rows)
189 continue
190 }
191 if rows[1].OldNote != c.oldNote || rows[1].NewNote != c.newNote {
192 t.Errorf("%s: notes %q %q", name, rows[1].OldNote, rows[1].NewNote)
193 }
194 }
195}
196
197func TestSplitEmptyCellsAreHiddenFromAssistiveTech(t *testing.T) {
198 out := renderCommitDiff(t, diffLayout{Split: true, UnifiedURL: "/x", SplitURL: "/y"}, nil)
199 if !strings.Contains(out, `class="ln none" aria-hidden="true"`) || !strings.Contains(out, `class="src none" aria-hidden="true"`) {
200 t.Error("empty cells are not aria-hidden")
201 }
202}
internal/web/static/style.css +2
@@ -1411,9 +1411,11 @@ table.difftable.split tr.threadrow td { overflow-wrap: anywhere; }
14111411table.difftable.split td.ln:target,
14121412table.difftable.split td.ln:target + td { background: color-mix(in srgb, var(--mark) 18%, transparent); }
14131413table.difftable.split td.ln:target { border-left: 3px solid var(--mark); }
1414table.difftable.split .nonl { display: block; color: var(--muted); font-style: italic; }
14141415p.layoutpick { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
14151416p.layoutpick strong { color: var(--fg); font-weight: 600; }
14161417@media (max-width: 64rem) {
1418 p.layoutpick { display: none; }
14171419 table.difftable.split, table.difftable.split tbody { display: block; }
14181420 table.difftable.split colgroup { display: none; }
14191421 table.difftable.split tr.pair { display: grid; grid-template-columns: auto 1fr; }
internal/web/templates/layout.html +3 −3
@@ -211,9 +211,9 @@
211211 {{else if $split}}<div class="tablewrap"><table class="difftable split"><colgroup><col class="cln"><col><col class="cln"><col></colgroup>
212212 {{range .Rows}}{{if eq .Kind "hunk"}}<tr class="hunk"><td class="ln"></td><td class="src" colspan="3">{{.Text}}</td></tr>
213213 {{else if eq .Kind "meta"}}<tr class="dmeta"><td class="ln"></td><td class="src" colspan="3">{{.Text}}</td></tr>
214 {{else}}<tr class="pair">
215 {{with .Old}}{{if eq .Class "del"}}<td class="ln del" id="f{{$fi}}-o{{.OldLine}}">{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src del chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td>{{else}}<td class="ln ctx old">{{.OldLine}}</td><td class="src ctx old chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td>{{end}}{{else}}<td class="ln none"></td><td class="src none"></td>{{end}}
216 {{with .New}}<td class="ln {{.Class}}" id="f{{$fi}}-n{{.NewLine}}">{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src {{.Class}} chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td>{{else}}<td class="ln none"></td><td class="src none"></td>{{end}}
214 {{else}}{{$r := .}}<tr class="pair">
215 {{with .Old}}{{if eq .Class "del"}}<td class="ln del" id="f{{$fi}}-o{{.OldLine}}">{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src del chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.OldNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{else}}<td class="ln ctx old">{{.OldLine}}</td><td class="src ctx old chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.OldNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{end}}{{else}}<td class="ln none" aria-hidden="true"></td><td class="src none" aria-hidden="true"></td>{{end}}
216 {{with .New}}<td class="ln {{.Class}}" id="f{{$fi}}-n{{.NewLine}}">{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src {{.Class}} chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.NewNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{else}}<td class="ln none" aria-hidden="true"></td><td class="src none" aria-hidden="true"></td>{{end}}
217217 </tr>
218218 {{with .Compose}}<tr class="threadrow"><td colspan="4"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
219219 <input type="hidden" name="path" value="{{.Path}}">