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