This repository has no description
1{{ define "repo/pulls/fragments/diff" }}
2 <div id="diff-files-content" hx-swap-oob="outerHTML">
3 {{ template "repo/fragments/fileTree" .FileTree }}
4 </div>
5 <div id="diff-stats" hx-swap-oob="outerHTML">
6 {{ template "repo/fragments/diffStatPill" .Stats }}
7 </div>
8 <div id="diff-settings" hx-swap-oob="outerHTML">
9 {{ template "repo/pulls/fragments/diffSettings"
10 (dict "DiffUrl" .DiffUrl
11 "Unified" .Unified) }}
12 </div>
13 <script>
14 (() => {
15 if (window.__diffRestrictSelect) return;
16 window.__diffRestrictSelect = true;
17 document.addEventListener('mousedown', (e) => {
18 document.querySelectorAll('.diff[data-restrict-select]')
19 .forEach(d => d.removeAttribute('data-restrict-select'));
20 const side = e.target.closest('.diff-side');
21 if (!side) return;
22 const diff = side.closest('.diff');
23 if (!diff) return;
24 const isLeft = side === side.parentElement.firstElementChild;
25 diff.setAttribute('data-restrict-select', isLeft ? 'left' : 'right');
26 });
27 })();
28 </script>
29 <div id="diff-list" class="space-y-4">
30 {{ if .ErrorMsg }}
31 <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700">
32 <span>{{ .ErrorMsg }}</span>
33 </div>
34 {{ else if .Files }}
35 {{ range .Files }}
36 <div class="drop-shadow-sm rounded overflow-clip">
37 <details id="file-{{ .Id }}" class="group w-full" open>
38 <summary class="list-none cursor-pointer sticky top-12 z-10 bg-slate-100 dark:bg-gray-900">
39 <div class="flex justify-between rounded border bg-white dark:bg-gray-800 group-open:rounded-b-none border-gray-200 dark:border-gray-700">
40 <div class="p-2 flex gap-2 items-center">
41 <span class="group-open:hidden inline">{{ i "chevron-right" "size-4" }}</span>
42 <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span>
43 {{ template "repo/fragments/diffStatPill" .Stats }}
44 <span>{{ .Path }}</span>
45 </div>
46 <div class="px-2 flex gap-2 items-center">
47 <label
48 data-review-btn="file-{{ .Id }}"
49 onclick="event.stopPropagation()"
50 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"
51 title="Mark as reviewed"
52 >
53 <input
54 type="checkbox"
55 class="sr-only peer review-checkbox"
56 data-file-id="file-{{ .Id }}"
57 />
58 <span class="peer-checked:hidden">{{ i "circle" "size-4" }}</span>
59 <span class="hidden peer-checked:inline text-green-600 dark:text-green-400">{{ i "circle-check" "size-4" }}</span>
60 <span class="hidden md:inline">Reviewed</span>
61 </label>
62 </div>
63 </div>
64 </summary>
65
66 <div class="rounded-b overflow-clip border-x border-b border-gray-200 dark:border-gray-700">
67 {{ if .Note }}
68 <div class="p-4 text-center text-gray-400 dark:text-gray-500 text-sm">{{ .Note }}</div>
69 {{ else if $.Unified }}
70 {{ template "diffUnified" . }}
71 {{ else }}
72 {{ template "diffSplit" . }}
73 {{ end }}
74 </div>
75 </details>
76 </div>
77 {{ end }}
78 {{ else }}
79 <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700">
80 <span>No change between two revisions.</span>
81 </div>
82 {{ end }}
83 </div>
84 {{ template "activeFileHighlightScript" }}
85 {{ template "fragments/line-quote-button" }}
86 {{ template "reviewStateScript" }}
87{{ end }}
88
89{{ define "diffUnified" }}
90<div class="diff w-full">
91 {{ $name := .Id }}
92 {{- range .Hunks -}}
93 {{- if not .AtFileStart -}}<div class="diff-splitter">···</div>{{- end -}}
94 {{- range $i, $line := .Lines -}}
95 {{- $lineId := "" -}}
96 {{- if ge .New 0 -}}
97 {{- $lineId = printf "%s-N%d" $name .New -}}
98 {{- else -}}
99 {{- $lineId = printf "%s-O%d" $name .Old -}}
100 {{- end -}}
101 {{- $cls := "" -}}
102 {{- if eq .Op "+" -}}
103 {{- $cls = "add" -}}
104 {{- else if eq .Op "-" -}}
105 {{- $cls = "del" -}}
106 {{- end -}}
107 <div id="{{ $lineId }}" class="diff-line {{ $cls }}">
108 <a href="#{{ $lineId }}" class="diff-num">
109 <span>{{ if gt .Old 0 }}{{ .Old }}{{ end }}</span>
110 <span>{{ if gt .New 0 }}{{ .New }}{{ end }}</span>
111 </a>
112 <span class="diff-indicator">{{ .Op }}</span>
113 <div class="diff-content">{{ .Content }}</div>
114 </div>
115 {{- end -}}
116 {{- end -}}
117</div>
118{{ end }}
119
120{{ define "diffSplit" }}
121<div class="diff w-full">
122 {{ $name := .Id }}
123 {{- range .Hunks -}}
124 {{- if not .AtFileStart -}}<div class="diff-splitter">···</div>{{- end -}}
125 {{- range $i, $row := .Rows -}}
126 <div class="diff-line">
127 {{- template "diffSplitSide" (list $name "O" $row.Left) -}}
128 {{- template "diffSplitSide" (list $name "N" $row.Right) -}}
129 </div>
130 {{- end -}}
131 {{- end -}}
132</div>
133{{ end }}
134
135{{ define "diffSplitSide" }}
136 {{- $name := index . 0 -}}
137 {{- $side := index . 1 -}}
138 {{- $line := index . 2 -}}
139 {{- $lineId := printf "%s-%s%d" $name $side $line.Num -}}
140 {{- $cls := "" -}}
141 {{- $mark := "" -}}
142 {{- if eq $line.Kind "del" }}{{ $cls = "del" }}{{ $mark = "-" -}}
143 {{- else if eq $line.Kind "add" }}{{ $cls = "add" }}{{ $mark = "+" -}}
144 {{- else if eq $line.Kind "empty" }}{{ $cls = "empty" -}}
145 {{- end -}}
146 <div {{ if ge $line.Num 0 }}id="{{ $lineId }}"{{ end }} class="diff-side {{ $cls }}">
147 {{ if gt $line.Num 0 -}}
148 <a href="#{{ $lineId }}" class="diff-num"><span>{{ $line.Num }}</span></a>
149 {{- else -}}
150 <span aria-hidden="true" class="diff-num"><span></span></span>
151 {{- end }}
152 <span class="diff-indicator">{{ $mark }}</span>
153 <div class="diff-content">{{ $line.Content }}</div>
154 </div>
155{{ end }}
156
157{{ define "activeFileHighlightScript" }}
158 <script>
159 (() => {
160 if (window.__activeFileScrollHandler) {
161 document.removeEventListener('scroll', window.__activeFileScrollHandler);
162 }
163
164 const filetreeLinks = document.querySelectorAll('.filetree-link');
165 if (filetreeLinks.length === 0) return;
166
167 const linkMap = new Map();
168 filetreeLinks.forEach(link => {
169 const path = link.getAttribute('data-path');
170 if (path) linkMap.set('file-' + path, link);
171 });
172
173 let currentActive = null;
174 const setActive = (link) => {
175 if (link && link !== currentActive) {
176 if (currentActive) currentActive.classList.remove('font-bold');
177 link.classList.add('font-bold');
178 currentActive = link;
179 }
180 };
181
182 filetreeLinks.forEach(link => {
183 link.addEventListener('click', () => setActive(link));
184 });
185
186 const topbar = document.querySelector('.sticky.top-0.z-20');
187 const headerHeight = topbar ? topbar.offsetHeight : 0;
188
189 const updateActiveFile = () => {
190 const diffFiles = document.querySelectorAll('details[id^="file-"]');
191 Array.from(diffFiles).some(file => {
192 const rect = file.getBoundingClientRect();
193 if (rect.top <= headerHeight && rect.bottom > headerHeight) {
194 setActive(linkMap.get(file.id));
195 return true;
196 }
197 return false;
198 });
199 };
200
201 window.__activeFileScrollHandler = updateActiveFile;
202 document.addEventListener('scroll', updateActiveFile);
203 updateActiveFile();
204 })();
205 </script>
206{{ end }}
207
208{{ define "reviewStateScript" }}
209 <script>
210 (() => {
211 const targetRepoDid = document.getElementById('pull-target-repo-did')?.value;
212 const pullId = document.getElementById('pull-id')?.value;
213 const activeVersionId = document.getElementById('pull-active-version-id')?.value;
214 if (!targetRepoDid || !pullId || !activeVersionId) return;
215
216 const REVIEWED_PREFIX = 'reviewed:';
217 const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
218
219 const storageKey = REVIEWED_PREFIX + ':' + targetRepoDid + ':' + pullId + ':' + activeVersionId;
220
221 const load = () => {
222 try {
223 const entry = JSON.parse(localStorage.getItem(storageKey) || '{}');
224 return new Set(Array.isArray(entry) ? entry : (entry.files || []));
225 }
226 catch { return new Set(); }
227 };
228
229 const save = (reviewed) => {
230 const liveIds = new Set(Array.from(allFiles()).map(d => d.id));
231 localStorage.setItem(storageKey, JSON.stringify({
232 files: Array.from(reviewed).filter(id => liveIds.has(id)),
233 ts: Date.now(),
234 }));
235 };
236
237 const pruneStale = () => {
238 const now = Date.now();
239 Object.keys(localStorage)
240 .filter(k => k.startsWith(REVIEWED_PREFIX) && k !== storageKey)
241 .forEach(k => {
242 try {
243 const entry = JSON.parse(localStorage.getItem(k));
244 if (!entry.ts || now - entry.ts > MAX_AGE_MS) localStorage.removeItem(k);
245 } catch { localStorage.removeItem(k); }
246 });
247 };
248 if (Math.random() < 0.1) pruneStale();
249
250 const allFiles = () =>
251 document.querySelectorAll('details[id^="file-"]');
252
253 const applyOne = (fileId, isReviewed) => {
254 const detail = document.getElementById(fileId);
255 if (!detail) return;
256
257 const btn = detail.querySelector('[data-review-btn]');
258 const checkbox = btn?.querySelector('input[type="checkbox"]');
259 const path = CSS.escape(fileId.replace('file-', ''));
260 const treeLink = document.querySelector(`.filetree-link[data-path="${path}"]`);
261
262 detail.classList.toggle('opacity-60', isReviewed);
263
264 if (checkbox) checkbox.checked = isReviewed;
265
266 if (treeLink) {
267 const existing = treeLink.parentElement.querySelector('.review-indicator');
268 if (isReviewed && !existing) {
269 const indicator = document.createElement('span');
270 indicator.className = 'review-indicator text-green-600 dark:text-green-400 flex-shrink-0';
271 indicator.innerHTML = '✓';
272 treeLink.parentElement.appendChild(indicator);
273 } else if (!isReviewed && existing) {
274 existing.remove();
275 }
276 }
277 };
278
279 const updateProgress = (reviewed) => {
280 const el = document.getElementById('changed-files-label');
281 if (!el) return;
282 const total = parseInt(el.dataset.total, 10);
283 const files = allFiles();
284 const count = Array.from(files).filter(d => reviewed.has(d.id)).length;
285 const suffix = total === 1 ? 'file' : 'files';
286 const allDone = count === total;
287 el.classList.toggle('text-green-600', allDone);
288 el.classList.toggle('dark:text-green-400', allDone);
289 el.classList.toggle('text-gray-600', !allDone);
290 el.classList.toggle('dark:text-gray-400', !allDone);
291 el.textContent = count > 0
292 ? `${count}/${total} ${suffix} reviewed`
293 : `${total} changed ${suffix}`;
294 };
295
296 const reviewed = load();
297
298 const toggleReview = (fileId) => {
299 const detail = document.getElementById(fileId);
300 if (!detail) return;
301 const isNowReviewed = !reviewed.has(fileId);
302 if (isNowReviewed) {
303 reviewed.add(fileId);
304 detail.open = false;
305 } else {
306 reviewed.delete(fileId);
307 }
308 save(reviewed);
309 applyOne(fileId, isNowReviewed);
310 updateProgress(reviewed);
311 };
312
313 document.getElementById('diff-list').addEventListener('change', (e) => {
314 const checkbox = e.target.closest('.review-checkbox');
315 if (!checkbox) return;
316 const fileId = checkbox.dataset.fileId;
317 if (fileId) toggleReview(fileId);
318 });
319
320 document.querySelectorAll('.review-btn').forEach(btn => {
321 btn.classList.remove('hidden');
322 btn.classList.add('flex');
323 });
324
325 allFiles().forEach(detail => {
326 if (reviewed.has(detail.id)) {
327 applyOne(detail.id, true);
328 detail.open = false;
329 }
330 });
331 updateProgress(reviewed);
332 })();
333 </script>
334{{ end }}