This repository has no description
1{{ define "repo/fragments/diff" }}
2 <style>
3 #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; }
4 #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; }
5 #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; }
6 #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; }
7 #filesToggle:not(:checked) ~ div div#files { width: 0; display: none; margin-right: 0; }
8 #filesToggle:not(:checked) ~ div div#resize-files { display: none; }
9 </style>
10
11 <div id="diff-area">
12 {{ template "diffTopbar" . }}
13 {{ block "diffLayout" . }} {{ end }}
14 {{ template "fragments/resizable" }}
15 {{ template "activeFileHighlight" }}
16 {{ template "fragments/line-quote-button" }}
17 {{ template "reviewState" }}
18 </div>
19{{ end }}
20
21{{ define "diffTopbar" }}
22 {{ $diff := index . 0 }}
23 {{ $opts := index . 1 }}
24 {{ $root := "" }}
25 {{ if gt (len .) 2 }}
26 {{ $root = index . 2 }}
27 {{ end }}
28
29 {{ block "filesCheckbox" $ }} {{ end }}
30 {{ block "subsCheckbox" $ }} {{ end }}
31
32 <!-- top bar -->
33 <div class="sticky top-0 z-10 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2 {{ if $root }}mt-4{{ end }}">
34 <!-- left panel toggle -->
35 {{ template "filesToggle" . }}
36
37 <!-- stats -->
38 {{ $stat := $diff.Stats }}
39 {{ $count := len $diff.ChangedFiles }}
40 {{ template "repo/fragments/diffStatPill" $stat }}
41 <span id="changed-files-label" class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex" data-total="{{ $count }}">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span>
42
43 {{ if $root }}
44 {{ if $root.IsInterdiff }}
45 <!-- interdiff indicator -->
46 <div class="flex items-center gap-2 before:content-['|'] before:text-gray-300 dark:before:text-gray-600 before:mr-2">
47 <span class="text-xs text-gray-600 dark:text-gray-400 tracking-wide">Interdiff</span>
48 <a
49 href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ sub $root.ActiveRound 1 }}"
50 class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
51 >
52 #{{ sub $root.ActiveRound 1 }}
53 </a>
54 <span class="text-gray-400 text-xs">→</span>
55 <a
56 href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $root.ActiveRound }}"
57 class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
58 >
59 #{{ $root.ActiveRound }}
60 </a>
61 </div>
62 {{ else if ne $root.ActiveRound nil }}
63 <!-- diff round indicator -->
64 <div class="flex items-center gap-2 before:content-['|'] before:text-gray-300 dark:before:text-gray-600 before:mr-2">
65 <span class="text-xs text-gray-600 dark:text-gray-400 tracking-wide">Diff</span>
66 <span class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs border border-gray-300 dark:border-gray-600">
67 <span class="hidden md:inline">Round </span>#{{ $root.ActiveRound }}
68 </span>
69 </div>
70 {{ end }}
71 {{ end }}
72
73 <!-- spacer -->
74 <div class="flex-grow"></div>
75
76 <!-- collapse diffs -->
77 {{ template "collapseToggle" }}
78
79 <!-- diff options -->
80 {{ template "repo/fragments/diffOpts" $opts }}
81
82 <!-- right panel toggle -->
83 {{ block "subsToggle" $ }} {{ end }}
84 </div>
85
86{{ end }}
87
88{{ define "resize-grip" }}
89 {{ $id := index . 0 }}
90 {{ $target := index . 1 }}
91 {{ $direction := index . 2 }}
92 <div id="{{ $id }}"
93 data-resizer="vertical"
94 data-target="{{ $target }}"
95 data-direction="{{ $direction }}"
96 class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group">
97 <div class="w-1 h-16 group-hover:h-24 group-[.resizing]:h-24 transition-all rounded-full bg-gray-400 dark:bg-gray-500 group-hover:bg-gray-500 group-hover:dark:bg-gray-400"></div>
98 </div>
99{{ end }}
100
101{{ define "diffLayout" }}
102 {{ $diff := index . 0 }}
103 {{ $opts := index . 1 }}
104 {{ $padBottom := and (gt (len .) 2) (index . 2) }}
105 {{ $blobBase := "" }}
106 {{ if gt (len .) 3 }}{{ $blobBase = index . 3 }}{{ end }}
107
108 <div class="flex col-span-full flex-grow">
109 <!-- left panel -->
110 <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto {{ if $padBottom }}pb-12{{ end }}">
111 <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm">
112 {{ template "repo/fragments/fileTree" $diff.FileTree }}
113 </section>
114 </div>
115
116 {{ template "resize-grip" (list "resize-files" "files" "before") }}
117
118 <!-- main content -->
119 <div id="diff-files" class="flex-1 min-w-0 sticky top-12 {{ if $padBottom }}pb-12{{ end }}">
120 {{ template "diffFiles" (list $diff $opts $blobBase) }}
121 </div>
122
123 </div>
124{{ end }}
125
126{{ define "diffFiles" }}
127 {{ $diff := index . 0 }}
128 {{ $opts := index . 1 }}
129 {{ $blobBase := "" }}
130 {{ if gt (len .) 2 }}{{ $blobBase = index . 2 }}{{ end }}
131 {{ $files := $diff.ChangedFiles }}
132 {{ $isSplit := $opts.Split }}
133 <div class="flex flex-col gap-4">
134 {{ if eq (len $files) 0 }}
135 <div class="text-center text-gray-500 dark:text-gray-400 py-8">
136 <p>No differences found between the selected revisions.</p>
137 </div>
138 {{ else }}
139 {{ range $idx, $file := $files }}
140 {{ template "diffFile" (list $idx $file $isSplit false $blobBase) }}
141 {{ end }}
142 {{ end }}
143 </div>
144{{ end }}
145
146{{ define "diffFile" }}
147 {{ $idx := index . 0 }}
148 {{ $file := index . 1 }}
149 {{ $isSplit := index . 2 }}
150 {{ $collapsed := false }}
151 {{ if gt (len .) 3 }}{{ $collapsed = index . 3 }}{{ end }}
152 {{ $blobBase := "" }}
153 {{ if gt (len .) 4 }}{{ $blobBase = index . 4 }}{{ end }}
154 {{ $isGenerated := false }}
155 {{ $isDeleted := false }}
156 {{ with $file }}
157 {{ $n := .Names }}
158 {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }}
159 {{ if $n.New }}
160 {{ $isGenerated = isGenerated $n.New }}
161 {{ else if $n.Old }}
162 {{ $isGenerated = isGenerated $n.Old }}
163 {{ end }}
164 <details {{ if and (not $isGenerated) (not $isDeleted) (not $collapsed) }}open{{ end }} id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
165 <summary class="list-none cursor-pointer {{ if not $collapsed }}sticky top-12{{ end }} group-open:border-b border-gray-200 dark:border-gray-700">
166 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
167 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
168 <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
169 <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span>
170 {{ template "repo/fragments/diffStatPill" .Stats }}
171
172 <div class="flex gap-2 items-center overflow-x-auto">
173 {{ if and $n.New $n.Old (ne $n.New $n.Old)}}
174 {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }}
175 {{ else if $n.New }}
176 {{ $n.New }}
177 {{ else }}
178 {{ $n.Old }}
179 {{ end }}
180 {{ if $isDeleted }}
181 <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default">
182 {{ i "circle-question-mark" "size-4" }}
183 </span>
184 {{ else if $isGenerated }}
185 <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default">
186 {{ i "circle-question-mark" "size-4" }}
187 </span>
188 {{ end }}
189 </div>
190 </div>
191 <div class="flex items-center pr-1">
192 {{ if and $blobBase $n.New }}
193 <a
194 href="{{ $blobBase }}/{{ $n.New }}"
195 onclick="event.stopPropagation()"
196 class="btn-flat hidden md:flex"
197 title="Open file"
198 >
199 {{ i "eye" "size-4" }}
200 <span class="hidden md:inline">View file</span>
201 </a>
202 {{ end }}
203 <label
204 data-review-btn="file-{{ .Id }}"
205 onclick="event.stopPropagation()"
206 class="review-btn hidden p-2 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
207 title="Mark as reviewed"
208 >
209 <input
210 type="checkbox"
211 class="sr-only peer review-checkbox"
212 data-file-id="file-{{ .Id }}"
213 />
214 <span class="peer-checked:hidden">{{ i "circle" "size-4" }}</span>
215 <span class="hidden peer-checked:inline text-green-600 dark:text-green-400">{{ i "circle-check" "size-4" }}</span>
216 <span class="hidden md:inline">Reviewed</span>
217 </label>
218 </div>
219 </div>
220 </summary>
221
222 <div class="transition-all duration-700 ease-in-out">
223 {{ $reason := .CanRender }}
224 {{ if $reason }}
225 <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p>
226 {{ else }}
227 {{ if $isSplit }}
228 {{- template "repo/fragments/splitDiff" .Split -}}
229 {{ else }}
230 {{- template "repo/fragments/unifiedDiff" . -}}
231 {{ end }}
232 {{- end -}}
233 </div>
234 </details>
235 {{ end }}
236{{ end }}
237
238{{ define "filesCheckbox" }}
239 <input type="checkbox" id="filesToggle" class="peer/files hidden" {{ if gt (len .) 2 }}checked{{ end }}/>
240{{ end }}
241
242{{ define "filesToggle" }}
243 <label title="Toggle filetree panel" for="filesToggle" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normalcase">
244 <span class="show-text">{{ i "panel-left-open" "size-4" }}</span>
245 <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span>
246 </label>
247{{ end }}
248
249{{ define "collapseToggle" }}
250 <label
251 title="Expand/Collapse diffs"
252 for="collapseToggle"
253 class="btn font-normal normal-case"
254 >
255 <input type="checkbox" id="collapseToggle" class="peer/collapse hidden" checked/>
256 <span class="peer-checked/collapse:hidden inline-flex items-center gap-2">
257 {{ i "unfold-vertical" "w-4 h-4" }}
258 <span class="hidden md:inline">Expand all</span>
259 </span>
260 <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2">
261 {{ i "fold-vertical" "w-4 h-4" }}
262 <span class="hidden md:inline">Collapse all</span>
263 </span>
264 </label>
265 <script>
266 (() => {
267 const checkbox = document.getElementById('collapseToggle');
268 const diffArea = document.getElementById('diff-area');
269
270 checkbox.addEventListener('change', () => {
271 document.querySelectorAll('details[id^="file-"]').forEach(detail => {
272 detail.open = checkbox.checked;
273 });
274 });
275
276 if (window.__collapseToggleHandler) {
277 diffArea.removeEventListener('toggle', window.__collapseToggleHandler, true);
278 }
279
280 const handler = (e) => {
281 if (!e.target.matches('details[id^="file-"]')) return;
282 const details = document.querySelectorAll('details[id^="file-"]');
283 const allOpen = Array.from(details).every(d => d.open);
284 const allClosed = Array.from(details).every(d => !d.open);
285
286 if (allOpen) checkbox.checked = true;
287 else if (allClosed) checkbox.checked = false;
288 };
289
290 window.__collapseToggleHandler = handler;
291 diffArea.addEventListener('toggle', handler, true);
292 })();
293 </script>
294{{ end }}
295
296{{ define "activeFileHighlight" }}
297 <script>
298 (() => {
299 if (window.__activeFileScrollHandler) {
300 document.removeEventListener('scroll', window.__activeFileScrollHandler);
301 }
302
303 const filetreeLinks = document.querySelectorAll('.filetree-link');
304 if (filetreeLinks.length === 0) return;
305
306 const linkMap = new Map();
307 filetreeLinks.forEach(link => {
308 const path = link.getAttribute('data-path');
309 if (path) linkMap.set('file-' + path, link);
310 });
311
312 let currentActive = null;
313 const setActive = (link) => {
314 if (link && link !== currentActive) {
315 if (currentActive) currentActive.classList.remove('font-bold');
316 link.classList.add('font-bold');
317 currentActive = link;
318 }
319 };
320
321 filetreeLinks.forEach(link => {
322 link.addEventListener('click', () => setActive(link));
323 });
324
325 const topbar = document.querySelector('.sticky.top-0.z-30');
326 const headerHeight = topbar ? topbar.offsetHeight : 0;
327
328 const updateActiveFile = () => {
329 const diffFiles = document.querySelectorAll('details[id^="file-"]');
330 Array.from(diffFiles).some(file => {
331 const rect = file.getBoundingClientRect();
332 if (rect.top <= headerHeight && rect.bottom > headerHeight) {
333 setActive(linkMap.get(file.id));
334 return true;
335 }
336 return false;
337 });
338 };
339
340 window.__activeFileScrollHandler = updateActiveFile;
341 document.addEventListener('scroll', updateActiveFile);
342 updateActiveFile();
343 })();
344 </script>
345{{ end }}
346
347{{ define "reviewState" }}
348 <script>
349 (() => {
350 const linkBase = document.getElementById('round-link-base');
351 if (!linkBase) return;
352
353 const isInterdiff = !!document.getElementById('is-interdiff');
354 const basePath = linkBase.value.replace(/^\//, '');
355 const storageKey = 'reviewed:' + basePath + (isInterdiff ? '/interdiff' : '');
356
357 const REVIEWED_PREFIX = 'reviewed:';
358 const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
359
360 const load = () => {
361 try {
362 const entry = JSON.parse(localStorage.getItem(storageKey) || '{}');
363 return new Set(Array.isArray(entry) ? entry : (entry.files || []));
364 }
365 catch { return new Set(); }
366 };
367
368 const save = (reviewed) => {
369 const liveIds = new Set(Array.from(allFiles()).map(d => d.id));
370 localStorage.setItem(storageKey, JSON.stringify({
371 files: Array.from(reviewed).filter(id => liveIds.has(id)),
372 ts: Date.now(),
373 }));
374 };
375
376 const pruneStale = () => {
377 const now = Date.now();
378 Object.keys(localStorage)
379 .filter(k => k.startsWith(REVIEWED_PREFIX) && k !== storageKey)
380 .forEach(k => {
381 try {
382 const entry = JSON.parse(localStorage.getItem(k));
383 if (!entry.ts || now - entry.ts > MAX_AGE_MS) localStorage.removeItem(k);
384 } catch { localStorage.removeItem(k); }
385 });
386 };
387 if (Math.random() < 0.1) pruneStale();
388
389 const allFiles = () =>
390 document.querySelectorAll('details[id^="file-"]');
391
392 const applyOne = (fileId, isReviewed) => {
393 const detail = document.getElementById(fileId);
394 if (!detail) return;
395
396 const btn = detail.querySelector('[data-review-btn]');
397 const checkbox = btn?.querySelector('input[type="checkbox"]');
398 const path = CSS.escape(fileId.replace('file-', ''));
399 const treeLink = document.querySelector(`.filetree-link[data-path="${path}"]`);
400
401 detail.classList.toggle('opacity-60', isReviewed);
402
403 if (checkbox) checkbox.checked = isReviewed;
404
405 if (treeLink) {
406 const existing = treeLink.parentElement.querySelector('.review-indicator');
407 if (isReviewed && !existing) {
408 const indicator = document.createElement('span');
409 indicator.className = 'review-indicator text-green-600 dark:text-green-400 flex-shrink-0';
410 indicator.innerHTML = '✓';
411 treeLink.parentElement.appendChild(indicator);
412 } else if (!isReviewed && existing) {
413 existing.remove();
414 }
415 }
416 };
417
418 const updateProgress = (reviewed) => {
419 const el = document.getElementById('changed-files-label');
420 if (!el) return;
421 const total = parseInt(el.dataset.total, 10);
422 const files = allFiles();
423 const count = Array.from(files).filter(d => reviewed.has(d.id)).length;
424 const suffix = total === 1 ? 'file' : 'files';
425 const allDone = count === total;
426 el.classList.toggle('text-green-600', allDone);
427 el.classList.toggle('dark:text-green-400', allDone);
428 el.classList.toggle('text-gray-600', !allDone);
429 el.classList.toggle('dark:text-gray-400', !allDone);
430 el.textContent = count > 0
431 ? `${count}/${total} ${suffix} reviewed`
432 : `${total} changed ${suffix}`;
433 };
434
435 const reviewed = load();
436
437 const toggleReview = (fileId) => {
438 const detail = document.getElementById(fileId);
439 if (!detail) return;
440 const isNowReviewed = !reviewed.has(fileId);
441 if (isNowReviewed) {
442 reviewed.add(fileId);
443 detail.open = false;
444 } else {
445 reviewed.delete(fileId);
446 }
447 save(reviewed);
448 applyOne(fileId, isNowReviewed);
449 updateProgress(reviewed);
450 };
451
452 document.getElementById('diff-area').addEventListener('change', (e) => {
453 const checkbox = e.target.closest('.review-checkbox');
454 if (!checkbox) return;
455 const fileId = checkbox.dataset.fileId;
456 if (fileId) toggleReview(fileId);
457 });
458
459 document.querySelectorAll('.review-btn').forEach(btn => {
460 btn.classList.remove('hidden');
461 btn.classList.add('flex');
462 });
463
464 allFiles().forEach(detail => {
465 if (reviewed.has(detail.id)) {
466 applyOne(detail.id, true);
467 detail.open = false;
468 }
469 });
470 updateProgress(reviewed);
471 })();
472 </script>
473{{ end }}