|
1
|
{% extends "base.html" %} |
|
2
|
{% load fossil_filters %} |
|
3
|
{% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %} |
|
4
|
|
|
5
|
{% block extra_head %} |
|
6
|
<style> |
|
7
|
.diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
|
8
|
.diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; } |
|
9
|
.diff-line-add { background: rgba(34, 197, 94, 0.1); } |
|
10
|
.diff-line-add td:last-child { color: #86efac; } |
|
11
|
.diff-line-del { background: rgba(239, 68, 68, 0.1); } |
|
12
|
.diff-line-del td:last-child { color: #fca5a5; } |
|
13
|
.diff-line-hunk { background: rgba(96, 165, 250, 0.08); } |
|
14
|
.diff-line-hunk td { color: #93c5fd; font-weight: 500; } |
|
15
|
.diff-line-header td { color: #6b7280; font-weight: 600; } |
|
16
|
.diff-gutter { width: 1%; user-select: none; color: #4b5563; text-align: right; padding: 0 6px; border-right: 1px solid #374151; cursor: pointer; white-space: nowrap; } |
|
17
|
.diff-gutter:hover { color: #DC394C; } |
|
18
|
.diff-gutter a { color: inherit; text-decoration: none; display: block; } |
|
19
|
.line-row:target { background: rgba(220, 57, 76, 0.15) !important; } |
|
20
|
.line-row:target .diff-gutter { color: #DC394C; font-weight: 600; } |
|
21
|
.line-row { scroll-margin-top: 40vh; } |
|
22
|
/* Split diff view */ |
|
23
|
.split-diff { display: grid; grid-template-columns: 1fr; } |
|
24
|
@media (min-width: 768px) { .split-diff { grid-template-columns: 1fr 1fr; } } |
|
25
|
.split-diff-side { overflow-x: auto; } |
|
26
|
@media (min-width: 768px) { .split-diff-side:first-child { border-right: 1px solid #374151; } } |
|
27
|
@media (max-width: 767px) { .split-diff-side:first-child { border-bottom: 1px solid #374151; } } |
|
28
|
.split-diff-side .diff-table td:last-child { width: 100%; } |
|
29
|
.split-line-add { background: rgba(34, 197, 94, 0.1); } |
|
30
|
.split-line-add td:last-child { color: #86efac; } |
|
31
|
.split-line-del { background: rgba(239, 68, 68, 0.1); } |
|
32
|
.split-line-del td:last-child { color: #fca5a5; } |
|
33
|
.split-line-empty { background: rgba(107, 114, 128, 0.05); } |
|
34
|
.split-line-empty td:last-child { color: transparent; } |
|
35
|
/* Syntax highlighting: preserve diff bg colors over hljs */ |
|
36
|
.diff-code .hljs { background: transparent !important; padding: 0 !important; } |
|
37
|
.diff-code { display: inline; } |
|
38
|
.line-popover { |
|
39
|
position: absolute; left: 100%; top: 50%; transform: translateY(-50%); |
|
40
|
margin-left: 4px; z-index: 20; white-space: nowrap; |
|
41
|
background: #1f2937; border: 1px solid #374151; border-radius: 6px; |
|
42
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px; |
|
43
|
display: flex; gap: 2px; |
|
44
|
} |
|
45
|
.line-popover button { |
|
46
|
display: flex; align-items: center; gap: 4px; padding: 4px 8px; |
|
47
|
font-size: 0.7rem; color: #d1d5db; background: transparent; |
|
48
|
border: none; border-radius: 4px; cursor: pointer; |
|
49
|
} |
|
50
|
.line-popover button:hover { background: #374151; color: #fff; } |
|
51
|
.line-popover button.copied { color: #22c55e; } |
|
52
|
</style> |
|
53
|
{% endblock %} |
|
54
|
|
|
55
|
{% block content %} |
|
56
|
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
|
57
|
{% include "fossil/_project_nav.html" %} |
|
58
|
|
|
59
|
<div class="space-y-4"> |
|
60
|
<!-- Commit header --> |
|
61
|
<div class="rounded-lg bg-gray-800 border border-gray-700 shadow-sm"> |
|
62
|
<div class="px-6 py-5"> |
|
63
|
<p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p> |
|
64
|
<div class="mt-3 flex items-center gap-x-4 gap-y-1 flex-wrap text-sm"> |
|
65
|
<a href="{% url 'fossil:user_activity' slug=project.slug username=checkin.user %}" class="font-medium text-gray-200 hover:text-brand-light">{{ checkin.user|display_user }}</a> |
|
66
|
<span class="text-gray-500">{{ checkin.timestamp|date:"Y-m-d H:i" }} UTC</span> |
|
67
|
{% if checkin.branch %} |
|
68
|
<span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light"> |
|
69
|
{{ checkin.branch }} |
|
70
|
</span> |
|
71
|
{% endif %} |
|
72
|
{% if checkin.is_merge %} |
|
73
|
<span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span> |
|
74
|
{% endif %} |
|
75
|
</div> |
|
76
|
</div> |
|
77
|
{% if status_checks %} |
|
78
|
<div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50"> |
|
79
|
<div class="flex items-center gap-2 mb-2"> |
|
80
|
<span class="text-xs font-medium text-gray-400">CI Status</span> |
|
81
|
<img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5"> |
|
82
|
</div> |
|
83
|
<div class="flex flex-wrap gap-x-2 gap-y-1.5"> |
|
84
|
{% for check in status_checks %} |
|
85
|
<a {% if check.target_url and check.target_url|slice:":4" == "http" %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %} |
|
86
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs |
|
87
|
{% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300 |
|
88
|
{% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300 |
|
89
|
{% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300 |
|
90
|
{% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}" |
|
91
|
{% if check.description %}title="{{ check.description }}"{% endif %}> |
|
92
|
{% if check.state == 'success' %} |
|
93
|
<svg class="h-3.5 w-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
|
94
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/> |
|
95
|
</svg> |
|
96
|
{% elif check.state == 'failure' or check.state == 'error' %} |
|
97
|
<svg class="h-3.5 w-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
|
98
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> |
|
99
|
</svg> |
|
100
|
{% else %} |
|
101
|
<svg class="h-3.5 w-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> |
|
102
|
<circle cx="12" cy="12" r="6"/> |
|
103
|
</svg> |
|
104
|
{% endif %} |
|
105
|
{{ check.context }} |
|
106
|
</a> |
|
107
|
{% endfor %} |
|
108
|
</div> |
|
109
|
</div> |
|
110
|
{% endif %} |
|
111
|
<div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-x-6 gap-y-2 flex-wrap text-xs"> |
|
112
|
<div class="flex items-center gap-2"> |
|
113
|
<span class="text-gray-500">Commit</span> |
|
114
|
<code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code> |
|
115
|
</div> |
|
116
|
{% if checkin.parent_uuid %} |
|
117
|
<div class="flex items-center gap-2"> |
|
118
|
<span class="text-gray-500">Parent</span> |
|
119
|
<a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=checkin.parent_uuid %}" |
|
120
|
class="font-mono text-brand-light hover:text-brand">{{ checkin.parent_uuid|truncatechars:16 }}</a> |
|
121
|
</div> |
|
122
|
{% endif %} |
|
123
|
</div> |
|
124
|
</div> |
|
125
|
|
|
126
|
<!-- File summary bar --> |
|
127
|
{% if file_diffs %} |
|
128
|
<div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-3 shadow-sm"> |
|
129
|
<div class="flex items-center gap-4 text-sm"> |
|
130
|
<span class="text-gray-300 font-medium">{{ file_diffs|length }} file{{ file_diffs|length|pluralize }} changed</span> |
|
131
|
{% with total_add=0 total_del=0 %} |
|
132
|
{% for fd in file_diffs %} |
|
133
|
{% if fd.additions %} |
|
134
|
<span class="text-green-400">+{{ fd.additions }}</span> |
|
135
|
{% endif %} |
|
136
|
{% if fd.deletions %} |
|
137
|
<span class="text-red-400">-{{ fd.deletions }}</span> |
|
138
|
{% endif %} |
|
139
|
{% endfor %} |
|
140
|
{% endwith %} |
|
141
|
</div> |
|
142
|
<div class="mt-2 flex flex-wrap gap-x-2 gap-y-1"> |
|
143
|
{% for fd in file_diffs %} |
|
144
|
<a href="#diff-{{ forloop.counter }}" class="text-xs font-mono text-brand-light hover:text-brand break-all"> |
|
145
|
{% if fd.change_type == "added" %} |
|
146
|
<span class="text-green-400">+</span> |
|
147
|
{% elif fd.change_type == "deleted" %} |
|
148
|
<span class="text-red-400">-</span> |
|
149
|
{% else %} |
|
150
|
<span class="text-yellow-400">~</span> |
|
151
|
{% endif %} |
|
152
|
{{ fd.name }} |
|
153
|
</a> |
|
154
|
{% endfor %} |
|
155
|
</div> |
|
156
|
</div> |
|
157
|
|
|
158
|
<!-- Diff mode toggle --> |
|
159
|
<div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle"> |
|
160
|
<div class="flex items-center gap-1 mb-3"> |
|
161
|
<button @click="mode = 'unified'" :class="mode === 'unified' ? 'bg-gray-700 text-gray-100 shadow-sm' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'" class="px-3 py-1 text-xs rounded-md border border-gray-700 transition-colors">Unified</button> |
|
162
|
<button @click="mode = 'split'" :class="mode === 'split' ? 'bg-gray-700 text-gray-100 shadow-sm' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'" class="px-3 py-1 text-xs rounded-md border border-gray-700 transition-colors">Split</button> |
|
163
|
</div> |
|
164
|
|
|
165
|
<!-- Diffs --> |
|
166
|
{% for fd in file_diffs %} |
|
167
|
<div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden mb-4 shadow-sm" id="diff-{{ forloop.counter }}" data-filename="{{ fd.name }}"> |
|
168
|
<div class="px-4 py-2.5 border-b border-gray-700 flex items-center justify-between bg-gray-900/50"> |
|
169
|
<div class="flex items-center gap-3"> |
|
170
|
{% if fd.change_type == "added" %} |
|
171
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span> |
|
172
|
{% elif fd.change_type == "deleted" %} |
|
173
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-red-900/50 text-red-300">D</span> |
|
174
|
{% else %} |
|
175
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-yellow-900/50 text-yellow-300">M</span> |
|
176
|
{% endif %} |
|
177
|
{% if fd.uuid %} |
|
178
|
<a href="{% url 'fossil:code_file' slug=project.slug filepath=fd.name %}" |
|
179
|
class="text-sm font-mono text-brand-light hover:text-brand">{{ fd.name }}</a> |
|
180
|
{% else %} |
|
181
|
<span class="text-sm font-mono text-gray-400">{{ fd.name }}</span> |
|
182
|
{% endif %} |
|
183
|
</div> |
|
184
|
<div class="flex items-center gap-2 text-xs"> |
|
185
|
{% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %} |
|
186
|
{% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %} |
|
187
|
</div> |
|
188
|
</div> |
|
189
|
|
|
190
|
{% if fd.is_binary %} |
|
191
|
<p class="p-4 text-sm text-gray-500">Binary file</p> |
|
192
|
{% elif fd.diff_lines %} |
|
193
|
|
|
194
|
<!-- Unified view --> |
|
195
|
<div class="overflow-x-auto" x-show="mode === 'unified'"> |
|
196
|
<table class="diff-table"> |
|
197
|
<tbody> |
|
198
|
{% for dl in fd.diff_lines %} |
|
199
|
{% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% elif dl.old_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}"><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'" @click.outside="pop = false">{{ dl.old_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% else %}<tr class="diff-line-{{ dl.type }}"><td class="diff-gutter"></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% endif %} |
|
200
|
{% endfor %} |
|
201
|
</tbody> |
|
202
|
</table> |
|
203
|
</div> |
|
204
|
|
|
205
|
<!-- Split view --> |
|
206
|
<div class="split-diff" x-show="mode === 'split'" x-cloak> |
|
207
|
<div class="split-diff-side"> |
|
208
|
<table class="diff-table"> |
|
209
|
<tbody> |
|
210
|
{% for dl in fd.split_left %} |
|
211
|
<tr class="{% if dl.type == 'del' %}split-line-del{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}"> |
|
212
|
<td class="diff-gutter">{{ dl.old_num }}</td> |
|
213
|
<td>{% if dl.type == 'empty' %} {% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td> |
|
214
|
</tr> |
|
215
|
{% endfor %} |
|
216
|
</tbody> |
|
217
|
</table> |
|
218
|
</div> |
|
219
|
<div class="split-diff-side"> |
|
220
|
<table class="diff-table"> |
|
221
|
<tbody> |
|
222
|
{% for dl in fd.split_right %} |
|
223
|
<tr class="{% if dl.type == 'add' %}split-line-add{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}"> |
|
224
|
<td class="diff-gutter">{{ dl.new_num }}</td> |
|
225
|
<td>{% if dl.type == 'empty' %} {% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td> |
|
226
|
</tr> |
|
227
|
{% endfor %} |
|
228
|
</tbody> |
|
229
|
</table> |
|
230
|
</div> |
|
231
|
</div> |
|
232
|
|
|
233
|
{% else %} |
|
234
|
<p class="p-4 text-sm text-gray-500">No diff available</p> |
|
235
|
{% endif %} |
|
236
|
</div> |
|
237
|
{% endfor %} |
|
238
|
</div> |
|
239
|
{% endif %} |
|
240
|
</div> |
|
241
|
<!-- scroll-margin-top on .line-row handles anchor positioning via CSS --> |
|
242
|
<script> |
|
243
|
(function() { |
|
244
|
var langMap = { |
|
245
|
'py': 'python', 'js': 'javascript', 'ts': 'typescript', |
|
246
|
'html': 'html', 'css': 'css', 'json': 'json', 'yaml': 'yaml', |
|
247
|
'yml': 'yaml', 'md': 'markdown', 'sh': 'bash', 'sql': 'sql', |
|
248
|
'rs': 'rust', 'go': 'go', 'rb': 'ruby', 'java': 'java', |
|
249
|
'toml': 'toml', 'xml': 'xml', 'jsx': 'javascript', 'tsx': 'typescript', |
|
250
|
'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp', |
|
251
|
}; |
|
252
|
function highlightDiffCode() { |
|
253
|
document.querySelectorAll('[data-filename]').forEach(function(container) { |
|
254
|
var filename = container.dataset.filename || ''; |
|
255
|
var ext = filename.split('.').pop(); |
|
256
|
var lang = langMap[ext] || ''; |
|
257
|
if (lang && window.hljs) { |
|
258
|
container.querySelectorAll('.diff-code').forEach(function(el) { |
|
259
|
if (el.dataset.highlighted) return; |
|
260
|
var text = el.textContent; |
|
261
|
if (!text.trim()) return; |
|
262
|
try { |
|
263
|
var result = hljs.highlight(text, { language: lang, ignoreIllegals: true }); |
|
264
|
el.innerHTML = result.value; |
|
265
|
el.dataset.highlighted = '1'; |
|
266
|
} catch(e) {} |
|
267
|
}); |
|
268
|
} |
|
269
|
}); |
|
270
|
} |
|
271
|
// Run after DOM ready and after Alpine toggles |
|
272
|
if (document.readyState === 'loading') { |
|
273
|
document.addEventListener('DOMContentLoaded', highlightDiffCode); |
|
274
|
} else { |
|
275
|
highlightDiffCode(); |
|
276
|
} |
|
277
|
// Re-run when split view is toggled (elements become visible) |
|
278
|
document.addEventListener('click', function(e) { |
|
279
|
if (e.target.closest('[x-ref="diffToggle"]')) { |
|
280
|
setTimeout(highlightDiffCode, 50); |
|
281
|
} |
|
282
|
}); |
|
283
|
})(); |
|
284
|
</script> |
|
285
|
{% endblock %} |
|
286
|
|