detached 98d36bf

Merge a01252ad02639c972907fdd5f0f483b84d23a2fd into cc7ba64ee52412f41c3ce5207bac7e47b9f01f30

commit
98d36bffa341ffc5d6915bb2e2d2d60e3d63c29c
author
ermite · · 2026-10-20 09:00 UTC
merge of
cc7ba64 + a01252a · diff against the first parent
signature
unsigned
40 files · +2545 -1249
M docs/merge-requests.md +29 -0
@@ -104,3 +104,32 @@ report whether more exist; ancestry, rather than the bounded list, detects a
104104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105105 (100) commits. These history queries remain on the merge-request service
106106 because it owns the merge-git port.
107+
108+### Review page panels
109+
110+The main request card ends with a footer of two tabs, Commits and Checks, both
111+collapsed by default. Clicking a tab opens its panel, clicking the open tab
112+folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113+move between the tabs. Without scripts the tabs are anchor links and the URL
114+fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115+the diff opens the Checks panel and continues to update live. Commit totals use
116+`N+` when the branch list is capped: the reader's existing count operation counts all reachable
117+ancestors, not the source-minus-target range.
118+
119+The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
120+are read-only information in the More (⋯) menu, available to every viewer.
121+Signing-key information is also there when a merge box offers signed modes;
122+merge actions remain restricted to their existing permissions.
123+
124+## Live source-branch updates
125+
126+A push to the source branch refreshes the web review in place: the diff, files
127+rail, commits/checks footer, diff range and merge controls follow the new head.
128+The review keeps its visible file/line, folded files and open footer panel.
129+The page request still reads only one 25-file window of hunks; the visible
130+anchor's window is fetched too if needed, and other windows remain lazy.
131+
132+While a comment is focused or has unsent edits, the refresh waits. A small
133+“branch updated — refreshing after your comment” status explains the delay.
134+Sending, cancelling, or reverting the draft and leaving its form lets the
135+pending refresh proceed. Refresh never preserves or discards an unsent draft.
@@ -104,3 +104,32 @@ report whether more exist; ancestry, rather than the bounded list, detects a
104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105 (100) commits. These history queries remain on the merge-request service105 (100) commits. These history queries remain on the merge-request service
106 because it owns the merge-git port.106 because it owns the merge-git port.
107+
108+### Review page panels
109+
110+The main request card ends with a footer of two tabs, Commits and Checks, both
111+collapsed by default. Clicking a tab opens its panel, clicking the open tab
112+folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113+move between the tabs. Without scripts the tabs are anchor links and the URL
114+fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115+the diff opens the Checks panel and continues to update live. Commit totals use
116+`N+` when the branch list is capped: the reader's existing count operation counts all reachable
117+ancestors, not the source-minus-target range.
118+
119+The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
120+are read-only information in the More (⋯) menu, available to every viewer.
121+Signing-key information is also there when a merge box offers signed modes;
122+merge actions remain restricted to their existing permissions.
123+
124+## Live source-branch updates
125+
126+A push to the source branch refreshes the web review in place: the diff, files
127+rail, commits/checks footer, diff range and merge controls follow the new head.
128+The review keeps its visible file/line, folded files and open footer panel.
129+The page request still reads only one 25-file window of hunks; the visible
130+anchor's window is fetched too if needed, and other windows remain lazy.
131+
132+While a comment is focused or has unsent edits, the refresh waits. A small
133+“branch updated — refreshing after your comment” status explains the delay.
134+Sending, cancelling, or reverting the draft and leaving its form lets the
135+pending refresh proceed. Refresh never preserves or discards an unsent draft.
M internal/adapters/web/actions.templ +2 -2
@@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) {
434434 }
435435
436436 // MRChecksSum is the state of the checks of a merge request in the bar above
437-// the diff, a link to the checks of the rail. The event stream replaces it
437+// the diff, a link to the checks of the card footer. The event stream replaces it
438438 // with the checks section (same event, fragments.go).
439439 templ MRChecksSum(c ChecksSummary) {
440440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
@@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) {
447447 </a>
448448 }
449449
450-// MRChecks is the checks section of the rail of a merge request.
450+// MRChecks is the checks section of the footer of a merge request.
451451 templ MRChecks(c ChecksSection) {
452452 <section aria-labelledby="r-checks" id="mr-checks">
453453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
@@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) {
434 }434 }
435 435
436 // MRChecksSum is the state of the checks of a merge request in the bar above436 // MRChecksSum is the state of the checks of a merge request in the bar above
437-// the diff, a link to the checks of the rail. The event stream replaces it437+// the diff, a link to the checks of the card footer. The event stream replaces it
438 // with the checks section (same event, fragments.go).438 // with the checks section (same event, fragments.go).
439 templ MRChecksSum(c ChecksSummary) {439 templ MRChecksSum(c ChecksSummary) {
440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
@@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) {
447 </a>447 </a>
448 }448 }
449 449
450-// MRChecks is the checks section of the rail of a merge request.450+// MRChecks is the checks section of the footer of a merge request.
451 templ MRChecks(c ChecksSection) {451 templ MRChecks(c ChecksSection) {
452 <section aria-labelledby="r-checks" id="mr-checks">452 <section aria-labelledby="r-checks" id="mr-checks">
453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
M internal/adapters/web/actions_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/checks.go +1 -1
@@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
8181 return sum
8282 }
8383
84-// checksSection is the checks of the rail of a merge request, with the rule of
84+// checksSection is the checks of the footer of a merge request, with the rule of
8585 // the repository that applies to them.
8686 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
8787 sec := ChecksSection{}
@@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
81 return sum81 return sum
82 }82 }
83 83
84-// checksSection is the checks of the rail of a merge request, with the rule of84+// checksSection is the checks of the footer of a merge request, with the rule of
85 // the repository that applies to them.85 // the repository that applies to them.
86 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {86 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
87 sec := ChecksSection{}87 sec := ChecksSection{}
M internal/adapters/web/diff.templ +6 -2
@@ -117,7 +117,7 @@ templ diffViewMenu(p DiffView) {
117117 <details class="menu vmenu">
118118 <summary class="btn" aria-label="Diff view">
119119 if p.Both {
120- <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
120+ <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121121 } else {
122122 { viewLabel(p.View) }
123123 }
@@ -139,12 +139,16 @@ templ viewLink(p DiffView, view, label, key, href string) {
139139 class="btn vbtn"
140140 data-view={ view }
141141 aria-keyshortcuts={ key }
142+ if view == "split" {
143+ :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false"
144+ :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'"
145+ }
142146 if p.View == view {
143147 aria-current="true"
144148 }
145149 if p.Both {
146150 data-fire={ view }
147- :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false"
151+ :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false"
148152 }
149153 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
150154 }
@@ -117,7 +117,7 @@ templ diffViewMenu(p DiffView) {
117 <details class="menu vmenu">117 <details class="menu vmenu">
118 <summary class="btn" aria-label="Diff view">118 <summary class="btn" aria-label="Diff view">
119 if p.Both {119 if p.Both {
120- <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>120+ <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121 } else {121 } else {
122 { viewLabel(p.View) }122 { viewLabel(p.View) }
123 }123 }
@@ -139,12 +139,16 @@ templ viewLink(p DiffView, view, label, key, href string) {
139 class="btn vbtn"139 class="btn vbtn"
140 data-view={ view }140 data-view={ view }
141 aria-keyshortcuts={ key }141 aria-keyshortcuts={ key }
142+ if view == "split" {
143+ :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false"
144+ :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'"
145+ }
142 if p.View == view {146 if p.View == view {
143 aria-current="true"147 aria-current="true"
144 }148 }
145 if p.Both {149 if p.Both {
146 data-fire={ view }150 data-fire={ view }
147- :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false"151+ :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false"
148 }152 }
149 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>153 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
150 }154 }
M internal/adapters/web/diff_templ.go +190 -184
@@ -530,14 +530,14 @@ func diffViewMenu(p DiffView) templ.Component {
530530 return templ_7745c5c3_Err
531531 }
532532 if p.Both {
533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span hx-live=\"this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'\">")
533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span hx-live=\"this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'\">")
534534 if templ_7745c5c3_Err != nil {
535535 return templ_7745c5c3_Err
536536 }
537537 var templ_7745c5c3_Var22 string
538538 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(viewLabel(p.View))
539539 if templ_7745c5c3_Err != nil {
540- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 120, Col: 122}
540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 120, Col: 168}
541541 }
542542 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
543543 if templ_7745c5c3_Err != nil {
@@ -649,58 +649,64 @@ func viewLink(p DiffView, view, label, key, href string) templ.Component {
649649 if templ_7745c5c3_Err != nil {
650650 return templ_7745c5c3_Err
651651 }
652+ if view == "split" {
653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " :aria-disabled=\"q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false\" :title=\"q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'\"")
654+ if templ_7745c5c3_Err != nil {
655+ return templ_7745c5c3_Err
656+ }
657+ }
652658 if p.View == view {
653- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " aria-current=\"true\"")
659+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"true\"")
654660 if templ_7745c5c3_Err != nil {
655661 return templ_7745c5c3_Err
656662 }
657663 }
658664 if p.Both {
659- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " data-fire=\"")
665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " data-fire=\"")
660666 if templ_7745c5c3_Err != nil {
661667 return templ_7745c5c3_Err
662668 }
663669 var templ_7745c5c3_Var28 string
664670 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)
665671 if templ_7745c5c3_Err != nil {
666- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 146, Col: 19}
672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 150, Col: 19}
667673 }
668674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
669675 if templ_7745c5c3_Err != nil {
670676 return templ_7745c5c3_Err
671677 }
672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"")
678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" :aria-current=\"(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false\"")
673679 if templ_7745c5c3_Err != nil {
674680 return templ_7745c5c3_Err
675681 }
676682 }
677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")
683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">")
678684 if templ_7745c5c3_Err != nil {
679685 return templ_7745c5c3_Err
680686 }
681687 var templ_7745c5c3_Var29 string
682688 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label)
683689 if templ_7745c5c3_Err != nil {
684- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 9}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 9}
685691 }
686692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
687693 if templ_7745c5c3_Err != nil {
688694 return templ_7745c5c3_Err
689695 }
690- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<kbd aria-hidden=\"true\">")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<kbd aria-hidden=\"true\">")
691697 if templ_7745c5c3_Err != nil {
692698 return templ_7745c5c3_Err
693699 }
694700 var templ_7745c5c3_Var30 string
695701 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(key)
696702 if templ_7745c5c3_Err != nil {
697- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 40}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 40}
698704 }
699705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
700706 if templ_7745c5c3_Err != nil {
701707 return templ_7745c5c3_Err
702708 }
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</kbd></a>")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</kbd></a>")
704710 if templ_7745c5c3_Err != nil {
705711 return templ_7745c5c3_Err
706712 }
@@ -731,7 +737,7 @@ func collapseButton() templ.Component {
731737 templ_7745c5c3_Var31 = templ.NopComponent
732738 }
733739 ctx = templ.ClearChildren(ctx)
734- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")
740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")
735741 if templ_7745c5c3_Err != nil {
736742 return templ_7745c5c3_Err
737743 }
@@ -763,13 +769,13 @@ func personView(pr Person) templ.Component {
763769 var templ_7745c5c3_Var33 string
764770 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name)
765771 if templ_7745c5c3_Err != nil {
766- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 161, Col: 10}
772+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 10}
767773 }
768774 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
769775 if templ_7745c5c3_Err != nil {
770776 return templ_7745c5c3_Err
771777 }
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ")
778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
773779 if templ_7745c5c3_Err != nil {
774780 return templ_7745c5c3_Err
775781 }
@@ -777,59 +783,59 @@ func personView(pr Person) templ.Component {
777783 var templ_7745c5c3_Var34 string
778784 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
779785 if templ_7745c5c3_Err != nil {
780- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 10}
786+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 10}
781787 }
782788 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
783789 if templ_7745c5c3_Err != nil {
784790 return templ_7745c5c3_Err
785791 }
786- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<time datetime=\"")
792+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<time datetime=\"")
787793 if templ_7745c5c3_Err != nil {
788794 return templ_7745c5c3_Err
789795 }
790796 var templ_7745c5c3_Var35 string
791797 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt)
792798 if templ_7745c5c3_Err != nil {
793- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 38}
799+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 38}
794800 }
795801 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
796802 if templ_7745c5c3_Err != nil {
797803 return templ_7745c5c3_Err
798804 }
799- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" title=\"")
805+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" title=\"")
800806 if templ_7745c5c3_Err != nil {
801807 return templ_7745c5c3_Err
802808 }
803809 var templ_7745c5c3_Var36 string
804810 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs)
805811 if templ_7745c5c3_Err != nil {
806- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 55}
812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 55}
807813 }
808814 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
809815 if templ_7745c5c3_Err != nil {
810816 return templ_7745c5c3_Err
811817 }
812- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
813819 if templ_7745c5c3_Err != nil {
814820 return templ_7745c5c3_Err
815821 }
816822 var templ_7745c5c3_Var37 string
817823 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When)
818824 if templ_7745c5c3_Err != nil {
819- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 67}
825+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 67}
820826 }
821827 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
822828 if templ_7745c5c3_Err != nil {
823829 return templ_7745c5c3_Err
824830 }
825- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</time>")
831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</time>")
826832 if templ_7745c5c3_Err != nil {
827833 return templ_7745c5c3_Err
828834 }
829835 var templ_7745c5c3_Var38 string
830836 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs)
831837 if templ_7745c5c3_Err != nil {
832- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 93}
838+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 93}
833839 }
834840 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
835841 if templ_7745c5c3_Err != nil {
@@ -861,20 +867,20 @@ func commitRail(p CommitPage) templ.Component {
861867 templ_7745c5c3_Var39 = templ.NopComponent
862868 }
863869 ctx = templ.ClearChildren(ctx)
864- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
870+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
865871 if templ_7745c5c3_Err != nil {
866872 return templ_7745c5c3_Err
867873 }
868874 var templ_7745c5c3_Var40 string
869875 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
870876 if templ_7745c5c3_Err != nil {
871- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 169, Col: 65}
877+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 173, Col: 65}
872878 }
873879 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
874880 if templ_7745c5c3_Err != nil {
875881 return templ_7745c5c3_Err
876882 }
877- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></h2>")
883+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></h2>")
878884 if templ_7745c5c3_Err != nil {
879885 return templ_7745c5c3_Err
880886 }
@@ -882,7 +888,7 @@ func commitRail(p CommitPage) templ.Component {
882888 if templ_7745c5c3_Err != nil {
883889 return templ_7745c5c3_Err
884890 }
885- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</section>")
891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</section>")
886892 if templ_7745c5c3_Err != nil {
887893 return templ_7745c5c3_Err
888894 }
@@ -914,230 +920,230 @@ func FileList(files []DiffFile) templ.Component {
914920 }
915921 ctx = templ.ClearChildren(ctx)
916922 if len(files) > fileFilterMin {
917- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>")
923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>")
918924 if templ_7745c5c3_Err != nil {
919925 return templ_7745c5c3_Err
920926 }
921927 }
922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<ol class=\"files\" aria-label=\"Changed files\">")
928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<ol class=\"files\" aria-label=\"Changed files\">")
923929 if templ_7745c5c3_Err != nil {
924930 return templ_7745c5c3_Err
925931 }
926932 for _, f := range files {
927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<li><a href=\"")
933+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<li><a href=\"")
928934 if templ_7745c5c3_Err != nil {
929935 return templ_7745c5c3_Err
930936 }
931937 var templ_7745c5c3_Var42 templ.SafeURL
932938 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))
933939 if templ_7745c5c3_Err != nil {
934- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 194, Col: 37}
940+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 37}
935941 }
936942 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
937943 if templ_7745c5c3_Err != nil {
938944 return templ_7745c5c3_Err
939945 }
940- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" title=\"")
946+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" title=\"")
941947 if templ_7745c5c3_Err != nil {
942948 return templ_7745c5c3_Err
943949 }
944950 var templ_7745c5c3_Var43 string
945951 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
946952 if templ_7745c5c3_Err != nil {
947- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 195, Col: 19}
953+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 19}
948954 }
949955 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
950956 if templ_7745c5c3_Err != nil {
951957 return templ_7745c5c3_Err
952958 }
953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"")
959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"")
954960 if templ_7745c5c3_Err != nil {
955961 return templ_7745c5c3_Err
956962 }
957963 var templ_7745c5c3_Var44 string
958964 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
959965 if templ_7745c5c3_Err != nil {
960- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 40}
966+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 40}
961967 }
962968 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
963969 if templ_7745c5c3_Err != nil {
964970 return templ_7745c5c3_Err
965971 }
966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")
972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" title=\"")
967973 if templ_7745c5c3_Err != nil {
968974 return templ_7745c5c3_Err
969975 }
970976 var templ_7745c5c3_Var45 string
971977 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status))
972978 if templ_7745c5c3_Err != nil {
973- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 71}
979+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 71}
974980 }
975981 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
976982 if templ_7745c5c3_Err != nil {
977983 return templ_7745c5c3_Err
978984 }
979- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")
985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
980986 if templ_7745c5c3_Err != nil {
981987 return templ_7745c5c3_Err
982988 }
983989 var templ_7745c5c3_Var46 string
984990 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
985991 if templ_7745c5c3_Err != nil {
986- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 84}
992+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 84}
987993 }
988994 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
989995 if templ_7745c5c3_Err != nil {
990996 return templ_7745c5c3_Err
991997 }
992- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <span class=\"nm\"><span class=\"p\">")
998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> <span class=\"nm\"><span class=\"p\">")
993999 if templ_7745c5c3_Err != nil {
9941000 return templ_7745c5c3_Err
9951001 }
9961002 var templ_7745c5c3_Var47 string
9971003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name)
9981004 if templ_7745c5c3_Err != nil {
999- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 46}
1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 46}
10001006 }
10011007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
10021008 if templ_7745c5c3_Err != nil {
10031009 return templ_7745c5c3_Err
10041010 }
1005- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span>")
1011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span>")
10061012 if templ_7745c5c3_Err != nil {
10071013 return templ_7745c5c3_Err
10081014 }
10091015 var templ_7745c5c3_Var48 string
10101016 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10111017 if templ_7745c5c3_Err != nil {
1012- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 60}
1018+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 60}
10131019 }
10141020 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
10151021 if templ_7745c5c3_Err != nil {
10161022 return templ_7745c5c3_Err
10171023 }
1018- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"d\"><bdi>")
1024+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"d\"><bdi>")
10191025 if templ_7745c5c3_Err != nil {
10201026 return templ_7745c5c3_Err
10211027 }
10221028 var templ_7745c5c3_Var49 string
10231029 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir)
10241030 if templ_7745c5c3_Err != nil {
1025- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 90}
1031+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 90}
10261032 }
10271033 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
10281034 if templ_7745c5c3_Err != nil {
10291035 return templ_7745c5c3_Err
10301036 }
1031- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</bdi></span></span> ")
1037+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</bdi></span></span> ")
10321038 if templ_7745c5c3_Err != nil {
10331039 return templ_7745c5c3_Err
10341040 }
10351041 if f.OpenNotes > 0 {
1036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<span class=\"cn\" title=\"")
1042+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"cn\" title=\"")
10371043 if templ_7745c5c3_Err != nil {
10381044 return templ_7745c5c3_Err
10391045 }
10401046 var templ_7745c5c3_Var50 string
10411047 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments"))
10421048 if templ_7745c5c3_Err != nil {
1043- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 201, Col: 83}
1049+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 205, Col: 83}
10441050 }
10451051 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
10461052 if templ_7745c5c3_Err != nil {
10471053 return templ_7745c5c3_Err
10481054 }
1049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">")
1055+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\">")
10501056 if templ_7745c5c3_Err != nil {
10511057 return templ_7745c5c3_Err
10521058 }
10531059 var templ_7745c5c3_Var51 string
10541060 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes))
10551061 if templ_7745c5c3_Err != nil {
1056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 201, Col: 105}
1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 205, Col: 105}
10571063 }
10581064 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10591065 if templ_7745c5c3_Err != nil {
10601066 return templ_7745c5c3_Err
10611067 }
1062- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span> ")
1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> ")
10631069 if templ_7745c5c3_Err != nil {
10641070 return templ_7745c5c3_Err
10651071 }
10661072 } else if f.Notes > 0 {
1067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"cn done\" title=\"")
1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<span class=\"cn done\" title=\"")
10681074 if templ_7745c5c3_Err != nil {
10691075 return templ_7745c5c3_Err
10701076 }
10711077 var templ_7745c5c3_Var52 string
10721078 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments"))
10731079 if templ_7745c5c3_Err != nil {
1074- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 92}
1080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 92}
10751081 }
10761082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
10771083 if templ_7745c5c3_Err != nil {
10781084 return templ_7745c5c3_Err
10791085 }
1080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\">")
1086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\">")
10811087 if templ_7745c5c3_Err != nil {
10821088 return templ_7745c5c3_Err
10831089 }
10841090 var templ_7745c5c3_Var53 string
10851091 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes))
10861092 if templ_7745c5c3_Err != nil {
1087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 110}
1093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 110}
10881094 }
10891095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
10901096 if templ_7745c5c3_Err != nil {
10911097 return templ_7745c5c3_Err
10921098 }
1093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span> ")
1099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> ")
10941100 if templ_7745c5c3_Err != nil {
10951101 return templ_7745c5c3_Err
10961102 }
10971103 }
10981104 if f.Bin {
1099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span class=\"n mute\">bin</span>")
1105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"n mute\">bin</span>")
11001106 if templ_7745c5c3_Err != nil {
11011107 return templ_7745c5c3_Err
11021108 }
11031109 } else {
1104- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"n\"><span class=\"plus\">+")
1110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<span class=\"n\"><span class=\"plus\">+")
11051111 if templ_7745c5c3_Err != nil {
11061112 return templ_7745c5c3_Err
11071113 }
11081114 var templ_7745c5c3_Var54 string
11091115 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
11101116 if templ_7745c5c3_Err != nil {
1111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 208, Col: 55}
1117+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 55}
11121118 }
11131119 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
11141120 if templ_7745c5c3_Err != nil {
11151121 return templ_7745c5c3_Err
11161122 }
1117- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span> <span class=\"minus\">-")
1123+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</span> <span class=\"minus\">-")
11181124 if templ_7745c5c3_Err != nil {
11191125 return templ_7745c5c3_Err
11201126 }
11211127 var templ_7745c5c3_Var55 string
11221128 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
11231129 if templ_7745c5c3_Err != nil {
1124- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 208, Col: 99}
1130+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 99}
11251131 }
11261132 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
11271133 if templ_7745c5c3_Err != nil {
11281134 return templ_7745c5c3_Err
11291135 }
1130- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</span></span>")
1136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</span></span>")
11311137 if templ_7745c5c3_Err != nil {
11321138 return templ_7745c5c3_Err
11331139 }
11341140 }
1135- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</a></li>")
1141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</a></li>")
11361142 if templ_7745c5c3_Err != nil {
11371143 return templ_7745c5c3_Err
11381144 }
11391145 }
1140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</ol>")
1146+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</ol>")
11411147 if templ_7745c5c3_Err != nil {
11421148 return templ_7745c5c3_Err
11431149 }
@@ -1175,7 +1181,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11751181 if templ_7745c5c3_Err != nil {
11761182 return templ_7745c5c3_Err
11771183 }
1178- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<details class=\"")
1184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<details class=\"")
11791185 if templ_7745c5c3_Err != nil {
11801186 return templ_7745c5c3_Err
11811187 }
@@ -1188,134 +1194,134 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11881194 if templ_7745c5c3_Err != nil {
11891195 return templ_7745c5c3_Err
11901196 }
1191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" id=\"")
1197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" id=\"")
11921198 if templ_7745c5c3_Err != nil {
11931199 return templ_7745c5c3_Err
11941200 }
11951201 var templ_7745c5c3_Var59 string
11961202 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
11971203 if templ_7745c5c3_Err != nil {
1198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 223, Col: 11}
1204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 227, Col: 11}
11991205 }
12001206 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
12011207 if templ_7745c5c3_Err != nil {
12021208 return templ_7745c5c3_Err
12031209 }
1204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" data-file=\"")
1210+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\" data-file=\"")
12051211 if templ_7745c5c3_Err != nil {
12061212 return templ_7745c5c3_Err
12071213 }
12081214 var templ_7745c5c3_Var60 string
12091215 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Key)
12101216 if templ_7745c5c3_Err != nil {
1211- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 224, Col: 19}
1217+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 228, Col: 19}
12121218 }
12131219 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
12141220 if templ_7745c5c3_Err != nil {
12151221 return templ_7745c5c3_Err
12161222 }
1217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")
1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\"")
12181224 if templ_7745c5c3_Err != nil {
12191225 return templ_7745c5c3_Err
12201226 }
12211227 if f.Open {
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " open")
1228+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " open")
12231229 if templ_7745c5c3_Err != nil {
12241230 return templ_7745c5c3_Err
12251231 }
12261232 }
1227- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "><summary><span class=\"st\">")
1233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "><summary><span class=\"st\">")
12281234 if templ_7745c5c3_Err != nil {
12291235 return templ_7745c5c3_Err
12301236 }
12311237 var templ_7745c5c3_Var61 string
12321238 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
12331239 if templ_7745c5c3_Err != nil {
1234- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 30}
1240+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 30}
12351241 }
12361242 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
12371243 if templ_7745c5c3_Err != nil {
12381244 return templ_7745c5c3_Err
12391245 }
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</span>")
1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span>")
12411247 if templ_7745c5c3_Err != nil {
12421248 return templ_7745c5c3_Err
12431249 }
12441250 var templ_7745c5c3_Var62 string
12451251 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
12461252 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 44}
1253+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 44}
12481254 }
12491255 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
12501256 if templ_7745c5c3_Err != nil {
12511257 return templ_7745c5c3_Err
12521258 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<span class=\"p\">")
1259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<span class=\"p\">")
12541260 if templ_7745c5c3_Err != nil {
12551261 return templ_7745c5c3_Err
12561262 }
12571263 var templ_7745c5c3_Var63 string
12581264 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
12591265 if templ_7745c5c3_Err != nil {
1260- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 70}
1266+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 70}
12611267 }
12621268 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
12631269 if templ_7745c5c3_Err != nil {
12641270 return templ_7745c5c3_Err
12651271 }
1266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span>")
1272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</span>")
12671273 if templ_7745c5c3_Err != nil {
12681274 return templ_7745c5c3_Err
12691275 }
12701276 var templ_7745c5c3_Var64 string
12711277 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
12721278 if templ_7745c5c3_Err != nil {
1273- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 84}
1279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 84}
12741280 }
12751281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
12761282 if templ_7745c5c3_Err != nil {
12771283 return templ_7745c5c3_Err
12781284 }
1279- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"plus\">+")
1285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<span class=\"plus\">+")
12801286 if templ_7745c5c3_Err != nil {
12811287 return templ_7745c5c3_Err
12821288 }
12831289 var templ_7745c5c3_Var65 string
12841290 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
12851291 if templ_7745c5c3_Err != nil {
1286- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 119}
1292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 119}
12871293 }
12881294 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
12891295 if templ_7745c5c3_Err != nil {
12901296 return templ_7745c5c3_Err
12911297 }
1292- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span>")
1298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</span>")
12931299 if templ_7745c5c3_Err != nil {
12941300 return templ_7745c5c3_Err
12951301 }
12961302 var templ_7745c5c3_Var66 string
12971303 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
12981304 if templ_7745c5c3_Err != nil {
1299- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 133}
1305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 133}
13001306 }
13011307 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
13021308 if templ_7745c5c3_Err != nil {
13031309 return templ_7745c5c3_Err
13041310 }
1305- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<span class=\"minus\">-")
1311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<span class=\"minus\">-")
13061312 if templ_7745c5c3_Err != nil {
13071313 return templ_7745c5c3_Err
13081314 }
13091315 var templ_7745c5c3_Var67 string
13101316 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
13111317 if templ_7745c5c3_Err != nil {
1312- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 169}
1318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 169}
13131319 }
13141320 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
13151321 if templ_7745c5c3_Err != nil {
13161322 return templ_7745c5c3_Err
13171323 }
1318- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span> ")
1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</span> ")
13191325 if templ_7745c5c3_Err != nil {
13201326 return templ_7745c5c3_Err
13211327 }
@@ -1323,36 +1329,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
13231329 var templ_7745c5c3_Var68 string
13241330 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13251331 if templ_7745c5c3_Err != nil {
1326- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 232, Col: 9}
1332+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 236, Col: 9}
13271333 }
13281334 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13291335 if templ_7745c5c3_Err != nil {
13301336 return templ_7745c5c3_Err
13311337 }
1332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<span class=\"note\">")
1338+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<span class=\"note\">")
13331339 if templ_7745c5c3_Err != nil {
13341340 return templ_7745c5c3_Err
13351341 }
13361342 var templ_7745c5c3_Var69 string
13371343 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
13381344 if templ_7745c5c3_Err != nil {
1339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 232, Col: 38}
1345+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 236, Col: 38}
13401346 }
13411347 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
13421348 if templ_7745c5c3_Err != nil {
13431349 return templ_7745c5c3_Err
13441350 }
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</span>")
1351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</span>")
13461352 if templ_7745c5c3_Err != nil {
13471353 return templ_7745c5c3_Err
13481354 }
13491355 }
1350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</summary> ")
1356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</summary> ")
13511357 if templ_7745c5c3_Err != nil {
13521358 return templ_7745c5c3_Err
13531359 }
13541360 if f.Lazy {
1355- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " ")
1361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " ")
13561362 if templ_7745c5c3_Err != nil {
13571363 return templ_7745c5c3_Err
13581364 }
@@ -1361,7 +1367,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
13611367 if templ_7745c5c3_Err != nil {
13621368 return templ_7745c5c3_Err
13631369 }
1364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<div class=\"")
1370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<div class=\"")
13651371 if templ_7745c5c3_Err != nil {
13661372 return templ_7745c5c3_Err
13671373 }
@@ -1374,74 +1380,74 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
13741380 if templ_7745c5c3_Err != nil {
13751381 return templ_7745c5c3_Err
13761382 }
1377- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" data-src=\"")
1383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" data-src=\"")
13781384 if templ_7745c5c3_Err != nil {
13791385 return templ_7745c5c3_Err
13801386 }
13811387 var templ_7745c5c3_Var72 string
13821388 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
13831389 if templ_7745c5c3_Err != nil {
1384- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 69}
1390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 243, Col: 69}
13851391 }
13861392 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
13871393 if templ_7745c5c3_Err != nil {
13881394 return templ_7745c5c3_Err
13891395 }
1390- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" data-notes=\"")
1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" data-notes=\"")
13911397 if templ_7745c5c3_Err != nil {
13921398 return templ_7745c5c3_Err
13931399 }
13941400 var templ_7745c5c3_Var73 string
13951401 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments)
13961402 if templ_7745c5c3_Err != nil {
1397- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 95}
1403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 243, Col: 95}
13981404 }
13991405 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
14001406 if templ_7745c5c3_Err != nil {
14011407 return templ_7745c5c3_Err
14021408 }
1403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\"><a href=\"")
1409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\"><a href=\"")
14041410 if templ_7745c5c3_Err != nil {
14051411 return templ_7745c5c3_Err
14061412 }
14071413 var templ_7745c5c3_Var74 templ.SafeURL
14081414 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID))
14091415 if templ_7745c5c3_Err != nil {
1410- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 240, Col: 65}
1416+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 244, Col: 65}
14111417 }
14121418 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
14131419 if templ_7745c5c3_Err != nil {
14141420 return templ_7745c5c3_Err
14151421 }
1416- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">show this file</a></div>")
1422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">show this file</a></div>")
14171423 if templ_7745c5c3_Err != nil {
14181424 return templ_7745c5c3_Err
14191425 }
14201426 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")
1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"dscroll\">")
14221428 if templ_7745c5c3_Err != nil {
14231429 return templ_7745c5c3_Err
14241430 }
14251431 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")
1432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<table class=\"dt uni\" aria-label=\"")
14271433 if templ_7745c5c3_Err != nil {
14281434 return templ_7745c5c3_Err
14291435 }
14301436 var templ_7745c5c3_Var75 string
14311437 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
14321438 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}
1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 249, Col: 60}
14341440 }
14351441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
14361442 if templ_7745c5c3_Err != nil {
14371443 return templ_7745c5c3_Err
14381444 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")
1445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")
14401446 if templ_7745c5c3_Err != nil {
14411447 return templ_7745c5c3_Err
14421448 }
14431449 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")
1450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tbody>")
14451451 if templ_7745c5c3_Err != nil {
14461452 return templ_7745c5c3_Err
14471453 }
@@ -1451,20 +1457,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14511457 return templ_7745c5c3_Err
14521458 }
14531459 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")
1460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr class=\"hunk\"><td colspan=\"4\">")
14551461 if templ_7745c5c3_Err != nil {
14561462 return templ_7745c5c3_Err
14571463 }
14581464 var templ_7745c5c3_Var76 string
14591465 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
14601466 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}
1467+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 255, Col: 51}
14621468 }
14631469 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
14641470 if templ_7745c5c3_Err != nil {
14651471 return templ_7745c5c3_Err
14661472 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")
1473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td></tr>")
14681474 if templ_7745c5c3_Err != nil {
14691475 return templ_7745c5c3_Err
14701476 }
@@ -1474,7 +1480,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14741480 if templ_7745c5c3_Err != nil {
14751481 return templ_7745c5c3_Err
14761482 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")
1483+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"")
14781484 if templ_7745c5c3_Err != nil {
14791485 return templ_7745c5c3_Err
14801486 }
@@ -1487,7 +1493,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14871493 if templ_7745c5c3_Err != nil {
14881494 return templ_7745c5c3_Err
14891495 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")
1496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><td class=\"ln\">")
14911497 if templ_7745c5c3_Err != nil {
14921498 return templ_7745c5c3_Err
14931499 }
@@ -1495,7 +1501,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
14951501 if templ_7745c5c3_Err != nil {
14961502 return templ_7745c5c3_Err
14971503 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")
1504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"ln\">")
14991505 if templ_7745c5c3_Err != nil {
15001506 return templ_7745c5c3_Err
15011507 }
@@ -1503,20 +1509,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15031509 if templ_7745c5c3_Err != nil {
15041510 return templ_7745c5c3_Err
15051511 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")
1512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"m\">")
15071513 if templ_7745c5c3_Err != nil {
15081514 return templ_7745c5c3_Err
15091515 }
15101516 var templ_7745c5c3_Var79 string
15111517 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
15121518 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}
1519+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 260, Col: 33}
15141520 }
15151521 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
15161522 if templ_7745c5c3_Err != nil {
15171523 return templ_7745c5c3_Err
15181524 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")
1525+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"tx chroma\">")
15201526 if templ_7745c5c3_Err != nil {
15211527 return templ_7745c5c3_Err
15221528 }
@@ -1524,12 +1530,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15241530 if templ_7745c5c3_Err != nil {
15251531 return templ_7745c5c3_Err
15261532 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")
1533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>")
15281534 if templ_7745c5c3_Err != nil {
15291535 return templ_7745c5c3_Err
15301536 }
15311537 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
15331539 if templ_7745c5c3_Err != nil {
15341540 return templ_7745c5c3_Err
15351541 }
@@ -1537,19 +1543,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15371543 if templ_7745c5c3_Err != nil {
15381544 return templ_7745c5c3_Err
15391545 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")
1546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
15411547 if templ_7745c5c3_Err != nil {
15421548 return templ_7745c5c3_Err
15431549 }
15441550 }
15451551 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")
1552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</tbody> ")
15471553 if templ_7745c5c3_Err != nil {
15481554 return templ_7745c5c3_Err
15491555 }
15501556 }
15511557 if f.Tail != nil {
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tbody>")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tbody>")
15531559 if templ_7745c5c3_Err != nil {
15541560 return templ_7745c5c3_Err
15551561 }
@@ -1557,36 +1563,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15571563 if templ_7745c5c3_Err != nil {
15581564 return templ_7745c5c3_Err
15591565 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")
1566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody>")
15611567 if templ_7745c5c3_Err != nil {
15621568 return templ_7745c5c3_Err
15631569 }
15641570 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")
1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</table>")
15661572 if templ_7745c5c3_Err != nil {
15671573 return templ_7745c5c3_Err
15681574 }
15691575 }
15701576 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<table class=\"dt spl\" aria-label=\"")
15721578 if templ_7745c5c3_Err != nil {
15731579 return templ_7745c5c3_Err
15741580 }
15751581 var templ_7745c5c3_Var80 string
15761582 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
15771583 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}
1584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 280, Col: 58}
15791585 }
15801586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
15811587 if templ_7745c5c3_Err != nil {
15821588 return templ_7745c5c3_Err
15831589 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
15851591 if templ_7745c5c3_Err != nil {
15861592 return templ_7745c5c3_Err
15871593 }
15881594 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")
1595+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tbody>")
15901596 if templ_7745c5c3_Err != nil {
15911597 return templ_7745c5c3_Err
15921598 }
@@ -1596,25 +1602,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
15961602 return templ_7745c5c3_Err
15971603 }
15981604 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")
1605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"hunk\"><td colspan=\"6\">")
16001606 if templ_7745c5c3_Err != nil {
16011607 return templ_7745c5c3_Err
16021608 }
16031609 var templ_7745c5c3_Var81 string
16041610 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
16051611 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}
1612+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 51}
16071613 }
16081614 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
16091615 if templ_7745c5c3_Err != nil {
16101616 return templ_7745c5c3_Err
16111617 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")
1618+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td></tr>")
16131619 if templ_7745c5c3_Err != nil {
16141620 return templ_7745c5c3_Err
16151621 }
16161622 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")
1623+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr>")
16181624 if templ_7745c5c3_Err != nil {
16191625 return templ_7745c5c3_Err
16201626 }
@@ -1626,12 +1632,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16261632 if templ_7745c5c3_Err != nil {
16271633 return templ_7745c5c3_Err
16281634 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")
1635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</tr>")
16301636 if templ_7745c5c3_Err != nil {
16311637 return templ_7745c5c3_Err
16321638 }
16331639 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")
1640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr class=\"fnote\"><td colspan=\"6\">")
16351641 if templ_7745c5c3_Err != nil {
16361642 return templ_7745c5c3_Err
16371643 }
@@ -1639,19 +1645,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16391645 if templ_7745c5c3_Err != nil {
16401646 return templ_7745c5c3_Err
16411647 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")
1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</td></tr>")
16431649 if templ_7745c5c3_Err != nil {
16441650 return templ_7745c5c3_Err
16451651 }
16461652 }
16471653 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")
1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tbody> ")
16491655 if templ_7745c5c3_Err != nil {
16501656 return templ_7745c5c3_Err
16511657 }
16521658 }
16531659 if f.Tail != nil {
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tbody>")
1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tbody>")
16551661 if templ_7745c5c3_Err != nil {
16561662 return templ_7745c5c3_Err
16571663 }
@@ -1659,22 +1665,22 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16591665 if templ_7745c5c3_Err != nil {
16601666 return templ_7745c5c3_Err
16611667 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")
1668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody>")
16631669 if templ_7745c5c3_Err != nil {
16641670 return templ_7745c5c3_Err
16651671 }
16661672 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")
1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</table>")
16681674 if templ_7745c5c3_Err != nil {
16691675 return templ_7745c5c3_Err
16701676 }
16711677 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")
1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</div>")
16731679 if templ_7745c5c3_Err != nil {
16741680 return templ_7745c5c3_Err
16751681 }
16761682 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")
1683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<p class=\"note pad\">")
16781684 if templ_7745c5c3_Err != nil {
16791685 return templ_7745c5c3_Err
16801686 }
@@ -1682,25 +1688,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
16821688 var templ_7745c5c3_Var82 string
16831689 templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
16841690 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}
1691+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 13}
16861692 }
16871693 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
16881694 if templ_7745c5c3_Err != nil {
16891695 return templ_7745c5c3_Err
16901696 }
16911697 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")
1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "no textual change.")
16931699 if templ_7745c5c3_Err != nil {
16941700 return templ_7745c5c3_Err
16951701 }
16961702 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")
1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</p>")
16981704 if templ_7745c5c3_Err != nil {
16991705 return templ_7745c5c3_Err
17001706 }
17011707 }
17021708 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")
1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<div class=\"dloose\">")
17041710 if templ_7745c5c3_Err != nil {
17051711 return templ_7745c5c3_Err
17061712 }
@@ -1710,12 +1716,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
17101716 return templ_7745c5c3_Err
17111717 }
17121718 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")
1719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")
17141720 if templ_7745c5c3_Err != nil {
17151721 return templ_7745c5c3_Err
17161722 }
17171723 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")
1724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</details>")
17191725 if templ_7745c5c3_Err != nil {
17201726 return templ_7745c5c3_Err
17211727 }
@@ -1779,7 +1785,7 @@ func MRFilesMoved() templ.Component {
17791785 templ_7745c5c3_Var84 = templ.NopComponent
17801786 }
17811787 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
17831789 if templ_7745c5c3_Err != nil {
17841790 return templ_7745c5c3_Err
17851791 }
@@ -1811,59 +1817,59 @@ func gapRow(g DiffGap, cols int) templ.Component {
18111817 templ_7745c5c3_Var85 = templ.NopComponent
18121818 }
18131819 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")
1820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<tr class=\"gap\" data-js data-gap=\"")
18151821 if templ_7745c5c3_Err != nil {
18161822 return templ_7745c5c3_Err
18171823 }
18181824 var templ_7745c5c3_Var86 string
18191825 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
18201826 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}
1827+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 346, Col: 41}
18221828 }
18231829 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
18241830 if templ_7745c5c3_Err != nil {
18251831 return templ_7745c5c3_Err
18261832 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")
1833+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><td colspan=\"")
18281834 if templ_7745c5c3_Err != nil {
18291835 return templ_7745c5c3_Err
18301836 }
18311837 var templ_7745c5c3_Var87 string
18321838 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
18331839 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}
1840+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 347, Col: 26}
18351841 }
18361842 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
18371843 if templ_7745c5c3_Err != nil {
18381844 return templ_7745c5c3_Err
18391845 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")
1846+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><button type=\"button\" data-src=\"")
18411847 if templ_7745c5c3_Err != nil {
18421848 return templ_7745c5c3_Err
18431849 }
18441850 var templ_7745c5c3_Var88 string
18451851 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
18461852 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}
1853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 348, Col: 41}
18481854 }
18491855 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
18501856 if templ_7745c5c3_Err != nil {
18511857 return templ_7745c5c3_Err
18521858 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")
1859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><span aria-hidden=\"true\">⋯</span> ")
18541860 if templ_7745c5c3_Err != nil {
18551861 return templ_7745c5c3_Err
18561862 }
18571863 var templ_7745c5c3_Var89 string
18581864 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
18591865 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}
1866+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 349, Col: 51}
18611867 }
18621868 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
18631869 if templ_7745c5c3_Err != nil {
18641870 return templ_7745c5c3_Err
18651871 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")
1872+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</button></td></tr>")
18671873 if templ_7745c5c3_Err != nil {
18681874 return templ_7745c5c3_Err
18691875 }
@@ -1894,51 +1900,51 @@ func DiffContext(c ContextRows) templ.Component {
18941900 templ_7745c5c3_Var90 = templ.NopComponent
18951901 }
18961902 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")
1903+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<table><tbody data-for=\"uni\">")
18981904 if templ_7745c5c3_Err != nil {
18991905 return templ_7745c5c3_Err
19001906 }
19011907 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")
1908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"ctx\"><td class=\"ln\">")
19031909 if templ_7745c5c3_Err != nil {
19041910 return templ_7745c5c3_Err
19051911 }
19061912 var templ_7745c5c3_Var91 string
19071913 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
19081914 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}
1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 362, Col: 28}
19101916 }
19111917 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
19121918 if templ_7745c5c3_Err != nil {
19131919 return templ_7745c5c3_Err
19141920 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")
1921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"ln\">")
19161922 if templ_7745c5c3_Err != nil {
19171923 return templ_7745c5c3_Err
19181924 }
19191925 var templ_7745c5c3_Var92 string
19201926 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
19211927 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}
1928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 363, Col: 28}
19231929 }
19241930 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
19251931 if templ_7745c5c3_Err != nil {
19261932 return templ_7745c5c3_Err
19271933 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")
1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"m\">")
19291935 if templ_7745c5c3_Err != nil {
19301936 return templ_7745c5c3_Err
19311937 }
19321938 var templ_7745c5c3_Var93 string
19331939 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
19341940 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}
1941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 28}
19361942 }
19371943 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
19381944 if templ_7745c5c3_Err != nil {
19391945 return templ_7745c5c3_Err
19401946 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")
1947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"tx chroma\">")
19421948 if templ_7745c5c3_Err != nil {
19431949 return templ_7745c5c3_Err
19441950 }
@@ -1946,7 +1952,7 @@ func DiffContext(c ContextRows) templ.Component {
19461952 if templ_7745c5c3_Err != nil {
19471953 return templ_7745c5c3_Err
19481954 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")
1955+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td></tr>")
19501956 if templ_7745c5c3_Err != nil {
19511957 return templ_7745c5c3_Err
19521958 }
@@ -1957,12 +1963,12 @@ func DiffContext(c ContextRows) templ.Component {
19571963 return templ_7745c5c3_Err
19581964 }
19591965 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")
1966+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</tbody> <tbody data-for=\"spl\">")
19611967 if templ_7745c5c3_Err != nil {
19621968 return templ_7745c5c3_Err
19631969 }
19641970 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")
1971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr>")
19661972 if templ_7745c5c3_Err != nil {
19671973 return templ_7745c5c3_Err
19681974 }
@@ -1974,7 +1980,7 @@ func DiffContext(c ContextRows) templ.Component {
19741980 if templ_7745c5c3_Err != nil {
19751981 return templ_7745c5c3_Err
19761982 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")
1983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tr>")
19781984 if templ_7745c5c3_Err != nil {
19791985 return templ_7745c5c3_Err
19801986 }
@@ -1985,7 +1991,7 @@ func DiffContext(c ContextRows) templ.Component {
19851991 return templ_7745c5c3_Err
19861992 }
19871993 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")
1994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</tbody></table>")
19891995 if templ_7745c5c3_Err != nil {
19901996 return templ_7745c5c3_Err
19911997 }
@@ -2017,7 +2023,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20172023 }
20182024 ctx = templ.ClearChildren(ctx)
20192025 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
20212027 if templ_7745c5c3_Err != nil {
20222028 return templ_7745c5c3_Err
20232029 }
@@ -2027,7 +2033,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20272033 if templ_7745c5c3_Err != nil {
20282034 return templ_7745c5c3_Err
20292035 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")
2036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<td class=\"")
20312037 if templ_7745c5c3_Err != nil {
20322038 return templ_7745c5c3_Err
20332039 }
@@ -2040,7 +2046,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20402046 if templ_7745c5c3_Err != nil {
20412047 return templ_7745c5c3_Err
20422048 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
2049+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\">")
20442050 if templ_7745c5c3_Err != nil {
20452051 return templ_7745c5c3_Err
20462052 }
@@ -2055,7 +2061,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20552061 return templ_7745c5c3_Err
20562062 }
20572063 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")
2064+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</td>")
20592065 if templ_7745c5c3_Err != nil {
20602066 return templ_7745c5c3_Err
20612067 }
@@ -2064,7 +2070,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20642070 if templ_7745c5c3_Err != nil {
20652071 return templ_7745c5c3_Err
20662072 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")
2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<td class=\"")
20682074 if templ_7745c5c3_Err != nil {
20692075 return templ_7745c5c3_Err
20702076 }
@@ -2077,20 +2083,20 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20772083 if templ_7745c5c3_Err != nil {
20782084 return templ_7745c5c3_Err
20792085 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")
2086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\">")
20812087 if templ_7745c5c3_Err != nil {
20822088 return templ_7745c5c3_Err
20832089 }
20842090 var templ_7745c5c3_Var99 string
20852091 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
20862092 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}
2093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 401, Col: 68}
20882094 }
20892095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
20902096 if templ_7745c5c3_Err != nil {
20912097 return templ_7745c5c3_Err
20922098 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")
2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</td>")
20942100 if templ_7745c5c3_Err != nil {
20952101 return templ_7745c5c3_Err
20962102 }
@@ -2099,7 +2105,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
20992105 if templ_7745c5c3_Err != nil {
21002106 return templ_7745c5c3_Err
21012107 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")
2108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
21032109 if templ_7745c5c3_Err != nil {
21042110 return templ_7745c5c3_Err
21052111 }
@@ -2112,7 +2118,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21122118 if templ_7745c5c3_Err != nil {
21132119 return templ_7745c5c3_Err
21142120 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")
2121+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
21162122 if templ_7745c5c3_Err != nil {
21172123 return templ_7745c5c3_Err
21182124 }
@@ -2120,7 +2126,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
21202126 if templ_7745c5c3_Err != nil {
21212127 return templ_7745c5c3_Err
21222128 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")
2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
21242130 if templ_7745c5c3_Err != nil {
21252131 return templ_7745c5c3_Err
21262132 }
@@ -2153,33 +2159,33 @@ func gutter(n, href string) templ.Component {
21532159 }
21542160 ctx = templ.ClearChildren(ctx)
21552161 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")
2162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<a href=\"")
21572163 if templ_7745c5c3_Err != nil {
21582164 return templ_7745c5c3_Err
21592165 }
21602166 var templ_7745c5c3_Var103 templ.SafeURL
21612167 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
21622168 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}
2169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 31}
21642170 }
21652171 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
21662172 if templ_7745c5c3_Err != nil {
21672173 return templ_7745c5c3_Err
21682174 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")
2175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" title=\"comment on this line\">")
21702176 if templ_7745c5c3_Err != nil {
21712177 return templ_7745c5c3_Err
21722178 }
21732179 var templ_7745c5c3_Var104 string
21742180 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21752181 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}
2182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 66}
21772183 }
21782184 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
21792185 if templ_7745c5c3_Err != nil {
21802186 return templ_7745c5c3_Err
21812187 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")
2188+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</a>")
21832189 if templ_7745c5c3_Err != nil {
21842190 return templ_7745c5c3_Err
21852191 }
@@ -2187,7 +2193,7 @@ func gutter(n, href string) templ.Component {
21872193 var templ_7745c5c3_Var105 string
21882194 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
21892195 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}
2196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 412, Col: 5}
21912197 }
21922198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
21932199 if templ_7745c5c3_Err != nil {
@@ -530,14 +530,14 @@ func diffViewMenu(p DiffView) templ.Component {
530 return templ_7745c5c3_Err530 return templ_7745c5c3_Err
531 }531 }
532 if p.Both {532 if p.Both {
533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span hx-live=\"this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'\">")533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span hx-live=\"this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'\">")
534 if templ_7745c5c3_Err != nil {534 if templ_7745c5c3_Err != nil {
535 return templ_7745c5c3_Err535 return templ_7745c5c3_Err
536 }536 }
537 var templ_7745c5c3_Var22 string537 var templ_7745c5c3_Var22 string
538 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(viewLabel(p.View))538 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(viewLabel(p.View))
539 if templ_7745c5c3_Err != nil {539 if templ_7745c5c3_Err != nil {
540- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 120, Col: 122}540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 120, Col: 168}
541 }541 }
542 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))542 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
543 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
@@ -649,58 +649,64 @@ func viewLink(p DiffView, view, label, key, href string) templ.Component {
649 if templ_7745c5c3_Err != nil {649 if templ_7745c5c3_Err != nil {
650 return templ_7745c5c3_Err650 return templ_7745c5c3_Err
651 }651 }
652+ if view == "split" {
653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " :aria-disabled=\"q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false\" :title=\"q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'\"")
654+ if templ_7745c5c3_Err != nil {
655+ return templ_7745c5c3_Err
656+ }
657+ }
652 if p.View == view {658 if p.View == view {
653- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " aria-current=\"true\"")659+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"true\"")
654 if templ_7745c5c3_Err != nil {660 if templ_7745c5c3_Err != nil {
655 return templ_7745c5c3_Err661 return templ_7745c5c3_Err
656 }662 }
657 }663 }
658 if p.Both {664 if p.Both {
659- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " data-fire=\"")665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " data-fire=\"")
660 if templ_7745c5c3_Err != nil {666 if templ_7745c5c3_Err != nil {
661 return templ_7745c5c3_Err667 return templ_7745c5c3_Err
662 }668 }
663 var templ_7745c5c3_Var28 string669 var templ_7745c5c3_Var28 string
664 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)670 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)
665 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
666- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 146, Col: 19}672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 150, Col: 19}
667 }673 }
668 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
669 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
670 return templ_7745c5c3_Err676 return templ_7745c5c3_Err
671 }677 }
672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"")678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" :aria-current=\"(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false\"")
673 if templ_7745c5c3_Err != nil {679 if templ_7745c5c3_Err != nil {
674 return templ_7745c5c3_Err680 return templ_7745c5c3_Err
675 }681 }
676 }682 }
677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">")
678 if templ_7745c5c3_Err != nil {684 if templ_7745c5c3_Err != nil {
679 return templ_7745c5c3_Err685 return templ_7745c5c3_Err
680 }686 }
681 var templ_7745c5c3_Var29 string687 var templ_7745c5c3_Var29 string
682 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label)688 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label)
683 if templ_7745c5c3_Err != nil {689 if templ_7745c5c3_Err != nil {
684- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 9}690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 9}
685 }691 }
686 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
687 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
688 return templ_7745c5c3_Err694 return templ_7745c5c3_Err
689 }695 }
690- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<kbd aria-hidden=\"true\">")696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<kbd aria-hidden=\"true\">")
691 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
692 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
693 }699 }
694 var templ_7745c5c3_Var30 string700 var templ_7745c5c3_Var30 string
695 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(key)701 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(key)
696 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
697- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 40}703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 40}
698 }704 }
699 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
700 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
701 return templ_7745c5c3_Err707 return templ_7745c5c3_Err
702 }708 }
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</kbd></a>")709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</kbd></a>")
704 if templ_7745c5c3_Err != nil {710 if templ_7745c5c3_Err != nil {
705 return templ_7745c5c3_Err711 return templ_7745c5c3_Err
706 }712 }
@@ -731,7 +737,7 @@ func collapseButton() templ.Component {
731 templ_7745c5c3_Var31 = templ.NopComponent737 templ_7745c5c3_Var31 = templ.NopComponent
732 }738 }
733 ctx = templ.ClearChildren(ctx)739 ctx = templ.ClearChildren(ctx)
734- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")
735 if templ_7745c5c3_Err != nil {741 if templ_7745c5c3_Err != nil {
736 return templ_7745c5c3_Err742 return templ_7745c5c3_Err
737 }743 }
@@ -763,13 +769,13 @@ func personView(pr Person) templ.Component {
763 var templ_7745c5c3_Var33 string769 var templ_7745c5c3_Var33 string
764 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name)770 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name)
765 if templ_7745c5c3_Err != nil {771 if templ_7745c5c3_Err != nil {
766- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 161, Col: 10}772+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 10}
767 }773 }
768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))774 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
769 if templ_7745c5c3_Err != nil {775 if templ_7745c5c3_Err != nil {
770 return templ_7745c5c3_Err776 return templ_7745c5c3_Err
771 }777 }
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ")778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
773 if templ_7745c5c3_Err != nil {779 if templ_7745c5c3_Err != nil {
774 return templ_7745c5c3_Err780 return templ_7745c5c3_Err
775 }781 }
@@ -777,59 +783,59 @@ func personView(pr Person) templ.Component {
777 var templ_7745c5c3_Var34 string783 var templ_7745c5c3_Var34 string
778 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")784 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
779 if templ_7745c5c3_Err != nil {785 if templ_7745c5c3_Err != nil {
780- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 10}786+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 10}
781 }787 }
782 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))788 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
783 if templ_7745c5c3_Err != nil {789 if templ_7745c5c3_Err != nil {
784 return templ_7745c5c3_Err790 return templ_7745c5c3_Err
785 }791 }
786- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<time datetime=\"")792+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<time datetime=\"")
787 if templ_7745c5c3_Err != nil {793 if templ_7745c5c3_Err != nil {
788 return templ_7745c5c3_Err794 return templ_7745c5c3_Err
789 }795 }
790 var templ_7745c5c3_Var35 string796 var templ_7745c5c3_Var35 string
791 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt)797 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt)
792 if templ_7745c5c3_Err != nil {798 if templ_7745c5c3_Err != nil {
793- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 38}799+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 38}
794 }800 }
795 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)801 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
796 if templ_7745c5c3_Err != nil {802 if templ_7745c5c3_Err != nil {
797 return templ_7745c5c3_Err803 return templ_7745c5c3_Err
798 }804 }
799- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" title=\"")805+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" title=\"")
800 if templ_7745c5c3_Err != nil {806 if templ_7745c5c3_Err != nil {
801 return templ_7745c5c3_Err807 return templ_7745c5c3_Err
802 }808 }
803 var templ_7745c5c3_Var36 string809 var templ_7745c5c3_Var36 string
804 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs)810 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs)
805 if templ_7745c5c3_Err != nil {811 if templ_7745c5c3_Err != nil {
806- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 55}812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 55}
807 }813 }
808 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)814 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
809 if templ_7745c5c3_Err != nil {815 if templ_7745c5c3_Err != nil {
810 return templ_7745c5c3_Err816 return templ_7745c5c3_Err
811 }817 }
812- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
813 if templ_7745c5c3_Err != nil {819 if templ_7745c5c3_Err != nil {
814 return templ_7745c5c3_Err820 return templ_7745c5c3_Err
815 }821 }
816 var templ_7745c5c3_Var37 string822 var templ_7745c5c3_Var37 string
817 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When)823 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When)
818 if templ_7745c5c3_Err != nil {824 if templ_7745c5c3_Err != nil {
819- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 67}825+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 67}
820 }826 }
821 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))827 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
822 if templ_7745c5c3_Err != nil {828 if templ_7745c5c3_Err != nil {
823 return templ_7745c5c3_Err829 return templ_7745c5c3_Err
824 }830 }
825- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</time>")831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</time>")
826 if templ_7745c5c3_Err != nil {832 if templ_7745c5c3_Err != nil {
827 return templ_7745c5c3_Err833 return templ_7745c5c3_Err
828 }834 }
829 var templ_7745c5c3_Var38 string835 var templ_7745c5c3_Var38 string
830 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs)836 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs)
831 if templ_7745c5c3_Err != nil {837 if templ_7745c5c3_Err != nil {
832- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 163, Col: 93}838+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 167, Col: 93}
833 }839 }
834 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))840 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
835 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
@@ -861,20 +867,20 @@ func commitRail(p CommitPage) templ.Component {
861 templ_7745c5c3_Var39 = templ.NopComponent867 templ_7745c5c3_Var39 = templ.NopComponent
862 }868 }
863 ctx = templ.ClearChildren(ctx)869 ctx = templ.ClearChildren(ctx)
864- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")870+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
865 if templ_7745c5c3_Err != nil {871 if templ_7745c5c3_Err != nil {
866 return templ_7745c5c3_Err872 return templ_7745c5c3_Err
867 }873 }
868 var templ_7745c5c3_Var40 string874 var templ_7745c5c3_Var40 string
869 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))875 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
870 if templ_7745c5c3_Err != nil {876 if templ_7745c5c3_Err != nil {
871- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 169, Col: 65}877+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 173, Col: 65}
872 }878 }
873 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))879 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
874 if templ_7745c5c3_Err != nil {880 if templ_7745c5c3_Err != nil {
875 return templ_7745c5c3_Err881 return templ_7745c5c3_Err
876 }882 }
877- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></h2>")883+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></h2>")
878 if templ_7745c5c3_Err != nil {884 if templ_7745c5c3_Err != nil {
879 return templ_7745c5c3_Err885 return templ_7745c5c3_Err
880 }886 }
@@ -882,7 +888,7 @@ func commitRail(p CommitPage) templ.Component {
882 if templ_7745c5c3_Err != nil {888 if templ_7745c5c3_Err != nil {
883 return templ_7745c5c3_Err889 return templ_7745c5c3_Err
884 }890 }
885- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</section>")891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</section>")
886 if templ_7745c5c3_Err != nil {892 if templ_7745c5c3_Err != nil {
887 return templ_7745c5c3_Err893 return templ_7745c5c3_Err
888 }894 }
@@ -914,230 +920,230 @@ func FileList(files []DiffFile) templ.Component {
914 }920 }
915 ctx = templ.ClearChildren(ctx)921 ctx = templ.ClearChildren(ctx)
916 if len(files) > fileFilterMin {922 if len(files) > fileFilterMin {
917- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>")923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>")
918 if templ_7745c5c3_Err != nil {924 if templ_7745c5c3_Err != nil {
919 return templ_7745c5c3_Err925 return templ_7745c5c3_Err
920 }926 }
921 }927 }
922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<ol class=\"files\" aria-label=\"Changed files\">")928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<ol class=\"files\" aria-label=\"Changed files\">")
923 if templ_7745c5c3_Err != nil {929 if templ_7745c5c3_Err != nil {
924 return templ_7745c5c3_Err930 return templ_7745c5c3_Err
925 }931 }
926 for _, f := range files {932 for _, f := range files {
927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<li><a href=\"")933+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<li><a href=\"")
928 if templ_7745c5c3_Err != nil {934 if templ_7745c5c3_Err != nil {
929 return templ_7745c5c3_Err935 return templ_7745c5c3_Err
930 }936 }
931 var templ_7745c5c3_Var42 templ.SafeURL937 var templ_7745c5c3_Var42 templ.SafeURL
932 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))938 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))
933 if templ_7745c5c3_Err != nil {939 if templ_7745c5c3_Err != nil {
934- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 194, Col: 37}940+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 37}
935 }941 }
936 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))942 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
937 if templ_7745c5c3_Err != nil {943 if templ_7745c5c3_Err != nil {
938 return templ_7745c5c3_Err944 return templ_7745c5c3_Err
939 }945 }
940- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" title=\"")946+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" title=\"")
941 if templ_7745c5c3_Err != nil {947 if templ_7745c5c3_Err != nil {
942 return templ_7745c5c3_Err948 return templ_7745c5c3_Err
943 }949 }
944 var templ_7745c5c3_Var43 string950 var templ_7745c5c3_Var43 string
945 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)951 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
946 if templ_7745c5c3_Err != nil {952 if templ_7745c5c3_Err != nil {
947- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 195, Col: 19}953+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 19}
948 }954 }
949 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)955 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
950 if templ_7745c5c3_Err != nil {956 if templ_7745c5c3_Err != nil {
951 return templ_7745c5c3_Err957 return templ_7745c5c3_Err
952 }958 }
953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"")959+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"")
954 if templ_7745c5c3_Err != nil {960 if templ_7745c5c3_Err != nil {
955 return templ_7745c5c3_Err961 return templ_7745c5c3_Err
956 }962 }
957 var templ_7745c5c3_Var44 string963 var templ_7745c5c3_Var44 string
958 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)964 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
959 if templ_7745c5c3_Err != nil {965 if templ_7745c5c3_Err != nil {
960- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 40}966+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 40}
961 }967 }
962 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)968 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
963 if templ_7745c5c3_Err != nil {969 if templ_7745c5c3_Err != nil {
964 return templ_7745c5c3_Err970 return templ_7745c5c3_Err
965 }971 }
966- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" title=\"")
967 if templ_7745c5c3_Err != nil {973 if templ_7745c5c3_Err != nil {
968 return templ_7745c5c3_Err974 return templ_7745c5c3_Err
969 }975 }
970 var templ_7745c5c3_Var45 string976 var templ_7745c5c3_Var45 string
971 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status))977 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status))
972 if templ_7745c5c3_Err != nil {978 if templ_7745c5c3_Err != nil {
973- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 71}979+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 71}
974 }980 }
975 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)981 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
976 if templ_7745c5c3_Err != nil {982 if templ_7745c5c3_Err != nil {
977 return templ_7745c5c3_Err983 return templ_7745c5c3_Err
978 }984 }
979- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
980 if templ_7745c5c3_Err != nil {986 if templ_7745c5c3_Err != nil {
981 return templ_7745c5c3_Err987 return templ_7745c5c3_Err
982 }988 }
983 var templ_7745c5c3_Var46 string989 var templ_7745c5c3_Var46 string
984 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)990 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
985 if templ_7745c5c3_Err != nil {991 if templ_7745c5c3_Err != nil {
986- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 198, Col: 84}992+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 84}
987 }993 }
988 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))994 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
989 if templ_7745c5c3_Err != nil {995 if templ_7745c5c3_Err != nil {
990 return templ_7745c5c3_Err996 return templ_7745c5c3_Err
991 }997 }
992- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <span class=\"nm\"><span class=\"p\">")998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> <span class=\"nm\"><span class=\"p\">")
993 if templ_7745c5c3_Err != nil {999 if templ_7745c5c3_Err != nil {
994 return templ_7745c5c3_Err1000 return templ_7745c5c3_Err
995 }1001 }
996 var templ_7745c5c3_Var47 string1002 var templ_7745c5c3_Var47 string
997 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name)1003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name)
998 if templ_7745c5c3_Err != nil {1004 if templ_7745c5c3_Err != nil {
999- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 46}1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 46}
1000 }1006 }
1001 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))1007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1002 if templ_7745c5c3_Err != nil {1008 if templ_7745c5c3_Err != nil {
1003 return templ_7745c5c3_Err1009 return templ_7745c5c3_Err
1004 }1010 }
1005- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span>")1011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span>")
1006 if templ_7745c5c3_Err != nil {1012 if templ_7745c5c3_Err != nil {
1007 return templ_7745c5c3_Err1013 return templ_7745c5c3_Err
1008 }1014 }
1009 var templ_7745c5c3_Var48 string1015 var templ_7745c5c3_Var48 string
1010 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" ")1016 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1011 if templ_7745c5c3_Err != nil {1017 if templ_7745c5c3_Err != nil {
1012- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 60}1018+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 60}
1013 }1019 }
1014 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))1020 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1015 if templ_7745c5c3_Err != nil {1021 if templ_7745c5c3_Err != nil {
1016 return templ_7745c5c3_Err1022 return templ_7745c5c3_Err
1017 }1023 }
1018- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"d\"><bdi>")1024+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"d\"><bdi>")
1019 if templ_7745c5c3_Err != nil {1025 if templ_7745c5c3_Err != nil {
1020 return templ_7745c5c3_Err1026 return templ_7745c5c3_Err
1021 }1027 }
1022 var templ_7745c5c3_Var49 string1028 var templ_7745c5c3_Var49 string
1023 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir)1029 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir)
1024 if templ_7745c5c3_Err != nil {1030 if templ_7745c5c3_Err != nil {
1025- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 90}1031+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 90}
1026 }1032 }
1027 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1033 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1028 if templ_7745c5c3_Err != nil {1034 if templ_7745c5c3_Err != nil {
1029 return templ_7745c5c3_Err1035 return templ_7745c5c3_Err
1030 }1036 }
1031- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</bdi></span></span> ")1037+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</bdi></span></span> ")
1032 if templ_7745c5c3_Err != nil {1038 if templ_7745c5c3_Err != nil {
1033 return templ_7745c5c3_Err1039 return templ_7745c5c3_Err
1034 }1040 }
1035 if f.OpenNotes > 0 {1041 if f.OpenNotes > 0 {
1036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<span class=\"cn\" title=\"")1042+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"cn\" title=\"")
1037 if templ_7745c5c3_Err != nil {1043 if templ_7745c5c3_Err != nil {
1038 return templ_7745c5c3_Err1044 return templ_7745c5c3_Err
1039 }1045 }
1040 var templ_7745c5c3_Var50 string1046 var templ_7745c5c3_Var50 string
1041 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments"))1047 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments"))
1042 if templ_7745c5c3_Err != nil {1048 if templ_7745c5c3_Err != nil {
1043- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 201, Col: 83}1049+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 205, Col: 83}
1044 }1050 }
1045 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)1051 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
1046 if templ_7745c5c3_Err != nil {1052 if templ_7745c5c3_Err != nil {
1047 return templ_7745c5c3_Err1053 return templ_7745c5c3_Err
1048 }1054 }
1049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">")1055+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\">")
1050 if templ_7745c5c3_Err != nil {1056 if templ_7745c5c3_Err != nil {
1051 return templ_7745c5c3_Err1057 return templ_7745c5c3_Err
1052 }1058 }
1053 var templ_7745c5c3_Var51 string1059 var templ_7745c5c3_Var51 string
1054 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes))1060 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes))
1055 if templ_7745c5c3_Err != nil {1061 if templ_7745c5c3_Err != nil {
1056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 201, Col: 105}1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 205, Col: 105}
1057 }1063 }
1058 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))1064 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1059 if templ_7745c5c3_Err != nil {1065 if templ_7745c5c3_Err != nil {
1060 return templ_7745c5c3_Err1066 return templ_7745c5c3_Err
1061 }1067 }
1062- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span> ")1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> ")
1063 if templ_7745c5c3_Err != nil {1069 if templ_7745c5c3_Err != nil {
1064 return templ_7745c5c3_Err1070 return templ_7745c5c3_Err
1065 }1071 }
1066 } else if f.Notes > 0 {1072 } else if f.Notes > 0 {
1067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span class=\"cn done\" title=\"")1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<span class=\"cn done\" title=\"")
1068 if templ_7745c5c3_Err != nil {1074 if templ_7745c5c3_Err != nil {
1069 return templ_7745c5c3_Err1075 return templ_7745c5c3_Err
1070 }1076 }
1071 var templ_7745c5c3_Var52 string1077 var templ_7745c5c3_Var52 string
1072 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments"))1078 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments"))
1073 if templ_7745c5c3_Err != nil {1079 if templ_7745c5c3_Err != nil {
1074- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 92}1080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 92}
1075 }1081 }
1076 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)1082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
1077 if templ_7745c5c3_Err != nil {1083 if templ_7745c5c3_Err != nil {
1078 return templ_7745c5c3_Err1084 return templ_7745c5c3_Err
1079 }1085 }
1080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\">")1086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\">")
1081 if templ_7745c5c3_Err != nil {1087 if templ_7745c5c3_Err != nil {
1082 return templ_7745c5c3_Err1088 return templ_7745c5c3_Err
1083 }1089 }
1084 var templ_7745c5c3_Var53 string1090 var templ_7745c5c3_Var53 string
1085 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes))1091 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes))
1086 if templ_7745c5c3_Err != nil {1092 if templ_7745c5c3_Err != nil {
1087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 203, Col: 110}1093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 110}
1088 }1094 }
1089 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1090 if templ_7745c5c3_Err != nil {1096 if templ_7745c5c3_Err != nil {
1091 return templ_7745c5c3_Err1097 return templ_7745c5c3_Err
1092 }1098 }
1093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span> ")1099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> ")
1094 if templ_7745c5c3_Err != nil {1100 if templ_7745c5c3_Err != nil {
1095 return templ_7745c5c3_Err1101 return templ_7745c5c3_Err
1096 }1102 }
1097 }1103 }
1098 if f.Bin {1104 if f.Bin {
1099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span class=\"n mute\">bin</span>")1105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"n mute\">bin</span>")
1100 if templ_7745c5c3_Err != nil {1106 if templ_7745c5c3_Err != nil {
1101 return templ_7745c5c3_Err1107 return templ_7745c5c3_Err
1102 }1108 }
1103 } else {1109 } else {
1104- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"n\"><span class=\"plus\">+")1110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<span class=\"n\"><span class=\"plus\">+")
1105 if templ_7745c5c3_Err != nil {1111 if templ_7745c5c3_Err != nil {
1106 return templ_7745c5c3_Err1112 return templ_7745c5c3_Err
1107 }1113 }
1108 var templ_7745c5c3_Var54 string1114 var templ_7745c5c3_Var54 string
1109 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))1115 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
1110 if templ_7745c5c3_Err != nil {1116 if templ_7745c5c3_Err != nil {
1111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 208, Col: 55}1117+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 55}
1112 }1118 }
1113 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))1119 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1114 if templ_7745c5c3_Err != nil {1120 if templ_7745c5c3_Err != nil {
1115 return templ_7745c5c3_Err1121 return templ_7745c5c3_Err
1116 }1122 }
1117- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span> <span class=\"minus\">-")1123+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</span> <span class=\"minus\">-")
1118 if templ_7745c5c3_Err != nil {1124 if templ_7745c5c3_Err != nil {
1119 return templ_7745c5c3_Err1125 return templ_7745c5c3_Err
1120 }1126 }
1121 var templ_7745c5c3_Var55 string1127 var templ_7745c5c3_Var55 string
1122 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))1128 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
1123 if templ_7745c5c3_Err != nil {1129 if templ_7745c5c3_Err != nil {
1124- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 208, Col: 99}1130+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 99}
1125 }1131 }
1126 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1132 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1127 if templ_7745c5c3_Err != nil {1133 if templ_7745c5c3_Err != nil {
1128 return templ_7745c5c3_Err1134 return templ_7745c5c3_Err
1129 }1135 }
1130- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</span></span>")1136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</span></span>")
1131 if templ_7745c5c3_Err != nil {1137 if templ_7745c5c3_Err != nil {
1132 return templ_7745c5c3_Err1138 return templ_7745c5c3_Err
1133 }1139 }
1134 }1140 }
1135- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</a></li>")1141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</a></li>")
1136 if templ_7745c5c3_Err != nil {1142 if templ_7745c5c3_Err != nil {
1137 return templ_7745c5c3_Err1143 return templ_7745c5c3_Err
1138 }1144 }
1139 }1145 }
1140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</ol>")1146+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</ol>")
1141 if templ_7745c5c3_Err != nil {1147 if templ_7745c5c3_Err != nil {
1142 return templ_7745c5c3_Err1148 return templ_7745c5c3_Err
1143 }1149 }
@@ -1175,7 +1181,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1175 if templ_7745c5c3_Err != nil {1181 if templ_7745c5c3_Err != nil {
1176 return templ_7745c5c3_Err1182 return templ_7745c5c3_Err
1177 }1183 }
1178- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<details class=\"")1184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<details class=\"")
1179 if templ_7745c5c3_Err != nil {1185 if templ_7745c5c3_Err != nil {
1180 return templ_7745c5c3_Err1186 return templ_7745c5c3_Err
1181 }1187 }
@@ -1188,134 +1194,134 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1188 if templ_7745c5c3_Err != nil {1194 if templ_7745c5c3_Err != nil {
1189 return templ_7745c5c3_Err1195 return templ_7745c5c3_Err
1190 }1196 }
1191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" id=\"")1197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" id=\"")
1192 if templ_7745c5c3_Err != nil {1198 if templ_7745c5c3_Err != nil {
1193 return templ_7745c5c3_Err1199 return templ_7745c5c3_Err
1194 }1200 }
1195 var templ_7745c5c3_Var59 string1201 var templ_7745c5c3_Var59 string
1196 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)1202 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
1197 if templ_7745c5c3_Err != nil {1203 if templ_7745c5c3_Err != nil {
1198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 223, Col: 11}1204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 227, Col: 11}
1199 }1205 }
1200 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)1206 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
1201 if templ_7745c5c3_Err != nil {1207 if templ_7745c5c3_Err != nil {
1202 return templ_7745c5c3_Err1208 return templ_7745c5c3_Err
1203 }1209 }
1204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" data-file=\"")1210+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\" data-file=\"")
1205 if templ_7745c5c3_Err != nil {1211 if templ_7745c5c3_Err != nil {
1206 return templ_7745c5c3_Err1212 return templ_7745c5c3_Err
1207 }1213 }
1208 var templ_7745c5c3_Var60 string1214 var templ_7745c5c3_Var60 string
1209 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Key)1215 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Key)
1210 if templ_7745c5c3_Err != nil {1216 if templ_7745c5c3_Err != nil {
1211- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 224, Col: 19}1217+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 228, Col: 19}
1212 }1218 }
1213 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)1219 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1214 if templ_7745c5c3_Err != nil {1220 if templ_7745c5c3_Err != nil {
1215 return templ_7745c5c3_Err1221 return templ_7745c5c3_Err
1216 }1222 }
1217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\"")
1218 if templ_7745c5c3_Err != nil {1224 if templ_7745c5c3_Err != nil {
1219 return templ_7745c5c3_Err1225 return templ_7745c5c3_Err
1220 }1226 }
1221 if f.Open {1227 if f.Open {
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " open")1228+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " open")
1223 if templ_7745c5c3_Err != nil {1229 if templ_7745c5c3_Err != nil {
1224 return templ_7745c5c3_Err1230 return templ_7745c5c3_Err
1225 }1231 }
1226 }1232 }
1227- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "><summary><span class=\"st\">")1233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "><summary><span class=\"st\">")
1228 if templ_7745c5c3_Err != nil {1234 if templ_7745c5c3_Err != nil {
1229 return templ_7745c5c3_Err1235 return templ_7745c5c3_Err
1230 }1236 }
1231 var templ_7745c5c3_Var61 string1237 var templ_7745c5c3_Var61 string
1232 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)1238 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
1233 if templ_7745c5c3_Err != nil {1239 if templ_7745c5c3_Err != nil {
1234- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 30}1240+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 30}
1235 }1241 }
1236 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1242 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1237 if templ_7745c5c3_Err != nil {1243 if templ_7745c5c3_Err != nil {
1238 return templ_7745c5c3_Err1244 return templ_7745c5c3_Err
1239 }1245 }
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</span>")1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span>")
1241 if templ_7745c5c3_Err != nil {1247 if templ_7745c5c3_Err != nil {
1242 return templ_7745c5c3_Err1248 return templ_7745c5c3_Err
1243 }1249 }
1244 var templ_7745c5c3_Var62 string1250 var templ_7745c5c3_Var62 string
1245 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")1251 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1246 if templ_7745c5c3_Err != nil {1252 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 44}1253+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 44}
1248 }1254 }
1249 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))1255 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1250 if templ_7745c5c3_Err != nil {1256 if templ_7745c5c3_Err != nil {
1251 return templ_7745c5c3_Err1257 return templ_7745c5c3_Err
1252 }1258 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<span class=\"p\">")1259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<span class=\"p\">")
1254 if templ_7745c5c3_Err != nil {1260 if templ_7745c5c3_Err != nil {
1255 return templ_7745c5c3_Err1261 return templ_7745c5c3_Err
1256 }1262 }
1257 var templ_7745c5c3_Var63 string1263 var templ_7745c5c3_Var63 string
1258 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)1264 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
1259 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
1260- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 70}1266+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 70}
1261 }1267 }
1262 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1268 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1263 if templ_7745c5c3_Err != nil {1269 if templ_7745c5c3_Err != nil {
1264 return templ_7745c5c3_Err1270 return templ_7745c5c3_Err
1265 }1271 }
1266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span>")1272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</span>")
1267 if templ_7745c5c3_Err != nil {1273 if templ_7745c5c3_Err != nil {
1268 return templ_7745c5c3_Err1274 return templ_7745c5c3_Err
1269 }1275 }
1270 var templ_7745c5c3_Var64 string1276 var templ_7745c5c3_Var64 string
1271 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ")1277 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1272 if templ_7745c5c3_Err != nil {1278 if templ_7745c5c3_Err != nil {
1273- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 84}1279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 84}
1274 }1280 }
1275 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))1281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1276 if templ_7745c5c3_Err != nil {1282 if templ_7745c5c3_Err != nil {
1277 return templ_7745c5c3_Err1283 return templ_7745c5c3_Err
1278 }1284 }
1279- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"plus\">+")1285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<span class=\"plus\">+")
1280 if templ_7745c5c3_Err != nil {1286 if templ_7745c5c3_Err != nil {
1281 return templ_7745c5c3_Err1287 return templ_7745c5c3_Err
1282 }1288 }
1283 var templ_7745c5c3_Var65 string1289 var templ_7745c5c3_Var65 string
1284 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))1290 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
1285 if templ_7745c5c3_Err != nil {1291 if templ_7745c5c3_Err != nil {
1286- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 119}1292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 119}
1287 }1293 }
1288 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))1294 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1289 if templ_7745c5c3_Err != nil {1295 if templ_7745c5c3_Err != nil {
1290 return templ_7745c5c3_Err1296 return templ_7745c5c3_Err
1291 }1297 }
1292- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span>")1298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</span>")
1293 if templ_7745c5c3_Err != nil {1299 if templ_7745c5c3_Err != nil {
1294 return templ_7745c5c3_Err1300 return templ_7745c5c3_Err
1295 }1301 }
1296 var templ_7745c5c3_Var66 string1302 var templ_7745c5c3_Var66 string
1297 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")1303 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1298 if templ_7745c5c3_Err != nil {1304 if templ_7745c5c3_Err != nil {
1299- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 133}1305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 133}
1300 }1306 }
1301 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1307 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1302 if templ_7745c5c3_Err != nil {1308 if templ_7745c5c3_Err != nil {
1303 return templ_7745c5c3_Err1309 return templ_7745c5c3_Err
1304 }1310 }
1305- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<span class=\"minus\">-")1311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<span class=\"minus\">-")
1306 if templ_7745c5c3_Err != nil {1312 if templ_7745c5c3_Err != nil {
1307 return templ_7745c5c3_Err1313 return templ_7745c5c3_Err
1308 }1314 }
1309 var templ_7745c5c3_Var67 string1315 var templ_7745c5c3_Var67 string
1310 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))1316 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
1311 if templ_7745c5c3_Err != nil {1317 if templ_7745c5c3_Err != nil {
1312- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 169}1318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 234, Col: 169}
1313 }1319 }
1314 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))1320 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1315 if templ_7745c5c3_Err != nil {1321 if templ_7745c5c3_Err != nil {
1316 return templ_7745c5c3_Err1322 return templ_7745c5c3_Err
1317 }1323 }
1318- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span> ")1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</span> ")
1319 if templ_7745c5c3_Err != nil {1325 if templ_7745c5c3_Err != nil {
1320 return templ_7745c5c3_Err1326 return templ_7745c5c3_Err
1321 }1327 }
@@ -1323,36 +1329,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1323 var templ_7745c5c3_Var68 string1329 var templ_7745c5c3_Var68 string
1324 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")1330 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1325 if templ_7745c5c3_Err != nil {1331 if templ_7745c5c3_Err != nil {
1326- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 232, Col: 9}1332+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 236, Col: 9}
1327 }1333 }
1328 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))1334 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1329 if templ_7745c5c3_Err != nil {1335 if templ_7745c5c3_Err != nil {
1330 return templ_7745c5c3_Err1336 return templ_7745c5c3_Err
1331 }1337 }
1332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<span class=\"note\">")1338+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<span class=\"note\">")
1333 if templ_7745c5c3_Err != nil {1339 if templ_7745c5c3_Err != nil {
1334 return templ_7745c5c3_Err1340 return templ_7745c5c3_Err
1335 }1341 }
1336 var templ_7745c5c3_Var69 string1342 var templ_7745c5c3_Var69 string
1337 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)1343 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1338 if templ_7745c5c3_Err != nil {1344 if templ_7745c5c3_Err != nil {
1339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 232, Col: 38}1345+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 236, Col: 38}
1340 }1346 }
1341 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))1347 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1342 if templ_7745c5c3_Err != nil {1348 if templ_7745c5c3_Err != nil {
1343 return templ_7745c5c3_Err1349 return templ_7745c5c3_Err
1344 }1350 }
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</span>")1351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</span>")
1346 if templ_7745c5c3_Err != nil {1352 if templ_7745c5c3_Err != nil {
1347 return templ_7745c5c3_Err1353 return templ_7745c5c3_Err
1348 }1354 }
1349 }1355 }
1350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</summary> ")1356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</summary> ")
1351 if templ_7745c5c3_Err != nil {1357 if templ_7745c5c3_Err != nil {
1352 return templ_7745c5c3_Err1358 return templ_7745c5c3_Err
1353 }1359 }
1354 if f.Lazy {1360 if f.Lazy {
1355- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " ")1361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " ")
1356 if templ_7745c5c3_Err != nil {1362 if templ_7745c5c3_Err != nil {
1357 return templ_7745c5c3_Err1363 return templ_7745c5c3_Err
1358 }1364 }
@@ -1361,7 +1367,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1361 if templ_7745c5c3_Err != nil {1367 if templ_7745c5c3_Err != nil {
1362 return templ_7745c5c3_Err1368 return templ_7745c5c3_Err
1363 }1369 }
1364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<div class=\"")1370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<div class=\"")
1365 if templ_7745c5c3_Err != nil {1371 if templ_7745c5c3_Err != nil {
1366 return templ_7745c5c3_Err1372 return templ_7745c5c3_Err
1367 }1373 }
@@ -1374,74 +1380,74 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1374 if templ_7745c5c3_Err != nil {1380 if templ_7745c5c3_Err != nil {
1375 return templ_7745c5c3_Err1381 return templ_7745c5c3_Err
1376 }1382 }
1377- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" data-src=\"")1383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" data-src=\"")
1378 if templ_7745c5c3_Err != nil {1384 if templ_7745c5c3_Err != nil {
1379 return templ_7745c5c3_Err1385 return templ_7745c5c3_Err
1380 }1386 }
1381 var templ_7745c5c3_Var72 string1387 var templ_7745c5c3_Var72 string
1382 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)1388 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src)
1383 if templ_7745c5c3_Err != nil {1389 if templ_7745c5c3_Err != nil {
1384- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 69}1390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 243, Col: 69}
1385 }1391 }
1386 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)1392 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1387 if templ_7745c5c3_Err != nil {1393 if templ_7745c5c3_Err != nil {
1388 return templ_7745c5c3_Err1394 return templ_7745c5c3_Err
1389 }1395 }
1390- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" data-notes=\"")1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" data-notes=\"")
1391 if templ_7745c5c3_Err != nil {1397 if templ_7745c5c3_Err != nil {
1392 return templ_7745c5c3_Err1398 return templ_7745c5c3_Err
1393 }1399 }
1394 var templ_7745c5c3_Var73 string1400 var templ_7745c5c3_Var73 string
1395 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments)1401 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments)
1396 if templ_7745c5c3_Err != nil {1402 if templ_7745c5c3_Err != nil {
1397- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 95}1403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 243, Col: 95}
1398 }1404 }
1399 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)1405 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
1400 if templ_7745c5c3_Err != nil {1406 if templ_7745c5c3_Err != nil {
1401 return templ_7745c5c3_Err1407 return templ_7745c5c3_Err
1402 }1408 }
1403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\"><a href=\"")1409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\"><a href=\"")
1404 if templ_7745c5c3_Err != nil {1410 if templ_7745c5c3_Err != nil {
1405 return templ_7745c5c3_Err1411 return templ_7745c5c3_Err
1406 }1412 }
1407 var templ_7745c5c3_Var74 templ.SafeURL1413 var templ_7745c5c3_Var74 templ.SafeURL
1408 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID))1414 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID))
1409 if templ_7745c5c3_Err != nil {1415 if templ_7745c5c3_Err != nil {
1410- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 240, Col: 65}1416+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 244, Col: 65}
1411 }1417 }
1412 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))1418 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
1413 if templ_7745c5c3_Err != nil {1419 if templ_7745c5c3_Err != nil {
1414 return templ_7745c5c3_Err1420 return templ_7745c5c3_Err
1415 }1421 }
1416- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">show this file</a></div>")1422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">show this file</a></div>")
1417 if templ_7745c5c3_Err != nil {1423 if templ_7745c5c3_Err != nil {
1418 return templ_7745c5c3_Err1424 return templ_7745c5c3_Err
1419 }1425 }
1420 } else if len(f.Hunks) > 0 {1426 } else if len(f.Hunks) > 0 {
1421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<div class=\"dscroll\">")1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"dscroll\">")
1422 if templ_7745c5c3_Err != nil {1428 if templ_7745c5c3_Err != nil {
1423 return templ_7745c5c3_Err1429 return templ_7745c5c3_Err
1424 }1430 }
1425 if p.ShowUnified {1431 if p.ShowUnified {
1426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt uni\" aria-label=\"")1432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<table class=\"dt uni\" aria-label=\"")
1427 if templ_7745c5c3_Err != nil {1433 if templ_7745c5c3_Err != nil {
1428 return templ_7745c5c3_Err1434 return templ_7745c5c3_Err
1429 }1435 }
1430 var templ_7745c5c3_Var75 string1436 var templ_7745c5c3_Var75 string
1431 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")1437 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1432 if templ_7745c5c3_Err != nil {1438 if templ_7745c5c3_Err != nil {
1433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 245, Col: 60}1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 249, Col: 60}
1434 }1440 }
1435 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)1441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1436 if templ_7745c5c3_Err != nil {1442 if templ_7745c5c3_Err != nil {
1437 return templ_7745c5c3_Err1443 return templ_7745c5c3_Err
1438 }1444 }
1439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")1445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">")
1440 if templ_7745c5c3_Err != nil {1446 if templ_7745c5c3_Err != nil {
1441 return templ_7745c5c3_Err1447 return templ_7745c5c3_Err
1442 }1448 }
1443 for _, h := range f.Hunks {1449 for _, h := range f.Hunks {
1444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody>")1450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tbody>")
1445 if templ_7745c5c3_Err != nil {1451 if templ_7745c5c3_Err != nil {
1446 return templ_7745c5c3_Err1452 return templ_7745c5c3_Err
1447 }1453 }
@@ -1451,20 +1457,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1451 return templ_7745c5c3_Err1457 return templ_7745c5c3_Err
1452 }1458 }
1453 }1459 }
1454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<tr class=\"hunk\"><td colspan=\"4\">")1460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr class=\"hunk\"><td colspan=\"4\">")
1455 if templ_7745c5c3_Err != nil {1461 if templ_7745c5c3_Err != nil {
1456 return templ_7745c5c3_Err1462 return templ_7745c5c3_Err
1457 }1463 }
1458 var templ_7745c5c3_Var76 string1464 var templ_7745c5c3_Var76 string
1459 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1465 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1460 if templ_7745c5c3_Err != nil {1466 if templ_7745c5c3_Err != nil {
1461- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 251, Col: 51}1467+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 255, Col: 51}
1462 }1468 }
1463 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1469 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1464 if templ_7745c5c3_Err != nil {1470 if templ_7745c5c3_Err != nil {
1465 return templ_7745c5c3_Err1471 return templ_7745c5c3_Err
1466 }1472 }
1467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td></tr>")1473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td></tr>")
1468 if templ_7745c5c3_Err != nil {1474 if templ_7745c5c3_Err != nil {
1469 return templ_7745c5c3_Err1475 return templ_7745c5c3_Err
1470 }1476 }
@@ -1474,7 +1480,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1474 if templ_7745c5c3_Err != nil {1480 if templ_7745c5c3_Err != nil {
1475 return templ_7745c5c3_Err1481 return templ_7745c5c3_Err
1476 }1482 }
1477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<tr class=\"")1483+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"")
1478 if templ_7745c5c3_Err != nil {1484 if templ_7745c5c3_Err != nil {
1479 return templ_7745c5c3_Err1485 return templ_7745c5c3_Err
1480 }1486 }
@@ -1487,7 +1493,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1487 if templ_7745c5c3_Err != nil {1493 if templ_7745c5c3_Err != nil {
1488 return templ_7745c5c3_Err1494 return templ_7745c5c3_Err
1489 }1495 }
1490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"><td class=\"ln\">")1496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><td class=\"ln\">")
1491 if templ_7745c5c3_Err != nil {1497 if templ_7745c5c3_Err != nil {
1492 return templ_7745c5c3_Err1498 return templ_7745c5c3_Err
1493 }1499 }
@@ -1495,7 +1501,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1495 if templ_7745c5c3_Err != nil {1501 if templ_7745c5c3_Err != nil {
1496 return templ_7745c5c3_Err1502 return templ_7745c5c3_Err
1497 }1503 }
1498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"ln\">")1504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"ln\">")
1499 if templ_7745c5c3_Err != nil {1505 if templ_7745c5c3_Err != nil {
1500 return templ_7745c5c3_Err1506 return templ_7745c5c3_Err
1501 }1507 }
@@ -1503,20 +1509,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1503 if templ_7745c5c3_Err != nil {1509 if templ_7745c5c3_Err != nil {
1504 return templ_7745c5c3_Err1510 return templ_7745c5c3_Err
1505 }1511 }
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"m\">")1512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"m\">")
1507 if templ_7745c5c3_Err != nil {1513 if templ_7745c5c3_Err != nil {
1508 return templ_7745c5c3_Err1514 return templ_7745c5c3_Err
1509 }1515 }
1510 var templ_7745c5c3_Var79 string1516 var templ_7745c5c3_Var79 string
1511 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1517 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1512 if templ_7745c5c3_Err != nil {1518 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 256, Col: 33}1519+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 260, Col: 33}
1514 }1520 }
1515 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))1521 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1516 if templ_7745c5c3_Err != nil {1522 if templ_7745c5c3_Err != nil {
1517 return templ_7745c5c3_Err1523 return templ_7745c5c3_Err
1518 }1524 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"tx chroma\">")1525+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td><td class=\"tx chroma\">")
1520 if templ_7745c5c3_Err != nil {1526 if templ_7745c5c3_Err != nil {
1521 return templ_7745c5c3_Err1527 return templ_7745c5c3_Err
1522 }1528 }
@@ -1524,12 +1530,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1524 if templ_7745c5c3_Err != nil {1530 if templ_7745c5c3_Err != nil {
1525 return templ_7745c5c3_Err1531 return templ_7745c5c3_Err
1526 }1532 }
1527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</td></tr>")1533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</td></tr>")
1528 if templ_7745c5c3_Err != nil {1534 if templ_7745c5c3_Err != nil {
1529 return templ_7745c5c3_Err1535 return templ_7745c5c3_Err
1530 }1536 }
1531 if len(ln.Notes) > 0 || ln.Form != nil {1537 if len(ln.Notes) > 0 || ln.Form != nil {
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")1538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1533 if templ_7745c5c3_Err != nil {1539 if templ_7745c5c3_Err != nil {
1534 return templ_7745c5c3_Err1540 return templ_7745c5c3_Err
1535 }1541 }
@@ -1537,19 +1543,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1537 if templ_7745c5c3_Err != nil {1543 if templ_7745c5c3_Err != nil {
1538 return templ_7745c5c3_Err1544 return templ_7745c5c3_Err
1539 }1545 }
1540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</td></tr>")1546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</td></tr>")
1541 if templ_7745c5c3_Err != nil {1547 if templ_7745c5c3_Err != nil {
1542 return templ_7745c5c3_Err1548 return templ_7745c5c3_Err
1543 }1549 }
1544 }1550 }
1545 }1551 }
1546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody> ")1552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</tbody> ")
1547 if templ_7745c5c3_Err != nil {1553 if templ_7745c5c3_Err != nil {
1548 return templ_7745c5c3_Err1554 return templ_7745c5c3_Err
1549 }1555 }
1550 }1556 }
1551 if f.Tail != nil {1557 if f.Tail != nil {
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<tbody>")1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<tbody>")
1553 if templ_7745c5c3_Err != nil {1559 if templ_7745c5c3_Err != nil {
1554 return templ_7745c5c3_Err1560 return templ_7745c5c3_Err
1555 }1561 }
@@ -1557,36 +1563,36 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1557 if templ_7745c5c3_Err != nil {1563 if templ_7745c5c3_Err != nil {
1558 return templ_7745c5c3_Err1564 return templ_7745c5c3_Err
1559 }1565 }
1560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</tbody>")1566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</tbody>")
1561 if templ_7745c5c3_Err != nil {1567 if templ_7745c5c3_Err != nil {
1562 return templ_7745c5c3_Err1568 return templ_7745c5c3_Err
1563 }1569 }
1564 }1570 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</table>")1571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</table>")
1566 if templ_7745c5c3_Err != nil {1572 if templ_7745c5c3_Err != nil {
1567 return templ_7745c5c3_Err1573 return templ_7745c5c3_Err
1568 }1574 }
1569 }1575 }
1570 if p.ShowSplit {1576 if p.ShowSplit {
1571- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<table class=\"dt spl\" aria-label=\"")1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<table class=\"dt spl\" aria-label=\"")
1572 if templ_7745c5c3_Err != nil {1578 if templ_7745c5c3_Err != nil {
1573 return templ_7745c5c3_Err1579 return templ_7745c5c3_Err
1574 }1580 }
1575 var templ_7745c5c3_Var80 string1581 var templ_7745c5c3_Var80 string
1576 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")1582 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1577 if templ_7745c5c3_Err != nil {1583 if templ_7745c5c3_Err != nil {
1578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 276, Col: 58}1584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 280, Col: 58}
1579 }1585 }
1580 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1581 if templ_7745c5c3_Err != nil {1587 if templ_7745c5c3_Err != nil {
1582 return templ_7745c5c3_Err1588 return templ_7745c5c3_Err
1583 }1589 }
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")1590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1585 if templ_7745c5c3_Err != nil {1591 if templ_7745c5c3_Err != nil {
1586 return templ_7745c5c3_Err1592 return templ_7745c5c3_Err
1587 }1593 }
1588 for _, h := range f.Hunks {1594 for _, h := range f.Hunks {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<tbody>")1595+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tbody>")
1590 if templ_7745c5c3_Err != nil {1596 if templ_7745c5c3_Err != nil {
1591 return templ_7745c5c3_Err1597 return templ_7745c5c3_Err
1592 }1598 }
@@ -1596,25 +1602,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1596 return templ_7745c5c3_Err1602 return templ_7745c5c3_Err
1597 }1603 }
1598 }1604 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<tr class=\"hunk\"><td colspan=\"6\">")1605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<tr class=\"hunk\"><td colspan=\"6\">")
1600 if templ_7745c5c3_Err != nil {1606 if templ_7745c5c3_Err != nil {
1601 return templ_7745c5c3_Err1607 return templ_7745c5c3_Err
1602 }1608 }
1603 var templ_7745c5c3_Var81 string1609 var templ_7745c5c3_Var81 string
1604 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)1610 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1605 if templ_7745c5c3_Err != nil {1611 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 283, Col: 51}1612+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 287, Col: 51}
1607 }1613 }
1608 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1614 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1609 if templ_7745c5c3_Err != nil {1615 if templ_7745c5c3_Err != nil {
1610 return templ_7745c5c3_Err1616 return templ_7745c5c3_Err
1611 }1617 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td></tr>")1618+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</td></tr>")
1613 if templ_7745c5c3_Err != nil {1619 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1620 return templ_7745c5c3_Err
1615 }1621 }
1616 for _, row := range h.Split {1622 for _, row := range h.Split {
1617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<tr>")1623+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<tr>")
1618 if templ_7745c5c3_Err != nil {1624 if templ_7745c5c3_Err != nil {
1619 return templ_7745c5c3_Err1625 return templ_7745c5c3_Err
1620 }1626 }
@@ -1626,12 +1632,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1626 if templ_7745c5c3_Err != nil {1632 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1633 return templ_7745c5c3_Err
1628 }1634 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</tr>")1635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</tr>")
1630 if templ_7745c5c3_Err != nil {1636 if templ_7745c5c3_Err != nil {
1631 return templ_7745c5c3_Err1637 return templ_7745c5c3_Err
1632 }1638 }
1633 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {1639 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<tr class=\"fnote\"><td colspan=\"6\">")1640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<tr class=\"fnote\"><td colspan=\"6\">")
1635 if templ_7745c5c3_Err != nil {1641 if templ_7745c5c3_Err != nil {
1636 return templ_7745c5c3_Err1642 return templ_7745c5c3_Err
1637 }1643 }
@@ -1639,19 +1645,19 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1639 if templ_7745c5c3_Err != nil {1645 if templ_7745c5c3_Err != nil {
1640 return templ_7745c5c3_Err1646 return templ_7745c5c3_Err
1641 }1647 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</td></tr>")1648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</td></tr>")
1643 if templ_7745c5c3_Err != nil {1649 if templ_7745c5c3_Err != nil {
1644 return templ_7745c5c3_Err1650 return templ_7745c5c3_Err
1645 }1651 }
1646 }1652 }
1647 }1653 }
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</tbody> ")1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</tbody> ")
1649 if templ_7745c5c3_Err != nil {1655 if templ_7745c5c3_Err != nil {
1650 return templ_7745c5c3_Err1656 return templ_7745c5c3_Err
1651 }1657 }
1652 }1658 }
1653 if f.Tail != nil {1659 if f.Tail != nil {
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<tbody>")1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<tbody>")
1655 if templ_7745c5c3_Err != nil {1661 if templ_7745c5c3_Err != nil {
1656 return templ_7745c5c3_Err1662 return templ_7745c5c3_Err
1657 }1663 }
@@ -1659,22 +1665,22 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1659 if templ_7745c5c3_Err != nil {1665 if templ_7745c5c3_Err != nil {
1660 return templ_7745c5c3_Err1666 return templ_7745c5c3_Err
1661 }1667 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</tbody>")1668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</tbody>")
1663 if templ_7745c5c3_Err != nil {1669 if templ_7745c5c3_Err != nil {
1664 return templ_7745c5c3_Err1670 return templ_7745c5c3_Err
1665 }1671 }
1666 }1672 }
1667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</table>")1673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</table>")
1668 if templ_7745c5c3_Err != nil {1674 if templ_7745c5c3_Err != nil {
1669 return templ_7745c5c3_Err1675 return templ_7745c5c3_Err
1670 }1676 }
1671 }1677 }
1672- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>")1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</div>")
1673 if templ_7745c5c3_Err != nil {1679 if templ_7745c5c3_Err != nil {
1674 return templ_7745c5c3_Err1680 return templ_7745c5c3_Err
1675 }1681 }
1676 } else {1682 } else {
1677- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<p class=\"note pad\">")1683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<p class=\"note pad\">")
1678 if templ_7745c5c3_Err != nil {1684 if templ_7745c5c3_Err != nil {
1679 return templ_7745c5c3_Err1685 return templ_7745c5c3_Err
1680 }1686 }
@@ -1682,25 +1688,25 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1682 var templ_7745c5c3_Var82 string1688 var templ_7745c5c3_Var82 string
1683 templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)1689 templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1684 if templ_7745c5c3_Err != nil {1690 if templ_7745c5c3_Err != nil {
1685- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 308, Col: 13}1691+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 312, Col: 13}
1686 }1692 }
1687 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))1693 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82))
1688 if templ_7745c5c3_Err != nil {1694 if templ_7745c5c3_Err != nil {
1689 return templ_7745c5c3_Err1695 return templ_7745c5c3_Err
1690 }1696 }
1691 } else {1697 } else {
1692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "no textual change.")1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "no textual change.")
1693 if templ_7745c5c3_Err != nil {1699 if templ_7745c5c3_Err != nil {
1694 return templ_7745c5c3_Err1700 return templ_7745c5c3_Err
1695 }1701 }
1696 }1702 }
1697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</p>")1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</p>")
1698 if templ_7745c5c3_Err != nil {1704 if templ_7745c5c3_Err != nil {
1699 return templ_7745c5c3_Err1705 return templ_7745c5c3_Err
1700 }1706 }
1701 }1707 }
1702 if len(f.Loose) > 0 {1708 if len(f.Loose) > 0 {
1703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<div class=\"dloose\">")1709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "<div class=\"dloose\">")
1704 if templ_7745c5c3_Err != nil {1710 if templ_7745c5c3_Err != nil {
1705 return templ_7745c5c3_Err1711 return templ_7745c5c3_Err
1706 }1712 }
@@ -1710,12 +1716,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
1710 return templ_7745c5c3_Err1716 return templ_7745c5c3_Err
1711 }1717 }
1712 }1718 }
1713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</div>")1719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")
1714 if templ_7745c5c3_Err != nil {1720 if templ_7745c5c3_Err != nil {
1715 return templ_7745c5c3_Err1721 return templ_7745c5c3_Err
1716 }1722 }
1717 }1723 }
1718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</details>")1724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</details>")
1719 if templ_7745c5c3_Err != nil {1725 if templ_7745c5c3_Err != nil {
1720 return templ_7745c5c3_Err1726 return templ_7745c5c3_Err
1721 }1727 }
@@ -1779,7 +1785,7 @@ func MRFilesMoved() templ.Component {
1779 templ_7745c5c3_Var84 = templ.NopComponent1785 templ_7745c5c3_Var84 = templ.NopComponent
1780 }1786 }
1781 ctx = templ.ClearChildren(ctx)1787 ctx = templ.ClearChildren(ctx)
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>")
1783 if templ_7745c5c3_Err != nil {1789 if templ_7745c5c3_Err != nil {
1784 return templ_7745c5c3_Err1790 return templ_7745c5c3_Err
1785 }1791 }
@@ -1811,59 +1817,59 @@ func gapRow(g DiffGap, cols int) templ.Component {
1811 templ_7745c5c3_Var85 = templ.NopComponent1817 templ_7745c5c3_Var85 = templ.NopComponent
1812 }1818 }
1813 ctx = templ.ClearChildren(ctx)1819 ctx = templ.ClearChildren(ctx)
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<tr class=\"gap\" data-js data-gap=\"")1820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<tr class=\"gap\" data-js data-gap=\"")
1815 if templ_7745c5c3_Err != nil {1821 if templ_7745c5c3_Err != nil {
1816 return templ_7745c5c3_Err1822 return templ_7745c5c3_Err
1817 }1823 }
1818 var templ_7745c5c3_Var86 string1824 var templ_7745c5c3_Var86 string
1819 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)1825 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Key)
1820 if templ_7745c5c3_Err != nil {1826 if templ_7745c5c3_Err != nil {
1821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 342, Col: 41}1827+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 346, Col: 41}
1822 }1828 }
1823 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)1829 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1824 if templ_7745c5c3_Err != nil {1830 if templ_7745c5c3_Err != nil {
1825 return templ_7745c5c3_Err1831 return templ_7745c5c3_Err
1826 }1832 }
1827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"><td colspan=\"")1833+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><td colspan=\"")
1828 if templ_7745c5c3_Err != nil {1834 if templ_7745c5c3_Err != nil {
1829 return templ_7745c5c3_Err1835 return templ_7745c5c3_Err
1830 }1836 }
1831 var templ_7745c5c3_Var87 string1837 var templ_7745c5c3_Var87 string
1832 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))1838 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(cols))
1833 if templ_7745c5c3_Err != nil {1839 if templ_7745c5c3_Err != nil {
1834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 343, Col: 26}1840+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 347, Col: 26}
1835 }1841 }
1836 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)1842 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1837 if templ_7745c5c3_Err != nil {1843 if templ_7745c5c3_Err != nil {
1838 return templ_7745c5c3_Err1844 return templ_7745c5c3_Err
1839 }1845 }
1840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"><button type=\"button\" data-src=\"")1846+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><button type=\"button\" data-src=\"")
1841 if templ_7745c5c3_Err != nil {1847 if templ_7745c5c3_Err != nil {
1842 return templ_7745c5c3_Err1848 return templ_7745c5c3_Err
1843 }1849 }
1844 var templ_7745c5c3_Var88 string1850 var templ_7745c5c3_Var88 string
1845 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)1851 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(g.Src)
1846 if templ_7745c5c3_Err != nil {1852 if templ_7745c5c3_Err != nil {
1847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 344, Col: 41}1853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 348, Col: 41}
1848 }1854 }
1849 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)1855 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1850 if templ_7745c5c3_Err != nil {1856 if templ_7745c5c3_Err != nil {
1851 return templ_7745c5c3_Err1857 return templ_7745c5c3_Err
1852 }1858 }
1853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"><span aria-hidden=\"true\">⋯</span> ")1859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"><span aria-hidden=\"true\">⋯</span> ")
1854 if templ_7745c5c3_Err != nil {1860 if templ_7745c5c3_Err != nil {
1855 return templ_7745c5c3_Err1861 return templ_7745c5c3_Err
1856 }1862 }
1857 var templ_7745c5c3_Var89 string1863 var templ_7745c5c3_Var89 string
1858 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())1864 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(g.Label())
1859 if templ_7745c5c3_Err != nil {1865 if templ_7745c5c3_Err != nil {
1860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 345, Col: 51}1866+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 349, Col: 51}
1861 }1867 }
1862 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))1868 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1863 if templ_7745c5c3_Err != nil {1869 if templ_7745c5c3_Err != nil {
1864 return templ_7745c5c3_Err1870 return templ_7745c5c3_Err
1865 }1871 }
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</button></td></tr>")1872+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</button></td></tr>")
1867 if templ_7745c5c3_Err != nil {1873 if templ_7745c5c3_Err != nil {
1868 return templ_7745c5c3_Err1874 return templ_7745c5c3_Err
1869 }1875 }
@@ -1894,51 +1900,51 @@ func DiffContext(c ContextRows) templ.Component {
1894 templ_7745c5c3_Var90 = templ.NopComponent1900 templ_7745c5c3_Var90 = templ.NopComponent
1895 }1901 }
1896 ctx = templ.ClearChildren(ctx)1902 ctx = templ.ClearChildren(ctx)
1897- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<table><tbody data-for=\"uni\">")1903+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<table><tbody data-for=\"uni\">")
1898 if templ_7745c5c3_Err != nil {1904 if templ_7745c5c3_Err != nil {
1899 return templ_7745c5c3_Err1905 return templ_7745c5c3_Err
1900 }1906 }
1901 for _, ln := range c.Lines {1907 for _, ln := range c.Lines {
1902- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<tr class=\"ctx\"><td class=\"ln\">")1908+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<tr class=\"ctx\"><td class=\"ln\">")
1903 if templ_7745c5c3_Err != nil {1909 if templ_7745c5c3_Err != nil {
1904 return templ_7745c5c3_Err1910 return templ_7745c5c3_Err
1905 }1911 }
1906 var templ_7745c5c3_Var91 string1912 var templ_7745c5c3_Var91 string
1907 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)1913 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1908 if templ_7745c5c3_Err != nil {1914 if templ_7745c5c3_Err != nil {
1909- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 358, Col: 28}1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 362, Col: 28}
1910 }1916 }
1911 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))1917 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1912 if templ_7745c5c3_Err != nil {1918 if templ_7745c5c3_Err != nil {
1913 return templ_7745c5c3_Err1919 return templ_7745c5c3_Err
1914 }1920 }
1915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</td><td class=\"ln\">")1921+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"ln\">")
1916 if templ_7745c5c3_Err != nil {1922 if templ_7745c5c3_Err != nil {
1917 return templ_7745c5c3_Err1923 return templ_7745c5c3_Err
1918 }1924 }
1919 var templ_7745c5c3_Var92 string1925 var templ_7745c5c3_Var92 string
1920 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)1926 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1921 if templ_7745c5c3_Err != nil {1927 if templ_7745c5c3_Err != nil {
1922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 359, Col: 28}1928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 363, Col: 28}
1923 }1929 }
1924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1930 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1925 if templ_7745c5c3_Err != nil {1931 if templ_7745c5c3_Err != nil {
1926 return templ_7745c5c3_Err1932 return templ_7745c5c3_Err
1927 }1933 }
1928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "</td><td class=\"m\">")1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"m\">")
1929 if templ_7745c5c3_Err != nil {1935 if templ_7745c5c3_Err != nil {
1930 return templ_7745c5c3_Err1936 return templ_7745c5c3_Err
1931 }1937 }
1932 var templ_7745c5c3_Var93 string1938 var templ_7745c5c3_Var93 string
1933 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)1939 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1934 if templ_7745c5c3_Err != nil {1940 if templ_7745c5c3_Err != nil {
1935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 360, Col: 28}1941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 364, Col: 28}
1936 }1942 }
1937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))1943 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1938 if templ_7745c5c3_Err != nil {1944 if templ_7745c5c3_Err != nil {
1939 return templ_7745c5c3_Err1945 return templ_7745c5c3_Err
1940 }1946 }
1941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</td><td class=\"tx chroma\">")1947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td><td class=\"tx chroma\">")
1942 if templ_7745c5c3_Err != nil {1948 if templ_7745c5c3_Err != nil {
1943 return templ_7745c5c3_Err1949 return templ_7745c5c3_Err
1944 }1950 }
@@ -1946,7 +1952,7 @@ func DiffContext(c ContextRows) templ.Component {
1946 if templ_7745c5c3_Err != nil {1952 if templ_7745c5c3_Err != nil {
1947 return templ_7745c5c3_Err1953 return templ_7745c5c3_Err
1948 }1954 }
1949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</td></tr>")1955+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</td></tr>")
1950 if templ_7745c5c3_Err != nil {1956 if templ_7745c5c3_Err != nil {
1951 return templ_7745c5c3_Err1957 return templ_7745c5c3_Err
1952 }1958 }
@@ -1957,12 +1963,12 @@ func DiffContext(c ContextRows) templ.Component {
1957 return templ_7745c5c3_Err1963 return templ_7745c5c3_Err
1958 }1964 }
1959 }1965 }
1960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</tbody> <tbody data-for=\"spl\">")1966+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</tbody> <tbody data-for=\"spl\">")
1961 if templ_7745c5c3_Err != nil {1967 if templ_7745c5c3_Err != nil {
1962 return templ_7745c5c3_Err1968 return templ_7745c5c3_Err
1963 }1969 }
1964 for i := range c.Lines {1970 for i := range c.Lines {
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<tr>")1971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<tr>")
1966 if templ_7745c5c3_Err != nil {1972 if templ_7745c5c3_Err != nil {
1967 return templ_7745c5c3_Err1973 return templ_7745c5c3_Err
1968 }1974 }
@@ -1974,7 +1980,7 @@ func DiffContext(c ContextRows) templ.Component {
1974 if templ_7745c5c3_Err != nil {1980 if templ_7745c5c3_Err != nil {
1975 return templ_7745c5c3_Err1981 return templ_7745c5c3_Err
1976 }1982 }
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</tr>")1983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tr>")
1978 if templ_7745c5c3_Err != nil {1984 if templ_7745c5c3_Err != nil {
1979 return templ_7745c5c3_Err1985 return templ_7745c5c3_Err
1980 }1986 }
@@ -1985,7 +1991,7 @@ func DiffContext(c ContextRows) templ.Component {
1985 return templ_7745c5c3_Err1991 return templ_7745c5c3_Err
1986 }1992 }
1987 }1993 }
1988- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</tbody></table>")1994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</tbody></table>")
1989 if templ_7745c5c3_Err != nil {1995 if templ_7745c5c3_Err != nil {
1990 return templ_7745c5c3_Err1996 return templ_7745c5c3_Err
1991 }1997 }
@@ -2017,7 +2023,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2017 }2023 }
2018 ctx = templ.ClearChildren(ctx)2024 ctx = templ.ClearChildren(ctx)
2019 if ln == nil {2025 if ln == nil {
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")2026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
2021 if templ_7745c5c3_Err != nil {2027 if templ_7745c5c3_Err != nil {
2022 return templ_7745c5c3_Err2028 return templ_7745c5c3_Err
2023 }2029 }
@@ -2027,7 +2033,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2027 if templ_7745c5c3_Err != nil {2033 if templ_7745c5c3_Err != nil {
2028 return templ_7745c5c3_Err2034 return templ_7745c5c3_Err
2029 }2035 }
2030- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<td class=\"")2036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<td class=\"")
2031 if templ_7745c5c3_Err != nil {2037 if templ_7745c5c3_Err != nil {
2032 return templ_7745c5c3_Err2038 return templ_7745c5c3_Err
2033 }2039 }
@@ -2040,7 +2046,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2040 if templ_7745c5c3_Err != nil {2046 if templ_7745c5c3_Err != nil {
2041 return templ_7745c5c3_Err2047 return templ_7745c5c3_Err
2042 }2048 }
2043- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")2049+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\">")
2044 if templ_7745c5c3_Err != nil {2050 if templ_7745c5c3_Err != nil {
2045 return templ_7745c5c3_Err2051 return templ_7745c5c3_Err
2046 }2052 }
@@ -2055,7 +2061,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2055 return templ_7745c5c3_Err2061 return templ_7745c5c3_Err
2056 }2062 }
2057 }2063 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "</td>")2064+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</td>")
2059 if templ_7745c5c3_Err != nil {2065 if templ_7745c5c3_Err != nil {
2060 return templ_7745c5c3_Err2066 return templ_7745c5c3_Err
2061 }2067 }
@@ -2064,7 +2070,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2064 if templ_7745c5c3_Err != nil {2070 if templ_7745c5c3_Err != nil {
2065 return templ_7745c5c3_Err2071 return templ_7745c5c3_Err
2066 }2072 }
2067- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<td class=\"")2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<td class=\"")
2068 if templ_7745c5c3_Err != nil {2074 if templ_7745c5c3_Err != nil {
2069 return templ_7745c5c3_Err2075 return templ_7745c5c3_Err
2070 }2076 }
@@ -2077,20 +2083,20 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2077 if templ_7745c5c3_Err != nil {2083 if templ_7745c5c3_Err != nil {
2078 return templ_7745c5c3_Err2084 return templ_7745c5c3_Err
2079 }2085 }
2080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">")2086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "\">")
2081 if templ_7745c5c3_Err != nil {2087 if templ_7745c5c3_Err != nil {
2082 return templ_7745c5c3_Err2088 return templ_7745c5c3_Err
2083 }2089 }
2084 var templ_7745c5c3_Var99 string2090 var templ_7745c5c3_Var99 string
2085 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)2091 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
2086 if templ_7745c5c3_Err != nil {2092 if templ_7745c5c3_Err != nil {
2087- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 397, Col: 68}2093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 401, Col: 68}
2088 }2094 }
2089 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))2095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2090 if templ_7745c5c3_Err != nil {2096 if templ_7745c5c3_Err != nil {
2091 return templ_7745c5c3_Err2097 return templ_7745c5c3_Err
2092 }2098 }
2093- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</td>")2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</td>")
2094 if templ_7745c5c3_Err != nil {2100 if templ_7745c5c3_Err != nil {
2095 return templ_7745c5c3_Err2101 return templ_7745c5c3_Err
2096 }2102 }
@@ -2099,7 +2105,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2099 if templ_7745c5c3_Err != nil {2105 if templ_7745c5c3_Err != nil {
2100 return templ_7745c5c3_Err2106 return templ_7745c5c3_Err
2101 }2107 }
2102- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<td class=\"")2108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<td class=\"")
2103 if templ_7745c5c3_Err != nil {2109 if templ_7745c5c3_Err != nil {
2104 return templ_7745c5c3_Err2110 return templ_7745c5c3_Err
2105 }2111 }
@@ -2112,7 +2118,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2112 if templ_7745c5c3_Err != nil {2118 if templ_7745c5c3_Err != nil {
2113 return templ_7745c5c3_Err2119 return templ_7745c5c3_Err
2114 }2120 }
2115- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\">")2121+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\">")
2116 if templ_7745c5c3_Err != nil {2122 if templ_7745c5c3_Err != nil {
2117 return templ_7745c5c3_Err2123 return templ_7745c5c3_Err
2118 }2124 }
@@ -2120,7 +2126,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
2120 if templ_7745c5c3_Err != nil {2126 if templ_7745c5c3_Err != nil {
2121 return templ_7745c5c3_Err2127 return templ_7745c5c3_Err
2122 }2128 }
2123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</td>")2129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</td>")
2124 if templ_7745c5c3_Err != nil {2130 if templ_7745c5c3_Err != nil {
2125 return templ_7745c5c3_Err2131 return templ_7745c5c3_Err
2126 }2132 }
@@ -2153,33 +2159,33 @@ func gutter(n, href string) templ.Component {
2153 }2159 }
2154 ctx = templ.ClearChildren(ctx)2160 ctx = templ.ClearChildren(ctx)
2155 if href != "" {2161 if href != "" {
2156- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<a href=\"")2162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<a href=\"")
2157 if templ_7745c5c3_Err != nil {2163 if templ_7745c5c3_Err != nil {
2158 return templ_7745c5c3_Err2164 return templ_7745c5c3_Err
2159 }2165 }
2160 var templ_7745c5c3_Var103 templ.SafeURL2166 var templ_7745c5c3_Var103 templ.SafeURL
2161 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))2167 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
2162 if templ_7745c5c3_Err != nil {2168 if templ_7745c5c3_Err != nil {
2163- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 31}2169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 31}
2164 }2170 }
2165 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))2171 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2166 if templ_7745c5c3_Err != nil {2172 if templ_7745c5c3_Err != nil {
2167 return templ_7745c5c3_Err2173 return templ_7745c5c3_Err
2168 }2174 }
2169- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\" title=\"comment on this line\">")2175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\" title=\"comment on this line\">")
2170 if templ_7745c5c3_Err != nil {2176 if templ_7745c5c3_Err != nil {
2171 return templ_7745c5c3_Err2177 return templ_7745c5c3_Err
2172 }2178 }
2173 var templ_7745c5c3_Var104 string2179 var templ_7745c5c3_Var104 string
2174 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)2180 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2175 if templ_7745c5c3_Err != nil {2181 if templ_7745c5c3_Err != nil {
2176- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 406, Col: 66}2182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 410, Col: 66}
2177 }2183 }
2178 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2184 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2179 if templ_7745c5c3_Err != nil {2185 if templ_7745c5c3_Err != nil {
2180 return templ_7745c5c3_Err2186 return templ_7745c5c3_Err
2181 }2187 }
2182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a>")2188+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</a>")
2183 if templ_7745c5c3_Err != nil {2189 if templ_7745c5c3_Err != nil {
2184 return templ_7745c5c3_Err2190 return templ_7745c5c3_Err
2185 }2191 }
@@ -2187,7 +2193,7 @@ func gutter(n, href string) templ.Component {
2187 var templ_7745c5c3_Var105 string2193 var templ_7745c5c3_Var105 string
2188 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)2194 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(n)
2189 if templ_7745c5c3_Err != nil {2195 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 408, Col: 5}2196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 412, Col: 5}
2191 }2197 }
2192 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2193 if templ_7745c5c3_Err != nil {2199 if templ_7745c5c3_Err != nil {
M internal/adapters/web/events.go +13 -3
@@ -31,7 +31,7 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=summary|refs
3232 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3333 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>
34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&head=<sha>&target=<target branch>
3535 // GET /-/events?topic=repo:<name>&view=issues
3636 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
3737 // GET /-/events?topic=repo:<name>&view=actions
@@ -44,7 +44,9 @@ import (
4444 // lists first: the banner counts the commits between that head and the new
4545 // tip, so it stays right however many pushes came in and however often the
4646 // stream reconnected. A Last-Event-ID header resumes the stream
47-// (resume_after).
47+// (resume_after). With no header, after=<event id> supplies the resume cursor
48+// for a replacement stream. The header wins. Both use Watch's parseID
49+// validation: an invalid or expired cursor produces a gap.
4850 //
4951 // Events, each with the id of the Watch event it comes from:
5052 //
@@ -60,6 +62,10 @@ import (
6062 // the badge of the merge box (a slot like mr-notice), when the
6163 // mergeability of the request shown settles or a push moves its
6264 // source or target
65+// mr-head data: <div id="mr-head"></div>; the source branch moved.
66+// ermiteMR fetches the current page and refreshes its head-dependent
67+// regions in place, preserving the reader's position and drafts.
68+// The replacement stream uses the new head for CI filtering.
6369 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
6470 // something changed, with the link that reloads the page
6571 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -184,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
184190 var startOnce sync.Once
185191 start := func() { startOnce.Do(func() { close(started) }) }
186192 finished := make(chan struct{})
193+ resume := r.Header.Get("Last-Event-ID")
194+ if resume == "" {
195+ resume = q.Get("after")
196+ }
187197 params := ports.WatchParams{
188- Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
198+ Topics: []string{topic}, ResumeAfter: resume,
189199 // The visitor is the client of the watch, for the limit per client.
190200 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
191201 Ready: start,
@@ -31,7 +31,7 @@ import (
31 // GET /-/events?topic=repo:<name>&view=summary|refs31 // GET /-/events?topic=repo:<name>&view=summary|refs
32 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>32 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
33 // GET /-/events?topic=repo:<name>&view=mrs33 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&head=<sha>&target=<target branch>
35 // GET /-/events?topic=repo:<name>&view=issues35 // GET /-/events?topic=repo:<name>&view=issues
36 // GET /-/events?topic=repo:<name>&view=issue&n=<number>36 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
37 // GET /-/events?topic=repo:<name>&view=actions37 // GET /-/events?topic=repo:<name>&view=actions
@@ -44,7 +44,9 @@ import (
44 // lists first: the banner counts the commits between that head and the new44 // lists first: the banner counts the commits between that head and the new
45 // tip, so it stays right however many pushes came in and however often the45 // tip, so it stays right however many pushes came in and however often the
46 // stream reconnected. A Last-Event-ID header resumes the stream46 // stream reconnected. A Last-Event-ID header resumes the stream
47-// (resume_after).47+// (resume_after). With no header, after=<event id> supplies the resume cursor
48+// for a replacement stream. The header wins. Both use Watch's parseID
49+// validation: an invalid or expired cursor produces a gap.
48 //50 //
49 // Events, each with the id of the Watch event it comes from:51 // Events, each with the id of the Watch event it comes from:
50 //52 //
@@ -60,6 +62,10 @@ import (
60 // the badge of the merge box (a slot like mr-notice), when the62 // the badge of the merge box (a slot like mr-notice), when the
61 // mergeability of the request shown settles or a push moves its63 // mergeability of the request shown settles or a push moves its
62 // source or target64 // source or target
65+// mr-head data: <div id="mr-head"></div>; the source branch moved.
66+// ermiteMR fetches the current page and refreshes its head-dependent
67+// regions in place, preserving the reader's position and drafts.
68+// The replacement stream uses the new head for CI filtering.
63 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:69 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
64 // something changed, with the link that reloads the page70 // something changed, with the link that reloads the page
65 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),71 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -184,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
184 var startOnce sync.Once190 var startOnce sync.Once
185 start := func() { startOnce.Do(func() { close(started) }) }191 start := func() { startOnce.Do(func() { close(started) }) }
186 finished := make(chan struct{})192 finished := make(chan struct{})
193+ resume := r.Header.Get("Last-Event-ID")
194+ if resume == "" {
195+ resume = q.Get("after")
196+ }
187 params := ports.WatchParams{197 params := ports.WatchParams{
188- Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),198+ Topics: []string{topic}, ResumeAfter: resume,
189 // The visitor is the client of the watch, for the limit per client.199 // The visitor is the client of the watch, for the limit per client.
190 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),200 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
191 Ready: start,201 Ready: start,
M internal/adapters/web/events_test.go +49 -0
@@ -478,3 +478,52 @@ func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
478478 require.Len(t, got, 1)
479479 assert.Contains(t, got[0].html, "the branch moved — reload")
480480 }
481+
482+func TestMRPushEventsKeepMergeability(t *testing.T) {
483+ t.Parallel()
484+ svc := mocks.NewMergeRequestService(t)
485+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
486+ Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Twice()
487+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
488+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
489+ for _, ref := range []string{"feature", "main"} {
490+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + ref}}})
491+ require.NoError(t, err)
492+ if ref == "feature" {
493+ require.Len(t, fs, 2)
494+ assert.Equal(t, "mr-head", fs[0].event)
495+ assert.Equal(t, "event: mr-head\nid: b-1\ndata: <div id=\"mr-head\"></div>\n\n", formatEvent("b-1", fs[0].event, fs[0].html, true))
496+ } else {
497+ require.Len(t, fs, 1)
498+ }
499+ assert.Equal(t, "mr-mergeability", fs[len(fs)-1].event)
500+ for _, f := range fs {
501+ assert.NotEqual(t, "mr-notice", f.event)
502+ }
503+ }
504+}
505+
506+func TestMRPushBothBranches(t *testing.T) {
507+ t.Parallel()
508+ for _, refs := range [][]string{{"feature", "main"}, {"main", "feature"}} {
509+ t.Run(strings.Join(refs, "-"), func(t *testing.T) {
510+ svc := mocks.NewMergeRequestService(t)
511+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Once()
512+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
513+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
514+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + refs[0]}, {Ref: "refs/heads/" + refs[1]}}})
515+ require.NoError(t, err)
516+ require.Len(t, fs, 2)
517+ assert.Equal(t, "mr-head", fs[0].event)
518+ assert.Equal(t, "mr-mergeability", fs[1].event)
519+ })
520+ }
521+}
522+
523+func TestNonMRPushRoutingNeverEmitsMRHead(t *testing.T) {
524+ t.Parallel()
525+ l := liveStream{s: &Server{}, repo: "demo", view: "mrs", n: "1", ref: "feature", target: "main"}
526+ fs, err := l.render(t.Context(), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "demo"), Updates: []domain.RefUpdate{{Ref: "refs/heads/feature"}}}})
527+ require.NoError(t, err)
528+ assert.Empty(t, fs)
529+}
@@ -478,3 +478,52 @@ func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
478 require.Len(t, got, 1)478 require.Len(t, got, 1)
479 assert.Contains(t, got[0].html, "the branch moved — reload")479 assert.Contains(t, got[0].html, "the branch moved — reload")
480 }480 }
481+
482+func TestMRPushEventsKeepMergeability(t *testing.T) {
483+ t.Parallel()
484+ svc := mocks.NewMergeRequestService(t)
485+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
486+ Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Twice()
487+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
488+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
489+ for _, ref := range []string{"feature", "main"} {
490+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + ref}}})
491+ require.NoError(t, err)
492+ if ref == "feature" {
493+ require.Len(t, fs, 2)
494+ assert.Equal(t, "mr-head", fs[0].event)
495+ assert.Equal(t, "event: mr-head\nid: b-1\ndata: <div id=\"mr-head\"></div>\n\n", formatEvent("b-1", fs[0].event, fs[0].html, true))
496+ } else {
497+ require.Len(t, fs, 1)
498+ }
499+ assert.Equal(t, "mr-mergeability", fs[len(fs)-1].event)
500+ for _, f := range fs {
501+ assert.NotEqual(t, "mr-notice", f.event)
502+ }
503+ }
504+}
505+
506+func TestMRPushBothBranches(t *testing.T) {
507+ t.Parallel()
508+ for _, refs := range [][]string{{"feature", "main"}, {"main", "feature"}} {
509+ t.Run(strings.Join(refs, "-"), func(t *testing.T) {
510+ svc := mocks.NewMergeRequestService(t)
511+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Once()
512+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
513+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
514+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + refs[0]}, {Ref: "refs/heads/" + refs[1]}}})
515+ require.NoError(t, err)
516+ require.Len(t, fs, 2)
517+ assert.Equal(t, "mr-head", fs[0].event)
518+ assert.Equal(t, "mr-mergeability", fs[1].event)
519+ })
520+ }
521+}
522+
523+func TestNonMRPushRoutingNeverEmitsMRHead(t *testing.T) {
524+ t.Parallel()
525+ l := liveStream{s: &Server{}, repo: "demo", view: "mrs", n: "1", ref: "feature", target: "main"}
526+ fs, err := l.render(t.Context(), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "demo"), Updates: []domain.RefUpdate{{Ref: "refs/heads/feature"}}}})
527+ require.NoError(t, err)
528+ assert.Empty(t, fs)
529+}
M internal/adapters/web/fragments.go +2 -7
@@ -474,8 +474,7 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
474474 // mrPushed announces a push to the source branch of the merge request shown,
475475 // and has the badge follow a push to its source or its target.
476476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
477- n, err := strconv.ParseInt(l.n, 10, 64)
478- if err != nil {
477+ if _, err := strconv.ParseInt(l.n, 10, 64); err != nil {
479478 return nil, nil
480479 }
481480 var out []fragment
@@ -483,11 +482,7 @@ func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragme
483482 for _, u := range p.Updates {
484483 switch {
485484 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
486- notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
487- if err != nil {
488- return nil, err
489- }
490- out = append(out, notice...)
485+ out = append(out, fragment{event: "mr-head", html: `<div id="mr-head"></div>`})
491486 moved = true
492487 case l.target != "" && u.Ref == "refs/heads/"+l.target:
493488 moved = true
@@ -474,8 +474,7 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
474 // mrPushed announces a push to the source branch of the merge request shown,474 // mrPushed announces a push to the source branch of the merge request shown,
475 // and has the badge follow a push to its source or its target.475 // and has the badge follow a push to its source or its target.
476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
477- n, err := strconv.ParseInt(l.n, 10, 64)477+ if _, err := strconv.ParseInt(l.n, 10, 64); err != nil {
478- if err != nil {
479 return nil, nil478 return nil, nil
480 }479 }
481 var out []fragment480 var out []fragment
@@ -483,11 +482,7 @@ func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragme
483 for _, u := range p.Updates {482 for _, u := range p.Updates {
484 switch {483 switch {
485 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:484 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
486- notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))485+ out = append(out, fragment{event: "mr-head", html: `<div id="mr-head"></div>`})
487- if err != nil {
488- return nil, err
489- }
490- out = append(out, notice...)
491 moved = true486 moved = true
492 case l.target != "" && u.Ref == "refs/heads/"+l.target:487 case l.target != "" && u.Ref == "refs/heads/"+l.target:
493 moved = true488 moved = true
M internal/adapters/web/fragments.templ +1 -0
@@ -103,6 +103,7 @@ templ Live(l Layout) {
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106+ mr-head -> ermiteMR.headChanged();
106107 gap -> ermiteLive.reload()"
107108 ></span>
108109 }
@@ -103,6 +103,7 @@ templ Live(l Layout) {
103 htmx:sse:error -> ermiteLive.error(event.detail.status);103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104 htmx:sse:before:connection -> ermiteLive.gate(event);104 htmx:sse:before:connection -> ermiteLive.gate(event);
105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106+ mr-head -> ermiteMR.headChanged();
106 gap -> ermiteLive.reload()"107 gap -> ermiteLive.reload()"
107 ></span>108 ></span>
108 }109 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +22 -0
@@ -7,6 +7,8 @@ import (
77 "strings"
88 "testing"
99
10+ "bnema.dev/ermite/internal/domain"
11+
1012 "github.com/a-h/templ"
1113 "github.com/stretchr/testify/assert"
1214 "github.com/stretchr/testify/require"
@@ -77,3 +79,23 @@ func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
7779 assert.Contains(t, link, `this.dataset.view`)
7880 assert.NotContains(t, link, "=== 'split'")
7981 }
82+
83+// The push signal is intentionally constant-sized: diff work belongs to the
84+// subsequent windowed page request, not to each subscriber's event handler.
85+func TestMRPushedHeadSignal(t *testing.T) {
86+ t.Parallel()
87+ l := liveStream{s: &Server{}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
88+ for _, tt := range []struct {
89+ ref string
90+ want int
91+ }{{"refs/heads/feature", 1}, {"refs/heads/main", 0}, {"refs/heads/other", 0}} {
92+ fs, err := l.mrPushed(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: tt.ref}}})
93+ require.NoError(t, err)
94+ require.Len(t, fs, tt.want)
95+ if tt.want > 0 {
96+ assert.Equal(t, "mr-head", fs[0].event)
97+ assert.Equal(t, `<div id="mr-head"></div>`, fs[0].html)
98+ assert.NotContains(t, fs[0].html, "the branch changed")
99+ }
100+ }
101+}
@@ -7,6 +7,8 @@ import (
7 "strings"7 "strings"
8 "testing"8 "testing"
9 9
10+ "bnema.dev/ermite/internal/domain"
11+
10 "github.com/a-h/templ"12 "github.com/a-h/templ"
11 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -77,3 +79,23 @@ func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
77 assert.Contains(t, link, `this.dataset.view`)79 assert.Contains(t, link, `this.dataset.view`)
78 assert.NotContains(t, link, "=== 'split'")80 assert.NotContains(t, link, "=== 'split'")
79 }81 }
82+
83+// The push signal is intentionally constant-sized: diff work belongs to the
84+// subsequent windowed page request, not to each subscriber's event handler.
85+func TestMRPushedHeadSignal(t *testing.T) {
86+ t.Parallel()
87+ l := liveStream{s: &Server{}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
88+ for _, tt := range []struct {
89+ ref string
90+ want int
91+ }{{"refs/heads/feature", 1}, {"refs/heads/main", 0}, {"refs/heads/other", 0}} {
92+ fs, err := l.mrPushed(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: tt.ref}}})
93+ require.NoError(t, err)
94+ require.Len(t, fs, tt.want)
95+ if tt.want > 0 {
96+ assert.Equal(t, "mr-head", fs[0].event)
97+ assert.Equal(t, `<div id="mr-head"></div>`, fs[0].html)
98+ assert.NotContains(t, fs[0].html, "the branch changed")
99+ }
100+ }
101+}
M internal/adapters/web/jstest/a11y.test.mjs +23 -0
@@ -358,3 +358,26 @@ test('activity moves a home row up only in newest activity order, preserving foc
358358 assert.ok(fresh.hasAttribute('data-cursor'));
359359 }
360360 });
361+
362+test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => {
363+ const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } });
364+ dom.body.replaceChildren(diffs);
365+ const oldQuery = doc.querySelector;
366+ doc.querySelector = query.one;
367+ const previous = globalThis.sessionStorage;
368+ const writes = [];
369+ globalThis.sessionStorage = { setItem: (...args) => writes.push(args) };
370+ try {
371+ window.ermite.layout('unified');
372+ window.ermite.layout('split');
373+ assert.equal(diffs.dataset.diff, 'split');
374+ assert.deepEqual(writes, []);
375+ diffs.dataset.mobile = 'false';
376+ window.ermite.layout('unified');
377+ assert.equal(diffs.dataset.diff, 'unified');
378+ assert.deepEqual(writes, [['diff', 'unified']]);
379+ } finally {
380+ globalThis.sessionStorage = previous;
381+ doc.querySelector = oldQuery;
382+ }
383+});
@@ -358,3 +358,26 @@ test('activity moves a home row up only in newest activity order, preserving foc
358 assert.ok(fresh.hasAttribute('data-cursor'));358 assert.ok(fresh.hasAttribute('data-cursor'));
359 }359 }
360 });360 });
361+
362+test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => {
363+ const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } });
364+ dom.body.replaceChildren(diffs);
365+ const oldQuery = doc.querySelector;
366+ doc.querySelector = query.one;
367+ const previous = globalThis.sessionStorage;
368+ const writes = [];
369+ globalThis.sessionStorage = { setItem: (...args) => writes.push(args) };
370+ try {
371+ window.ermite.layout('unified');
372+ window.ermite.layout('split');
373+ assert.equal(diffs.dataset.diff, 'split');
374+ assert.deepEqual(writes, []);
375+ diffs.dataset.mobile = 'false';
376+ window.ermite.layout('unified');
377+ assert.equal(diffs.dataset.diff, 'unified');
378+ assert.deepEqual(writes, [['diff', 'unified']]);
379+ } finally {
380+ globalThis.sessionStorage = previous;
381+ doc.querySelector = oldQuery;
382+ }
383+});
M internal/adapters/web/jstest/live.test.mjs +43 -2
@@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = '));
1212 function setup(t) {
1313 t.mock.timers.enable({ apis: ['setTimeout'] });
1414 const hint = { dataset: { state: 'connecting' } };
15- globalThis.window = {};
15+ const listeners = {};
16+ globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } };
1617 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };
1718 new Function(live)();
18- return { live: globalThis.window.ermiteLive, hint };
19+ return { live: globalThis.window.ermiteLive, hint, listeners };
1920 }
2021
2122 test('a drop shorter than the delay says nothing', (t) => {
@@ -38,3 +39,43 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
3839 live.back();
3940 assert.equal(hint.dataset.state, 'live');
4041 });
42+
43+test('stream teardown handles the reader cancellation that hx-sse leaves uncaught', async (t) => {
44+ const { live, listeners } = setup(t);
45+ assert.equal(listeners.unhandledrejection, undefined, 'no global rejection filter');
46+ const abort = Object.assign(new Error('signal is aborted without reason'), { name: 'AbortError' });
47+ let cancelled = 0;
48+ const connection = { reader: { cancel: () => { cancelled++; return Promise.reject(abort); } } };
49+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
50+ const escaped = [];
51+ const onRejection = (reason) => escaped.push(reason);
52+ process.on('unhandledRejection', onRejection);
53+ try {
54+ // remove stands for hx-sse's cleanup: r.reader?.cancel?.(), result ignored.
55+ await live.teardown(() => { connection.reader?.cancel?.(); });
56+ await new Promise((resolve) => setImmediate(resolve));
57+ await new Promise((resolve) => setImmediate(resolve));
58+ } finally {
59+ process.off('unhandledRejection', onRejection);
60+ }
61+ assert.equal(cancelled, 1, 'the real reader is still cancelled');
62+ assert.deepEqual(escaped, [], 'no AbortError escapes the teardown');
63+});
64+
65+test('replacement stream URL carries the last connection id, including id-only events', (t) => {
66+ const { live, listeners } = setup(t);
67+ const connection = { lastEventId: 'boot-7' };
68+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
69+ connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events.
70+ const saved = globalThis.location;
71+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' };
72+ const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; },
73+ setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } };
74+ try {
75+ live.resumeStream(element);
76+ const url = new URL(element.url, location.href);
77+ assert.equal(url.searchParams.get('after'), 'boot-8');
78+ assert.equal(url.searchParams.get('head'), 'new');
79+ assert.equal(url.searchParams.get('topic'), 'repo:demo');
80+ } finally { globalThis.location = saved; }
81+});
@@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = '));
12 function setup(t) {12 function setup(t) {
13 t.mock.timers.enable({ apis: ['setTimeout'] });13 t.mock.timers.enable({ apis: ['setTimeout'] });
14 const hint = { dataset: { state: 'connecting' } };14 const hint = { dataset: { state: 'connecting' } };
15- globalThis.window = {};15+ const listeners = {};
16+ globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } };
16 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };17 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };
17 new Function(live)();18 new Function(live)();
18- return { live: globalThis.window.ermiteLive, hint };19+ return { live: globalThis.window.ermiteLive, hint, listeners };
19 }20 }
20 21
21 test('a drop shorter than the delay says nothing', (t) => {22 test('a drop shorter than the delay says nothing', (t) => {
@@ -38,3 +39,43 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
38 live.back();39 live.back();
39 assert.equal(hint.dataset.state, 'live');40 assert.equal(hint.dataset.state, 'live');
40 });41 });
42+
43+test('stream teardown handles the reader cancellation that hx-sse leaves uncaught', async (t) => {
44+ const { live, listeners } = setup(t);
45+ assert.equal(listeners.unhandledrejection, undefined, 'no global rejection filter');
46+ const abort = Object.assign(new Error('signal is aborted without reason'), { name: 'AbortError' });
47+ let cancelled = 0;
48+ const connection = { reader: { cancel: () => { cancelled++; return Promise.reject(abort); } } };
49+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
50+ const escaped = [];
51+ const onRejection = (reason) => escaped.push(reason);
52+ process.on('unhandledRejection', onRejection);
53+ try {
54+ // remove stands for hx-sse's cleanup: r.reader?.cancel?.(), result ignored.
55+ await live.teardown(() => { connection.reader?.cancel?.(); });
56+ await new Promise((resolve) => setImmediate(resolve));
57+ await new Promise((resolve) => setImmediate(resolve));
58+ } finally {
59+ process.off('unhandledRejection', onRejection);
60+ }
61+ assert.equal(cancelled, 1, 'the real reader is still cancelled');
62+ assert.deepEqual(escaped, [], 'no AbortError escapes the teardown');
63+});
64+
65+test('replacement stream URL carries the last connection id, including id-only events', (t) => {
66+ const { live, listeners } = setup(t);
67+ const connection = { lastEventId: 'boot-7' };
68+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
69+ connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events.
70+ const saved = globalThis.location;
71+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' };
72+ const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; },
73+ setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } };
74+ try {
75+ live.resumeStream(element);
76+ const url = new URL(element.url, location.href);
77+ assert.equal(url.searchParams.get('after'), 'boot-8');
78+ assert.equal(url.searchParams.get('head'), 'new');
79+ assert.equal(url.searchParams.get('topic'), 'repo:demo');
80+ } finally { globalThis.location = saved; }
81+});
M internal/adapters/web/jstest/mr.test.mjs +430 -0
@@ -92,3 +92,433 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
9292 globalThis.requestAnimationFrame=savedRAF;
9393 }
9494 });
95+
96+function footerHarness(extra = {}) {
97+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
98+ const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
99+ setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },
100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; },
101+ scrollIntoView() { this.scrolled = (this.scrolled ?? 0) + 1; } });
102+ const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
103+ const panels = [node('mr-commits'), node('mr-checks-panel')];
104+ const footer = node('footer'); footer.querySelectorAll = () => panels;
105+ const nav = node('tabs'); nav.querySelectorAll = () => tabs;
106+ const listeners = {}, replaced = [];
107+ globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
108+ querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
109+ globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
110+ globalThis.addEventListener = (k, v) => { listeners[k] = v; };
111+ globalThis.history = { replaceState: (...args) => replaced.push(args) };
112+ const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
113+ const state = () => panels.map((p) => !p.hidden);
114+ return { tabs, panels, footer, listeners, replaced, restore, state };
115+}
116+
117+test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
118+ const h = footerHarness();
119+ try {
120+ window.ermiteMR.panels();
121+ assert.deepEqual(h.state(), [false, false], 'initially closed');
122+ assert.equal(h.tabs[0].attrs['aria-expanded'], 'false');
123+ assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone');
124+ h.tabs[1].events.click({ preventDefault() {} });
125+ assert.deepEqual(h.state(), [false, true]);
126+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
127+ assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]);
128+ location.hash = '#mr-checks-panel';
129+ h.tabs[1].events.click({ preventDefault() {} });
130+ assert.deepEqual(h.state(), [false, false], 'a second click closes');
131+ assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment');
132+ location.hash = '';
133+ h.tabs[1].events.click({ preventDefault() {} });
134+ h.tabs[0].events.click({ preventDefault() {} });
135+ assert.deepEqual(h.state(), [true, false], 'the other tab switches');
136+ h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} });
137+ assert.equal(h.tabs[1].focused, true, 'arrows move focus only');
138+ assert.deepEqual(h.state(), [true, false]);
139+ location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange();
140+ assert.deepEqual(h.state(), [false, true], 'hash opens checks');
141+ assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL');
142+ location.hash = '#mr-commits'; h.listeners.hashchange();
143+ assert.deepEqual(h.state(), [true, false]);
144+ assert.equal(C.tabIndex('Home', 1, 2), 0);
145+ assert.equal(C.tabIndex('End', 0, 2), 1);
146+ assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1);
147+ assert.equal(C.tabIndex('Escape', 0, 2), null);
148+ } finally { h.restore(); }
149+});
150+
151+test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
152+ const text0 = { v: '' }; let writes = 0;
153+ const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
154+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
155+ const h = footerHarness({ summary });
156+ let observed = null, callback = null;
157+ globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
158+ const nav = document.querySelector('[data-mr-tabs]');
159+ nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
160+ try {
161+ window.ermiteMR.panels();
162+ assert.equal(label.textContent, '1/2 failed', 'initial label sync');
163+ assert.equal(observed[0], summary.parentElement);
164+ assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
165+ const before = writes; callback(); callback();
166+ assert.equal(writes, before, 'an unchanged label is not written');
167+ summary.title = ''; callback();
168+ assert.equal(label.textContent, '1/2');
169+ summary.textContent = ' '; callback();
170+ assert.equal(label.textContent, 'no checks');
171+ const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } };
172+ h.listeners.click(click);
173+ assert.deepEqual(h.state(), [false, true]);
174+ h.listeners.click(click);
175+ assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open');
176+ } finally { h.restore(); }
177+});
178+
179+test('footer: panels(index) restores the open panel without touching the URL', () => {
180+ const h = footerHarness();
181+ try {
182+ location.hash = '#mr-commits';
183+ window.ermiteMR.panels(1);
184+ assert.deepEqual(h.state(), [false, true], 'a restore ignores the fragment');
185+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
186+ assert.deepEqual(h.replaced, []);
187+ assert.equal(h.panels[0].scrolled, undefined, 'a restore does not scroll');
188+ assert.equal(h.panels[1].scrolled, undefined);
189+ location.hash = '';
190+ window.ermiteMR.panels(-1);
191+ assert.deepEqual(h.state(), [false, false]);
192+ } finally { h.restore(); }
193+});
194+
195+test('footer: scripts take over the panels; a fragment opens and scrolls to its panel', () => {
196+ const h = footerHarness();
197+ try {
198+ location.hash = '#mr-checks';
199+ window.ermiteMR.panels();
200+ assert.equal(h.footer.attrs['data-panels'], '', 'the CSS fallback of the fragment stops');
201+ assert.deepEqual(h.state(), [false, true]);
202+ assert.equal(h.panels[1].scrolled, 1, 'the panel of the fragment is brought in');
203+ location.hash = '#mr-commits'; h.listeners.hashchange();
204+ assert.deepEqual(h.state(), [true, false]);
205+ assert.equal(h.panels[0].scrolled, 1);
206+ } finally { h.restore(); }
207+});
208+
209+test('touch range: first tap anchors, second tap opens the sorted range', () => {
210+ const base = 'https://forge.test/demo/merge-requests/1';
211+ const first = base + '?on=new:24:a.go#f1';
212+ const last = base + '?on=new:10:a.go#f1';
213+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
214+ const result = C.touchRange(first, last, base);
215+ assert.equal(result.start, null);
216+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
217+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
218+});
219+
220+test('touch range: a different file, side or request becomes a new anchor', () => {
221+ const base = 'https://forge.test/demo/merge-requests/1';
222+ const first = base + '?on=new:24:a.go#f1';
223+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
224+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
225+ }
226+});
227+
228+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
229+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
230+ assert.equal(C.mobileURL(href, 'split', false), null);
231+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
232+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
233+ const url = new URL(C.mobileURL(href, 'split', true));
234+ assert.equal(url.searchParams.get('view'), 'unified');
235+ assert.equal(url.searchParams.get('from'), '25');
236+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
237+ assert.equal(url.hash, '#f26');
238+});
239+
240+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
241+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
242+ const make = () => ({
243+ attrs: {}, events: {}, children: [], textContent: '',
244+ setAttribute(k, v) { this.attrs[k] = v; },
245+ removeAttribute(k) { delete this.attrs[k]; },
246+ addEventListener(k, fn) { this.events[k] = fn; },
247+ append(...nodes) { this.children.push(...nodes); },
248+ prepend(node) { this.children.unshift(node); },
249+ querySelector() { return null; }, querySelectorAll() { return []; },
250+ });
251+ const diffs = make();
252+ diffs.dataset = { diff: 'split' };
253+ const base = 'https://forge.test/demo/merge-requests/1';
254+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
255+ const a = line(20), b = line(10);
256+ let loaded = false;
257+ diffs.querySelector = () => loaded ? a : null;
258+ let assigned = null;
259+ globalThis.document = {
260+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
261+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
262+ };
263+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
264+ globalThis.addEventListener = () => {};
265+ const changes = {};
266+ const medias = {};
267+ globalThis.matchMedia = (q) => medias[q] ??= { matches: true, addEventListener(type, fn) { changes[q] = fn; } };
268+ const media = globalThis.matchMedia('(max-width: 720px)');
269+ const touchQuery = '(pointer: coarse), (hover: none)';
270+ const resize = () => changes['(max-width: 720px)']();
271+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
272+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
273+ const click = (link, modifiers = {}) => {
274+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
275+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
276+ diffs.events.click(e);
277+ return e;
278+ };
279+ try {
280+ window.ermiteMR.start();
281+ const bar = diffs.children[0];
282+ const [button, status] = bar.children;
283+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
284+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
285+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
286+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
287+ assert.equal(diffs.dataset.mobile, 'true');
288+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
289+ media.matches = false;
290+ resize();
291+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
292+ assert.equal(diffs.dataset.diff, 'split');
293+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
294+ button.events.click();
295+ assert.equal(button.attrs['aria-pressed'], 'true');
296+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
297+ const event = click(a);
298+ assert.equal(event.prevented, true);
299+ assert.equal(event.stopped, true);
300+ assert.ok('data-range-start' in a.attrs);
301+ assert.match(status.textContent, /20/);
302+ click(b);
303+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
304+ assert.equal('data-range-start' in a.attrs, false);
305+ click(a);
306+ button.events.click();
307+ assert.equal(button.attrs['aria-pressed'], 'false');
308+ assert.equal(status.textContent, '');
309+ assert.equal('data-range-start' in a.attrs, false);
310+ assert.equal(click(b).prevented, false);
311+ // The touch media query stops matching: range mode is switched off.
312+ button.events.click();
313+ click(a);
314+ assert.equal(button.attrs['aria-pressed'], 'true');
315+ assert.ok('data-range-start' in a.attrs);
316+ medias[touchQuery].matches = false;
317+ changes[touchQuery]();
318+ assert.equal(button.attrs['aria-pressed'], 'false');
319+ assert.equal(status.textContent, '');
320+ assert.equal('data-range-start' in a.attrs, false);
321+ assert.equal(click(b).prevented, false, 'clicks pass through once touch stops matching');
322+ } finally {
323+ for (const [k, v] of Object.entries(saved)) {
324+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
325+ }
326+ }
327+});
328+
329+// Deterministic timers: no sleeps, and each test restores every mocked global.
330+function clock() {
331+ const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
332+ const tasks = new Map(); let n = 0, now = 0;
333+ globalThis.setTimeout = (fn, delay = 0) => { tasks.set(++n, { fn, at: now + delay }); return n; };
334+ globalThis.clearTimeout = (id) => tasks.delete(id);
335+ return { async tick(ms = 300) { now += ms; const batch = [...tasks].filter(([, t]) => t.at <= now); for (const [id] of batch) tasks.delete(id); for (const [, t] of batch) await t.fn(); },
336+ size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
337+}
338+
339+test('head refresh defers a real textarea draft, resumes after emptying and blurring', async () => {
340+ const saved = globalThis.document, c = clock();
341+ const text = { value: 'unsent', defaultValue: '' };
342+ let fetched = 0, applied = 0; const notices = [];
343+ globalThis.document = { activeElement: text, querySelectorAll: () => [text] };
344+ const r = window.ermiteMR.refresher({ blocked: window.ermiteMR.hasDraft,
345+ fetchPage: async () => { fetched++; return 'page'; }, apply: () => applied++,
346+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected fallback') });
347+ try {
348+ r.request(); await c.tick();
349+ assert.equal(fetched, 0); assert.deepEqual(notices, [true]);
350+ text.value = ''; r.resume(); await c.tick();
351+ assert.equal(fetched, 0, 'focus still protects an empty draft');
352+ document.activeElement = null; r.resume(); await c.tick();
353+ assert.equal(fetched, 1); assert.equal(applied, 1); assert.equal(r.pending(), false);
354+ } finally { globalThis.document = saved; c.restore(); }
355+});
356+
357+test('head refresh debounces bursts, serializes requests, and reruns after an in-flight push', async () => {
358+ const c = clock(); let fetched = 0, applied = 0, resolve;
359+ const r = window.ermiteMR.refresher({ blocked: () => false,
360+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); },
361+ apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
362+ try {
363+ r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
364+ await c.tick(299); assert.equal(fetched, 0);
365+ await c.tick(0); assert.equal(fetched, 0);
366+ const first = c.tick(1); assert.equal(fetched, 1);
367+ r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');
368+ resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);
369+ const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
370+ assert.equal(applied, 2); assert.equal(c.size(), 0);
371+ } finally { c.restore(); }
372+});
373+
374+test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => {
375+ const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = [];
376+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
377+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; },
378+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
379+ try {
380+ r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
381+ assert.equal(applied, 0); assert.deepEqual(notices, [true]);
382+ r.request(true); const manual = c.tick();
383+ assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft');
384+ await manual; assert.equal(applied, 0);
385+ blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
386+ assert.equal(applied, 1); assert.equal(notices.at(-1), false);
387+ } finally { c.restore(); }
388+});
389+
390+test('fetch failures and non-MR responses take the fallback, never apply', async () => {
391+ const saved = { fetch: globalThis.fetch, location: globalThis.location, DOMParser: globalThis.DOMParser };
392+ const c = clock(); let fallback = 0;
393+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1?view=split&from=25' };
394+ const r = window.ermiteMR.refresher({ blocked: () => false, fetchPage: window.ermiteMR.fetchPage,
395+ apply: () => assert.fail('must not swap'), notice() {}, failed: () => fallback++ });
396+ try {
397+ for (const result of ['network', { ok: false }, { ok: true, redirected: true }, { ok: true, text: async () => '<html>login</html>' }]) {
398+ globalThis.fetch = async (url, opts) => {
399+ assert.equal(url, location.href); assert.equal(opts.credentials, 'same-origin');
400+ if (result === 'network') throw new Error('offline'); return result;
401+ };
402+ globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null }; } };
403+ r.request(); await c.tick();
404+ }
405+ assert.equal(fallback, 4);
406+ } finally {
407+ for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
408+ c.restore();
409+ }
410+});
411+
412+test('viewport probe finds a stable path/side/line and restores its offset without line scans', () => {
413+ const names = ['document', 'location', 'innerHeight', 'innerWidth', 'scrollY', 'scrollBy', 'scrollTo', 'CSS', 'matchMedia'];
414+ const saved = Object.fromEntries(names.map((k) => [k, globalThis[k]]));
415+ const rect = (top, bottom = top + 20) => ({ top, bottom, left: 0, width: 900 });
416+ let lineTop = 160, headerTop = -200, found = true; const calls = [];
417+ const header = { getBoundingClientRect: () => rect(headerTop) };
418+ const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };
419+ const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row, getAttribute: () => link.href };
420+ const file = { getBoundingClientRect: () => rect(headerTop), id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
421+ const hit = { closest: (s) => s === 'tr' ? row : file };
422+ const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
423+ globalThis.document = {
424+ querySelector: (s) => s === '#diffs' ? diffs : s === '.hdr' ? { getBoundingClientRect: () => rect(0, 100) } : null,
425+ querySelectorAll: (s) => { assert.equal(s, '#diffs details.dfile', 'only files, never every line'); return found === 'gone' ? [] : [file]; },
426+ elementFromPoint: () => hit, documentElement: { scrollHeight: 10000 },
427+ };
428+ Object.assign(globalThis, { location: { href: link.href }, innerHeight: 800, innerWidth: 1200, scrollY: 2500,
429+ scrollBy: (arg) => calls.push(['by', arg]), scrollTo: (arg) => calls.push(['to', arg]), CSS: { escape: (s) => s }, matchMedia: () => ({ matches: false }) });
430+ try {
431+ const a = window.ermiteMR.scrollAnchor();
432+ assert.equal(a.file, 'a.go'); assert.equal(a.on, 'new:42:a.go'); assert.equal(a.offset, 160);
433+ file.id = 'f3'; lineTop = 260; window.ermiteMR.restoreAnchor(a);
434+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
435+ found = false; headerTop = -100; window.ermiteMR.restoreAnchor(a);
436+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
437+ found = 'gone'; window.ermiteMR.restoreAnchor({ ...a, y: 20000 });
438+ assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);
439+ } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }
440+});
441+
442+test('empty edited default and focused cform controls protect drafts', () => {
443+ const saved = document;
444+ try {
445+ globalThis.document = { querySelectorAll: () => [{ value: '', defaultValue: 'existing' }], activeElement: null };
446+ assert.equal(window.ermiteMR.hasDraft(), true);
447+ document.querySelectorAll = () => [];
448+ document.activeElement = { closest: (s) => s === '.cform' ? {} : null };
449+ assert.equal(window.ermiteMR.hasDraft(), true);
450+ } finally { globalThis.document = saved; }
451+});
452+
453+test('failure during a draft keeps pending and resumes after the draft ends', async () => {
454+ const c = clock(); let blocked = false, reject, applied = 0, count = 0;
455+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
456+ fetchPage: () => ++count === 1 ? new Promise((_, no) => { reject = no; }) : Promise.resolve('page'),
457+ apply: () => applied++, notice() {}, failed: () => assert.fail('draft must defer fallback') });
458+ try {
459+ r.request(); const first = c.tick(); blocked = true; reject(new Error('offline')); await first;
460+ assert.equal(r.pending(), true); assert.equal(applied, 0);
461+ blocked = false; r.resume(); await c.tick(); assert.equal(applied, 1);
462+ } finally { c.restore(); }
463+});
464+
465+test('anchor restoration retains the server encoding of tilde and star paths', () => {
466+ const saved = { document, CSS: globalThis.CSS, scrollBy: globalThis.scrollBy };
467+ try {
468+ globalThis.CSS = { escape: (s) => s };
469+ globalThis.scrollBy = () => {};
470+ for (const path of ['a~.go', 'a*.go']) {
471+ const encoded = path.replace('*', '%2A');
472+ const row = { getBoundingClientRect: () => ({ top: 5 }) };
473+ const file = { id: 'f9', dataset: { file: path }, querySelector: (s) => {
474+ if (s === '.dt.uni') return file;
475+ assert.equal(s, 'a[href$="on=new:12:' + encoded + '#f9"]');
476+ return { closest: () => row };
477+ } };
478+ globalThis.document = { querySelectorAll: () => [file], querySelector: () => ({ dataset: { diff: 'unified' } }) };
479+ window.ermiteMR.restoreAnchor({ file: path, suffix: 'new:12:' + encoded, on: 'new:12:' + path, offset: 5 });
480+ }
481+ } finally { Object.assign(globalThis, saved); }
482+});
483+
484+for (const control of [
485+ { type: 'radio', checked: true, defaultChecked: false },
486+ { type: 'checkbox', checked: false, defaultChecked: true },
487+ { type: 'number', name: 'start_line', value: '10', defaultValue: '12' },
488+ { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] },
489+]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => {
490+ const saved = document;
491+ try {
492+ globalThis.document = { activeElement: null, querySelectorAll(sel) {
493+ assert.equal(sel, ':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)');
494+ return [control];
495+ } };
496+ assert.equal(window.ermiteMR.hasDraft(), true);
497+ } finally { globalThis.document = saved; }
498+});
499+
500+test('intercepted Cancel resets the entire comment form', () => {
501+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]]));
502+ const c = clock(), handlers = {};
503+ const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } };
504+ const radio = { type: 'radio', checked: true, defaultChecked: false };
505+ const line = { type: 'number', value: '2', defaultValue: '4' };
506+ let reset = 0, prevented = false;
507+ const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } };
508+ const cancel = { closest: () => form };
509+ globalThis.document = { activeElement: null,
510+ querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null,
511+ querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [],
512+ addEventListener: (type, fn) => { handlers[type] = fn; } };
513+ globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} };
514+ globalThis.addEventListener = () => {};
515+ globalThis.matchMedia = () => ({ matches: true, addEventListener() {} });
516+ try {
517+ window.ermiteMR.start();
518+ window.ermiteMR.headChanged();
519+ handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } });
520+ assert.equal(prevented, true);
521+ assert.equal(reset, 1);
522+ assert.equal(window.ermiteMR.hasDraft(), false);
523+ } finally { Object.assign(globalThis, saved); c.restore(); }
524+});
@@ -92,3 +92,433 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
92 globalThis.requestAnimationFrame=savedRAF;92 globalThis.requestAnimationFrame=savedRAF;
93 }93 }
94 });94 });
95+
96+function footerHarness(extra = {}) {
97+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
98+ const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
99+ setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },
100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; },
101+ scrollIntoView() { this.scrolled = (this.scrolled ?? 0) + 1; } });
102+ const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
103+ const panels = [node('mr-commits'), node('mr-checks-panel')];
104+ const footer = node('footer'); footer.querySelectorAll = () => panels;
105+ const nav = node('tabs'); nav.querySelectorAll = () => tabs;
106+ const listeners = {}, replaced = [];
107+ globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
108+ querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
109+ globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
110+ globalThis.addEventListener = (k, v) => { listeners[k] = v; };
111+ globalThis.history = { replaceState: (...args) => replaced.push(args) };
112+ const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
113+ const state = () => panels.map((p) => !p.hidden);
114+ return { tabs, panels, footer, listeners, replaced, restore, state };
115+}
116+
117+test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
118+ const h = footerHarness();
119+ try {
120+ window.ermiteMR.panels();
121+ assert.deepEqual(h.state(), [false, false], 'initially closed');
122+ assert.equal(h.tabs[0].attrs['aria-expanded'], 'false');
123+ assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone');
124+ h.tabs[1].events.click({ preventDefault() {} });
125+ assert.deepEqual(h.state(), [false, true]);
126+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
127+ assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]);
128+ location.hash = '#mr-checks-panel';
129+ h.tabs[1].events.click({ preventDefault() {} });
130+ assert.deepEqual(h.state(), [false, false], 'a second click closes');
131+ assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment');
132+ location.hash = '';
133+ h.tabs[1].events.click({ preventDefault() {} });
134+ h.tabs[0].events.click({ preventDefault() {} });
135+ assert.deepEqual(h.state(), [true, false], 'the other tab switches');
136+ h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} });
137+ assert.equal(h.tabs[1].focused, true, 'arrows move focus only');
138+ assert.deepEqual(h.state(), [true, false]);
139+ location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange();
140+ assert.deepEqual(h.state(), [false, true], 'hash opens checks');
141+ assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL');
142+ location.hash = '#mr-commits'; h.listeners.hashchange();
143+ assert.deepEqual(h.state(), [true, false]);
144+ assert.equal(C.tabIndex('Home', 1, 2), 0);
145+ assert.equal(C.tabIndex('End', 0, 2), 1);
146+ assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1);
147+ assert.equal(C.tabIndex('Escape', 0, 2), null);
148+ } finally { h.restore(); }
149+});
150+
151+test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
152+ const text0 = { v: '' }; let writes = 0;
153+ const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
154+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
155+ const h = footerHarness({ summary });
156+ let observed = null, callback = null;
157+ globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
158+ const nav = document.querySelector('[data-mr-tabs]');
159+ nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
160+ try {
161+ window.ermiteMR.panels();
162+ assert.equal(label.textContent, '1/2 failed', 'initial label sync');
163+ assert.equal(observed[0], summary.parentElement);
164+ assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
165+ const before = writes; callback(); callback();
166+ assert.equal(writes, before, 'an unchanged label is not written');
167+ summary.title = ''; callback();
168+ assert.equal(label.textContent, '1/2');
169+ summary.textContent = ' '; callback();
170+ assert.equal(label.textContent, 'no checks');
171+ const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } };
172+ h.listeners.click(click);
173+ assert.deepEqual(h.state(), [false, true]);
174+ h.listeners.click(click);
175+ assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open');
176+ } finally { h.restore(); }
177+});
178+
179+test('footer: panels(index) restores the open panel without touching the URL', () => {
180+ const h = footerHarness();
181+ try {
182+ location.hash = '#mr-commits';
183+ window.ermiteMR.panels(1);
184+ assert.deepEqual(h.state(), [false, true], 'a restore ignores the fragment');
185+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
186+ assert.deepEqual(h.replaced, []);
187+ assert.equal(h.panels[0].scrolled, undefined, 'a restore does not scroll');
188+ assert.equal(h.panels[1].scrolled, undefined);
189+ location.hash = '';
190+ window.ermiteMR.panels(-1);
191+ assert.deepEqual(h.state(), [false, false]);
192+ } finally { h.restore(); }
193+});
194+
195+test('footer: scripts take over the panels; a fragment opens and scrolls to its panel', () => {
196+ const h = footerHarness();
197+ try {
198+ location.hash = '#mr-checks';
199+ window.ermiteMR.panels();
200+ assert.equal(h.footer.attrs['data-panels'], '', 'the CSS fallback of the fragment stops');
201+ assert.deepEqual(h.state(), [false, true]);
202+ assert.equal(h.panels[1].scrolled, 1, 'the panel of the fragment is brought in');
203+ location.hash = '#mr-commits'; h.listeners.hashchange();
204+ assert.deepEqual(h.state(), [true, false]);
205+ assert.equal(h.panels[0].scrolled, 1);
206+ } finally { h.restore(); }
207+});
208+
209+test('touch range: first tap anchors, second tap opens the sorted range', () => {
210+ const base = 'https://forge.test/demo/merge-requests/1';
211+ const first = base + '?on=new:24:a.go#f1';
212+ const last = base + '?on=new:10:a.go#f1';
213+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
214+ const result = C.touchRange(first, last, base);
215+ assert.equal(result.start, null);
216+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
217+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
218+});
219+
220+test('touch range: a different file, side or request becomes a new anchor', () => {
221+ const base = 'https://forge.test/demo/merge-requests/1';
222+ const first = base + '?on=new:24:a.go#f1';
223+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
224+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
225+ }
226+});
227+
228+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
229+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
230+ assert.equal(C.mobileURL(href, 'split', false), null);
231+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
232+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
233+ const url = new URL(C.mobileURL(href, 'split', true));
234+ assert.equal(url.searchParams.get('view'), 'unified');
235+ assert.equal(url.searchParams.get('from'), '25');
236+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
237+ assert.equal(url.hash, '#f26');
238+});
239+
240+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
241+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
242+ const make = () => ({
243+ attrs: {}, events: {}, children: [], textContent: '',
244+ setAttribute(k, v) { this.attrs[k] = v; },
245+ removeAttribute(k) { delete this.attrs[k]; },
246+ addEventListener(k, fn) { this.events[k] = fn; },
247+ append(...nodes) { this.children.push(...nodes); },
248+ prepend(node) { this.children.unshift(node); },
249+ querySelector() { return null; }, querySelectorAll() { return []; },
250+ });
251+ const diffs = make();
252+ diffs.dataset = { diff: 'split' };
253+ const base = 'https://forge.test/demo/merge-requests/1';
254+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
255+ const a = line(20), b = line(10);
256+ let loaded = false;
257+ diffs.querySelector = () => loaded ? a : null;
258+ let assigned = null;
259+ globalThis.document = {
260+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
261+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
262+ };
263+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
264+ globalThis.addEventListener = () => {};
265+ const changes = {};
266+ const medias = {};
267+ globalThis.matchMedia = (q) => medias[q] ??= { matches: true, addEventListener(type, fn) { changes[q] = fn; } };
268+ const media = globalThis.matchMedia('(max-width: 720px)');
269+ const touchQuery = '(pointer: coarse), (hover: none)';
270+ const resize = () => changes['(max-width: 720px)']();
271+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
272+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
273+ const click = (link, modifiers = {}) => {
274+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
275+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
276+ diffs.events.click(e);
277+ return e;
278+ };
279+ try {
280+ window.ermiteMR.start();
281+ const bar = diffs.children[0];
282+ const [button, status] = bar.children;
283+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
284+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
285+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
286+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
287+ assert.equal(diffs.dataset.mobile, 'true');
288+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
289+ media.matches = false;
290+ resize();
291+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
292+ assert.equal(diffs.dataset.diff, 'split');
293+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
294+ button.events.click();
295+ assert.equal(button.attrs['aria-pressed'], 'true');
296+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
297+ const event = click(a);
298+ assert.equal(event.prevented, true);
299+ assert.equal(event.stopped, true);
300+ assert.ok('data-range-start' in a.attrs);
301+ assert.match(status.textContent, /20/);
302+ click(b);
303+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
304+ assert.equal('data-range-start' in a.attrs, false);
305+ click(a);
306+ button.events.click();
307+ assert.equal(button.attrs['aria-pressed'], 'false');
308+ assert.equal(status.textContent, '');
309+ assert.equal('data-range-start' in a.attrs, false);
310+ assert.equal(click(b).prevented, false);
311+ // The touch media query stops matching: range mode is switched off.
312+ button.events.click();
313+ click(a);
314+ assert.equal(button.attrs['aria-pressed'], 'true');
315+ assert.ok('data-range-start' in a.attrs);
316+ medias[touchQuery].matches = false;
317+ changes[touchQuery]();
318+ assert.equal(button.attrs['aria-pressed'], 'false');
319+ assert.equal(status.textContent, '');
320+ assert.equal('data-range-start' in a.attrs, false);
321+ assert.equal(click(b).prevented, false, 'clicks pass through once touch stops matching');
322+ } finally {
323+ for (const [k, v] of Object.entries(saved)) {
324+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
325+ }
326+ }
327+});
328+
329+// Deterministic timers: no sleeps, and each test restores every mocked global.
330+function clock() {
331+ const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
332+ const tasks = new Map(); let n = 0, now = 0;
333+ globalThis.setTimeout = (fn, delay = 0) => { tasks.set(++n, { fn, at: now + delay }); return n; };
334+ globalThis.clearTimeout = (id) => tasks.delete(id);
335+ return { async tick(ms = 300) { now += ms; const batch = [...tasks].filter(([, t]) => t.at <= now); for (const [id] of batch) tasks.delete(id); for (const [, t] of batch) await t.fn(); },
336+ size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
337+}
338+
339+test('head refresh defers a real textarea draft, resumes after emptying and blurring', async () => {
340+ const saved = globalThis.document, c = clock();
341+ const text = { value: 'unsent', defaultValue: '' };
342+ let fetched = 0, applied = 0; const notices = [];
343+ globalThis.document = { activeElement: text, querySelectorAll: () => [text] };
344+ const r = window.ermiteMR.refresher({ blocked: window.ermiteMR.hasDraft,
345+ fetchPage: async () => { fetched++; return 'page'; }, apply: () => applied++,
346+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected fallback') });
347+ try {
348+ r.request(); await c.tick();
349+ assert.equal(fetched, 0); assert.deepEqual(notices, [true]);
350+ text.value = ''; r.resume(); await c.tick();
351+ assert.equal(fetched, 0, 'focus still protects an empty draft');
352+ document.activeElement = null; r.resume(); await c.tick();
353+ assert.equal(fetched, 1); assert.equal(applied, 1); assert.equal(r.pending(), false);
354+ } finally { globalThis.document = saved; c.restore(); }
355+});
356+
357+test('head refresh debounces bursts, serializes requests, and reruns after an in-flight push', async () => {
358+ const c = clock(); let fetched = 0, applied = 0, resolve;
359+ const r = window.ermiteMR.refresher({ blocked: () => false,
360+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); },
361+ apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
362+ try {
363+ r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
364+ await c.tick(299); assert.equal(fetched, 0);
365+ await c.tick(0); assert.equal(fetched, 0);
366+ const first = c.tick(1); assert.equal(fetched, 1);
367+ r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');
368+ resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);
369+ const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
370+ assert.equal(applied, 2); assert.equal(c.size(), 0);
371+ } finally { c.restore(); }
372+});
373+
374+test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => {
375+ const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = [];
376+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
377+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; },
378+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
379+ try {
380+ r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
381+ assert.equal(applied, 0); assert.deepEqual(notices, [true]);
382+ r.request(true); const manual = c.tick();
383+ assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft');
384+ await manual; assert.equal(applied, 0);
385+ blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
386+ assert.equal(applied, 1); assert.equal(notices.at(-1), false);
387+ } finally { c.restore(); }
388+});
389+
390+test('fetch failures and non-MR responses take the fallback, never apply', async () => {
391+ const saved = { fetch: globalThis.fetch, location: globalThis.location, DOMParser: globalThis.DOMParser };
392+ const c = clock(); let fallback = 0;
393+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1?view=split&from=25' };
394+ const r = window.ermiteMR.refresher({ blocked: () => false, fetchPage: window.ermiteMR.fetchPage,
395+ apply: () => assert.fail('must not swap'), notice() {}, failed: () => fallback++ });
396+ try {
397+ for (const result of ['network', { ok: false }, { ok: true, redirected: true }, { ok: true, text: async () => '<html>login</html>' }]) {
398+ globalThis.fetch = async (url, opts) => {
399+ assert.equal(url, location.href); assert.equal(opts.credentials, 'same-origin');
400+ if (result === 'network') throw new Error('offline'); return result;
401+ };
402+ globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null }; } };
403+ r.request(); await c.tick();
404+ }
405+ assert.equal(fallback, 4);
406+ } finally {
407+ for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
408+ c.restore();
409+ }
410+});
411+
412+test('viewport probe finds a stable path/side/line and restores its offset without line scans', () => {
413+ const names = ['document', 'location', 'innerHeight', 'innerWidth', 'scrollY', 'scrollBy', 'scrollTo', 'CSS', 'matchMedia'];
414+ const saved = Object.fromEntries(names.map((k) => [k, globalThis[k]]));
415+ const rect = (top, bottom = top + 20) => ({ top, bottom, left: 0, width: 900 });
416+ let lineTop = 160, headerTop = -200, found = true; const calls = [];
417+ const header = { getBoundingClientRect: () => rect(headerTop) };
418+ const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };
419+ const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row, getAttribute: () => link.href };
420+ const file = { getBoundingClientRect: () => rect(headerTop), id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
421+ const hit = { closest: (s) => s === 'tr' ? row : file };
422+ const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
423+ globalThis.document = {
424+ querySelector: (s) => s === '#diffs' ? diffs : s === '.hdr' ? { getBoundingClientRect: () => rect(0, 100) } : null,
425+ querySelectorAll: (s) => { assert.equal(s, '#diffs details.dfile', 'only files, never every line'); return found === 'gone' ? [] : [file]; },
426+ elementFromPoint: () => hit, documentElement: { scrollHeight: 10000 },
427+ };
428+ Object.assign(globalThis, { location: { href: link.href }, innerHeight: 800, innerWidth: 1200, scrollY: 2500,
429+ scrollBy: (arg) => calls.push(['by', arg]), scrollTo: (arg) => calls.push(['to', arg]), CSS: { escape: (s) => s }, matchMedia: () => ({ matches: false }) });
430+ try {
431+ const a = window.ermiteMR.scrollAnchor();
432+ assert.equal(a.file, 'a.go'); assert.equal(a.on, 'new:42:a.go'); assert.equal(a.offset, 160);
433+ file.id = 'f3'; lineTop = 260; window.ermiteMR.restoreAnchor(a);
434+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
435+ found = false; headerTop = -100; window.ermiteMR.restoreAnchor(a);
436+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
437+ found = 'gone'; window.ermiteMR.restoreAnchor({ ...a, y: 20000 });
438+ assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);
439+ } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }
440+});
441+
442+test('empty edited default and focused cform controls protect drafts', () => {
443+ const saved = document;
444+ try {
445+ globalThis.document = { querySelectorAll: () => [{ value: '', defaultValue: 'existing' }], activeElement: null };
446+ assert.equal(window.ermiteMR.hasDraft(), true);
447+ document.querySelectorAll = () => [];
448+ document.activeElement = { closest: (s) => s === '.cform' ? {} : null };
449+ assert.equal(window.ermiteMR.hasDraft(), true);
450+ } finally { globalThis.document = saved; }
451+});
452+
453+test('failure during a draft keeps pending and resumes after the draft ends', async () => {
454+ const c = clock(); let blocked = false, reject, applied = 0, count = 0;
455+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
456+ fetchPage: () => ++count === 1 ? new Promise((_, no) => { reject = no; }) : Promise.resolve('page'),
457+ apply: () => applied++, notice() {}, failed: () => assert.fail('draft must defer fallback') });
458+ try {
459+ r.request(); const first = c.tick(); blocked = true; reject(new Error('offline')); await first;
460+ assert.equal(r.pending(), true); assert.equal(applied, 0);
461+ blocked = false; r.resume(); await c.tick(); assert.equal(applied, 1);
462+ } finally { c.restore(); }
463+});
464+
465+test('anchor restoration retains the server encoding of tilde and star paths', () => {
466+ const saved = { document, CSS: globalThis.CSS, scrollBy: globalThis.scrollBy };
467+ try {
468+ globalThis.CSS = { escape: (s) => s };
469+ globalThis.scrollBy = () => {};
470+ for (const path of ['a~.go', 'a*.go']) {
471+ const encoded = path.replace('*', '%2A');
472+ const row = { getBoundingClientRect: () => ({ top: 5 }) };
473+ const file = { id: 'f9', dataset: { file: path }, querySelector: (s) => {
474+ if (s === '.dt.uni') return file;
475+ assert.equal(s, 'a[href$="on=new:12:' + encoded + '#f9"]');
476+ return { closest: () => row };
477+ } };
478+ globalThis.document = { querySelectorAll: () => [file], querySelector: () => ({ dataset: { diff: 'unified' } }) };
479+ window.ermiteMR.restoreAnchor({ file: path, suffix: 'new:12:' + encoded, on: 'new:12:' + path, offset: 5 });
480+ }
481+ } finally { Object.assign(globalThis, saved); }
482+});
483+
484+for (const control of [
485+ { type: 'radio', checked: true, defaultChecked: false },
486+ { type: 'checkbox', checked: false, defaultChecked: true },
487+ { type: 'number', name: 'start_line', value: '10', defaultValue: '12' },
488+ { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] },
489+]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => {
490+ const saved = document;
491+ try {
492+ globalThis.document = { activeElement: null, querySelectorAll(sel) {
493+ assert.equal(sel, ':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)');
494+ return [control];
495+ } };
496+ assert.equal(window.ermiteMR.hasDraft(), true);
497+ } finally { globalThis.document = saved; }
498+});
499+
500+test('intercepted Cancel resets the entire comment form', () => {
501+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]]));
502+ const c = clock(), handlers = {};
503+ const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } };
504+ const radio = { type: 'radio', checked: true, defaultChecked: false };
505+ const line = { type: 'number', value: '2', defaultValue: '4' };
506+ let reset = 0, prevented = false;
507+ const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } };
508+ const cancel = { closest: () => form };
509+ globalThis.document = { activeElement: null,
510+ querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null,
511+ querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [],
512+ addEventListener: (type, fn) => { handlers[type] = fn; } };
513+ globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} };
514+ globalThis.addEventListener = () => {};
515+ globalThis.matchMedia = () => ({ matches: true, addEventListener() {} });
516+ try {
517+ window.ermiteMR.start();
518+ window.ermiteMR.headChanged();
519+ handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } });
520+ assert.equal(prevented, true);
521+ assert.equal(reset, 1);
522+ assert.equal(window.ermiteMR.hasDraft(), false);
523+ } finally { Object.assign(globalThis, saved); c.restore(); }
524+});
M internal/adapters/web/mr.go +3 -0
@@ -291,6 +291,7 @@ type MRPage struct {
291291
292292 Commits []MRCommit
293293 CommitsNote string
294+ CommitCount string
294295 Modes []string
295296 ChecksBlock bool
296297 // Checks are the CI checks of the head of the request, ChecksSum their
@@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M
734735 })
735736 }
736737 lastRead := res.LastReadID.String()
738+ p.CommitCount = itoa(len(p.Commits))
737739 if res.CommitsTruncated {
740+ p.CommitCount += "+"
738741 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
739742 }
740743
@@ -291,6 +291,7 @@ type MRPage struct {
291 291
292 Commits []MRCommit292 Commits []MRCommit
293 CommitsNote string293 CommitsNote string
294+ CommitCount string
294 Modes []string295 Modes []string
295 ChecksBlock bool296 ChecksBlock bool
296 // Checks are the CI checks of the head of the request, ChecksSum their297 // Checks are the CI checks of the head of the request, ChecksSum their
@@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M
734 })735 })
735 }736 }
736 lastRead := res.LastReadID.String()737 lastRead := res.LastReadID.String()
738+ p.CommitCount = itoa(len(p.Commits))
737 if res.CommitsTruncated {739 if res.CommitsTruncated {
740+ p.CommitCount += "+"
738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."741 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
739 }742 }
740 743
M internal/adapters/web/mr.templ +141 -112
@@ -13,6 +13,7 @@ templ MRList(l Layout, p MRListPage) {
1313 @Shell(l, mrListRail(p)) {
1414 if p.Live {
1515 @LiveSlot("mr-notice")
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
1617 }
1718 <div class="filter">
1819 <label for="mr-filter" class="sr">filter</label>
@@ -108,8 +109,9 @@ templ mrListRail(p MRListPage) {
108109 }
109110
110111 // MergeRequest is a merge request: its facts, its description, the diff with
111-// the notes under their lines, the general comments; and in the rail the merge
112-// form, the checks, the settings, the commits and the files.
112+// the notes under their lines, the general comments. The merge and More menus
113+// sit in the toolbar, the commits and checks in the card footer, and the rail
114+// holds the files and verdicts.
113115 templ MergeRequest(l Layout, p MRPage) {
114116 @Shell(l, mrRail(p)) {
115117 <section class="mr-card">
@@ -147,6 +149,7 @@ templ MergeRequest(l Layout, p MRPage) {
147149 @templ.Raw(string(p.Desc))
148150 </article>
149151 }
152+ @mrFooter(p)
150153 </section>
151154 if p.Deleting != "" {
152155 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
@@ -155,35 +158,40 @@ templ MergeRequest(l Layout, p MRPage) {
155158 <p class="d-err" role="alert">{ p.MergeError }</p>
156159 }
157160 @LiveSlot("mr-notice")
158- if p.Since != nil {
159- <nav class="since" aria-label="Diff range">
160- <a
161- href={ templ.SafeURL(p.Since.AllHref) }
162- if !p.Since.Current {
163- aria-current="page"
164- }
165- >all changes <span class="mute">vs { p.Target }</span></a>
166- <a
167- href={ templ.SafeURL(p.Since.SinceHref) }
168- if p.Since.Current {
169- aria-current="page"
170- }
171- >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
172- </nav>
173- }
174- if p.SinceNote != "" {
175- <p class="notice">{ p.SinceNote }</p>
176- }
161+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
162+ <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
163+ if p.Since != nil {
164+ <nav class="since" aria-label="Diff range">
165+ <a
166+ href={ templ.SafeURL(p.Since.AllHref) }
167+ if !p.Since.Current {
168+ aria-current="page"
169+ }
170+ >all changes <span class="mute">vs { p.Target }</span></a>
171+ <a
172+ href={ templ.SafeURL(p.Since.SinceHref) }
173+ if p.Since.Current {
174+ aria-current="page"
175+ }
176+ >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
177+ </nav>
178+ }
179+ if p.SinceNote != "" {
180+ <p class="notice">{ p.SinceNote }</p>
181+ }
182+ </div>
177183 @mrBar(p)
178- if p.Invisible {
179- <p class="notice">
180- this diff holds invisible characters (direction controls, zero-width characters). They are shown as
181- <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
182- </p>
183- }
184- if p.Note != "" {
185- <p class="notice">{ p.Note }</p>
186- }
184+ <div id="mr-diff-notices" hidden?={ !p.Invisible && p.Note == "" }>
185+ if p.Invisible {
186+ <p class="notice">
187+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
188+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
189+ </p>
190+ }
191+ if p.Note != "" {
192+ <p class="notice">{ p.Note }</p>
193+ }
194+ </div>
187195 <div
188196 id="diffs"
189197 data-mr
@@ -206,6 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
206214 <p class="note">no changed files.</p>
207215 }
208216 </div>
217+ <div id="mr-comments">
209218 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
210219 for _, c := range p.Loose {
211220 @commentView(c, "")
@@ -219,6 +228,7 @@ templ MergeRequest(l Layout, p MRPage) {
219228 if p.GeneralForm != nil {
220229 @commentForm(*p.GeneralForm, "")
221230 }
231+ </div>
222232 }
223233 }
224234
@@ -366,6 +376,62 @@ templ commentForm(f CommentForm, suffix string) {
366376 </form>
367377 }
368378
379+templ mrFooter(p MRPage) {
380+ <footer class="mr-footer">
381+ <nav data-mr-tabs aria-label="Branch details">
382+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
383+ <a id="mr-tab-checks" href="#mr-checks">
384+ { "Checks " }
385+ <span data-checks-label>
386+ if p.ChecksSum.Total > 0 {
387+ { p.ChecksSum.Title }
388+ } else {
389+ no checks
390+ }
391+ </span>
392+ </a>
393+ </nav>
394+ <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
395+ <table class="mr-commits">
396+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397+ <tbody>
398+ for _, c := range p.Commits {
399+ <tr data-new?={ c.New }>
400+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
401+ <td>{ c.Subject }</td>
402+ <td>{ c.Author }</td>
403+ <td>
404+ if c.WhenAt != "" {
405+ <time datetime={ c.WhenAt }>{ c.When }</time>
406+ }
407+ </td>
408+ <td>
409+ if c.Sig != nil {
410+ @signaturePill(c.Sig)
411+ }
412+ </td>
413+ <td>
414+ if c.New {
415+ <span>new</span>
416+ }
417+ </td>
418+ </tr>
419+ }
420+ </tbody>
421+ </table>
422+ if len(p.Commits) == 0 {
423+ <p class="dim">no commit to merge.</p>
424+ }
425+ if p.CommitsNote != "" {
426+ <p class="note">{ p.CommitsNote }</p>
427+ }
428+ </div>
429+ <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
430+ @MRChecks(p.Checks)
431+ </div>
432+ </footer>
433+}
434+
369435 templ mrRail(p MRPage) {
370436 <section aria-labelledby="r-files" class="r-files">
371437 <h2 id="r-files">
@@ -391,51 +457,6 @@ templ mrRail(p MRPage) {
391457 </ul>
392458 </section>
393459 }
394- @MRChecks(p.Checks)
395- <section aria-labelledby="r-commits">
396- <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397- <ul class="pcommits">
398- for _, c := range p.Commits {
399- <li data-new?={ c.New }>
400- <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401- <span class="s">{ c.Subject }</span>
402- if c.Sig != nil {
403- @signaturePill(c.Sig)
404- }
405- <span class="mute">
406- { c.Author }
407- if c.WhenAt != "" {
408- { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409- }
410- if c.New {
411- { " · new" }
412- }
413- </span>
414- </li>
415- }
416- </ul>
417- if len(p.Commits) == 0 {
418- <p class="dim">no commit to merge.</p>
419- }
420- if p.CommitsNote != "" {
421- <p class="note">{ p.CommitsNote }</p>
422- }
423- </section>
424- <section aria-labelledby="r-settings">
425- <h2 id="r-settings">Settings</h2>
426- <dl class="kv">
427- <dt>modes</dt>
428- <dd>{ strings.Join(p.Modes, ", ") }</dd>
429- <dt>checks</dt>
430- <dd>
431- if p.ChecksBlock {
432- failing ones block the merge
433- } else {
434- do not block the merge
435- }
436- </dd>
437- </dl>
438- </section>
439460 }
440461
441462 // mrBar is the bar above the diff, which follows the review: the totals, the
@@ -450,8 +471,8 @@ templ mrBar(p MRPage) {
450471 @MRChecksSum(p.ChecksSum)
451472 if p.Box != nil {
452473 @mergeMenu(p)
453- @moreMenu(p)
454474 }
475+ @moreMenu(p)
455476 </div>
456477 }
457478
@@ -474,18 +495,9 @@ templ mergeMenu(p MRPage) {
474495 <kbd aria-hidden="true">M</kbd>
475496 </summary>
476497 <div class="menu-pop" role="group" aria-label="Merge">
477- <dl class="kv">
478- <dt>head</dt>
479- <dd>
480- if p.Box.HeadGone {
481- gone: the source branch was deleted
482- } else {
483- { p.HeadShort }
484- }
485- </dd>
486- <dt>into</dt>
487- <dd>{ p.Target }</dd>
488- </dl>
498+ if p.Box.HeadGone {
499+ <p class="dim">the source branch was deleted</p>
500+ }
489501 if p.Box.Error != "" {
490502 <p class="d-err" role="alert">{ p.Box.Error }</p>
491503 }
@@ -519,15 +531,6 @@ templ mergeMenu(p MRPage) {
519531 </label>
520532 }
521533 </fieldset>
522- if p.Box.Signed {
523- <p class="d-note" id="mr-signing">
524- squash and merge commits are signed with ermite's own key (generated on first start).
525- if p.Box.Fingerprint != "" {
526- fingerprint <code>{ p.Box.Fingerprint }</code>
527- }
528- <code>ermite signing-key show</code> prints it.
529- </p>
530- }
531534 if p.Box.CanDeleteBranch {
532535 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
533536 }
@@ -589,26 +592,52 @@ templ MRMergeabilityFragment(v MergeabilityView) {
589592 @mrMergeabilitySlot(v)
590593 }
591594
592-// moreMenu is what else the owner does with an open request: mark the head
593-// read, close it, merge it from a terminal.
595+// moreMenu shows read-only settings to every viewer, and keeps owner actions
596+// behind the same merge-box permission as before.
594597 templ moreMenu(p MRPage) {
595598 <details class="menu omenu">
596599 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597600 <div class="menu-pop" role="group" aria-label="More actions">
598- if p.Box.CanMarkRead {
599- <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
600- <input type="hidden" name="head_id" value={ p.Box.Head }/>
601- <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
601+ <section class="merge-settings">
602+ <h2>Merge settings</h2>
603+ <dl class="kv">
604+ <dt>modes</dt>
605+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
606+ <dt>checks</dt>
607+ <dd>
608+ if p.ChecksBlock {
609+ failing ones block the merge
610+ } else {
611+ do not block the merge
612+ }
613+ </dd>
614+ </dl>
615+ </section>
616+ if p.Box != nil {
617+ if p.Box.Signed {
618+ <p class="d-note" id="mr-signing">
619+ squash and merge commits are signed with ermite's own key (generated on first start).
620+ if p.Box.Fingerprint != "" {
621+ fingerprint <code>{ p.Box.Fingerprint }</code>
622+ }
623+ <code>ermite signing-key show</code> prints it.
624+ </p>
625+ }
626+ if p.Box.CanMarkRead {
627+ <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
628+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
629+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
630+ </form>
631+ }
632+ <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
633+ <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
602634 </form>
603- }
604- <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
605- <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
606- </form>
607- if !p.Box.HeadGone && p.Box.ReadyAction == "" {
608- <details class="d-or">
609- <summary class="mute">or from a terminal</summary>
610- @CloneBlock(p.Box.CLI)
611- </details>
635+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
636+ <details class="d-or">
637+ <summary class="mute">or from a terminal</summary>
638+ @CloneBlock(p.Box.CLI)
639+ </details>
640+ }
612641 }
613642 </div>
614643 </details>
@@ -13,6 +13,7 @@ templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {13 @Shell(l, mrListRail(p)) {
14 if p.Live {14 if p.Live {
15 @LiveSlot("mr-notice")15 @LiveSlot("mr-notice")
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
16 }17 }
17 <div class="filter">18 <div class="filter">
18 <label for="mr-filter" class="sr">filter</label>19 <label for="mr-filter" class="sr">filter</label>
@@ -108,8 +109,9 @@ templ mrListRail(p MRListPage) {
108 }109 }
109 110
110 // MergeRequest is a merge request: its facts, its description, the diff with111 // MergeRequest is a merge request: its facts, its description, the diff with
111-// the notes under their lines, the general comments; and in the rail the merge112+// the notes under their lines, the general comments. The merge and More menus
112-// form, the checks, the settings, the commits and the files.113+// sit in the toolbar, the commits and checks in the card footer, and the rail
114+// holds the files and verdicts.
113 templ MergeRequest(l Layout, p MRPage) {115 templ MergeRequest(l Layout, p MRPage) {
114 @Shell(l, mrRail(p)) {116 @Shell(l, mrRail(p)) {
115 <section class="mr-card">117 <section class="mr-card">
@@ -147,6 +149,7 @@ templ MergeRequest(l Layout, p MRPage) {
147 @templ.Raw(string(p.Desc))149 @templ.Raw(string(p.Desc))
148 </article>150 </article>
149 }151 }
152+ @mrFooter(p)
150 </section>153 </section>
151 if p.Deleting != "" {154 if p.Deleting != "" {
152 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>155 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
@@ -155,35 +158,40 @@ templ MergeRequest(l Layout, p MRPage) {
155 <p class="d-err" role="alert">{ p.MergeError }</p>158 <p class="d-err" role="alert">{ p.MergeError }</p>
156 }159 }
157 @LiveSlot("mr-notice")160 @LiveSlot("mr-notice")
158- if p.Since != nil {161+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
159- <nav class="since" aria-label="Diff range">162+ <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
160- <a163+ if p.Since != nil {
161- href={ templ.SafeURL(p.Since.AllHref) }164+ <nav class="since" aria-label="Diff range">
162- if !p.Since.Current {165+ <a
163- aria-current="page"166+ href={ templ.SafeURL(p.Since.AllHref) }
164- }167+ if !p.Since.Current {
165- >all changes <span class="mute">vs { p.Target }</span></a>168+ aria-current="page"
166- <a169+ }
167- href={ templ.SafeURL(p.Since.SinceHref) }170+ >all changes <span class="mute">vs { p.Target }</span></a>
168- if p.Since.Current {171+ <a
169- aria-current="page"172+ href={ templ.SafeURL(p.Since.SinceHref) }
170- }173+ if p.Since.Current {
171- >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>174+ aria-current="page"
172- </nav>175+ }
173- }176+ >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
174- if p.SinceNote != "" {177+ </nav>
175- <p class="notice">{ p.SinceNote }</p>178+ }
176- }179+ if p.SinceNote != "" {
180+ <p class="notice">{ p.SinceNote }</p>
181+ }
182+ </div>
177 @mrBar(p)183 @mrBar(p)
178- if p.Invisible {184+ <div id="mr-diff-notices" hidden?={ !p.Invisible && p.Note == "" }>
179- <p class="notice">185+ if p.Invisible {
180- this diff holds invisible characters (direction controls, zero-width characters). They are shown as186+ <p class="notice">
181- <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.187+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
182- </p>188+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
183- }189+ </p>
184- if p.Note != "" {190+ }
185- <p class="notice">{ p.Note }</p>191+ if p.Note != "" {
186- }192+ <p class="notice">{ p.Note }</p>
193+ }
194+ </div>
187 <div195 <div
188 id="diffs"196 id="diffs"
189 data-mr197 data-mr
@@ -206,6 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
206 <p class="note">no changed files.</p>214 <p class="note">no changed files.</p>
207 }215 }
208 </div>216 </div>
217+ <div id="mr-comments">
209 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>218 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
210 for _, c := range p.Loose {219 for _, c := range p.Loose {
211 @commentView(c, "")220 @commentView(c, "")
@@ -219,6 +228,7 @@ templ MergeRequest(l Layout, p MRPage) {
219 if p.GeneralForm != nil {228 if p.GeneralForm != nil {
220 @commentForm(*p.GeneralForm, "")229 @commentForm(*p.GeneralForm, "")
221 }230 }
231+ </div>
222 }232 }
223 }233 }
224 234
@@ -366,6 +376,62 @@ templ commentForm(f CommentForm, suffix string) {
366 </form>376 </form>
367 }377 }
368 378
379+templ mrFooter(p MRPage) {
380+ <footer class="mr-footer">
381+ <nav data-mr-tabs aria-label="Branch details">
382+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
383+ <a id="mr-tab-checks" href="#mr-checks">
384+ { "Checks " }
385+ <span data-checks-label>
386+ if p.ChecksSum.Total > 0 {
387+ { p.ChecksSum.Title }
388+ } else {
389+ no checks
390+ }
391+ </span>
392+ </a>
393+ </nav>
394+ <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
395+ <table class="mr-commits">
396+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397+ <tbody>
398+ for _, c := range p.Commits {
399+ <tr data-new?={ c.New }>
400+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
401+ <td>{ c.Subject }</td>
402+ <td>{ c.Author }</td>
403+ <td>
404+ if c.WhenAt != "" {
405+ <time datetime={ c.WhenAt }>{ c.When }</time>
406+ }
407+ </td>
408+ <td>
409+ if c.Sig != nil {
410+ @signaturePill(c.Sig)
411+ }
412+ </td>
413+ <td>
414+ if c.New {
415+ <span>new</span>
416+ }
417+ </td>
418+ </tr>
419+ }
420+ </tbody>
421+ </table>
422+ if len(p.Commits) == 0 {
423+ <p class="dim">no commit to merge.</p>
424+ }
425+ if p.CommitsNote != "" {
426+ <p class="note">{ p.CommitsNote }</p>
427+ }
428+ </div>
429+ <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
430+ @MRChecks(p.Checks)
431+ </div>
432+ </footer>
433+}
434+
369 templ mrRail(p MRPage) {435 templ mrRail(p MRPage) {
370 <section aria-labelledby="r-files" class="r-files">436 <section aria-labelledby="r-files" class="r-files">
371 <h2 id="r-files">437 <h2 id="r-files">
@@ -391,51 +457,6 @@ templ mrRail(p MRPage) {
391 </ul>457 </ul>
392 </section>458 </section>
393 }459 }
394- @MRChecks(p.Checks)
395- <section aria-labelledby="r-commits">
396- <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397- <ul class="pcommits">
398- for _, c := range p.Commits {
399- <li data-new?={ c.New }>
400- <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401- <span class="s">{ c.Subject }</span>
402- if c.Sig != nil {
403- @signaturePill(c.Sig)
404- }
405- <span class="mute">
406- { c.Author }
407- if c.WhenAt != "" {
408- { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409- }
410- if c.New {
411- { " · new" }
412- }
413- </span>
414- </li>
415- }
416- </ul>
417- if len(p.Commits) == 0 {
418- <p class="dim">no commit to merge.</p>
419- }
420- if p.CommitsNote != "" {
421- <p class="note">{ p.CommitsNote }</p>
422- }
423- </section>
424- <section aria-labelledby="r-settings">
425- <h2 id="r-settings">Settings</h2>
426- <dl class="kv">
427- <dt>modes</dt>
428- <dd>{ strings.Join(p.Modes, ", ") }</dd>
429- <dt>checks</dt>
430- <dd>
431- if p.ChecksBlock {
432- failing ones block the merge
433- } else {
434- do not block the merge
435- }
436- </dd>
437- </dl>
438- </section>
439 }460 }
440 461
441 // mrBar is the bar above the diff, which follows the review: the totals, the462 // mrBar is the bar above the diff, which follows the review: the totals, the
@@ -450,8 +471,8 @@ templ mrBar(p MRPage) {
450 @MRChecksSum(p.ChecksSum)471 @MRChecksSum(p.ChecksSum)
451 if p.Box != nil {472 if p.Box != nil {
452 @mergeMenu(p)473 @mergeMenu(p)
453- @moreMenu(p)
454 }474 }
475+ @moreMenu(p)
455 </div>476 </div>
456 }477 }
457 478
@@ -474,18 +495,9 @@ templ mergeMenu(p MRPage) {
474 <kbd aria-hidden="true">M</kbd>495 <kbd aria-hidden="true">M</kbd>
475 </summary>496 </summary>
476 <div class="menu-pop" role="group" aria-label="Merge">497 <div class="menu-pop" role="group" aria-label="Merge">
477- <dl class="kv">498+ if p.Box.HeadGone {
478- <dt>head</dt>499+ <p class="dim">the source branch was deleted</p>
479- <dd>500+ }
480- if p.Box.HeadGone {
481- gone: the source branch was deleted
482- } else {
483- { p.HeadShort }
484- }
485- </dd>
486- <dt>into</dt>
487- <dd>{ p.Target }</dd>
488- </dl>
489 if p.Box.Error != "" {501 if p.Box.Error != "" {
490 <p class="d-err" role="alert">{ p.Box.Error }</p>502 <p class="d-err" role="alert">{ p.Box.Error }</p>
491 }503 }
@@ -519,15 +531,6 @@ templ mergeMenu(p MRPage) {
519 </label>531 </label>
520 }532 }
521 </fieldset>533 </fieldset>
522- if p.Box.Signed {
523- <p class="d-note" id="mr-signing">
524- squash and merge commits are signed with ermite's own key (generated on first start).
525- if p.Box.Fingerprint != "" {
526- fingerprint <code>{ p.Box.Fingerprint }</code>
527- }
528- <code>ermite signing-key show</code> prints it.
529- </p>
530- }
531 if p.Box.CanDeleteBranch {534 if p.Box.CanDeleteBranch {
532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>535 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
533 }536 }
@@ -589,26 +592,52 @@ templ MRMergeabilityFragment(v MergeabilityView) {
589 @mrMergeabilitySlot(v)592 @mrMergeabilitySlot(v)
590 }593 }
591 594
592-// moreMenu is what else the owner does with an open request: mark the head595+// moreMenu shows read-only settings to every viewer, and keeps owner actions
593-// read, close it, merge it from a terminal.596+// behind the same merge-box permission as before.
594 templ moreMenu(p MRPage) {597 templ moreMenu(p MRPage) {
595 <details class="menu omenu">598 <details class="menu omenu">
596 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>599 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597 <div class="menu-pop" role="group" aria-label="More actions">600 <div class="menu-pop" role="group" aria-label="More actions">
598- if p.Box.CanMarkRead {601+ <section class="merge-settings">
599- <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>602+ <h2>Merge settings</h2>
600- <input type="hidden" name="head_id" value={ p.Box.Head }/>603+ <dl class="kv">
601- <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>604+ <dt>modes</dt>
605+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
606+ <dt>checks</dt>
607+ <dd>
608+ if p.ChecksBlock {
609+ failing ones block the merge
610+ } else {
611+ do not block the merge
612+ }
613+ </dd>
614+ </dl>
615+ </section>
616+ if p.Box != nil {
617+ if p.Box.Signed {
618+ <p class="d-note" id="mr-signing">
619+ squash and merge commits are signed with ermite's own key (generated on first start).
620+ if p.Box.Fingerprint != "" {
621+ fingerprint <code>{ p.Box.Fingerprint }</code>
622+ }
623+ <code>ermite signing-key show</code> prints it.
624+ </p>
625+ }
626+ if p.Box.CanMarkRead {
627+ <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
628+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
629+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
630+ </form>
631+ }
632+ <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
633+ <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
602 </form>634 </form>
603- }635+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
604- <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>636+ <details class="d-or">
605- <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>637+ <summary class="mute">or from a terminal</summary>
606- </form>638+ @CloneBlock(p.Box.CLI)
607- if !p.Box.HeadGone && p.Box.ReadyAction == "" {639+ </details>
608- <details class="d-or">640+ }
609- <summary class="mute">or from a terminal</summary>
610- @CloneBlock(p.Box.CLI)
611- </details>
612 }641 }
613 </div>642 </div>
614 </details>643 </details>
M internal/adapters/web/mr_templ.go +938 -894 generated file: collapsed
M internal/adapters/web/mr_test.go +67 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "context"
7+ "time"
78 "testing"
89
910 "github.com/stretchr/testify/assert"
@@ -128,3 +129,69 @@ func TestMergeabilityLiveEvents(t *testing.T) {
128129 require.NoError(t, err)
129130 assert.Empty(t, fs)
130131 }
132+
133+func TestMRPanelsAndMenus(t *testing.T) {
134+ p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135+ ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136+ footer := renderToString(t, mrFooter(p))
137+ assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138+ assert.NotContains(t, footer, `<details`)
139+ assert.NotContains(t, footer, `<summary`)
140+ // No hidden attribute: the CSS shows the panel of the URL fragment until
141+ // mr.js takes over, so the tabs work as anchors without scripts.
142+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144+ assert.NotContains(t, footer, `data-mr-panel hidden`)
145+ css, err := staticFS.ReadFile("static/css/60-mr.css")
146+ require.NoError(t, err)
147+ assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
148+ assert.Contains(t, footer, `data-mr-tabs`)
149+ assert.Contains(t, footer, `id="mr-commits"`)
150+ assert.Contains(t, footer, `id="mr-checks"`)
151+ assert.Contains(t, footer, `<table class="mr-commits">`)
152+ assert.Contains(t, footer, "500+")
153+ assert.Contains(t, footer, "more commits")
154+ assert.Contains(t, footer, "1/2 failed")
155+ rail := renderToString(t, mrRail(p))
156+ assert.Contains(t, rail, "g f")
157+ for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
158+ assert.NotContains(t, rail, text)
159+ }
160+ more := renderToString(t, moreMenu(p))
161+ for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
162+ assert.Contains(t, more, text)
163+ }
164+ merge := renderToString(t, mergeMenu(p))
165+ assert.NotContains(t, merge, `<dl`)
166+ assert.NotContains(t, merge, `mr-signing`)
167+ assert.Contains(t, merge, "the source branch was deleted")
168+ bar := renderToString(t, mrBar(MRPage{}))
169+ assert.Contains(t, bar, `id="mr-ck"`)
170+ assert.Contains(t, bar, `href="#mr-checks"`)
171+ assert.Contains(t, bar, "Merge settings")
172+ assert.NotContains(t, bar, ">Close</button>")
173+}
174+
175+func TestMRReadPresentationCommitCount(t *testing.T) {
176+ commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
177+ for _, tc := range []struct {
178+ name string
179+ commits []domain.Commit
180+ truncated bool
181+ count string
182+ note string
183+ }{
184+ {"empty", nil, false, "0", ""},
185+ {"complete", commits(2), false, "2", ""},
186+ {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
187+ } {
188+ t.Run(tc.name, func(t *testing.T) {
189+ s := &Server{cfg: Config{Now: time.Now}}
190+ p := &MRPage{}
191+ s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
192+ assert.Equal(t, tc.count, p.CommitCount)
193+ assert.Equal(t, tc.note, p.CommitsNote)
194+ assert.Len(t, p.Commits, len(tc.commits))
195+ })
196+ }
197+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "time"
7 "testing"8 "testing"
8 9
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
@@ -128,3 +129,69 @@ func TestMergeabilityLiveEvents(t *testing.T) {
128 require.NoError(t, err)129 require.NoError(t, err)
129 assert.Empty(t, fs)130 assert.Empty(t, fs)
130 }131 }
132+
133+func TestMRPanelsAndMenus(t *testing.T) {
134+ p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135+ ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136+ footer := renderToString(t, mrFooter(p))
137+ assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138+ assert.NotContains(t, footer, `<details`)
139+ assert.NotContains(t, footer, `<summary`)
140+ // No hidden attribute: the CSS shows the panel of the URL fragment until
141+ // mr.js takes over, so the tabs work as anchors without scripts.
142+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144+ assert.NotContains(t, footer, `data-mr-panel hidden`)
145+ css, err := staticFS.ReadFile("static/css/60-mr.css")
146+ require.NoError(t, err)
147+ assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
148+ assert.Contains(t, footer, `data-mr-tabs`)
149+ assert.Contains(t, footer, `id="mr-commits"`)
150+ assert.Contains(t, footer, `id="mr-checks"`)
151+ assert.Contains(t, footer, `<table class="mr-commits">`)
152+ assert.Contains(t, footer, "500+")
153+ assert.Contains(t, footer, "more commits")
154+ assert.Contains(t, footer, "1/2 failed")
155+ rail := renderToString(t, mrRail(p))
156+ assert.Contains(t, rail, "g f")
157+ for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
158+ assert.NotContains(t, rail, text)
159+ }
160+ more := renderToString(t, moreMenu(p))
161+ for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
162+ assert.Contains(t, more, text)
163+ }
164+ merge := renderToString(t, mergeMenu(p))
165+ assert.NotContains(t, merge, `<dl`)
166+ assert.NotContains(t, merge, `mr-signing`)
167+ assert.Contains(t, merge, "the source branch was deleted")
168+ bar := renderToString(t, mrBar(MRPage{}))
169+ assert.Contains(t, bar, `id="mr-ck"`)
170+ assert.Contains(t, bar, `href="#mr-checks"`)
171+ assert.Contains(t, bar, "Merge settings")
172+ assert.NotContains(t, bar, ">Close</button>")
173+}
174+
175+func TestMRReadPresentationCommitCount(t *testing.T) {
176+ commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
177+ for _, tc := range []struct {
178+ name string
179+ commits []domain.Commit
180+ truncated bool
181+ count string
182+ note string
183+ }{
184+ {"empty", nil, false, "0", ""},
185+ {"complete", commits(2), false, "2", ""},
186+ {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
187+ } {
188+ t.Run(tc.name, func(t *testing.T) {
189+ s := &Server{cfg: Config{Now: time.Now}}
190+ p := &MRPage{}
191+ s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
192+ assert.Equal(t, tc.count, p.CommitCount)
193+ assert.Equal(t, tc.note, p.CommitsNote)
194+ assert.Len(t, p.Commits, len(tc.commits))
195+ })
196+ }
197+}
M internal/adapters/web/signature_test.go +1 -1
@@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
145145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
146146 t.Parallel()
147147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
148- got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{
148+ got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{
149149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
150150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
151151 }}))
@@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
146 t.Parallel()146 t.Parallel()
147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
148- got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{148+ got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{
149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
151 }}))151 }}))
M internal/adapters/web/static/css/60-mr.css +34 -2
@@ -8,6 +8,38 @@
88 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
99 }
1010
11+ .mr-footer {
12+ border-top: 1px solid var(--border); padding: var(--s2) var(--s4);
13+ /* the shade of the card header; the card's overflow clips the corners */
14+ background: var(--bg-raised);
15+ font-size: var(--fs-s);
16+ /* the tabs are buttons: a thin border, the count in a darker badge;
17+ the open one in the teal accent */
18+ & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
19+ & [data-mr-tabs] a {
20+ display: inline-flex; align-items: center; gap: var(--s1);
21+ padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
22+ color: var(--ink-dim); text-decoration: none; cursor: pointer;
23+ }
24+ & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
25+ & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
26+ & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
27+ & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
29+ & [hidden] { display: none; }
30+ /* Until mr.js drives the panels (data-panels, then [hidden]), only the
31+ panel of the URL fragment shows: the tabs work as anchors without
32+ scripts, and no panel flashes open before the script runs. */
33+ &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
34+ & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
35+ & table { width: 100%; border-collapse: collapse; }
36+ & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
37+ & th { color: var(--ink-dim); font-weight: 500; }
38+ & tbody tr:last-child > :is(td, th) { border-bottom: 0; }
39+ }
40+ .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
41+ .merge-settings h2 { font-size: var(--fs-s); }
42+
1143 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
1244 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
1345 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
@@ -112,11 +144,11 @@
112144 & .d-or > summary:hover { color: var(--ink-hi); }
113145 & .d-or[open] > .clone { margin-top: var(--s2); }
114146 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
147+ & #mr-signing code { overflow-wrap: anywhere; }
115148 }
116149
117150 /* the rail of a merge request follows the review: it sticks under the
118- header, one screen high, and scrolls on its own to its checks, commits
119- and settings. The list of files keeps its own scroll, under a height, so
151+ header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so
120152 that the blocks below it stay in reach. Only on a two-column layout. */
121153 @media (min-width: 1081px) {
122154 .rail:has(.r-files) {
@@ -8,6 +8,38 @@
8 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }8 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
9 }9 }
10 10
11+ .mr-footer {
12+ border-top: 1px solid var(--border); padding: var(--s2) var(--s4);
13+ /* the shade of the card header; the card's overflow clips the corners */
14+ background: var(--bg-raised);
15+ font-size: var(--fs-s);
16+ /* the tabs are buttons: a thin border, the count in a darker badge;
17+ the open one in the teal accent */
18+ & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
19+ & [data-mr-tabs] a {
20+ display: inline-flex; align-items: center; gap: var(--s1);
21+ padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
22+ color: var(--ink-dim); text-decoration: none; cursor: pointer;
23+ }
24+ & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
25+ & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
26+ & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
27+ & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
29+ & [hidden] { display: none; }
30+ /* Until mr.js drives the panels (data-panels, then [hidden]), only the
31+ panel of the URL fragment shows: the tabs work as anchors without
32+ scripts, and no panel flashes open before the script runs. */
33+ &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
34+ & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
35+ & table { width: 100%; border-collapse: collapse; }
36+ & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
37+ & th { color: var(--ink-dim); font-weight: 500; }
38+ & tbody tr:last-child > :is(td, th) { border-bottom: 0; }
39+ }
40+ .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
41+ .merge-settings h2 { font-size: var(--fs-s); }
42+
11 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }43 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
12 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }44 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
13 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }45 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
@@ -112,11 +144,11 @@
112 & .d-or > summary:hover { color: var(--ink-hi); }144 & .d-or > summary:hover { color: var(--ink-hi); }
113 & .d-or[open] > .clone { margin-top: var(--s2); }145 & .d-or[open] > .clone { margin-top: var(--s2); }
114 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }146 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
147+ & #mr-signing code { overflow-wrap: anywhere; }
115 }148 }
116 149
117 /* the rail of a merge request follows the review: it sticks under the150 /* the rail of a merge request follows the review: it sticks under the
118- header, one screen high, and scrolls on its own to its checks, commits151+ header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so
119- and settings. The list of files keeps its own scroll, under a height, so
120 that the blocks below it stay in reach. Only on a two-column layout. */152 that the blocks below it stay in reach. Only on a two-column layout. */
121 @media (min-width: 1081px) {153 @media (min-width: 1081px) {
122 .rail:has(.r-files) {154 .rail:has(.r-files) {
M internal/adapters/web/static/css/80-live.css +5 -0
@@ -8,5 +8,10 @@
88 padding: var(--s3) var(--s4); font-weight: 700;
99 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
1010 }
11+ /* A deferred MR refresh must not insert height above a long diff. */
12+ #mr-refresh-status:has(a) {
13+ position: fixed; bottom: var(--s4); left: var(--s4); z-index: 24;
14+ max-width: min(42rem, calc(100vw - 2 * var(--s4))); font-size: var(--fs-s);
15+ }
1116 .live-slot a { color: var(--ink-hi); }
1217 }
@@ -8,5 +8,10 @@
8 padding: var(--s3) var(--s4); font-weight: 700;8 padding: var(--s3) var(--s4); font-weight: 700;
9 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);9 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
10 }10 }
11+ /* A deferred MR refresh must not insert height above a long diff. */
12+ #mr-refresh-status:has(a) {
13+ position: fixed; bottom: var(--s4); left: var(--s4); z-index: 24;
14+ max-width: min(42rem, calc(100vw - 2 * var(--s4))); font-size: var(--fs-s);
15+ }
11 .live-slot a { color: var(--ink-hi); }16 .live-slot a { color: var(--ink-hi); }
12 }17 }
A internal/adapters/web/static/css/85-mobile.css +53 -0
@@ -0,0 +1,53 @@
1+@layer components {
2+ .mr-touch-range { display: none; }
3+
4+ /* MR only: retain the shell's desktop layout and other pages' spacing. */
5+ @media (max-width: 720px) {
6+ body:has(#diffs[data-mr]) {
7+ --pad: 0px;
8+ --hdr-h: 97px;
9+ overflow-x: clip;
10+ }
11+ body:has(#diffs[data-mr]) .work { padding-inline: 0; margin-inline: 0; gap: var(--s3); }
12+ body:has(#diffs[data-mr]) main { min-width: 0; padding-inline: 0; }
13+ body:has(#diffs[data-mr]) :is(.card, .mr-card, .mr-desc, .dfile, .dscroll, .code, .rail section) {
14+ margin-inline: 0; border-inline: 0; border-radius: 0; min-width: 0; max-width: 100%;
15+ }
16+ body:has(#diffs[data-mr]) :is(.mr-h, .mr-desc.prose, .card-h, .mr-footer) { padding-inline: 4px; }
17+ body:has(#diffs[data-mr]) .mr-footer :is(th, td) { padding-inline: var(--s1); }
18+ body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); }
19+ #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; }
20+ #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; }
21+ #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; }
22+ #diffs[data-mr] .dt .m { width: 1ch; }
23+ #diffs[data-mr][data-diff] .uni { display: table; }
24+ #diffs[data-mr][data-diff] .spl { display: none; }
25+ #diffs[data-mr] .fnote td { padding-inline: 0; }
26+ #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; }
27+ body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; }
28+ body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; }
29+ body:has(#diffs[data-mr]) .menu .menu-pop {
30+ position: fixed; inset: auto 0 0; width: auto; max-width: 100%;
31+ max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0;
32+ padding-bottom: max(var(--s4), env(safe-area-inset-bottom));
33+ }
34+ body:has(#diffs[data-mr]) .mr-bar { position: static; }
35+ /* A wrapping toolbar has no reliable fixed sticky height. */
36+ body:has(#diffs[data-mr]) { --bar-h: 0px; }
37+ }
38+
39+ @media (pointer: coarse), (hover: none) {
40+ .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
41+ /* Real, disjoint cells: never expand invisible targets into other lines
42+ or into the neighbouring old/new gutter. */
43+ #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); }
44+ #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; }
45+ #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); }
46+ #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; }
47+ .mr-touch-range[hidden] { display: none; }
48+ #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
49+ body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
50+ #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51+ .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
52+ }
53+}
@@ -0,0 +1,53 @@
1+@layer components {
2+ .mr-touch-range { display: none; }
3+
4+ /* MR only: retain the shell's desktop layout and other pages' spacing. */
5+ @media (max-width: 720px) {
6+ body:has(#diffs[data-mr]) {
7+ --pad: 0px;
8+ --hdr-h: 97px;
9+ overflow-x: clip;
10+ }
11+ body:has(#diffs[data-mr]) .work { padding-inline: 0; margin-inline: 0; gap: var(--s3); }
12+ body:has(#diffs[data-mr]) main { min-width: 0; padding-inline: 0; }
13+ body:has(#diffs[data-mr]) :is(.card, .mr-card, .mr-desc, .dfile, .dscroll, .code, .rail section) {
14+ margin-inline: 0; border-inline: 0; border-radius: 0; min-width: 0; max-width: 100%;
15+ }
16+ body:has(#diffs[data-mr]) :is(.mr-h, .mr-desc.prose, .card-h, .mr-footer) { padding-inline: 4px; }
17+ body:has(#diffs[data-mr]) .mr-footer :is(th, td) { padding-inline: var(--s1); }
18+ body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); }
19+ #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; }
20+ #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; }
21+ #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; }
22+ #diffs[data-mr] .dt .m { width: 1ch; }
23+ #diffs[data-mr][data-diff] .uni { display: table; }
24+ #diffs[data-mr][data-diff] .spl { display: none; }
25+ #diffs[data-mr] .fnote td { padding-inline: 0; }
26+ #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; }
27+ body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; }
28+ body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; }
29+ body:has(#diffs[data-mr]) .menu .menu-pop {
30+ position: fixed; inset: auto 0 0; width: auto; max-width: 100%;
31+ max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0;
32+ padding-bottom: max(var(--s4), env(safe-area-inset-bottom));
33+ }
34+ body:has(#diffs[data-mr]) .mr-bar { position: static; }
35+ /* A wrapping toolbar has no reliable fixed sticky height. */
36+ body:has(#diffs[data-mr]) { --bar-h: 0px; }
37+ }
38+
39+ @media (pointer: coarse), (hover: none) {
40+ .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
41+ /* Real, disjoint cells: never expand invisible targets into other lines
42+ or into the neighbouring old/new gutter. */
43+ #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); }
44+ #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; }
45+ #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); }
46+ #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; }
47+ .mr-touch-range[hidden] { display: none; }
48+ #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
49+ body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
50+ #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51+ .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
52+ }
53+}
M internal/adapters/web/static/js/ermite.js +36 -2
@@ -183,6 +183,7 @@
183183 layout(mode) {
184184 const el = $('[data-diff]');
185185 if (!el) return;
186+ if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return;
186187 el.dataset.diff = mode;
187188 try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
188189 for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
@@ -730,13 +731,21 @@
730731 mark();
731732 }, { rootMargin: '0px 0px -67% 0px' });
732733 for (const f of files) io.observe(f);
734+ // A refreshed MR replaced the rail and the windowed diff. Rebuild once,
735+ // not once per file (which would make a large file list quadratic).
736+ ermite.refreshFiles = () => {
737+ io.disconnect(); inBand.clear(); current = null;
738+ files = $$('.dfile'); links.clear();
739+ for (const a of $$('.files a[href^="#"]')) links.set(a.hash.slice(1), a);
740+ for (const f of files) io.observe(f);
741+ };
733742 // A window of files of a merge request came in (mr.js): its files replace
734743 // placeholders, which the observer and the list knew by element.
735744 ermite.watchFile = (f, old) => {
736745 if (old) { io.unobserve(old); inBand.delete(old); }
737746 files = $$('.dfile');
738747 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
739- if (current && !current.isConnected) current = document.getElementById(current.id);
748+ if (current && !current.isConnected) current = null;
740749 io.observe(f);
741750 };
742751 }
@@ -844,6 +853,10 @@
844853 // reloads the page. A stream the server refuses on reconnection (the
845854 // repository is gone, the session ended) makes the page reload.
846855 window.ermiteLive = (() => {
856+ let connection = null;
857+ window.addEventListener?.('htmx:sse:after:connection', (event) => {
858+ if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
859+ });
847860 let reloading = false;
848861 let stopped = false;
849862 // A stream that drops and comes back within LOST_AFTER (a proxy or the
@@ -898,6 +911,24 @@ window.ermiteLive = (() => {
898911 // fitIn lays out the one-line chip lists of a fragment now in the page.
899912 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
900913 return {
914+ // teardown runs remove, which deletes the stream element. hx-sse has no
915+ // public close API: its cleanup aborts the fetch, then calls
916+ // connection.reader.cancel() without catching the AbortError it rejects
917+ // with. Wrapping that one reader handles exactly this rejection, with no
918+ // timing window and no global filter.
919+ async teardown(remove) {
920+ const reader = connection?.reader;
921+ if (reader) connection.reader = { cancel: () => reader.cancel().catch(() => {}) };
922+ return remove();
923+ },
924+ resumeStream(element) {
925+ // The public connection includes id-only messages as well as named ones.
926+ const id = connection?.lastEventId;
927+ if (!id) return;
928+ const url = new URL(element.getAttribute('hx-sse:connect'), location.href);
929+ url.searchParams.set('after', id);
930+ element.setAttribute('hx-sse:connect', url.pathname + url.search);
931+ },
901932 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
902933 // only if the stream is still down LOST_AFTER later.
903934 lost() {
@@ -956,7 +987,10 @@ window.ermiteLive = (() => {
956987 reload() {
957988 if (reloading) return;
958989 reloading = true;
959- setTimeout(() => location.reload(), 300);
990+ setTimeout(() => {
991+ reloading = false;
992+ if (window.ermiteLive.canReload?.() !== false) location.reload();
993+ }, 300);
960994 },
961995 // error handles htmx:sse:error (detail.status is the status of a refused
962996 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
@@ -183,6 +183,7 @@
183 layout(mode) {183 layout(mode) {
184 const el = $('[data-diff]');184 const el = $('[data-diff]');
185 if (!el) return;185 if (!el) return;
186+ if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return;
186 el.dataset.diff = mode;187 el.dataset.diff = mode;
187 try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }188 try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
188 for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;189 for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
@@ -730,13 +731,21 @@
730 mark();731 mark();
731 }, { rootMargin: '0px 0px -67% 0px' });732 }, { rootMargin: '0px 0px -67% 0px' });
732 for (const f of files) io.observe(f);733 for (const f of files) io.observe(f);
734+ // A refreshed MR replaced the rail and the windowed diff. Rebuild once,
735+ // not once per file (which would make a large file list quadratic).
736+ ermite.refreshFiles = () => {
737+ io.disconnect(); inBand.clear(); current = null;
738+ files = $$('.dfile'); links.clear();
739+ for (const a of $$('.files a[href^="#"]')) links.set(a.hash.slice(1), a);
740+ for (const f of files) io.observe(f);
741+ };
733 // A window of files of a merge request came in (mr.js): its files replace742 // A window of files of a merge request came in (mr.js): its files replace
734 // placeholders, which the observer and the list knew by element.743 // placeholders, which the observer and the list knew by element.
735 ermite.watchFile = (f, old) => {744 ermite.watchFile = (f, old) => {
736 if (old) { io.unobserve(old); inBand.delete(old); }745 if (old) { io.unobserve(old); inBand.delete(old); }
737 files = $$('.dfile');746 files = $$('.dfile');
738 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);747 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
739- if (current && !current.isConnected) current = document.getElementById(current.id);748+ if (current && !current.isConnected) current = null;
740 io.observe(f);749 io.observe(f);
741 };750 };
742 }751 }
@@ -844,6 +853,10 @@
844 // reloads the page. A stream the server refuses on reconnection (the853 // reloads the page. A stream the server refuses on reconnection (the
845 // repository is gone, the session ended) makes the page reload.854 // repository is gone, the session ended) makes the page reload.
846 window.ermiteLive = (() => {855 window.ermiteLive = (() => {
856+ let connection = null;
857+ window.addEventListener?.('htmx:sse:after:connection', (event) => {
858+ if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
859+ });
847 let reloading = false;860 let reloading = false;
848 let stopped = false;861 let stopped = false;
849 // A stream that drops and comes back within LOST_AFTER (a proxy or the862 // A stream that drops and comes back within LOST_AFTER (a proxy or the
@@ -898,6 +911,24 @@ window.ermiteLive = (() => {
898 // fitIn lays out the one-line chip lists of a fragment now in the page.911 // fitIn lays out the one-line chip lists of a fragment now in the page.
899 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };912 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
900 return {913 return {
914+ // teardown runs remove, which deletes the stream element. hx-sse has no
915+ // public close API: its cleanup aborts the fetch, then calls
916+ // connection.reader.cancel() without catching the AbortError it rejects
917+ // with. Wrapping that one reader handles exactly this rejection, with no
918+ // timing window and no global filter.
919+ async teardown(remove) {
920+ const reader = connection?.reader;
921+ if (reader) connection.reader = { cancel: () => reader.cancel().catch(() => {}) };
922+ return remove();
923+ },
924+ resumeStream(element) {
925+ // The public connection includes id-only messages as well as named ones.
926+ const id = connection?.lastEventId;
927+ if (!id) return;
928+ const url = new URL(element.getAttribute('hx-sse:connect'), location.href);
929+ url.searchParams.set('after', id);
930+ element.setAttribute('hx-sse:connect', url.pathname + url.search);
931+ },
901 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows932 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
902 // only if the stream is still down LOST_AFTER later.933 // only if the stream is still down LOST_AFTER later.
903 lost() {934 lost() {
@@ -956,7 +987,10 @@ window.ermiteLive = (() => {
956 reload() {987 reload() {
957 if (reloading) return;988 if (reloading) return;
958 reloading = true;989 reloading = true;
959- setTimeout(() => location.reload(), 300);990+ setTimeout(() => {
991+ reloading = false;
992+ if (window.ermiteLive.canReload?.() !== false) location.reload();
993+ }, 300);
960 },994 },
961 // error handles htmx:sse:error (detail.status is the status of a refused995 // error handles htmx:sse:error (detail.status is the status of a refused
962 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by996 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
M internal/adapters/web/static/js/mr.js +408 -2
@@ -27,6 +27,20 @@ window.ermiteMR = (() => {
2727 b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
2828 return b.href;
2929 },
30+ // Range mode is explicit: ordinary taps still open single-line forms.
31+ touchRange(previous, current, base) {
32+ if (!previous) return { start: current, url: null };
33+ const url = core.rangeURL(previous, current, base);
34+ if (url) return { start: null, url };
35+ if (previous === current) return { start: null, url: current };
36+ return { start: current, url: null }; // another file/side starts afresh
37+ },
38+ mobileURL(href, view, narrow) {
39+ if (!narrow || view !== 'split') return null;
40+ const url = new URL(href);
41+ url.searchParams.set('view', 'unified');
42+ return url.href;
43+ },
3044 quote(button) {
3145 const thread = button.closest('.cmt').closest('[data-thread]');
3246 const details = thread?.querySelector(':scope > details[data-reply]');
@@ -49,6 +63,13 @@ window.ermiteMR = (() => {
4963 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
5064 return null;
5165 },
66+ tabIndex(key, index, count) {
67+ if (key === 'Home') return 0;
68+ if (key === 'End') return count - 1;
69+ if (key === 'ArrowRight') return (index + 1) % count;
70+ if (key === 'ArrowLeft') return (index + count - 1) % count;
71+ return null;
72+ },
5273 // step moves an index within [0, n).
5374 step(i, d, n) {
5475 if (n === 0) return -1;
@@ -60,13 +81,16 @@ window.ermiteMR = (() => {
6081 const $ = (sel, root = document) => root.querySelector(sel);
6182 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
6283 const loading = new Map(); // src -> promise of the swap
84+ let refreshTouchComments = () => {};
6385 let moved = false;
6486 let lazyIO = null; // the observer of the placeholders (watch)
87+ let diffGeneration = 0;
6588 let revealGen = 0; // the last reveal wins the scroll
6689
6790 // load fetches the window of a placeholder and swaps its files in. The
6891 // promise resolves once they are in the page (or the page said to reload).
6992 function load(ph) {
93+ const generation = diffGeneration;
7094 const src = ph?.dataset.src;
7195 if (!src || moved) return Promise.resolve();
7296 if (loading.has(src)) return loading.get(src);
@@ -80,12 +104,14 @@ window.ermiteMR = (() => {
80104 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81105 .then(async (res) => {
82106 const html = await res.text();
107+ if (generation !== diffGeneration) return;
83108 if (res.status === 409) return stale(html);
84109 if (!res.ok) throw new Error('loading files: ' + res.status);
85110 // A window of the old diff after the page went stale stays out.
86111 if (!moved) swap(html);
87112 })
88113 .catch((err) => {
114+ if (generation !== diffGeneration) return;
89115 loading.delete(src);
90116 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91117 window.ermite?.flash?.(err.message, 4000);
@@ -108,6 +134,7 @@ window.ermiteMR = (() => {
108134 window.htmx?.process(f);
109135 window.ermite?.watchFile?.(f, old);
110136 }
137+ refreshTouchComments();
111138 }
112139
113140 // stale: the diff changed under the page. The placeholders say so; no file
@@ -130,7 +157,7 @@ window.ermiteMR = (() => {
130157 // A page with both layouts says which shows in data-diff; a large diff
131158 // rendered in one layout only says it in data-view.
132159 const diffs = $('#diffs')?.dataset;
133- if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
160+ if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') {
134161 const split = document.getElementById(id + '-s');
135162 if (split) return split;
136163 }
@@ -172,6 +199,7 @@ window.ermiteMR = (() => {
172199 // that come in are marked in the list of files (ermite.js marks the one in
173200 // view).
174201 function watch() {
202+ lazyIO?.disconnect();
175203 if (!('IntersectionObserver' in window)) {
176204 for (const p of $$('.dlazy[data-src]')) load(p);
177205 return;
@@ -285,8 +313,386 @@ window.ermiteMR = (() => {
285313 reveal(id);
286314 }
287315
316+ // Enhance the ordinary anchor navigation only when scripts are available.
317+ let disposePanels = () => {};
318+ // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
319+ // tablist: a tablist always has a selected tab, and here every panel can be
320+ // folded. State: the index of the open panel, or -1 for none.
321+ function panels(initial = -1) {
322+ disposePanels();
323+ const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
324+ if (!footer || !nav) return;
325+ const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
326+ const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
327+ let current = -1;
328+ // show applies the state; fromUser also keeps the URL fragment in step.
329+ function show(index, fromUser = false) {
330+ current = index;
331+ tabs.forEach((tab, i) => {
332+ tab.setAttribute('aria-controls', panels[i].id);
333+ tab.setAttribute('aria-expanded', String(i === index));
334+ panels[i].hidden = i !== index;
335+ });
336+ if (!fromUser) return;
337+ if (index >= 0) {
338+ if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
339+ } else if (ids.has(location.hash)) {
340+ history.replaceState(null, '', location.pathname + location.search);
341+ }
342+ }
343+ const toggle = (i) => show(i === current ? -1 : i, true);
344+ tabs.forEach((tab, i) => {
345+ tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); });
346+ tab.addEventListener('keydown', (e) => {
347+ const next = core.tabIndex(e.key, i, tabs.length);
348+ if (next === null) return;
349+ e.preventDefault(); tabs[next].focus();
350+ });
351+ });
352+ // hash opens the panel of the URL fragment. Its target was hidden when the
353+ // browser scrolled, so the panel is brought in; never on a refresh, which
354+ // keeps the scroll.
355+ function hash() {
356+ const h = location.hash;
357+ const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
358+ if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); }
359+ }
360+ // The checks badge always opens Checks; it never folds it.
361+ const checksClick = (e) => {
362+ if (e.target.closest?.('#mr-ck')) show(1);
363+ };
364+ document.addEventListener('click', checksClick);
365+ let observer;
366+ const summary = $('#mr-ck');
367+ if (summary) {
368+ // The live stream replaces #mr-ck as a whole: watch only the children of
369+ // its parent, and write the label only when it changes. hx-live rewrites
370+ // other text in the same bar on every mutation of the document; a write
371+ // here on each of them would wake hx-live again, a loop that never yields.
372+ const sync = () => {
373+ const label = $('[data-checks-label]', nav);
374+ const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
375+ if (label && label.textContent !== text) label.textContent = text;
376+ };
377+ observer = new MutationObserver(sync);
378+ observer.observe(summary.parentElement, { childList: true });
379+ sync();
380+ }
381+ show(initial);
382+ footer.setAttribute('data-panels', '');
383+ if (initial < 0) hash();
384+ addEventListener('hashchange', hash);
385+ disposePanels = () => {
386+ document.removeEventListener?.('click', checksClick);
387+ window.removeEventListener?.('hashchange', hash);
388+ observer?.disconnect?.();
389+ };
390+ }
391+
392+ let disposeTouch = () => {};
393+ function touchComments(scroll = true) {
394+ disposeTouch();
395+ const diffs = $('#diffs');
396+ const bar = document.createElement('div');
397+ bar.className = 'mr-touch-range';
398+ const button = document.createElement('button');
399+ button.type = 'button';
400+ button.className = 'btn';
401+ button.textContent = 'Select line range';
402+ button.setAttribute('aria-pressed', 'false');
403+ const status = document.createElement('span');
404+ status.setAttribute('role', 'status');
405+ bar.append(button, status);
406+ diffs.prepend(bar);
407+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
408+ refreshTouchComments();
409+ const touch = window.matchMedia?.('(pointer: coarse), (hover: none)');
410+ let enabled = false, start = null, marker = null;
411+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
412+ button.addEventListener('click', () => {
413+ enabled = !enabled;
414+ clear();
415+ button.setAttribute('aria-pressed', String(enabled));
416+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
417+ });
418+ diffs.addEventListener('click', (e) => {
419+ const link = e.target.closest?.('.ln a[href*="on="]');
420+ if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
421+ e.preventDefault();
422+ e.stopPropagation(); // do not alter the desktop shift-click anchor
423+ const next = core.touchRange(start, link.href, location.href);
424+ clear();
425+ if (next.url) { location.assign(next.url); return; }
426+ start = next.start;
427+ marker = link;
428+ marker.setAttribute('data-range-start', '');
429+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
430+ });
431+ const changed = () => {
432+ if (touch.matches) return;
433+ enabled = false;
434+ clear();
435+ button.setAttribute('aria-pressed', 'false');
436+ status.textContent = '';
437+ };
438+ touch?.addEventListener('change', changed);
439+ disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
440+ // The server opens the line form. Scroll after layout without stealing
441+ // focus or forcing a mobile keyboard to appear.
442+ if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
443+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
444+ form?.scrollIntoView({ block: 'center' });
445+ });
446+ }
447+
448+ // A refresh scheduler has one request in flight, a trailing debounce, and
449+ // a dirty bit for pushes received during that request. Drafts are checked
450+ // again after the fetch: typing while the network is slow is safe too.
451+ function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
452+ let timer, busy = false, pending = false;
453+ const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
454+ async function run() {
455+ if (busy || !pending) return;
456+ if (blocked()) { notice(true); return; }
457+ busy = true;
458+ pending = false;
459+ try {
460+ const page = await fetchPage();
461+ if (blocked()) { pending = true; notice(true); return; }
462+ if (await apply(page) === false) { pending = true; notice(true); }
463+ else notice(false);
464+ } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
465+ finally {
466+ busy = false;
467+ if (pending && !blocked()) schedule();
468+ }
469+ }
470+ return {
471+ request() { pending = true; schedule(); },
472+ resume() { if (pending) schedule(); },
473+ pending() { return pending || busy; },
474+ };
475+ }
476+
477+ function hasDraft() {
478+ // Only form controls, never diff rows. Inline edits live in .cmt forms; the
479+ // merge form (d-acts) holds a chosen mode and the delete-branch box.
480+ return $$(':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)').some((t) => {
481+ if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
482+ if (document.activeElement === t) return true;
483+ if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
484+ if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
485+ return t.value !== t.defaultValue;
486+ }) ||
487+ !!document.activeElement?.closest?.('.cform');
488+ }
489+
490+ // Path + side + line is stable when files are inserted before this one;
491+ // fN is only a positional anchor. Hidden unified/split rows are ignored.
492+ function scrollAnchor() {
493+ const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
494+ $('.mr-bar')?.getBoundingClientRect().bottom || 0);
495+ const diffs = $('#diffs');
496+ const bounds = diffs.getBoundingClientRect();
497+ const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
498+ // A bounded number of probes, independent of the number of diff lines.
499+ let y = top + 2, fallback;
500+ for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
501+ const hit = document.elementFromPoint(x, y);
502+ const file = hit?.closest('details.dfile');
503+ if (!file || !diffs.contains(file)) { y += 32; continue; }
504+ const row = hit.closest('tr');
505+ const link = row && $('.ln a[href*="on="]', row);
506+ const header = $(':scope > summary', file);
507+ const headerOffset = file.getBoundingClientRect().top;
508+ fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
509+ if (link) return { ...fallback,
510+ on: new URL(link.href, location.href).searchParams.get('on'),
511+ suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
512+ offset: row.getBoundingClientRect().top };
513+ if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
514+ else return fallback;
515+ }
516+ if (fallback) return fallback;
517+ return { y: scrollY };
518+ }
519+
520+ function restoreAnchor(anchor) {
521+ const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
522+ let el, offset = anchor.offset;
523+ if (file && anchor.on) {
524+ const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
525+ const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
526+ const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
527+ if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
528+ }
529+ if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
530+ if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
531+ else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
532+ }
533+
534+ async function fetchPage(url = location.href, mobileRetry = false) {
535+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
536+ if (!res.ok || res.redirected) throw new Error('MR refresh refused');
537+ const page = new DOMParser().parseFromString(await res.text(), 'text/html');
538+ if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
539+ const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
540+ !!window.matchMedia?.('(max-width: 720px)').matches);
541+ if (unified && !mobileRetry) return fetchPage(unified, true);
542+ return page;
543+ }
544+
545+ // htmx 4's public delete swap invokes recursive before:cleanup (including
546+ // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
547+ // string is parsed; the fetched nodes themselves are adopted, never serialized.
548+ async function replaceRegion(old, fresh) {
549+ if (!old || !fresh) throw new Error('missing MR refresh region');
550+ for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
551+ const marker = document.createComment('MR region');
552+ old.before(marker);
553+ const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
554+ swap: 'delete settle:0 transition:false', push: false, replace: false });
555+ if (old.matches('.hint-live')) {
556+ window.ermiteLive.resumeStream(fresh);
557+ await window.ermiteLive.teardown(remove);
558+ } else await remove();
559+ const node = document.adoptNode(fresh);
560+ marker.replaceWith(node);
561+ window.htmx.process(node);
562+ }
563+
564+ async function refreshPage(page) {
565+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
566+ for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
567+ let anchor = scrollAnchor();
568+ const nextDiffs = $('#diffs', page);
569+ const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
570+ const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
571+ if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
572+ // A one-layout response must use its own contract, never the old page's.
573+ if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
574+ const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
575+ const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
576+ if (placeholder) {
577+ const d = nextDiffs.dataset;
578+ const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
579+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
580+ if (!res.ok || res.redirected) throw new Error('anchor window changed');
581+ let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
582+ if (hasDraft()) return false;
583+ anchor = scrollAnchor();
584+ const windowFiles = $$('details.dfile', windowPage);
585+ // If the reader moved outside this window, discard it; lazy loading will
586+ // fetch the new position rather than committing an unrelated window.
587+ if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
588+ const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
589+ for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
590+ }
591+ windowPage = null;
592+ }
593+ if (hasDraft()) return false;
594+ const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
595+ const folded = $('.r-files')?.hasAttribute('data-folded');
596+ const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
597+ const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
598+ const active = document.activeElement;
599+ const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600+ for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601+ ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
602+ $('#diffs', page).dataset.mrStarted = 'true';
603+ // The fresh footer comes in with the panels as they are, so that an open
604+ // panel does not fold and open again while the other regions swap.
605+ const freshFooter = $('.mr-footer', page);
606+ if (freshFooter) {
607+ freshFooter.setAttribute('data-panels', '');
608+ $$('[data-mr-panel]', freshFooter).forEach((p, i) => { p.hidden = i !== openPanel; });
609+ }
610+ for (const sel of regions) {
611+ const fresh = $(sel, page);
612+ await replaceRegion($(sel), fresh);
613+ }
614+ for (const key of ['ref', 'sha', 'paths', 'blob']) {
615+ if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
616+ }
617+ const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
618+ if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
619+ page = null;
620+ panels(openPanel);
621+ foldFiles(!!folded);
622+ if ($('.rail')) $('.rail').scrollTop = railY;
623+ if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
624+ touchComments(false);
625+ $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626+ window.ermite?.refreshFiles?.();
627+ watch();
628+ if (focus && active !== document.body) {
629+ const region = focus.region ? $(focus.region) : document;
630+ const menu = focus.menu && document.getElementById(focus.menu);
631+ if (menu) menu.open = true;
632+ const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
633+ focus.id ? document.getElementById(focus.id) :
634+ focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
635+ focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
636+ menu?.querySelector(':scope > summary');
637+ el?.focus({ preventScroll: true });
638+ }
639+ restoreAnchor(anchor);
640+ }
641+
642+ function refreshNotice(deferred) {
643+ const slot = $('#mr-refresh-status');
644+ if (!slot) return;
645+ slot.replaceChildren();
646+ if (!deferred) {
647+ const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
648+ return;
649+ }
650+ slot.append('branch updated — refreshing after your comment. ');
651+
652+ }
653+ const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
654+ failed: () => window.ermiteLive?.reload() });
655+ function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
656+
288657 function start() {
289658 if (!$('#diffs[data-mr]')) return;
659+ if (window.ermiteLive) window.ermiteLive.canReload = () => {
660+ if (!hasDraft()) return true;
661+ headRefresh.request(); refreshNotice(true); return false;
662+ };
663+ // Global delegation is installed once; replacement regions have their own
664+ // disposable panel/touch hooks below.
665+ if ($('#diffs').dataset.mrStarted) return;
666+ $('#diffs').dataset.mrStarted = 'true';
667+ for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
668+ document.addEventListener?.('click', (e) => {
669+ const cancel = e.target.closest?.('.cform .f-btns a');
670+ if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
671+ e.preventDefault();
672+ const form = cancel.closest('.cform');
673+ form.reset();
674+ if (form.contains(document.activeElement)) document.activeElement.blur();
675+ headRefresh.resume();
676+ });
677+ panels();
678+ // Large diffs may contain only the split table. Ask the existing server
679+ // route for unified rather than writing the saved session preference.
680+ const narrow = window.matchMedia?.('(max-width: 720px)');
681+ const unify = () => {
682+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
683+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
684+ if (url) { location.replace(url); return true; }
685+ return false;
686+ };
687+ if (unify()) return;
688+ narrow?.addEventListener('change', unify);
689+ addEventListener('click', (e) => {
690+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
691+ e.preventDefault();
692+ e.stopImmediatePropagation();
693+ }
694+ }, true);
695+ touchComments();
290696 let folded = false;
291697 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292698 if (folded) foldFiles(true);
@@ -316,7 +722,7 @@ window.ermiteMR = (() => {
316722 });
317723 }
318724
319- return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
725+ return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
320726 })();
321727
322728 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
@@ -27,6 +27,20 @@ window.ermiteMR = (() => {
27 b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);27 b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
28 return b.href;28 return b.href;
29 },29 },
30+ // Range mode is explicit: ordinary taps still open single-line forms.
31+ touchRange(previous, current, base) {
32+ if (!previous) return { start: current, url: null };
33+ const url = core.rangeURL(previous, current, base);
34+ if (url) return { start: null, url };
35+ if (previous === current) return { start: null, url: current };
36+ return { start: current, url: null }; // another file/side starts afresh
37+ },
38+ mobileURL(href, view, narrow) {
39+ if (!narrow || view !== 'split') return null;
40+ const url = new URL(href);
41+ url.searchParams.set('view', 'unified');
42+ return url.href;
43+ },
30 quote(button) {44 quote(button) {
31 const thread = button.closest('.cmt').closest('[data-thread]');45 const thread = button.closest('.cmt').closest('[data-thread]');
32 const details = thread?.querySelector(':scope > details[data-reply]');46 const details = thread?.querySelector(':scope > details[data-reply]');
@@ -49,6 +63,13 @@ window.ermiteMR = (() => {
49 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };63 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
50 return null;64 return null;
51 },65 },
66+ tabIndex(key, index, count) {
67+ if (key === 'Home') return 0;
68+ if (key === 'End') return count - 1;
69+ if (key === 'ArrowRight') return (index + 1) % count;
70+ if (key === 'ArrowLeft') return (index + count - 1) % count;
71+ return null;
72+ },
52 // step moves an index within [0, n).73 // step moves an index within [0, n).
53 step(i, d, n) {74 step(i, d, n) {
54 if (n === 0) return -1;75 if (n === 0) return -1;
@@ -60,13 +81,16 @@ window.ermiteMR = (() => {
60 const $ = (sel, root = document) => root.querySelector(sel);81 const $ = (sel, root = document) => root.querySelector(sel);
61 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];82 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62 const loading = new Map(); // src -> promise of the swap83 const loading = new Map(); // src -> promise of the swap
84+ let refreshTouchComments = () => {};
63 let moved = false;85 let moved = false;
64 let lazyIO = null; // the observer of the placeholders (watch)86 let lazyIO = null; // the observer of the placeholders (watch)
87+ let diffGeneration = 0;
65 let revealGen = 0; // the last reveal wins the scroll88 let revealGen = 0; // the last reveal wins the scroll
66 89
67 // load fetches the window of a placeholder and swaps its files in. The90 // load fetches the window of a placeholder and swaps its files in. The
68 // promise resolves once they are in the page (or the page said to reload).91 // promise resolves once they are in the page (or the page said to reload).
69 function load(ph) {92 function load(ph) {
93+ const generation = diffGeneration;
70 const src = ph?.dataset.src;94 const src = ph?.dataset.src;
71 if (!src || moved) return Promise.resolve();95 if (!src || moved) return Promise.resolve();
72 if (loading.has(src)) return loading.get(src);96 if (loading.has(src)) return loading.get(src);
@@ -80,12 +104,14 @@ window.ermiteMR = (() => {
80 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })104 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
81 .then(async (res) => {105 .then(async (res) => {
82 const html = await res.text();106 const html = await res.text();
107+ if (generation !== diffGeneration) return;
83 if (res.status === 409) return stale(html);108 if (res.status === 409) return stale(html);
84 if (!res.ok) throw new Error('loading files: ' + res.status);109 if (!res.ok) throw new Error('loading files: ' + res.status);
85 // A window of the old diff after the page went stale stays out.110 // A window of the old diff after the page went stale stays out.
86 if (!moved) swap(html);111 if (!moved) swap(html);
87 })112 })
88 .catch((err) => {113 .catch((err) => {
114+ if (generation !== diffGeneration) return;
89 loading.delete(src);115 loading.delete(src);
90 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');116 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
91 window.ermite?.flash?.(err.message, 4000);117 window.ermite?.flash?.(err.message, 4000);
@@ -108,6 +134,7 @@ window.ermiteMR = (() => {
108 window.htmx?.process(f);134 window.htmx?.process(f);
109 window.ermite?.watchFile?.(f, old);135 window.ermite?.watchFile?.(f, old);
110 }136 }
137+ refreshTouchComments();
111 }138 }
112 139
113 // stale: the diff changed under the page. The placeholders say so; no file140 // stale: the diff changed under the page. The placeholders say so; no file
@@ -130,7 +157,7 @@ window.ermiteMR = (() => {
130 // A page with both layouts says which shows in data-diff; a large diff157 // A page with both layouts says which shows in data-diff; a large diff
131 // rendered in one layout only says it in data-view.158 // rendered in one layout only says it in data-view.
132 const diffs = $('#diffs')?.dataset;159 const diffs = $('#diffs')?.dataset;
133- if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {160+ if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') {
134 const split = document.getElementById(id + '-s');161 const split = document.getElementById(id + '-s');
135 if (split) return split;162 if (split) return split;
136 }163 }
@@ -172,6 +199,7 @@ window.ermiteMR = (() => {
172 // that come in are marked in the list of files (ermite.js marks the one in199 // that come in are marked in the list of files (ermite.js marks the one in
173 // view).200 // view).
174 function watch() {201 function watch() {
202+ lazyIO?.disconnect();
175 if (!('IntersectionObserver' in window)) {203 if (!('IntersectionObserver' in window)) {
176 for (const p of $$('.dlazy[data-src]')) load(p);204 for (const p of $$('.dlazy[data-src]')) load(p);
177 return;205 return;
@@ -285,8 +313,386 @@ window.ermiteMR = (() => {
285 reveal(id);313 reveal(id);
286 }314 }
287 315
316+ // Enhance the ordinary anchor navigation only when scripts are available.
317+ let disposePanels = () => {};
318+ // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
319+ // tablist: a tablist always has a selected tab, and here every panel can be
320+ // folded. State: the index of the open panel, or -1 for none.
321+ function panels(initial = -1) {
322+ disposePanels();
323+ const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
324+ if (!footer || !nav) return;
325+ const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
326+ const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
327+ let current = -1;
328+ // show applies the state; fromUser also keeps the URL fragment in step.
329+ function show(index, fromUser = false) {
330+ current = index;
331+ tabs.forEach((tab, i) => {
332+ tab.setAttribute('aria-controls', panels[i].id);
333+ tab.setAttribute('aria-expanded', String(i === index));
334+ panels[i].hidden = i !== index;
335+ });
336+ if (!fromUser) return;
337+ if (index >= 0) {
338+ if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
339+ } else if (ids.has(location.hash)) {
340+ history.replaceState(null, '', location.pathname + location.search);
341+ }
342+ }
343+ const toggle = (i) => show(i === current ? -1 : i, true);
344+ tabs.forEach((tab, i) => {
345+ tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); });
346+ tab.addEventListener('keydown', (e) => {
347+ const next = core.tabIndex(e.key, i, tabs.length);
348+ if (next === null) return;
349+ e.preventDefault(); tabs[next].focus();
350+ });
351+ });
352+ // hash opens the panel of the URL fragment. Its target was hidden when the
353+ // browser scrolled, so the panel is brought in; never on a refresh, which
354+ // keeps the scroll.
355+ function hash() {
356+ const h = location.hash;
357+ const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
358+ if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); }
359+ }
360+ // The checks badge always opens Checks; it never folds it.
361+ const checksClick = (e) => {
362+ if (e.target.closest?.('#mr-ck')) show(1);
363+ };
364+ document.addEventListener('click', checksClick);
365+ let observer;
366+ const summary = $('#mr-ck');
367+ if (summary) {
368+ // The live stream replaces #mr-ck as a whole: watch only the children of
369+ // its parent, and write the label only when it changes. hx-live rewrites
370+ // other text in the same bar on every mutation of the document; a write
371+ // here on each of them would wake hx-live again, a loop that never yields.
372+ const sync = () => {
373+ const label = $('[data-checks-label]', nav);
374+ const text = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks';
375+ if (label && label.textContent !== text) label.textContent = text;
376+ };
377+ observer = new MutationObserver(sync);
378+ observer.observe(summary.parentElement, { childList: true });
379+ sync();
380+ }
381+ show(initial);
382+ footer.setAttribute('data-panels', '');
383+ if (initial < 0) hash();
384+ addEventListener('hashchange', hash);
385+ disposePanels = () => {
386+ document.removeEventListener?.('click', checksClick);
387+ window.removeEventListener?.('hashchange', hash);
388+ observer?.disconnect?.();
389+ };
390+ }
391+
392+ let disposeTouch = () => {};
393+ function touchComments(scroll = true) {
394+ disposeTouch();
395+ const diffs = $('#diffs');
396+ const bar = document.createElement('div');
397+ bar.className = 'mr-touch-range';
398+ const button = document.createElement('button');
399+ button.type = 'button';
400+ button.className = 'btn';
401+ button.textContent = 'Select line range';
402+ button.setAttribute('aria-pressed', 'false');
403+ const status = document.createElement('span');
404+ status.setAttribute('role', 'status');
405+ bar.append(button, status);
406+ diffs.prepend(bar);
407+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
408+ refreshTouchComments();
409+ const touch = window.matchMedia?.('(pointer: coarse), (hover: none)');
410+ let enabled = false, start = null, marker = null;
411+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
412+ button.addEventListener('click', () => {
413+ enabled = !enabled;
414+ clear();
415+ button.setAttribute('aria-pressed', String(enabled));
416+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
417+ });
418+ diffs.addEventListener('click', (e) => {
419+ const link = e.target.closest?.('.ln a[href*="on="]');
420+ if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
421+ e.preventDefault();
422+ e.stopPropagation(); // do not alter the desktop shift-click anchor
423+ const next = core.touchRange(start, link.href, location.href);
424+ clear();
425+ if (next.url) { location.assign(next.url); return; }
426+ start = next.start;
427+ marker = link;
428+ marker.setAttribute('data-range-start', '');
429+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
430+ });
431+ const changed = () => {
432+ if (touch.matches) return;
433+ enabled = false;
434+ clear();
435+ button.setAttribute('aria-pressed', 'false');
436+ status.textContent = '';
437+ };
438+ touch?.addEventListener('change', changed);
439+ disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
440+ // The server opens the line form. Scroll after layout without stealing
441+ // focus or forcing a mobile keyboard to appear.
442+ if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
443+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
444+ form?.scrollIntoView({ block: 'center' });
445+ });
446+ }
447+
448+ // A refresh scheduler has one request in flight, a trailing debounce, and
449+ // a dirty bit for pushes received during that request. Drafts are checked
450+ // again after the fetch: typing while the network is slow is safe too.
451+ function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
452+ let timer, busy = false, pending = false;
453+ const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
454+ async function run() {
455+ if (busy || !pending) return;
456+ if (blocked()) { notice(true); return; }
457+ busy = true;
458+ pending = false;
459+ try {
460+ const page = await fetchPage();
461+ if (blocked()) { pending = true; notice(true); return; }
462+ if (await apply(page) === false) { pending = true; notice(true); }
463+ else notice(false);
464+ } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
465+ finally {
466+ busy = false;
467+ if (pending && !blocked()) schedule();
468+ }
469+ }
470+ return {
471+ request() { pending = true; schedule(); },
472+ resume() { if (pending) schedule(); },
473+ pending() { return pending || busy; },
474+ };
475+ }
476+
477+ function hasDraft() {
478+ // Only form controls, never diff rows. Inline edits live in .cmt forms; the
479+ // merge form (d-acts) holds a chosen mode and the delete-branch box.
480+ return $$(':is(form.cform, .cmt form, form.d-acts) :is(textarea, input, select)').some((t) => {
481+ if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
482+ if (document.activeElement === t) return true;
483+ if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
484+ if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
485+ return t.value !== t.defaultValue;
486+ }) ||
487+ !!document.activeElement?.closest?.('.cform');
488+ }
489+
490+ // Path + side + line is stable when files are inserted before this one;
491+ // fN is only a positional anchor. Hidden unified/split rows are ignored.
492+ function scrollAnchor() {
493+ const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
494+ $('.mr-bar')?.getBoundingClientRect().bottom || 0);
495+ const diffs = $('#diffs');
496+ const bounds = diffs.getBoundingClientRect();
497+ const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
498+ // A bounded number of probes, independent of the number of diff lines.
499+ let y = top + 2, fallback;
500+ for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
501+ const hit = document.elementFromPoint(x, y);
502+ const file = hit?.closest('details.dfile');
503+ if (!file || !diffs.contains(file)) { y += 32; continue; }
504+ const row = hit.closest('tr');
505+ const link = row && $('.ln a[href*="on="]', row);
506+ const header = $(':scope > summary', file);
507+ const headerOffset = file.getBoundingClientRect().top;
508+ fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
509+ if (link) return { ...fallback,
510+ on: new URL(link.href, location.href).searchParams.get('on'),
511+ suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
512+ offset: row.getBoundingClientRect().top };
513+ if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
514+ else return fallback;
515+ }
516+ if (fallback) return fallback;
517+ return { y: scrollY };
518+ }
519+
520+ function restoreAnchor(anchor) {
521+ const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
522+ let el, offset = anchor.offset;
523+ if (file && anchor.on) {
524+ const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
525+ const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
526+ const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
527+ if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
528+ }
529+ if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
530+ if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
531+ else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
532+ }
533+
534+ async function fetchPage(url = location.href, mobileRetry = false) {
535+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
536+ if (!res.ok || res.redirected) throw new Error('MR refresh refused');
537+ const page = new DOMParser().parseFromString(await res.text(), 'text/html');
538+ if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
539+ const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
540+ !!window.matchMedia?.('(max-width: 720px)').matches);
541+ if (unified && !mobileRetry) return fetchPage(unified, true);
542+ return page;
543+ }
544+
545+ // htmx 4's public delete swap invokes recursive before:cleanup (including
546+ // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
547+ // string is parsed; the fetched nodes themselves are adopted, never serialized.
548+ async function replaceRegion(old, fresh) {
549+ if (!old || !fresh) throw new Error('missing MR refresh region');
550+ for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
551+ const marker = document.createComment('MR region');
552+ old.before(marker);
553+ const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
554+ swap: 'delete settle:0 transition:false', push: false, replace: false });
555+ if (old.matches('.hint-live')) {
556+ window.ermiteLive.resumeStream(fresh);
557+ await window.ermiteLive.teardown(remove);
558+ } else await remove();
559+ const node = document.adoptNode(fresh);
560+ marker.replaceWith(node);
561+ window.htmx.process(node);
562+ }
563+
564+ async function refreshPage(page) {
565+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
566+ for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
567+ let anchor = scrollAnchor();
568+ const nextDiffs = $('#diffs', page);
569+ const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
570+ const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
571+ if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
572+ // A one-layout response must use its own contract, never the old page's.
573+ if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
574+ const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
575+ const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
576+ if (placeholder) {
577+ const d = nextDiffs.dataset;
578+ const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
579+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
580+ if (!res.ok || res.redirected) throw new Error('anchor window changed');
581+ let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
582+ if (hasDraft()) return false;
583+ anchor = scrollAnchor();
584+ const windowFiles = $$('details.dfile', windowPage);
585+ // If the reader moved outside this window, discard it; lazy loading will
586+ // fetch the new position rather than committing an unrelated window.
587+ if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
588+ const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
589+ for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
590+ }
591+ windowPage = null;
592+ }
593+ if (hasDraft()) return false;
594+ const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
595+ const folded = $('.r-files')?.hasAttribute('data-folded');
596+ const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
597+ const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
598+ const active = document.activeElement;
599+ const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
600+ for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601+ ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
602+ $('#diffs', page).dataset.mrStarted = 'true';
603+ // The fresh footer comes in with the panels as they are, so that an open
604+ // panel does not fold and open again while the other regions swap.
605+ const freshFooter = $('.mr-footer', page);
606+ if (freshFooter) {
607+ freshFooter.setAttribute('data-panels', '');
608+ $$('[data-mr-panel]', freshFooter).forEach((p, i) => { p.hidden = i !== openPanel; });
609+ }
610+ for (const sel of regions) {
611+ const fresh = $(sel, page);
612+ await replaceRegion($(sel), fresh);
613+ }
614+ for (const key of ['ref', 'sha', 'paths', 'blob']) {
615+ if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
616+ }
617+ const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
618+ if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
619+ page = null;
620+ panels(openPanel);
621+ foldFiles(!!folded);
622+ if ($('.rail')) $('.rail').scrollTop = railY;
623+ if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
624+ touchComments(false);
625+ $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
626+ window.ermite?.refreshFiles?.();
627+ watch();
628+ if (focus && active !== document.body) {
629+ const region = focus.region ? $(focus.region) : document;
630+ const menu = focus.menu && document.getElementById(focus.menu);
631+ if (menu) menu.open = true;
632+ const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
633+ focus.id ? document.getElementById(focus.id) :
634+ focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
635+ focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
636+ menu?.querySelector(':scope > summary');
637+ el?.focus({ preventScroll: true });
638+ }
639+ restoreAnchor(anchor);
640+ }
641+
642+ function refreshNotice(deferred) {
643+ const slot = $('#mr-refresh-status');
644+ if (!slot) return;
645+ slot.replaceChildren();
646+ if (!deferred) {
647+ const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
648+ return;
649+ }
650+ slot.append('branch updated — refreshing after your comment. ');
651+
652+ }
653+ const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
654+ failed: () => window.ermiteLive?.reload() });
655+ function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
656+
288 function start() {657 function start() {
289 if (!$('#diffs[data-mr]')) return;658 if (!$('#diffs[data-mr]')) return;
659+ if (window.ermiteLive) window.ermiteLive.canReload = () => {
660+ if (!hasDraft()) return true;
661+ headRefresh.request(); refreshNotice(true); return false;
662+ };
663+ // Global delegation is installed once; replacement regions have their own
664+ // disposable panel/touch hooks below.
665+ if ($('#diffs').dataset.mrStarted) return;
666+ $('#diffs').dataset.mrStarted = 'true';
667+ for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
668+ document.addEventListener?.('click', (e) => {
669+ const cancel = e.target.closest?.('.cform .f-btns a');
670+ if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
671+ e.preventDefault();
672+ const form = cancel.closest('.cform');
673+ form.reset();
674+ if (form.contains(document.activeElement)) document.activeElement.blur();
675+ headRefresh.resume();
676+ });
677+ panels();
678+ // Large diffs may contain only the split table. Ask the existing server
679+ // route for unified rather than writing the saved session preference.
680+ const narrow = window.matchMedia?.('(max-width: 720px)');
681+ const unify = () => {
682+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
683+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
684+ if (url) { location.replace(url); return true; }
685+ return false;
686+ };
687+ if (unify()) return;
688+ narrow?.addEventListener('change', unify);
689+ addEventListener('click', (e) => {
690+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
691+ e.preventDefault();
692+ e.stopImmediatePropagation();
693+ }
694+ }, true);
695+ touchComments();
290 let folded = false;696 let folded = false;
291 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }697 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292 if (folded) foldFiles(true);698 if (folded) foldFiles(true);
@@ -316,7 +722,7 @@ window.ermiteMR = (() => {
316 });722 });
317 }723 }
318 724
319- return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };725+ return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
320 })();726 })();
321 727
322 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());728 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
M internal/app/testdata/web/commit-big.html +3 -3
@@ -127,12 +127,12 @@
127127 </span>
128128 <details class="menu vmenu">
129129 <summary class="btn" aria-label="Diff view">
130-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
130+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
131131 </summary>
132132 <div class="menu-pop" role="group" aria-label="Diff view">
133-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
133+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
134134 </a>
135-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
135+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
136136 </a>
137137 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
138138 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -127,12 +127,12 @@
127 </span>127 </span>
128 <details class="menu vmenu">128 <details class="menu vmenu">
129 <summary class="btn" aria-label="Diff view">129 <summary class="btn" aria-label="Diff view">
130-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>130+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
131 </summary>131 </summary>
132 <div class="menu-pop" role="group" aria-label="Diff view">132 <div class="menu-pop" role="group" aria-label="Diff view">
133-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>133+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
134 </a>134 </a>
135-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>135+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
136 </a>136 </a>
137 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">137 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
138 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>138 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-binary.html +3 -3
@@ -123,12 +123,12 @@
123123 </span>
124124 <details class="menu vmenu">
125125 <summary class="btn" aria-label="Diff view">
126-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127127 </summary>
128128 <div class="menu-pop" role="group" aria-label="Diff view">
129-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
130130 </a>
131-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
131+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
132132 </a>
133133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -123,12 +123,12 @@
123 </span>123 </span>
124 <details class="menu vmenu">124 <details class="menu vmenu">
125 <summary class="btn" aria-label="Diff view">125 <summary class="btn" aria-label="Diff view">
126-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>126+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127 </summary>127 </summary>
128 <div class="menu-pop" role="group" aria-label="Diff view">128 <div class="menu-pop" role="group" aria-label="Diff view">
129-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>129+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
130 </a>130 </a>
131-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>131+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
132 </a>132 </a>
133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-merge.html +3 -3
@@ -122,12 +122,12 @@
122122 </span>
123123 <details class="menu vmenu">
124124 <summary class="btn" aria-label="Diff view">
125-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
125+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126126 </summary>
127127 <div class="menu-pop" role="group" aria-label="Diff view">
128-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
128+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129129 </a>
130-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
130+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
131131 </a>
132132 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
133133 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -122,12 +122,12 @@
122 </span>122 </span>
123 <details class="menu vmenu">123 <details class="menu vmenu">
124 <summary class="btn" aria-label="Diff view">124 <summary class="btn" aria-label="Diff view">
125-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>125+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126 </summary>126 </summary>
127 <div class="menu-pop" role="group" aria-label="Diff view">127 <div class="menu-pop" role="group" aria-label="Diff view">
128-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>128+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129 </a>129 </a>
130-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>130+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
131 </a>131 </a>
132 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">132 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
133 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>133 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-modify-split.html +3 -3
@@ -129,12 +129,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129129 </span>
130130 <details class="menu vmenu">
131131 <summary class="btn" aria-label="Diff view">
132-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>
132+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>
133133 </summary>
134134 <div class="menu-pop" role="group" aria-label="Diff view">
135-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
135+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
136136 </a>
137-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
137+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" aria-current="true" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
138138 </a>
139139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -129,12 +129,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129 </span>129 </span>
130 <details class="menu vmenu">130 <details class="menu vmenu">
131 <summary class="btn" aria-label="Diff view">131 <summary class="btn" aria-label="Diff view">
132-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>132+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>
133 </summary>133 </summary>
134 <div class="menu-pop" role="group" aria-label="Diff view">134 <div class="menu-pop" role="group" aria-label="Diff view">
135-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>135+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
136 </a>136 </a>
137-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>137+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" aria-current="true" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
138 </a>138 </a>
139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-modify.html +3 -3
@@ -129,12 +129,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129129 </span>
130130 <details class="menu vmenu">
131131 <summary class="btn" aria-label="Diff view">
132-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
132+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
133133 </summary>
134134 <div class="menu-pop" role="group" aria-label="Diff view">
135-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
135+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
136136 </a>
137-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
137+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
138138 </a>
139139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -129,12 +129,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129 </span>129 </span>
130 <details class="menu vmenu">130 <details class="menu vmenu">
131 <summary class="btn" aria-label="Diff view">131 <summary class="btn" aria-label="Diff view">
132-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>132+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
133 </summary>133 </summary>
134 <div class="menu-pop" role="group" aria-label="Diff view">134 <div class="menu-pop" role="group" aria-label="Diff view">
135-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>135+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
136 </a>136 </a>
137-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>137+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
138 </a>138 </a>
139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>140 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-rename.html +3 -3
@@ -123,12 +123,12 @@
123123 </span>
124124 <details class="menu vmenu">
125125 <summary class="btn" aria-label="Diff view">
126-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127127 </summary>
128128 <div class="menu-pop" role="group" aria-label="Diff view">
129-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
130130 </a>
131-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
131+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
132132 </a>
133133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -123,12 +123,12 @@
123 </span>123 </span>
124 <details class="menu vmenu">124 <details class="menu vmenu">
125 <summary class="btn" aria-label="Diff view">125 <summary class="btn" aria-label="Diff view">
126-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>126+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127 </summary>127 </summary>
128 <div class="menu-pop" role="group" aria-label="Diff view">128 <div class="menu-pop" role="group" aria-label="Diff view">
129-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>129+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
130 </a>130 </a>
131-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>131+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
132 </a>132 </a>
133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>134 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-root.html +3 -3
@@ -121,12 +121,12 @@
121121 </span>
122122 <details class="menu vmenu">
123123 <summary class="btn" aria-label="Diff view">
124-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
124+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
125125 </summary>
126126 <div class="menu-pop" role="group" aria-label="Diff view">
127-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
127+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
128128 </a>
129-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
129+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
130130 </a>
131131 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
132132 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -121,12 +121,12 @@
121 </span>121 </span>
122 <details class="menu vmenu">122 <details class="menu vmenu">
123 <summary class="btn" aria-label="Diff view">123 <summary class="btn" aria-label="Diff view">
124-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>124+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
125 </summary>125 </summary>
126 <div class="menu-pop" role="group" aria-label="Diff view">126 <div class="menu-pop" role="group" aria-label="Diff view">
127-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>127+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
128 </a>128 </a>
129-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>129+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
130 </a>130 </a>
131 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">131 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
132 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>132 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commit-trojan.html +3 -3
@@ -124,12 +124,12 @@
124124 </span>
125125 <details class="menu vmenu">
126126 <summary class="btn" aria-label="Diff view">
127-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
128128 </summary>
129129 <div class="menu-pop" role="group" aria-label="Diff view">
130-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
130+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131131 </a>
132-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
132+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
133133 </a>
134134 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
135135 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
@@ -124,12 +124,12 @@
124 </span>124 </span>
125 <details class="menu vmenu">125 <details class="menu vmenu">
126 <summary class="btn" aria-label="Diff view">126 <summary class="btn" aria-label="Diff view">
127-<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>127+<span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
128 </summary>128 </summary>
129 <div class="menu-pop" role="group" aria-label="Diff view">129 <div class="menu-pop" role="group" aria-label="Diff view">
130-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>130+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131 </a>131 </a>
132-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>132+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
133 </a>133 </a>
134 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">134 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
135 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>135 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
M internal/app/testdata/web/commits-root.html +1 -0
@@ -973,6 +973,7 @@
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976+ mr-head -> ermiteMR.headChanged();
976977 gap -> ermiteLive.reload()">
977978 </span>
978979 </div>
@@ -973,6 +973,7 @@
973 htmx:sse:error -> ermiteLive.error(event.detail.status);973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974 htmx:sse:before:connection -> ermiteLive.gate(event);974 htmx:sse:before:connection -> ermiteLive.gate(event);
975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976+ mr-head -> ermiteMR.headChanged();
976 gap -> ermiteLive.reload()">977 gap -> ermiteLive.reload()">
977 </span>978 </span>
978 </div>979 </div>
M internal/app/testdata/web/commits-small.html +1 -0
@@ -318,6 +318,7 @@
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321+ mr-head -> ermiteMR.headChanged();
321322 gap -> ermiteLive.reload()">
322323 </span>
323324 </div>
@@ -318,6 +318,7 @@
318 htmx:sse:error -> ermiteLive.error(event.detail.status);318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319 htmx:sse:before:connection -> ermiteLive.gate(event);319 htmx:sse:before:connection -> ermiteLive.gate(event);
320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321+ mr-head -> ermiteMR.headChanged();
321 gap -> ermiteLive.reload()">322 gap -> ermiteLive.reload()">
322 </span>323 </span>
323 </div>324 </div>
M internal/app/testdata/web/home.html +1 -0
@@ -194,6 +194,7 @@
194194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195195 htmx:sse:before:connection -> ermiteLive.gate(event);
196196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197+ mr-head -> ermiteMR.headChanged();
197198 gap -> ermiteLive.reload()">
198199 </span>
199200 </div>
@@ -194,6 +194,7 @@
194 htmx:sse:error -> ermiteLive.error(event.detail.status);194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195 htmx:sse:before:connection -> ermiteLive.gate(event);195 htmx:sse:before:connection -> ermiteLive.gate(event);
196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197+ mr-head -> ermiteMR.headChanged();
197 gap -> ermiteLive.reload()">198 gap -> ermiteLive.reload()">
198 </span>199 </span>
199 </div>200 </div>
M internal/app/testdata/web/refs.html +1 -0
@@ -200,6 +200,7 @@
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203+ mr-head -> ermiteMR.headChanged();
203204 gap -> ermiteLive.reload()">
204205 </span>
205206 </div>
@@ -200,6 +200,7 @@
200 htmx:sse:error -> ermiteLive.error(event.detail.status);200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201 htmx:sse:before:connection -> ermiteLive.gate(event);201 htmx:sse:before:connection -> ermiteLive.gate(event);
202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203+ mr-head -> ermiteMR.headChanged();
203 gap -> ermiteLive.reload()">204 gap -> ermiteLive.reload()">
204 </span>205 </span>
205 </div>206 </div>
M internal/app/testdata/web/summary.html +1 -0
@@ -226,6 +226,7 @@
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229+ mr-head -> ermiteMR.headChanged();
229230 gap -> ermiteLive.reload()">
230231 </span>
231232 </div>
@@ -226,6 +226,7 @@
226 htmx:sse:error -> ermiteLive.error(event.detail.status);226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227 htmx:sse:before:connection -> ermiteLive.gate(event);227 htmx:sse:before:connection -> ermiteLive.gate(event);
228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229+ mr-head -> ermiteMR.headChanged();
229 gap -> ermiteLive.reload()">230 gap -> ermiteLive.reload()">
230 </span>231 </span>
231 </div>232 </div>
M internal/app/web_events_test.go +12 -5
@@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) {
245245 }
246246 s.cancel()
247247
248- r := h.sse("topic=home", ids[0])
249- for _, want := range ids[1:] {
250- ev := r.next(t, watchWait)
251- assert.Equal(t, want, ev.id)
252- assert.Equal(t, "home-list", ev.name)
248+ // A replacement stream resumes via the URL; reconnect headers still win.
249+ for _, r := range []*sseStream{
250+ h.sse("topic=home&after="+ids[0], ""),
251+ h.sse("topic=home&after=invalid", ids[0]),
252+ } {
253+ for _, want := range ids[1:] {
254+ ev := r.next(t, watchWait)
255+ assert.Equal(t, want, ev.id)
256+ assert.Equal(t, "home-list", ev.name)
257+ }
258+ r.cancel()
253259 }
260+ r := h.sse("topic=home", ids[2])
254261 h.writeFile(wt, "live.txt", []byte("x\n"))
255262 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
256263 live := r.next(t, watchWait)
@@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) {
245 }245 }
246 s.cancel()246 s.cancel()
247 247
248- r := h.sse("topic=home", ids[0])248+ // A replacement stream resumes via the URL; reconnect headers still win.
249- for _, want := range ids[1:] {249+ for _, r := range []*sseStream{
250- ev := r.next(t, watchWait)250+ h.sse("topic=home&after="+ids[0], ""),
251- assert.Equal(t, want, ev.id)251+ h.sse("topic=home&after=invalid", ids[0]),
252- assert.Equal(t, "home-list", ev.name)252+ } {
253+ for _, want := range ids[1:] {
254+ ev := r.next(t, watchWait)
255+ assert.Equal(t, want, ev.id)
256+ assert.Equal(t, "home-list", ev.name)
257+ }
258+ r.cancel()
253 }259 }
260+ r := h.sse("topic=home", ids[2])
254 h.writeFile(wt, "live.txt", []byte("x\n"))261 h.writeFile(wt, "live.txt", []byte("x\n"))
255 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")262 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
256 live := r.next(t, watchWait)263 live := r.next(t, watchWait)
M internal/app/web_mr_test.go +4 -3
@@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
335335 all := b.get(base, as(token))
336336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337337 assert.Contains(t, all.body, "since your last read")
338- assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
338+ assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked")
339339 assert.Contains(t, all.body, "data-read>Mark read")
340340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341341 since := b.get(base+"/since", as(token))
@@ -595,8 +595,9 @@ func TestWebMergeRequestEvents(t *testing.T) {
595595
596596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
597597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
598- ev = nextNamed(t, one, "mr-notice")
599- assert.Contains(t, ev.data, "the branch changed")
598+ ev = nextNamed(t, one, "mr-head")
599+ assert.Equal(t, `<div id="mr-head"></div>`, ev.data)
600+ assert.NotContains(t, ev.data, "the branch changed")
600601
601602 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
602603 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
@@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
335 all := b.get(base, as(token))335 all := b.get(base, as(token))
336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337 assert.Contains(t, all.body, "since your last read")337 assert.Contains(t, all.body, "since your last read")
338- assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")338+ assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked")
339 assert.Contains(t, all.body, "data-read>Mark read")339 assert.Contains(t, all.body, "data-read>Mark read")
340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341 since := b.get(base+"/since", as(token))341 since := b.get(base+"/since", as(token))
@@ -595,8 +595,9 @@ func TestWebMergeRequestEvents(t *testing.T) {
595 595
596 r.writeFile(r.wt, "more.txt", []byte("more\n"))596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
598- ev = nextNamed(t, one, "mr-notice")598+ ev = nextNamed(t, one, "mr-head")
599- assert.Contains(t, ev.data, "the branch changed")599+ assert.Equal(t, `<div id="mr-head"></div>`, ev.data)
600+ assert.NotContains(t, ev.data, "the branch changed")
600 601
601 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)602 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
602 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")603 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close