diffstory 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffstory/__init__.py +3 -0
- diffstory/__main__.py +5 -0
- diffstory/cli.py +402 -0
- diffstory/diff_parser.py +298 -0
- diffstory/git_utils.py +365 -0
- diffstory/html_generator.py +2343 -0
- diffstory/syntax.py +145 -0
- diffstory-0.2.0.dist-info/METADATA +207 -0
- diffstory-0.2.0.dist-info/RECORD +12 -0
- diffstory-0.2.0.dist-info/WHEEL +5 -0
- diffstory-0.2.0.dist-info/entry_points.txt +2 -0
- diffstory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2343 @@
|
|
|
1
|
+
"""Generate self-contained HTML reports from parsed diff data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from html import escape
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from diffstory.diff_parser import DiffFile, DiffLine, Hunk, compute_word_diffs, IMAGE_EXTENSIONS
|
|
13
|
+
from diffstory.syntax import get_highlighted_line, get_syntax_styles
|
|
14
|
+
from diffstory.git_utils import get_blame_for_revision, get_commit_info, get_repo_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _compute_stats(files: list[DiffFile], blame_data: Optional[dict] = None, commits_data: Optional[dict] = None) -> dict:
|
|
18
|
+
"""Compute summary statistics, including blame-derived stats if available."""
|
|
19
|
+
total_additions = 0
|
|
20
|
+
total_deletions = 0
|
|
21
|
+
total_files = len(files)
|
|
22
|
+
added_files = 0
|
|
23
|
+
deleted_files = 0
|
|
24
|
+
modified_files = 0
|
|
25
|
+
renamed_files = 0
|
|
26
|
+
changed_lines_per_file: list[dict] = []
|
|
27
|
+
|
|
28
|
+
for f in files:
|
|
29
|
+
adds = sum(1 for h in f.hunks for l in h.lines if l.line_type == "addition")
|
|
30
|
+
dels = sum(1 for h in f.hunks for l in h.lines if l.line_type == "deletion")
|
|
31
|
+
total_additions += adds
|
|
32
|
+
total_deletions += dels
|
|
33
|
+
|
|
34
|
+
if f.status == "added":
|
|
35
|
+
added_files += 1
|
|
36
|
+
elif f.status == "deleted":
|
|
37
|
+
deleted_files += 1
|
|
38
|
+
elif f.status == "renamed":
|
|
39
|
+
renamed_files += 1
|
|
40
|
+
else:
|
|
41
|
+
modified_files += 1
|
|
42
|
+
|
|
43
|
+
changed_lines_per_file.append({
|
|
44
|
+
"file": f.display_path,
|
|
45
|
+
"additions": adds,
|
|
46
|
+
"deletions": dels,
|
|
47
|
+
"total": adds + dels,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
changed_lines_per_file.sort(key=lambda x: x["total"], reverse=True)
|
|
51
|
+
|
|
52
|
+
# Collect author breakdown from blame data
|
|
53
|
+
authors: dict = {}
|
|
54
|
+
unique_commits: set = set()
|
|
55
|
+
if blame_data:
|
|
56
|
+
for key, entry in blame_data.items():
|
|
57
|
+
auth = entry.get("author", "Unknown")
|
|
58
|
+
if auth not in authors:
|
|
59
|
+
authors[auth] = {"additions": 0, "deletions": 0, "commits": set()}
|
|
60
|
+
# Rough estimate: count lines per author
|
|
61
|
+
chash = entry.get("commit", "")
|
|
62
|
+
if chash:
|
|
63
|
+
unique_commits.add(chash)
|
|
64
|
+
authors[auth]["commits"].add(chash)
|
|
65
|
+
|
|
66
|
+
# If we have commit data, use it for richer stats
|
|
67
|
+
if commits_data:
|
|
68
|
+
for chash, info in commits_data.items():
|
|
69
|
+
unique_commits.add(chash)
|
|
70
|
+
|
|
71
|
+
author_breakdown = [
|
|
72
|
+
{"name": name, "commits": len(d["commits"])}
|
|
73
|
+
for name, d in sorted(authors.items(), key=lambda x: -len(x[1]["commits"]))
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"files_changed": total_files,
|
|
78
|
+
"additions": total_additions,
|
|
79
|
+
"deletions": total_deletions,
|
|
80
|
+
"added_files": added_files,
|
|
81
|
+
"deleted_files": deleted_files,
|
|
82
|
+
"modified_files": modified_files,
|
|
83
|
+
"renamed_files": renamed_files,
|
|
84
|
+
"authors": len(authors),
|
|
85
|
+
"commits": len(unique_commits),
|
|
86
|
+
"author_breakdown": author_breakdown[:10],
|
|
87
|
+
"largest_files": changed_lines_per_file[:10],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _hunk_header_extra(header: str) -> str:
|
|
92
|
+
"""Build the optional header text span for a hunk."""
|
|
93
|
+
if header:
|
|
94
|
+
return ' <span class="hunk-header-text">' + escape(header) + '</span>'
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render_unified_hunk(hunk: Hunk, filepath: str, lexer_cache: dict) -> str:
|
|
99
|
+
"""Render a hunk in unified view mode."""
|
|
100
|
+
lines_html = ""
|
|
101
|
+
for line in hunk.lines:
|
|
102
|
+
line_type = line.line_type
|
|
103
|
+
old_no = str(line.old_lineno or "")
|
|
104
|
+
new_no = str(line.new_lineno or "")
|
|
105
|
+
prefix = " " if line_type == "context" else line_type[0]
|
|
106
|
+
css_class = "diff-line diff-" + line_type
|
|
107
|
+
|
|
108
|
+
highlighted = get_highlighted_line(line.content, filepath, lexer_cache)
|
|
109
|
+
lines_html += (
|
|
110
|
+
'<div class="' + css_class + '" data-old="' + old_no + '" data-new="' + new_no + '">'
|
|
111
|
+
'<span class="line-prefix">' + prefix + '</span>'
|
|
112
|
+
'<span class="line-num line-num-old">' + old_no + '</span>'
|
|
113
|
+
'<span class="line-num line-num-new">' + new_no + '</span>'
|
|
114
|
+
'<span class="line-content">' + highlighted + '</span>'
|
|
115
|
+
'</div>\n'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
header_extra = _hunk_header_extra(hunk.header)
|
|
119
|
+
hunk_html = (
|
|
120
|
+
'<div class="hunk-header">@@ ' + str(hunk.old_start) + ',' + str(hunk.old_count) + ' '
|
|
121
|
+
+ str(hunk.new_start) + ',' + str(hunk.new_count) + ' @@'
|
|
122
|
+
+ header_extra
|
|
123
|
+
+ '</div>\n'
|
|
124
|
+
+ '<div class="hunk-body">' + lines_html + '</div>\n'
|
|
125
|
+
)
|
|
126
|
+
return hunk_html
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_sidebyside_hunk(hunk: Hunk, filepath: str, lexer_cache: dict) -> str:
|
|
130
|
+
"""Render a hunk in side-by-side view mode."""
|
|
131
|
+
rows = ""
|
|
132
|
+
left_lines: list[Optional[DiffLine]] = []
|
|
133
|
+
right_lines: list[Optional[DiffLine]] = []
|
|
134
|
+
|
|
135
|
+
for line in hunk.lines:
|
|
136
|
+
if line.line_type == "deletion":
|
|
137
|
+
left_lines.append(line)
|
|
138
|
+
right_lines.append(None)
|
|
139
|
+
elif line.line_type == "addition":
|
|
140
|
+
left_lines.append(None)
|
|
141
|
+
right_lines.append(line)
|
|
142
|
+
else:
|
|
143
|
+
left_lines.append(line)
|
|
144
|
+
right_lines.append(line)
|
|
145
|
+
|
|
146
|
+
max_lines = max(len(left_lines), len(right_lines))
|
|
147
|
+
|
|
148
|
+
for i in range(max_lines):
|
|
149
|
+
left = left_lines[i] if i < len(left_lines) else None
|
|
150
|
+
right = right_lines[i] if i < len(right_lines) else None
|
|
151
|
+
|
|
152
|
+
left_content = ""
|
|
153
|
+
right_content = ""
|
|
154
|
+
left_class = "diff-empty"
|
|
155
|
+
right_class = "diff-empty"
|
|
156
|
+
|
|
157
|
+
if left:
|
|
158
|
+
left_class = "diff-" + left.line_type
|
|
159
|
+
old_no = str(left.old_lineno or "")
|
|
160
|
+
highlighted = get_highlighted_line(left.content, filepath, lexer_cache)
|
|
161
|
+
left_content = (
|
|
162
|
+
'<span class="line-num line-num-old">' + old_no + '</span>'
|
|
163
|
+
'<span class="line-content">' + highlighted + '</span>'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if right:
|
|
167
|
+
right_class = "diff-" + right.line_type
|
|
168
|
+
new_no = str(right.new_lineno or "")
|
|
169
|
+
highlighted = get_highlighted_line(right.content, filepath, lexer_cache)
|
|
170
|
+
right_content = (
|
|
171
|
+
'<span class="line-num line-num-new">' + new_no + '</span>'
|
|
172
|
+
'<span class="line-content">' + highlighted + '</span>'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
rows += (
|
|
176
|
+
'<div class="sbs-row">'
|
|
177
|
+
'<div class="sbs-left ' + left_class + '">' + left_content + '</div>'
|
|
178
|
+
'<div class="sbs-right ' + right_class + '">' + right_content + '</div>'
|
|
179
|
+
'</div>\n'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
header_extra = _hunk_header_extra(hunk.header)
|
|
183
|
+
hunk_html = (
|
|
184
|
+
'<div class="hunk-header">@@ ' + str(hunk.old_start) + ',' + str(hunk.old_count) + ' '
|
|
185
|
+
+ str(hunk.new_start) + ',' + str(hunk.new_count) + ' @@'
|
|
186
|
+
+ header_extra
|
|
187
|
+
+ '</div>\n'
|
|
188
|
+
+ '<div class="sbs-hunk">' + rows + '</div>\n'
|
|
189
|
+
)
|
|
190
|
+
return hunk_html
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _render_inline_hunk(hunk: Hunk, filepath: str, lexer_cache: dict) -> str:
|
|
194
|
+
"""Render a hunk in inline edit mode (word-level diff)."""
|
|
195
|
+
lines_html = ""
|
|
196
|
+
|
|
197
|
+
for line in hunk.lines:
|
|
198
|
+
if line.line_type == "context":
|
|
199
|
+
old_no = str(line.old_lineno or "")
|
|
200
|
+
new_no = str(line.new_lineno or "")
|
|
201
|
+
highlighted = get_highlighted_line(line.content, filepath, lexer_cache)
|
|
202
|
+
lines_html += (
|
|
203
|
+
'<div class="diff-line diff-context" data-old="' + old_no + '" data-new="' + new_no + '">'
|
|
204
|
+
'<span class="line-prefix"> </span>'
|
|
205
|
+
'<span class="line-num line-num-old">' + old_no + '</span>'
|
|
206
|
+
'<span class="line-num line-num-new">' + new_no + '</span>'
|
|
207
|
+
'<span class="line-content">' + highlighted + '</span>'
|
|
208
|
+
'</div>\n'
|
|
209
|
+
)
|
|
210
|
+
elif line.line_type == "deletion":
|
|
211
|
+
old_no = str(line.old_lineno or "")
|
|
212
|
+
lines_html += (
|
|
213
|
+
'<div class="diff-line diff-deletion" data-old="' + old_no + '">'
|
|
214
|
+
'<span class="line-prefix">-</span>'
|
|
215
|
+
'<span class="line-num line-num-old">' + old_no + '</span>'
|
|
216
|
+
'<span class="line-num line-num-new"></span>'
|
|
217
|
+
'<span class="line-content">'
|
|
218
|
+
)
|
|
219
|
+
if line.word_diff:
|
|
220
|
+
for part in line.word_diff.parts:
|
|
221
|
+
if part["type"] == "delete":
|
|
222
|
+
lines_html += '<span class="wd-removed">' + escape(part["text"]) + '</span>'
|
|
223
|
+
elif part["type"] == "equal":
|
|
224
|
+
lines_html += '<span class="wd-equal">' + escape(part["text"]) + '</span>'
|
|
225
|
+
else:
|
|
226
|
+
lines_html += escape(line.content)
|
|
227
|
+
lines_html += '</span></div>\n'
|
|
228
|
+
|
|
229
|
+
elif line.line_type == "addition":
|
|
230
|
+
new_no = str(line.new_lineno or "")
|
|
231
|
+
lines_html += (
|
|
232
|
+
'<div class="diff-line diff-addition" data-new="' + new_no + '">'
|
|
233
|
+
'<span class="line-prefix">+</span>'
|
|
234
|
+
'<span class="line-num line-num-old"></span>'
|
|
235
|
+
'<span class="line-num line-num-new">' + new_no + '</span>'
|
|
236
|
+
'<span class="line-content">'
|
|
237
|
+
)
|
|
238
|
+
if line.word_diff:
|
|
239
|
+
for part in line.word_diff.parts:
|
|
240
|
+
if part["type"] == "add":
|
|
241
|
+
lines_html += '<span class="wd-added">' + escape(part["text"]) + '</span>'
|
|
242
|
+
elif part["type"] == "equal":
|
|
243
|
+
lines_html += '<span class="wd-equal">' + escape(part["text"]) + '</span>'
|
|
244
|
+
else:
|
|
245
|
+
lines_html += escape(line.content)
|
|
246
|
+
lines_html += '</span></div>\n'
|
|
247
|
+
|
|
248
|
+
header_extra = _hunk_header_extra(hunk.header)
|
|
249
|
+
hunk_html = (
|
|
250
|
+
'<div class="hunk-header">@@ ' + str(hunk.old_start) + ',' + str(hunk.old_count) + ' '
|
|
251
|
+
+ str(hunk.new_start) + ',' + str(hunk.new_count) + ' @@'
|
|
252
|
+
+ header_extra
|
|
253
|
+
+ '</div>\n'
|
|
254
|
+
+ '<div class="hunk-body">' + lines_html + '</div>\n'
|
|
255
|
+
)
|
|
256
|
+
return hunk_html
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _render_file_section(file: DiffFile, file_index: int, lexer_cache: dict) -> str:
|
|
260
|
+
"""Render a complete file section with all three view modes."""
|
|
261
|
+
file_id = "file-" + str(file_index)
|
|
262
|
+
file_label = file.display_path
|
|
263
|
+
file_status = file.status
|
|
264
|
+
status_icon = {
|
|
265
|
+
"added": "+",
|
|
266
|
+
"deleted": "−",
|
|
267
|
+
"renamed": "→",
|
|
268
|
+
"modified": "●",
|
|
269
|
+
}.get(file_status, "●")
|
|
270
|
+
|
|
271
|
+
status_class = "file-status-" + file_status
|
|
272
|
+
|
|
273
|
+
# Compute word diffs for inline mode
|
|
274
|
+
compute_word_diffs(file)
|
|
275
|
+
|
|
276
|
+
# Render all three views
|
|
277
|
+
unified_html = ""
|
|
278
|
+
sbs_html = ""
|
|
279
|
+
inline_html = ""
|
|
280
|
+
|
|
281
|
+
for hunk in file.hunks:
|
|
282
|
+
unified_html += _render_unified_hunk(hunk, file.display_path, lexer_cache)
|
|
283
|
+
sbs_html += _render_sidebyside_hunk(hunk, file.display_path, lexer_cache)
|
|
284
|
+
inline_html += _render_inline_hunk(hunk, file.display_path, lexer_cache)
|
|
285
|
+
|
|
286
|
+
additions = sum(1 for h in file.hunks for l in h.lines if l.line_type == "addition")
|
|
287
|
+
deletions = sum(1 for h in file.hunks for l in h.lines if l.line_type == "deletion")
|
|
288
|
+
|
|
289
|
+
# Build the rename badge if applicable
|
|
290
|
+
rename_badge = ""
|
|
291
|
+
if file.status == "renamed" and file.old_path != file.new_path:
|
|
292
|
+
rename_badge = ('<span class="rename-badge" title="Renamed from '
|
|
293
|
+
+ escape(file.old_path) + '">'
|
|
294
|
+
+ escape(file.old_path) + ' → </span>')
|
|
295
|
+
elif file.status == "renamed":
|
|
296
|
+
rename_badge = '<span class="rename-badge">Renamed</span>'
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
'<div class="file-section" id="' + file_id + '" data-file="' + escape(file.display_path) + '">\n'
|
|
300
|
+
' <div class="file-header" onclick="toggleFile(this)">\n'
|
|
301
|
+
' <span class="file-status-icon ' + status_class + '">' + status_icon + '</span>\n'
|
|
302
|
+
' <span class="file-label">' + rename_badge + escape(file_label) + '</span>\n'
|
|
303
|
+
' <span class="file-stats">\n'
|
|
304
|
+
' <span class="stat-add">+' + str(additions) + '</span>\n'
|
|
305
|
+
' <span class="stat-del">-' + str(deletions) + '</span>\n'
|
|
306
|
+
' </span>\n'
|
|
307
|
+
' <span class="file-toggle">▼</span>\n'
|
|
308
|
+
' </div>\n'
|
|
309
|
+
' <div class="file-diff-content">\n'
|
|
310
|
+
' <div class="diff-view unified-view active-view">' + unified_html + '</div>\n'
|
|
311
|
+
' <div class="diff-view sidebyside-view">' + sbs_html + '</div>\n'
|
|
312
|
+
' <div class="diff-view inline-view">' + inline_html + '</div>\n'
|
|
313
|
+
' </div>\n'
|
|
314
|
+
'</div>\n'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _build_stats_table(stats: dict) -> str:
|
|
319
|
+
"""Build the stats table HTML rows."""
|
|
320
|
+
rows = ""
|
|
321
|
+
for f in stats["largest_files"]:
|
|
322
|
+
rows += (
|
|
323
|
+
'<tr><td>' + escape(f["file"]) + '</td>'
|
|
324
|
+
'<td class="stat-add">+' + str(f["additions"]) + '</td>'
|
|
325
|
+
'<td class="stat-del">-' + str(f["deletions"]) + '</td>'
|
|
326
|
+
'<td>' + str(f["total"]) + '</td></tr>'
|
|
327
|
+
)
|
|
328
|
+
return rows
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _collect_blame_data(
|
|
332
|
+
files: list[DiffFile],
|
|
333
|
+
staged: bool = False,
|
|
334
|
+
commit_a: Optional[str] = None,
|
|
335
|
+
commit_b: Optional[str] = None,
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""Collect blame and commit metadata for all changed lines.
|
|
338
|
+
|
|
339
|
+
Returns a dict with:
|
|
340
|
+
- line_blame: dict mapping "file_idx:lineno" to blame entry
|
|
341
|
+
- commits: dict mapping commit_hash to commit metadata
|
|
342
|
+
"""
|
|
343
|
+
line_blame: dict = {}
|
|
344
|
+
all_commits: set = set()
|
|
345
|
+
|
|
346
|
+
for fi, file in enumerate(files):
|
|
347
|
+
filepath = file.display_path
|
|
348
|
+
if filepath == "/dev/null":
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Determine which revision to blame
|
|
352
|
+
new_revision = None # None means working tree
|
|
353
|
+
if commit_b:
|
|
354
|
+
new_revision = commit_b
|
|
355
|
+
elif staged:
|
|
356
|
+
# For staged, blame working tree to capture staged additions
|
|
357
|
+
pass # blame working tree
|
|
358
|
+
|
|
359
|
+
# Get blame for current (new) version
|
|
360
|
+
# TODO: handle renamed files — for renames, file.display_path is the new path
|
|
361
|
+
# but blame on the old revision needs the old path
|
|
362
|
+
blame_new = get_blame_for_revision(filepath, revision=new_revision)
|
|
363
|
+
|
|
364
|
+
# Map blame by new line number for additions and context
|
|
365
|
+
for hunk in file.hunks:
|
|
366
|
+
for line in hunk.lines:
|
|
367
|
+
if line.line_type in ("addition", "context") and line.new_lineno:
|
|
368
|
+
entry = blame_new.get(line.new_lineno)
|
|
369
|
+
if entry:
|
|
370
|
+
key = str(fi) + ":" + str(line.new_lineno)
|
|
371
|
+
line_blame[key] = entry
|
|
372
|
+
all_commits.add(entry["commit"])
|
|
373
|
+
|
|
374
|
+
elif line.line_type == "deletion" and line.old_lineno:
|
|
375
|
+
# For deletions, try to blame the old version of the file
|
|
376
|
+
# commit_a is the old version being diffed
|
|
377
|
+
old_revision = None
|
|
378
|
+
if commit_a:
|
|
379
|
+
old_revision = commit_a
|
|
380
|
+
elif staged:
|
|
381
|
+
old_revision = "HEAD"
|
|
382
|
+
|
|
383
|
+
if old_revision:
|
|
384
|
+
try:
|
|
385
|
+
blame_old = get_blame_for_revision(filepath, revision=old_revision)
|
|
386
|
+
entry = blame_old.get(line.old_lineno)
|
|
387
|
+
if entry:
|
|
388
|
+
key = str(fi) + ":" + str(line.old_lineno)
|
|
389
|
+
line_blame[key] = entry
|
|
390
|
+
all_commits.add(entry["commit"])
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
# Collect commit metadata for all unique commits
|
|
395
|
+
commits: dict = {}
|
|
396
|
+
for chash in all_commits:
|
|
397
|
+
if chash and len(chash) == 40:
|
|
398
|
+
info = get_commit_info(chash)
|
|
399
|
+
commits[chash] = info
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
"line_blame": line_blame,
|
|
403
|
+
"commits": commits,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _collect_search_data(files: list[DiffFile], blame_data: dict, commits_data: dict) -> dict:
|
|
408
|
+
"""Collect searchable data from files, blame, and commits for frontend search."""
|
|
409
|
+
file_names = [f.display_path for f in files]
|
|
410
|
+
author_names = set()
|
|
411
|
+
commit_subjects = []
|
|
412
|
+
|
|
413
|
+
for entry in blame_data.values():
|
|
414
|
+
if entry.get("author"):
|
|
415
|
+
author_names.add(entry["author"])
|
|
416
|
+
|
|
417
|
+
for info in commits_data.values():
|
|
418
|
+
if info.get("subject"):
|
|
419
|
+
commit_subjects.append(info["subject"])
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
"files": file_names,
|
|
423
|
+
"authors": sorted(author_names),
|
|
424
|
+
"subjects": commit_subjects,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def generate_report(
|
|
429
|
+
files: list[DiffFile],
|
|
430
|
+
output_path: str = "diffstory-report.html",
|
|
431
|
+
repo_name: Optional[str] = None,
|
|
432
|
+
staged: bool = False,
|
|
433
|
+
commit_a: Optional[str] = None,
|
|
434
|
+
commit_b: Optional[str] = None,
|
|
435
|
+
verbose: bool = False,
|
|
436
|
+
) -> str:
|
|
437
|
+
"""Generate a self-contained HTML report.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
files: List of parsed DiffFile objects.
|
|
441
|
+
output_path: Path where the report will be saved.
|
|
442
|
+
repo_name: Optional repository name override.
|
|
443
|
+
staged: Whether the diff is from staged changes.
|
|
444
|
+
commit_a: First commit in a range comparison.
|
|
445
|
+
commit_b: Second commit in a range comparison.
|
|
446
|
+
verbose: Whether to print progress messages.
|
|
447
|
+
"""
|
|
448
|
+
if verbose:
|
|
449
|
+
print(f" Generating report for {len(files)} file(s)...")
|
|
450
|
+
from pathlib import Path
|
|
451
|
+
|
|
452
|
+
lexer_cache: dict = {}
|
|
453
|
+
report_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
454
|
+
|
|
455
|
+
# Collect blame data
|
|
456
|
+
try:
|
|
457
|
+
blame_data_dict = _collect_blame_data(files, staged=staged, commit_a=commit_a, commit_b=commit_b)
|
|
458
|
+
except Exception:
|
|
459
|
+
blame_data_dict = {"line_blame": {}, "commits": {}}
|
|
460
|
+
|
|
461
|
+
stats = _compute_stats(files, blame_data=blame_data_dict["line_blame"], commits_data=blame_data_dict["commits"])
|
|
462
|
+
|
|
463
|
+
# Collect search data
|
|
464
|
+
search_data = _collect_search_data(files, blame_data_dict["line_blame"], blame_data_dict["commits"])
|
|
465
|
+
|
|
466
|
+
# Render all file sections
|
|
467
|
+
file_sections_html = ""
|
|
468
|
+
file_sidebar_items_html = ""
|
|
469
|
+
|
|
470
|
+
for i, file in enumerate(files):
|
|
471
|
+
file_sections_html += _render_file_section(file, i, lexer_cache)
|
|
472
|
+
additions = sum(1 for h in file.hunks for l in h.lines if l.line_type == "addition")
|
|
473
|
+
deletions = sum(1 for h in file.hunks for l in h.lines if l.line_type == "deletion")
|
|
474
|
+
status_icon = {
|
|
475
|
+
"added": "+",
|
|
476
|
+
"deleted": "−",
|
|
477
|
+
"renamed": "→",
|
|
478
|
+
"modified": "●",
|
|
479
|
+
}.get(file.status, "●")
|
|
480
|
+
status_class = "file-status-" + file.status
|
|
481
|
+
file_sidebar_items_html += (
|
|
482
|
+
'<div class="sidebar-file" onclick="scrollToFile(' + "'file-" + str(i) + "'" + ')">\n'
|
|
483
|
+
'<span class="sidebar-file-icon ' + status_class + '">' + status_icon + '</span>\n'
|
|
484
|
+
'<span class="sidebar-file-name">' + escape(file.display_path) + '</span>\n'
|
|
485
|
+
'<span class="sidebar-file-stats">'
|
|
486
|
+
'<span class="stat-add">+' + str(additions) + '</span>'
|
|
487
|
+
'<span class="stat-del">-' + str(deletions) + '</span>'
|
|
488
|
+
'</span>\n'
|
|
489
|
+
'</div>\n'
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
repo = repo_name or get_repo_name()
|
|
493
|
+
stats_table = _build_stats_table(stats)
|
|
494
|
+
|
|
495
|
+
# Compute file extensions for filter chips
|
|
496
|
+
all_extensions = sorted(set(
|
|
497
|
+
"." + f.display_path.rsplit(".", 1)[1].lower()
|
|
498
|
+
for f in files if "." in f.display_path
|
|
499
|
+
))
|
|
500
|
+
change_types = sorted(set(f.status for f in files))
|
|
501
|
+
|
|
502
|
+
# Serialize data for embedding in HTML
|
|
503
|
+
blame_json = json.dumps(blame_data_dict["line_blame"])
|
|
504
|
+
commits_json = json.dumps(blame_data_dict["commits"])
|
|
505
|
+
search_json = json.dumps(search_data)
|
|
506
|
+
|
|
507
|
+
# Build extension filter buttons HTML
|
|
508
|
+
ext_filters_html = ""
|
|
509
|
+
for ext in all_extensions:
|
|
510
|
+
ext_filters_html += (
|
|
511
|
+
'<button class="filter-chip filter-ext" data-ext="' + ext + '" onclick="toggleFilterExt(\'' + ext + '\')">'
|
|
512
|
+
+ ext + '</button>'
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Build change type filter buttons HTML
|
|
516
|
+
type_filters_html = ""
|
|
517
|
+
for ct in ["added", "deleted", "modified", "renamed"]:
|
|
518
|
+
if ct in change_types:
|
|
519
|
+
cls = "filter-chip filter-type filter-" + ct
|
|
520
|
+
label = ct.capitalize()
|
|
521
|
+
type_filters_html += (
|
|
522
|
+
'<button class="' + cls + '" data-type="' + ct + '" onclick="toggleFilterType(\'' + ct + '\')">'
|
|
523
|
+
+ label + '</button>'
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
html = _build_html_template(
|
|
527
|
+
repo=repo,
|
|
528
|
+
report_time=report_time,
|
|
529
|
+
stats=stats,
|
|
530
|
+
stats_table=stats_table,
|
|
531
|
+
file_sections_html=file_sections_html,
|
|
532
|
+
file_sidebar_html=file_sidebar_items_html,
|
|
533
|
+
blame_json=blame_json,
|
|
534
|
+
commits_json=commits_json,
|
|
535
|
+
search_json=search_json,
|
|
536
|
+
ext_filters_html=ext_filters_html,
|
|
537
|
+
type_filters_html=type_filters_html,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
output = Path(output_path)
|
|
541
|
+
output.write_text(html, encoding="utf-8")
|
|
542
|
+
return str(output.resolve())
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _build_html_template(
|
|
546
|
+
repo: str,
|
|
547
|
+
report_time: str,
|
|
548
|
+
stats: dict,
|
|
549
|
+
stats_table: str,
|
|
550
|
+
file_sections_html: str,
|
|
551
|
+
file_sidebar_html: str,
|
|
552
|
+
blame_json: str = "{}",
|
|
553
|
+
commits_json: str = "{}",
|
|
554
|
+
search_json: str = "{}",
|
|
555
|
+
ext_filters_html: str = "",
|
|
556
|
+
type_filters_html: str = "",
|
|
557
|
+
) -> str:
|
|
558
|
+
"""Build the complete self-contained HTML document."""
|
|
559
|
+
css = _get_css()
|
|
560
|
+
syntax_css = get_syntax_styles()
|
|
561
|
+
js = _get_javascript()
|
|
562
|
+
|
|
563
|
+
escaped_repo = escape(repo)
|
|
564
|
+
escaped_time = escape(report_time)
|
|
565
|
+
|
|
566
|
+
# Build author breakdown HTML
|
|
567
|
+
author_html = ""
|
|
568
|
+
for a in stats.get("author_breakdown", []):
|
|
569
|
+
author_html += '<div class="stats-author"><span class="author-name">' + escape(a["name"]) + '</span><span class="author-commits">' + str(a["commits"]) + ' commits</span></div>'
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
'<!DOCTYPE html>\n'
|
|
573
|
+
'<html lang="en" data-theme="light">\n'
|
|
574
|
+
'<head>\n'
|
|
575
|
+
'<meta charset="UTF-8">\n'
|
|
576
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
|
577
|
+
'<title>DiffStory — ' + escaped_repo + '</title>\n'
|
|
578
|
+
'<style>\n'
|
|
579
|
+
+ css + '\n'
|
|
580
|
+
+ syntax_css + '\n'
|
|
581
|
+
+ '</style>\n'
|
|
582
|
+
'</head>\n'
|
|
583
|
+
'<body>\n'
|
|
584
|
+
'<!-- Embedded data -->\n'
|
|
585
|
+
'<script id="diffstory-blame-data" type="application/json">' + blame_json + '</script>\n'
|
|
586
|
+
'<script id="diffstory-commit-data" type="application/json">' + commits_json + '</script>\n'
|
|
587
|
+
'<script id="diffstory-search-data" type="application/json">' + search_json + '</script>\n'
|
|
588
|
+
'<div id="app">\n'
|
|
589
|
+
' <header id="toolbar">\n'
|
|
590
|
+
' <div class="toolbar-left">\n'
|
|
591
|
+
' <span class="toolbar-title">DiffStory</span>\n'
|
|
592
|
+
' <span class="toolbar-repo">' + escaped_repo + '</span>\n'
|
|
593
|
+
' </div>\n'
|
|
594
|
+
' <div class="toolbar-center">\n'
|
|
595
|
+
' <button class="view-btn active" data-view="unified" onclick="switchView(\'unified\')" title="Unified View (U)">Unified</button>\n'
|
|
596
|
+
' <button class="view-btn" data-view="sidebyside" onclick="switchView(\'sidebyside\')" title="Side-by-Side (S)">Side-by-Side</button>\n'
|
|
597
|
+
' <button class="view-btn" data-view="inline" onclick="switchView(\'inline\')" title="Inline Edit (I)">Inline</button>\n'
|
|
598
|
+
' </div>\n'
|
|
599
|
+
' <div class="toolbar-right">\n'
|
|
600
|
+
' <button class="tool-btn" onclick="focusSearch()" id="search-btn" title="Search (F or /)">\U0001f50d</button>\n'
|
|
601
|
+
' <button class="tool-btn" onclick="toggleTheme()" id="theme-btn" title="Toggle Theme (D)">\U0001f319</button>\n'
|
|
602
|
+
' <button class="tool-btn" onclick="toggleStats()" id="stats-btn" title="Statistics">\U0001f4ca</button>\n'
|
|
603
|
+
' <button class="tool-btn" onclick="toggleSidebar()" id="sidebar-btn" title="File List">\U0001f4c1</button>\n'
|
|
604
|
+
' </div>\n'
|
|
605
|
+
' </header>\n'
|
|
606
|
+
' <!-- Global Search Bar -->\n'
|
|
607
|
+
' <div id="search-bar" class="search-bar hidden">\n'
|
|
608
|
+
' <input type="text" id="global-search" placeholder="Search files, authors, commits, code... (Esc to close)" oninput="doGlobalSearch()">\n'
|
|
609
|
+
' <span id="search-count" class="search-count"></span>\n'
|
|
610
|
+
' <button class="search-clear" onclick="clearGlobalSearch()">×</button>\n'
|
|
611
|
+
' </div>\n'
|
|
612
|
+
' <!-- Filter Bar -->\n'
|
|
613
|
+
' <div id="filter-bar" class="filter-bar">\n'
|
|
614
|
+
' <div class="filter-group">\n'
|
|
615
|
+
' <span class="filter-label">Type:</span>\n'
|
|
616
|
+
+ type_filters_html + '\n'
|
|
617
|
+
' </div>\n'
|
|
618
|
+
' <div class="filter-group">\n'
|
|
619
|
+
' <span class="filter-label">Ext:</span>\n'
|
|
620
|
+
+ ext_filters_html + '\n'
|
|
621
|
+
' </div>\n'
|
|
622
|
+
' <button class="filter-chip filter-clear" onclick="clearFilters()">Clear all</button>\n'
|
|
623
|
+
' </div>\n'
|
|
624
|
+
' <div id="stats-panel" class="stats-panel hidden">\n'
|
|
625
|
+
' <div class="stats-header">\n'
|
|
626
|
+
' <h2>Statistics</h2>\n'
|
|
627
|
+
' <button class="close-btn" onclick="toggleStats()">×</button>\n'
|
|
628
|
+
' </div>\n'
|
|
629
|
+
' <div class="stats-grid">\n'
|
|
630
|
+
' <div class="stat-card">\n'
|
|
631
|
+
' <div class="stat-value">' + str(stats["files_changed"]) + '</div>\n'
|
|
632
|
+
' <div class="stat-label">Files Changed</div>\n'
|
|
633
|
+
' </div>\n'
|
|
634
|
+
' <div class="stat-card stat-add-bg">\n'
|
|
635
|
+
' <div class="stat-value">+' + str(stats["additions"]) + '</div>\n'
|
|
636
|
+
' <div class="stat-label">Additions</div>\n'
|
|
637
|
+
' </div>\n'
|
|
638
|
+
' <div class="stat-card stat-del-bg">\n'
|
|
639
|
+
' <div class="stat-value">-' + str(stats["deletions"]) + '</div>\n'
|
|
640
|
+
' <div class="stat-label">Deletions</div>\n'
|
|
641
|
+
' </div>\n'
|
|
642
|
+
' <div class="stat-card">\n'
|
|
643
|
+
' <div class="stat-value">' + str(stats["added_files"]) + '</div>\n'
|
|
644
|
+
' <div class="stat-label">Added</div>\n'
|
|
645
|
+
' </div>\n'
|
|
646
|
+
' <div class="stat-card">\n'
|
|
647
|
+
' <div class="stat-value">' + str(stats["deleted_files"]) + '</div>\n'
|
|
648
|
+
' <div class="stat-label">Deleted</div>\n'
|
|
649
|
+
' </div>\n'
|
|
650
|
+
' <div class="stat-card">\n'
|
|
651
|
+
' <div class="stat-value">' + str(stats["modified_files"]) + '</div>\n'
|
|
652
|
+
' <div class="stat-label">Modified</div>\n'
|
|
653
|
+
' </div>\n'
|
|
654
|
+
' <div class="stat-card">\n'
|
|
655
|
+
' <div class="stat-value">' + str(stats["renamed_files"]) + '</div>\n'
|
|
656
|
+
' <div class="stat-label">Renamed</div>\n'
|
|
657
|
+
' </div>\n'
|
|
658
|
+
' <div class="stat-card">\n'
|
|
659
|
+
' <div class="stat-value">' + str(stats.get("authors", 0)) + '</div>\n'
|
|
660
|
+
' <div class="stat-label">Authors</div>\n'
|
|
661
|
+
' </div>\n'
|
|
662
|
+
' <div class="stat-card">\n'
|
|
663
|
+
' <div class="stat-value">' + str(stats.get("commits", 0)) + '</div>\n'
|
|
664
|
+
' <div class="stat-label">Commits</div>\n'
|
|
665
|
+
' </div>\n'
|
|
666
|
+
' </div>\n'
|
|
667
|
+
' <div class="stats-table-section">\n'
|
|
668
|
+
' <h3>Most Changed Files</h3>\n'
|
|
669
|
+
' <table class="stats-table">\n'
|
|
670
|
+
' <thead>\n'
|
|
671
|
+
' <tr><th>File</th><th>+</th><th>-</th><th>Total</th></tr>\n'
|
|
672
|
+
' </thead>\n'
|
|
673
|
+
' <tbody>\n'
|
|
674
|
+
+ stats_table + '\n'
|
|
675
|
+
' </tbody>\n'
|
|
676
|
+
' </table>\n'
|
|
677
|
+
' </div>\n'
|
|
678
|
+
' <div class="stats-table-section">\n'
|
|
679
|
+
' <h3>Contributors</h3>\n'
|
|
680
|
+
' <div class="stats-authors">' + author_html + '</div>\n'
|
|
681
|
+
' </div>\n'
|
|
682
|
+
' </div>\n'
|
|
683
|
+
' <!-- Tooltip -->\n'
|
|
684
|
+
' <div id="tooltip" class="tooltip hidden"></div>\n'
|
|
685
|
+
' <!-- Commit Drawer -->\n'
|
|
686
|
+
' <div id="commit-drawer" class="commit-drawer hidden">\n'
|
|
687
|
+
' <div class="drawer-header">\n'
|
|
688
|
+
' <h2>Commit Details</h2>\n'
|
|
689
|
+
' <button class="close-btn" onclick="closeDrawer()">×</button>\n'
|
|
690
|
+
' </div>\n'
|
|
691
|
+
' <div id="drawer-content" class="drawer-content">\n'
|
|
692
|
+
' <div class="drawer-loading">Select a changed line to view commit details...</div>\n'
|
|
693
|
+
' </div>\n'
|
|
694
|
+
' </div>\n'
|
|
695
|
+
' <div id="drawer-overlay" class="drawer-overlay hidden" onclick="closeDrawer()"></div>\n'
|
|
696
|
+
' <div id="main-content">\n'
|
|
697
|
+
' <nav id="sidebar" class="sidebar">\n'
|
|
698
|
+
' <div class="sidebar-search">\n'
|
|
699
|
+
' <input type="text" id="file-search" placeholder="Search files..." oninput="filterFiles()">\n'
|
|
700
|
+
' </div>\n'
|
|
701
|
+
' <div class="sidebar-files">\n'
|
|
702
|
+
+ file_sidebar_html + '\n'
|
|
703
|
+
' </div>\n'
|
|
704
|
+
' </nav>\n'
|
|
705
|
+
' <main id="diff-content" class="diff-content">\n'
|
|
706
|
+
' <div class="report-meta">\n'
|
|
707
|
+
' Generated on ' + escaped_time + '\n'
|
|
708
|
+
' <span id="active-filters" class="active-filters"></span>\n'
|
|
709
|
+
' </div>\n'
|
|
710
|
+
+ file_sections_html + '\n'
|
|
711
|
+
' </main>\n'
|
|
712
|
+
' </div>\n'
|
|
713
|
+
'</div>\n'
|
|
714
|
+
'<script>\n'
|
|
715
|
+
+ js + '\n'
|
|
716
|
+
'</script>\n'
|
|
717
|
+
'</body>\n'
|
|
718
|
+
'</html>'
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _get_css() -> str:
|
|
723
|
+
"""Get all CSS styles for the report."""
|
|
724
|
+
return """\
|
|
725
|
+
/* Reset & Base */
|
|
726
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
727
|
+
|
|
728
|
+
:root, [data-theme="light"] {
|
|
729
|
+
--bg: #ffffff;
|
|
730
|
+
--bg-secondary: #f6f8fa;
|
|
731
|
+
--bg-tertiary: #eaeef2;
|
|
732
|
+
--text: #1f2328;
|
|
733
|
+
--text-secondary: #656d76;
|
|
734
|
+
--border: #d0d7de;
|
|
735
|
+
--border-light: #e0e4e8;
|
|
736
|
+
--accent: #0969da;
|
|
737
|
+
--accent-hover: #0550ae;
|
|
738
|
+
--add-bg: #e6ffec;
|
|
739
|
+
--add-text: #116329;
|
|
740
|
+
--add-icon: #1a7f37;
|
|
741
|
+
--del-bg: #ffebe9;
|
|
742
|
+
--del-text: #82071e;
|
|
743
|
+
--del-icon: #cf222e;
|
|
744
|
+
--wd-add-bg: #abf2bc;
|
|
745
|
+
--wd-del-bg: #fbbfbc;
|
|
746
|
+
--hunk-header-bg: #f0f4f8;
|
|
747
|
+
--hunk-header-text: #57606a;
|
|
748
|
+
--toolbar-bg: #f6f8fa;
|
|
749
|
+
--toolbar-border: #d0d7de;
|
|
750
|
+
--sidebar-bg: #f6f8fa;
|
|
751
|
+
--sidebar-hover: #eaeef2;
|
|
752
|
+
--sidebar-active: #ddf4ff;
|
|
753
|
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
754
|
+
--line-num-color: #6e7681;
|
|
755
|
+
--btn-hover: #eaeef2;
|
|
756
|
+
--scrollbar-thumb: #c0c8d0;
|
|
757
|
+
--stats-bg: #ffffff;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
[data-theme="dark"] {
|
|
761
|
+
--bg: #0d1117;
|
|
762
|
+
--bg-secondary: #161b22;
|
|
763
|
+
--bg-tertiary: #21262d;
|
|
764
|
+
--text: #e6edf3;
|
|
765
|
+
--text-secondary: #8b949e;
|
|
766
|
+
--border: #30363d;
|
|
767
|
+
--border-light: #21262d;
|
|
768
|
+
--accent: #58a6ff;
|
|
769
|
+
--accent-hover: #79c0ff;
|
|
770
|
+
--add-bg: #12262b;
|
|
771
|
+
--add-text: #7ee787;
|
|
772
|
+
--add-icon: #3fb950;
|
|
773
|
+
--del-bg: #25171c;
|
|
774
|
+
--del-text: #ffa198;
|
|
775
|
+
--del-icon: #f85149;
|
|
776
|
+
--wd-add-bg: #1b3626;
|
|
777
|
+
--wd-del-bg: #362024;
|
|
778
|
+
--hunk-header-bg: #161b22;
|
|
779
|
+
--hunk-header-text: #8b949e;
|
|
780
|
+
--toolbar-bg: #161b22;
|
|
781
|
+
--toolbar-border: #30363d;
|
|
782
|
+
--sidebar-bg: #161b22;
|
|
783
|
+
--sidebar-hover: #21262d;
|
|
784
|
+
--sidebar-active: #1f2e3d;
|
|
785
|
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
786
|
+
--line-num-color: #484f58;
|
|
787
|
+
--btn-hover: #21262d;
|
|
788
|
+
--scrollbar-thumb: #30363d;
|
|
789
|
+
--stats-bg: #161b22;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
body {
|
|
793
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
|
794
|
+
font-size: 14px;
|
|
795
|
+
line-height: 1.5;
|
|
796
|
+
color: var(--text);
|
|
797
|
+
background: var(--bg);
|
|
798
|
+
overflow: hidden;
|
|
799
|
+
height: 100vh;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
#app {
|
|
803
|
+
display: flex;
|
|
804
|
+
flex-direction: column;
|
|
805
|
+
height: 100vh;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/* Toolbar */
|
|
809
|
+
#toolbar {
|
|
810
|
+
display: flex;
|
|
811
|
+
align-items: center;
|
|
812
|
+
justify-content: space-between;
|
|
813
|
+
padding: 8px 16px;
|
|
814
|
+
background: var(--toolbar-bg);
|
|
815
|
+
border-bottom: 1px solid var(--toolbar-border);
|
|
816
|
+
flex-shrink: 0;
|
|
817
|
+
z-index: 100;
|
|
818
|
+
gap: 12px;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.toolbar-left {
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: center;
|
|
824
|
+
gap: 12px;
|
|
825
|
+
min-width: 200px;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.toolbar-title {
|
|
829
|
+
font-weight: 700;
|
|
830
|
+
font-size: 16px;
|
|
831
|
+
color: var(--accent);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.toolbar-repo {
|
|
835
|
+
font-size: 13px;
|
|
836
|
+
color: var(--text-secondary);
|
|
837
|
+
max-width: 300px;
|
|
838
|
+
overflow: hidden;
|
|
839
|
+
text-overflow: ellipsis;
|
|
840
|
+
white-space: nowrap;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
.toolbar-center {
|
|
844
|
+
display: flex;
|
|
845
|
+
gap: 4px;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
.view-btn {
|
|
849
|
+
padding: 6px 14px;
|
|
850
|
+
border: 1px solid var(--border);
|
|
851
|
+
background: transparent;
|
|
852
|
+
color: var(--text-secondary);
|
|
853
|
+
border-radius: 6px;
|
|
854
|
+
cursor: pointer;
|
|
855
|
+
font-size: 13px;
|
|
856
|
+
font-weight: 500;
|
|
857
|
+
transition: all 0.15s ease;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.view-btn:hover {
|
|
861
|
+
background: var(--btn-hover);
|
|
862
|
+
color: var(--text);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
.view-btn.active {
|
|
866
|
+
background: var(--accent);
|
|
867
|
+
color: #ffffff;
|
|
868
|
+
border-color: var(--accent);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.toolbar-right {
|
|
872
|
+
display: flex;
|
|
873
|
+
gap: 4px;
|
|
874
|
+
min-width: 100px;
|
|
875
|
+
justify-content: flex-end;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.tool-btn {
|
|
879
|
+
padding: 6px 10px;
|
|
880
|
+
border: 1px solid var(--border);
|
|
881
|
+
background: transparent;
|
|
882
|
+
color: var(--text-secondary);
|
|
883
|
+
border-radius: 6px;
|
|
884
|
+
cursor: pointer;
|
|
885
|
+
font-size: 14px;
|
|
886
|
+
transition: all 0.15s ease;
|
|
887
|
+
line-height: 1;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.tool-btn:hover {
|
|
891
|
+
background: var(--btn-hover);
|
|
892
|
+
color: var(--text);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* Main Content Layout */
|
|
896
|
+
#main-content {
|
|
897
|
+
display: flex;
|
|
898
|
+
flex: 1;
|
|
899
|
+
overflow: hidden;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/* Sidebar */
|
|
903
|
+
.sidebar {
|
|
904
|
+
width: 300px;
|
|
905
|
+
min-width: 300px;
|
|
906
|
+
background: var(--sidebar-bg);
|
|
907
|
+
border-right: 1px solid var(--border);
|
|
908
|
+
display: flex;
|
|
909
|
+
flex-direction: column;
|
|
910
|
+
overflow: hidden;
|
|
911
|
+
transition: margin-left 0.2s ease, min-width 0.2s ease;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.sidebar.hidden {
|
|
915
|
+
margin-left: -300px;
|
|
916
|
+
min-width: 0;
|
|
917
|
+
width: 0;
|
|
918
|
+
border-right: none;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.sidebar-search {
|
|
922
|
+
padding: 12px;
|
|
923
|
+
border-bottom: 1px solid var(--border);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.sidebar-search input {
|
|
927
|
+
width: 100%;
|
|
928
|
+
padding: 8px 12px;
|
|
929
|
+
border: 1px solid var(--border);
|
|
930
|
+
border-radius: 6px;
|
|
931
|
+
background: var(--bg);
|
|
932
|
+
color: var(--text);
|
|
933
|
+
font-size: 13px;
|
|
934
|
+
outline: none;
|
|
935
|
+
transition: border-color 0.15s;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
.sidebar-search input:focus {
|
|
939
|
+
border-color: var(--accent);
|
|
940
|
+
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.sidebar-files {
|
|
944
|
+
flex: 1;
|
|
945
|
+
overflow-y: auto;
|
|
946
|
+
padding: 4px 0;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
.sidebar-file {
|
|
950
|
+
display: flex;
|
|
951
|
+
align-items: center;
|
|
952
|
+
gap: 8px;
|
|
953
|
+
padding: 8px 12px;
|
|
954
|
+
cursor: pointer;
|
|
955
|
+
transition: background 0.1s;
|
|
956
|
+
border-left: 3px solid transparent;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.sidebar-file:hover {
|
|
960
|
+
background: var(--sidebar-hover);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.sidebar-file.active {
|
|
964
|
+
background: var(--sidebar-active);
|
|
965
|
+
border-left-color: var(--accent);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.sidebar-file-icon {
|
|
969
|
+
font-weight: 700;
|
|
970
|
+
font-size: 12px;
|
|
971
|
+
width: 18px;
|
|
972
|
+
text-align: center;
|
|
973
|
+
flex-shrink: 0;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.sidebar-file-icon.file-status-added { color: var(--add-icon); }
|
|
977
|
+
.sidebar-file-icon.file-status-deleted { color: var(--del-icon); }
|
|
978
|
+
.sidebar-file-icon.file-status-modified { color: var(--accent); }
|
|
979
|
+
.sidebar-file-icon.file-status-renamed { color: var(--text-secondary); }
|
|
980
|
+
|
|
981
|
+
.sidebar-file-name {
|
|
982
|
+
flex: 1;
|
|
983
|
+
font-size: 13px;
|
|
984
|
+
overflow: hidden;
|
|
985
|
+
text-overflow: ellipsis;
|
|
986
|
+
white-space: nowrap;
|
|
987
|
+
color: var(--text);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.sidebar-file-stats {
|
|
991
|
+
font-size: 11px;
|
|
992
|
+
font-weight: 600;
|
|
993
|
+
flex-shrink: 0;
|
|
994
|
+
display: flex;
|
|
995
|
+
gap: 4px;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/* Diff Content */
|
|
999
|
+
.diff-content {
|
|
1000
|
+
flex: 1;
|
|
1001
|
+
overflow-y: auto;
|
|
1002
|
+
padding: 16px 24px;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
.report-meta {
|
|
1006
|
+
font-size: 12px;
|
|
1007
|
+
color: var(--text-secondary);
|
|
1008
|
+
padding-bottom: 12px;
|
|
1009
|
+
border-bottom: 1px solid var(--border-light);
|
|
1010
|
+
margin-bottom: 16px;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* File Section */
|
|
1014
|
+
.file-section {
|
|
1015
|
+
margin-bottom: 16px;
|
|
1016
|
+
border: 1px solid var(--border);
|
|
1017
|
+
border-radius: 8px;
|
|
1018
|
+
overflow: hidden;
|
|
1019
|
+
background: var(--bg);
|
|
1020
|
+
box-shadow: var(--card-shadow);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.file-header {
|
|
1024
|
+
display: flex;
|
|
1025
|
+
align-items: center;
|
|
1026
|
+
gap: 8px;
|
|
1027
|
+
padding: 10px 14px;
|
|
1028
|
+
background: var(--bg-secondary);
|
|
1029
|
+
cursor: pointer;
|
|
1030
|
+
user-select: none;
|
|
1031
|
+
transition: background 0.1s;
|
|
1032
|
+
border-bottom: 1px solid var(--border);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.file-header:hover {
|
|
1036
|
+
background: var(--bg-tertiary);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.file-status-icon {
|
|
1040
|
+
font-weight: 700;
|
|
1041
|
+
font-size: 13px;
|
|
1042
|
+
width: 20px;
|
|
1043
|
+
text-align: center;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.file-status-icon.file-status-added { color: var(--add-icon); }
|
|
1047
|
+
.file-status-icon.file-status-deleted { color: var(--del-icon); }
|
|
1048
|
+
.file-status-icon.file-status-modified { color: var(--accent); }
|
|
1049
|
+
.file-status-icon.file-status-renamed { color: var(--text-secondary); }
|
|
1050
|
+
|
|
1051
|
+
.file-label {
|
|
1052
|
+
flex: 1;
|
|
1053
|
+
font-size: 14px;
|
|
1054
|
+
font-weight: 600;
|
|
1055
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1056
|
+
overflow: hidden;
|
|
1057
|
+
text-overflow: ellipsis;
|
|
1058
|
+
white-space: nowrap;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.rename-badge {
|
|
1062
|
+
font-size: 12px;
|
|
1063
|
+
color: var(--text-secondary);
|
|
1064
|
+
font-weight: 400;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.file-stats {
|
|
1068
|
+
font-size: 12px;
|
|
1069
|
+
font-weight: 600;
|
|
1070
|
+
display: flex;
|
|
1071
|
+
gap: 6px;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.stat-add { color: var(--add-icon); }
|
|
1075
|
+
.stat-del { color: var(--del-icon); }
|
|
1076
|
+
|
|
1077
|
+
.file-toggle {
|
|
1078
|
+
font-size: 11px;
|
|
1079
|
+
color: var(--text-secondary);
|
|
1080
|
+
transition: transform 0.15s ease;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.file-section.collapsed .file-toggle {
|
|
1084
|
+
transform: rotate(-90deg);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
.file-section.collapsed .file-diff-content {
|
|
1088
|
+
display: none;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/* File diff content */
|
|
1092
|
+
.file-diff-content {
|
|
1093
|
+
overflow-x: auto;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/* Diff Views */
|
|
1097
|
+
.diff-view {
|
|
1098
|
+
display: none;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.diff-view.active-view {
|
|
1102
|
+
display: block;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/* Hunk Header */
|
|
1106
|
+
.hunk-header {
|
|
1107
|
+
padding: 6px 14px;
|
|
1108
|
+
background: var(--hunk-header-bg);
|
|
1109
|
+
color: var(--hunk-header-text);
|
|
1110
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1111
|
+
font-size: 12px;
|
|
1112
|
+
border-bottom: 1px solid var(--border-light);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.hunk-header-text {
|
|
1116
|
+
color: var(--text-secondary);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/* Unified View Lines */
|
|
1120
|
+
.diff-line {
|
|
1121
|
+
display: flex;
|
|
1122
|
+
align-items: stretch;
|
|
1123
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1124
|
+
font-size: 12px;
|
|
1125
|
+
line-height: 1.5;
|
|
1126
|
+
min-height: 22px;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.diff-line:hover {
|
|
1130
|
+
background: rgba(0,0,0,0.02);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
[data-theme="dark"] .diff-line:hover {
|
|
1134
|
+
background: rgba(255,255,255,0.03);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.diff-context { background: transparent; }
|
|
1138
|
+
.diff-addition { background: var(--add-bg); }
|
|
1139
|
+
.diff-deletion { background: var(--del-bg); }
|
|
1140
|
+
|
|
1141
|
+
.line-prefix {
|
|
1142
|
+
width: 20px;
|
|
1143
|
+
min-width: 20px;
|
|
1144
|
+
text-align: center;
|
|
1145
|
+
color: var(--text-secondary);
|
|
1146
|
+
user-select: none;
|
|
1147
|
+
flex-shrink: 0;
|
|
1148
|
+
padding-top: 1px;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.diff-addition .line-prefix { color: var(--add-icon); }
|
|
1152
|
+
.diff-deletion .line-prefix { color: var(--del-icon); }
|
|
1153
|
+
|
|
1154
|
+
.line-num {
|
|
1155
|
+
min-width: 40px;
|
|
1156
|
+
text-align: right;
|
|
1157
|
+
padding: 0 8px;
|
|
1158
|
+
color: var(--line-num-color);
|
|
1159
|
+
user-select: none;
|
|
1160
|
+
flex-shrink: 0;
|
|
1161
|
+
font-size: 11px;
|
|
1162
|
+
border-right: 1px solid var(--border-light);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
.line-num-old {
|
|
1166
|
+
width: 50px;
|
|
1167
|
+
min-width: 50px;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.line-num-new {
|
|
1171
|
+
width: 50px;
|
|
1172
|
+
min-width: 50px;
|
|
1173
|
+
border-right: 2px solid var(--border-light);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.line-content {
|
|
1177
|
+
flex: 1;
|
|
1178
|
+
padding: 0 10px;
|
|
1179
|
+
white-space: pre-wrap;
|
|
1180
|
+
word-break: break-all;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/* Side-by-Side View */
|
|
1184
|
+
.sbs-hunk { }
|
|
1185
|
+
|
|
1186
|
+
.sbs-row {
|
|
1187
|
+
display: flex;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
.sbs-left, .sbs-right {
|
|
1191
|
+
width: 50%;
|
|
1192
|
+
display: flex;
|
|
1193
|
+
align-items: stretch;
|
|
1194
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1195
|
+
font-size: 12px;
|
|
1196
|
+
line-height: 1.5;
|
|
1197
|
+
min-height: 22px;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
.sbs-left {
|
|
1201
|
+
border-right: 1px solid var(--border);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
.sbs-left .line-num-old,
|
|
1205
|
+
.sbs-right .line-num-new {
|
|
1206
|
+
width: 50px;
|
|
1207
|
+
min-width: 50px;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
.sbs-left .line-content,
|
|
1211
|
+
.sbs-right .line-content {
|
|
1212
|
+
flex: 1;
|
|
1213
|
+
padding: 0 10px;
|
|
1214
|
+
white-space: pre-wrap;
|
|
1215
|
+
word-break: break-all;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.sbs-left.diff-addition,
|
|
1219
|
+
.sbs-right.diff-addition { background: var(--add-bg); }
|
|
1220
|
+
.sbs-left.diff-deletion,
|
|
1221
|
+
.sbs-right.diff-deletion { background: var(--del-bg); }
|
|
1222
|
+
.diff-empty { background: var(--bg-secondary); }
|
|
1223
|
+
|
|
1224
|
+
/* Inline Edit View - Word Diff */
|
|
1225
|
+
.wd-removed {
|
|
1226
|
+
background: var(--wd-del-bg);
|
|
1227
|
+
color: var(--del-text);
|
|
1228
|
+
text-decoration: line-through;
|
|
1229
|
+
border-radius: 3px;
|
|
1230
|
+
padding: 0 2px;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
.wd-added {
|
|
1234
|
+
background: var(--wd-add-bg);
|
|
1235
|
+
color: var(--add-text);
|
|
1236
|
+
border-radius: 3px;
|
|
1237
|
+
padding: 0 2px;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
.wd-equal {
|
|
1241
|
+
color: var(--text);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/* Statistics Panel */
|
|
1245
|
+
.stats-panel {
|
|
1246
|
+
position: fixed;
|
|
1247
|
+
top: 50px;
|
|
1248
|
+
right: 16px;
|
|
1249
|
+
width: 420px;
|
|
1250
|
+
max-height: calc(100vh - 70px);
|
|
1251
|
+
background: var(--stats-bg);
|
|
1252
|
+
border: 1px solid var(--border);
|
|
1253
|
+
border-radius: 12px;
|
|
1254
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
|
1255
|
+
z-index: 200;
|
|
1256
|
+
overflow-y: auto;
|
|
1257
|
+
padding: 20px;
|
|
1258
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
.stats-panel.hidden {
|
|
1262
|
+
opacity: 0;
|
|
1263
|
+
pointer-events: none;
|
|
1264
|
+
transform: translateY(-8px);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
.stats-header {
|
|
1268
|
+
display: flex;
|
|
1269
|
+
align-items: center;
|
|
1270
|
+
justify-content: space-between;
|
|
1271
|
+
margin-bottom: 16px;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.stats-header h2 {
|
|
1275
|
+
font-size: 18px;
|
|
1276
|
+
font-weight: 600;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
.close-btn {
|
|
1280
|
+
background: none;
|
|
1281
|
+
border: none;
|
|
1282
|
+
font-size: 22px;
|
|
1283
|
+
color: var(--text-secondary);
|
|
1284
|
+
cursor: pointer;
|
|
1285
|
+
padding: 4px 8px;
|
|
1286
|
+
border-radius: 4px;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.close-btn:hover {
|
|
1290
|
+
background: var(--btn-hover);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.stats-grid {
|
|
1294
|
+
display: grid;
|
|
1295
|
+
grid-template-columns: repeat(3, 1fr);
|
|
1296
|
+
gap: 10px;
|
|
1297
|
+
margin-bottom: 20px;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.stat-card {
|
|
1301
|
+
padding: 12px;
|
|
1302
|
+
border: 1px solid var(--border-light);
|
|
1303
|
+
border-radius: 8px;
|
|
1304
|
+
text-align: center;
|
|
1305
|
+
background: var(--bg);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
.stat-card.stat-add-bg { border-color: var(--add-icon); background: var(--add-bg); }
|
|
1309
|
+
.stat-card.stat-del-bg { border-color: var(--del-icon); background: var(--del-bg); }
|
|
1310
|
+
|
|
1311
|
+
.stat-value {
|
|
1312
|
+
font-size: 24px;
|
|
1313
|
+
font-weight: 700;
|
|
1314
|
+
color: var(--text);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
.stat-label {
|
|
1318
|
+
font-size: 11px;
|
|
1319
|
+
color: var(--text-secondary);
|
|
1320
|
+
margin-top: 2px;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
.stats-table-section h3 {
|
|
1324
|
+
font-size: 14px;
|
|
1325
|
+
font-weight: 600;
|
|
1326
|
+
margin-bottom: 8px;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
.stats-table {
|
|
1330
|
+
width: 100%;
|
|
1331
|
+
border-collapse: collapse;
|
|
1332
|
+
font-size: 13px;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
.stats-table th {
|
|
1336
|
+
text-align: left;
|
|
1337
|
+
padding: 6px 8px;
|
|
1338
|
+
border-bottom: 1px solid var(--border);
|
|
1339
|
+
color: var(--text-secondary);
|
|
1340
|
+
font-weight: 500;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
.stats-table td {
|
|
1344
|
+
padding: 6px 8px;
|
|
1345
|
+
border-bottom: 1px solid var(--border-light);
|
|
1346
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1347
|
+
font-size: 12px;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
.stats-table td:first-child {
|
|
1351
|
+
max-width: 200px;
|
|
1352
|
+
overflow: hidden;
|
|
1353
|
+
text-overflow: ellipsis;
|
|
1354
|
+
white-space: nowrap;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/* Scrollbar Styling */
|
|
1358
|
+
::-webkit-scrollbar {
|
|
1359
|
+
width: 8px;
|
|
1360
|
+
height: 8px;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
::-webkit-scrollbar-track {
|
|
1364
|
+
background: transparent;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
::-webkit-scrollbar-thumb {
|
|
1368
|
+
background: var(--scrollbar-thumb);
|
|
1369
|
+
border-radius: 4px;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
::-webkit-scrollbar-thumb:hover {
|
|
1373
|
+
background: var(--text-secondary);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/* Syntax highlighting overrides */
|
|
1377
|
+
.highlight { background: transparent; }
|
|
1378
|
+
.highlight .lineno { display: none; }
|
|
1379
|
+
|
|
1380
|
+
/* Tooltip */
|
|
1381
|
+
.tooltip {
|
|
1382
|
+
position: fixed;
|
|
1383
|
+
z-index: 300;
|
|
1384
|
+
background: var(--bg);
|
|
1385
|
+
border: 1px solid var(--border);
|
|
1386
|
+
border-radius: 8px;
|
|
1387
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
1388
|
+
padding: 10px 14px;
|
|
1389
|
+
font-size: 12px;
|
|
1390
|
+
line-height: 1.5;
|
|
1391
|
+
max-width: 360px;
|
|
1392
|
+
pointer-events: none;
|
|
1393
|
+
transition: opacity 0.12s ease;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
.tooltip.hidden {
|
|
1397
|
+
opacity: 0;
|
|
1398
|
+
pointer-events: none;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
.tooltip-author {
|
|
1402
|
+
font-weight: 600;
|
|
1403
|
+
color: var(--text);
|
|
1404
|
+
font-size: 13px;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
.tooltip-commit {
|
|
1408
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1409
|
+
font-size: 11px;
|
|
1410
|
+
color: var(--text-secondary);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.tooltip-subject {
|
|
1414
|
+
color: var(--text);
|
|
1415
|
+
margin-top: 2px;
|
|
1416
|
+
white-space: nowrap;
|
|
1417
|
+
overflow: hidden;
|
|
1418
|
+
text-overflow: ellipsis;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.tooltip-date {
|
|
1422
|
+
color: var(--text-secondary);
|
|
1423
|
+
font-size: 11px;
|
|
1424
|
+
margin-top: 1px;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
.tooltip-click-hint {
|
|
1428
|
+
color: var(--text-secondary);
|
|
1429
|
+
font-size: 10px;
|
|
1430
|
+
margin-top: 4px;
|
|
1431
|
+
border-top: 1px solid var(--border-light);
|
|
1432
|
+
padding-top: 3px;
|
|
1433
|
+
font-style: italic;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/* Commit Drawer */
|
|
1437
|
+
.commit-drawer {
|
|
1438
|
+
position: fixed;
|
|
1439
|
+
top: 0;
|
|
1440
|
+
right: 0;
|
|
1441
|
+
width: 460px;
|
|
1442
|
+
max-width: 100vw;
|
|
1443
|
+
height: 100vh;
|
|
1444
|
+
background: var(--bg);
|
|
1445
|
+
border-left: 1px solid var(--border);
|
|
1446
|
+
box-shadow: -4px 0 24px rgba(0,0,0,0.15);
|
|
1447
|
+
z-index: 400;
|
|
1448
|
+
display: flex;
|
|
1449
|
+
flex-direction: column;
|
|
1450
|
+
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
.commit-drawer.hidden {
|
|
1454
|
+
transform: translateX(100%);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
.drawer-header {
|
|
1458
|
+
display: flex;
|
|
1459
|
+
align-items: center;
|
|
1460
|
+
justify-content: space-between;
|
|
1461
|
+
padding: 16px 20px;
|
|
1462
|
+
border-bottom: 1px solid var(--border);
|
|
1463
|
+
flex-shrink: 0;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
.drawer-header h2 {
|
|
1467
|
+
font-size: 16px;
|
|
1468
|
+
font-weight: 600;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
.drawer-content {
|
|
1472
|
+
flex: 1;
|
|
1473
|
+
overflow-y: auto;
|
|
1474
|
+
padding: 20px;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
.drawer-loading {
|
|
1478
|
+
color: var(--text-secondary);
|
|
1479
|
+
text-align: center;
|
|
1480
|
+
padding: 40px 0;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
.drawer-section {
|
|
1484
|
+
margin-bottom: 16px;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
.drawer-section-title {
|
|
1488
|
+
font-size: 11px;
|
|
1489
|
+
font-weight: 600;
|
|
1490
|
+
text-transform: uppercase;
|
|
1491
|
+
letter-spacing: 0.5px;
|
|
1492
|
+
color: var(--text-secondary);
|
|
1493
|
+
margin-bottom: 4px;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
.drawer-commit-hash {
|
|
1497
|
+
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
|
|
1498
|
+
font-size: 13px;
|
|
1499
|
+
color: var(--accent);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
.drawer-subject {
|
|
1503
|
+
font-size: 15px;
|
|
1504
|
+
font-weight: 600;
|
|
1505
|
+
color: var(--text);
|
|
1506
|
+
line-height: 1.4;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
.drawer-body {
|
|
1510
|
+
font-size: 13px;
|
|
1511
|
+
color: var(--text-secondary);
|
|
1512
|
+
line-height: 1.6;
|
|
1513
|
+
white-space: pre-wrap;
|
|
1514
|
+
margin-top: 8px;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
.drawer-meta-grid {
|
|
1518
|
+
display: grid;
|
|
1519
|
+
grid-template-columns: 90px 1fr;
|
|
1520
|
+
gap: 6px 12px;
|
|
1521
|
+
font-size: 13px;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
.drawer-meta-label {
|
|
1525
|
+
color: var(--text-secondary);
|
|
1526
|
+
font-weight: 500;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
.drawer-meta-value {
|
|
1530
|
+
color: var(--text);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
.drawer-stats {
|
|
1534
|
+
display: flex;
|
|
1535
|
+
gap: 16px;
|
|
1536
|
+
margin-top: 8px;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
.drawer-stat {
|
|
1540
|
+
text-align: center;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
.drawer-stat-value {
|
|
1544
|
+
font-size: 20px;
|
|
1545
|
+
font-weight: 700;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
.drawer-stat-label {
|
|
1549
|
+
font-size: 11px;
|
|
1550
|
+
color: var(--text-secondary);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
.drawer-overlay {
|
|
1554
|
+
position: fixed;
|
|
1555
|
+
top: 0;
|
|
1556
|
+
left: 0;
|
|
1557
|
+
width: 100%;
|
|
1558
|
+
height: 100%;
|
|
1559
|
+
background: rgba(0,0,0,0.3);
|
|
1560
|
+
z-index: 399;
|
|
1561
|
+
transition: opacity 0.2s;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
.drawer-overlay.hidden {
|
|
1565
|
+
opacity: 0;
|
|
1566
|
+
pointer-events: none;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/* Search Bar */
|
|
1570
|
+
.search-bar {
|
|
1571
|
+
display: flex;
|
|
1572
|
+
align-items: center;
|
|
1573
|
+
gap: 8px;
|
|
1574
|
+
padding: 8px 16px;
|
|
1575
|
+
background: var(--bg-secondary);
|
|
1576
|
+
border-bottom: 1px solid var(--border);
|
|
1577
|
+
flex-shrink: 0;
|
|
1578
|
+
transition: all 0.15s ease;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
.search-bar.hidden {
|
|
1582
|
+
display: none;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
.search-bar input {
|
|
1586
|
+
flex: 1;
|
|
1587
|
+
padding: 7px 12px;
|
|
1588
|
+
border: 1px solid var(--border);
|
|
1589
|
+
border-radius: 6px;
|
|
1590
|
+
background: var(--bg);
|
|
1591
|
+
color: var(--text);
|
|
1592
|
+
font-size: 13px;
|
|
1593
|
+
outline: none;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
.search-bar input:focus {
|
|
1597
|
+
border-color: var(--accent);
|
|
1598
|
+
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
.search-count {
|
|
1602
|
+
font-size: 12px;
|
|
1603
|
+
color: var(--text-secondary);
|
|
1604
|
+
white-space: nowrap;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
.search-clear {
|
|
1608
|
+
background: none;
|
|
1609
|
+
border: none;
|
|
1610
|
+
font-size: 18px;
|
|
1611
|
+
color: var(--text-secondary);
|
|
1612
|
+
cursor: pointer;
|
|
1613
|
+
padding: 2px 8px;
|
|
1614
|
+
border-radius: 4px;
|
|
1615
|
+
line-height: 1;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
.search-clear:hover {
|
|
1619
|
+
background: var(--btn-hover);
|
|
1620
|
+
color: var(--text);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/* Filter Bar */
|
|
1624
|
+
.filter-bar {
|
|
1625
|
+
display: flex;
|
|
1626
|
+
align-items: center;
|
|
1627
|
+
gap: 8px;
|
|
1628
|
+
padding: 6px 16px;
|
|
1629
|
+
background: var(--bg-secondary);
|
|
1630
|
+
border-bottom: 1px solid var(--border);
|
|
1631
|
+
flex-wrap: wrap;
|
|
1632
|
+
flex-shrink: 0;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
.filter-group {
|
|
1636
|
+
display: flex;
|
|
1637
|
+
align-items: center;
|
|
1638
|
+
gap: 4px;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
.filter-label {
|
|
1642
|
+
font-size: 11px;
|
|
1643
|
+
font-weight: 600;
|
|
1644
|
+
color: var(--text-secondary);
|
|
1645
|
+
text-transform: uppercase;
|
|
1646
|
+
letter-spacing: 0.3px;
|
|
1647
|
+
margin-right: 2px;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
.filter-chip {
|
|
1651
|
+
padding: 3px 10px;
|
|
1652
|
+
border: 1px solid var(--border);
|
|
1653
|
+
background: var(--bg);
|
|
1654
|
+
color: var(--text-secondary);
|
|
1655
|
+
border-radius: 12px;
|
|
1656
|
+
cursor: pointer;
|
|
1657
|
+
font-size: 11px;
|
|
1658
|
+
font-weight: 500;
|
|
1659
|
+
transition: all 0.15s ease;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
.filter-chip:hover {
|
|
1663
|
+
background: var(--btn-hover);
|
|
1664
|
+
color: var(--text);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
.filter-chip.active {
|
|
1668
|
+
background: var(--accent);
|
|
1669
|
+
color: #ffffff;
|
|
1670
|
+
border-color: var(--accent);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
.filter-chip.active.filter-added { background: var(--add-icon); border-color: var(--add-icon); }
|
|
1674
|
+
.filter-chip.active.filter-deleted { background: var(--del-icon); border-color: var(--del-icon); }
|
|
1675
|
+
.filter-chip.active.filter-modified { background: var(--accent); border-color: var(--accent); }
|
|
1676
|
+
.filter-chip.active.filter-renamed { background: var(--text-secondary); border-color: var(--text-secondary); }
|
|
1677
|
+
|
|
1678
|
+
.filter-clear {
|
|
1679
|
+
margin-left: auto;
|
|
1680
|
+
font-size: 11px;
|
|
1681
|
+
padding: 3px 10px;
|
|
1682
|
+
color: var(--del-icon);
|
|
1683
|
+
border-color: var(--del-bg);
|
|
1684
|
+
background: var(--del-bg);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.filter-clear:hover {
|
|
1688
|
+
background: var(--del-bg);
|
|
1689
|
+
color: var(--del-text);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/* Active filters display */
|
|
1693
|
+
.active-filters {
|
|
1694
|
+
margin-left: 12px;
|
|
1695
|
+
font-size: 12px;
|
|
1696
|
+
color: var(--text-secondary);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/* Hide filtered-out file sections */
|
|
1700
|
+
.file-section.hidden-by-search,
|
|
1701
|
+
.file-section.hidden-by-filter {
|
|
1702
|
+
display: none;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/* Search match highlight */
|
|
1706
|
+
.search-match {
|
|
1707
|
+
background: #ffd70044;
|
|
1708
|
+
border-radius: 2px;
|
|
1709
|
+
padding: 0 1px;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
[data-theme="dark"] .search-match {
|
|
1713
|
+
background: #ffd70033;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/* Diff line hover cursor for blame */
|
|
1717
|
+
.diff-line {
|
|
1718
|
+
cursor: pointer;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/* Responsive */
|
|
1722
|
+
@media (max-width: 768px) {
|
|
1723
|
+
.sidebar { display: none; }
|
|
1724
|
+
.diff-content { padding: 8px 12px; }
|
|
1725
|
+
.toolbar-center .view-btn { padding: 4px 8px; font-size: 11px; }
|
|
1726
|
+
.stats-panel { width: calc(100% - 32px); right: 16px; }
|
|
1727
|
+
.commit-drawer { width: 100vw; }
|
|
1728
|
+
}
|
|
1729
|
+
"""
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def _get_javascript() -> str:
|
|
1733
|
+
"""Get JavaScript for interactivity — blame tooltips, commit drawer, etc."""
|
|
1734
|
+
return """\
|
|
1735
|
+
// Load blame, commit, and search data
|
|
1736
|
+
var blameData = {};
|
|
1737
|
+
var commitData = {};
|
|
1738
|
+
var searchData = {};
|
|
1739
|
+
try {
|
|
1740
|
+
var blameEl = document.getElementById('diffstory-blame-data');
|
|
1741
|
+
if (blameEl) blameData = JSON.parse(blameEl.textContent);
|
|
1742
|
+
var commitEl = document.getElementById('diffstory-commit-data');
|
|
1743
|
+
if (commitEl) commitData = JSON.parse(commitEl.textContent);
|
|
1744
|
+
var searchEl = document.getElementById('diffstory-search-data');
|
|
1745
|
+
if (searchEl) searchData = JSON.parse(searchEl.textContent);
|
|
1746
|
+
} catch(e) {}
|
|
1747
|
+
|
|
1748
|
+
// Helper: format a timestamp as relative time
|
|
1749
|
+
function relativeTime(dateStr) {
|
|
1750
|
+
var now = new Date();
|
|
1751
|
+
var d = new Date(dateStr);
|
|
1752
|
+
var diff = Math.floor((now - d) / 1000);
|
|
1753
|
+
if (diff < 60) return 'just now';
|
|
1754
|
+
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
1755
|
+
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
1756
|
+
if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago';
|
|
1757
|
+
if (diff < 31536000) return Math.floor(diff / 2592000) + 'mo ago';
|
|
1758
|
+
return Math.floor(diff / 31536000) + 'y ago';
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Helper: format date nicely
|
|
1762
|
+
function formatDate(dateStr) {
|
|
1763
|
+
try {
|
|
1764
|
+
var d = new Date(dateStr);
|
|
1765
|
+
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
1766
|
+
} catch(e) {
|
|
1767
|
+
return dateStr;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Helper: short commit hash
|
|
1772
|
+
function shortHash(hash) {
|
|
1773
|
+
return hash ? hash.substring(0, 7) : '???????';
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Tooltip
|
|
1777
|
+
var tooltipEl = document.getElementById('tooltip');
|
|
1778
|
+
|
|
1779
|
+
function getBlameKey(fileIdx, lineType, oldNo, newNo) {
|
|
1780
|
+
if (lineType === 'deletion' && oldNo) return fileIdx + ':' + oldNo;
|
|
1781
|
+
if (newNo) return fileIdx + ':' + newNo;
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function buildTooltipHtml(key) {
|
|
1786
|
+
if (!key || !blameData[key]) return null;
|
|
1787
|
+
var blame = blameData[key];
|
|
1788
|
+
var commitHash = blame.commit;
|
|
1789
|
+
var short = shortHash(commitHash);
|
|
1790
|
+
var author = blame.author || 'Unknown';
|
|
1791
|
+
var subject = blame.summary || '';
|
|
1792
|
+
var dateStr = '';
|
|
1793
|
+
if (blame.date && blame.date.match(/^\\d+$/)) {
|
|
1794
|
+
var d = new Date(parseInt(blame.date) * 1000);
|
|
1795
|
+
dateStr = d.toISOString();
|
|
1796
|
+
} else if (blame.date) {
|
|
1797
|
+
dateStr = blame.date;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
var commitInfo = commitData[commitHash] || {};
|
|
1801
|
+
var fullSubject = commitInfo.subject || subject;
|
|
1802
|
+
var authorName = commitInfo.author || author;
|
|
1803
|
+
var authorDate = commitInfo.author_date || dateStr;
|
|
1804
|
+
|
|
1805
|
+
var html = '';
|
|
1806
|
+
html += '<div class="tooltip-author">' + escapeHtml(authorName) + '</div>';
|
|
1807
|
+
html += '<div class="tooltip-commit">' + short + '</div>';
|
|
1808
|
+
if (fullSubject) {
|
|
1809
|
+
html += '<div class="tooltip-subject">' + escapeHtml(fullSubject) + '</div>';
|
|
1810
|
+
}
|
|
1811
|
+
if (authorDate) {
|
|
1812
|
+
html += '<div class="tooltip-date">' + formatDate(authorDate) + ' (' + relativeTime(authorDate) + ')</div>';
|
|
1813
|
+
}
|
|
1814
|
+
html += '<div class="tooltip-click-hint">Click for details</div>';
|
|
1815
|
+
return html;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
var tooltipCurrentKey = null;
|
|
1819
|
+
|
|
1820
|
+
function showTooltip(event, fileIdx, lineType, oldNo, newNo) {
|
|
1821
|
+
var key = getBlameKey(fileIdx, lineType, oldNo, newNo);
|
|
1822
|
+
if (!key) { hideTooltip(); return; }
|
|
1823
|
+
|
|
1824
|
+
// Rebuild HTML only if the key changed
|
|
1825
|
+
if (key !== tooltipCurrentKey) {
|
|
1826
|
+
var html = buildTooltipHtml(key);
|
|
1827
|
+
if (!html) { hideTooltip(); return; }
|
|
1828
|
+
tooltipEl.innerHTML = html;
|
|
1829
|
+
tooltipCurrentKey = key;
|
|
1830
|
+
tooltipEl.classList.remove('hidden');
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Position tooltip
|
|
1834
|
+
positionTooltip(event);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function positionTooltip(event) {
|
|
1838
|
+
if (tooltipEl.classList.contains('hidden')) return;
|
|
1839
|
+
var x = event.clientX + 14;
|
|
1840
|
+
var y = event.clientY - 10;
|
|
1841
|
+
var tw = tooltipEl.offsetWidth;
|
|
1842
|
+
var th = tooltipEl.offsetHeight;
|
|
1843
|
+
if (x + tw > window.innerWidth - 10) x = event.clientX - tw - 14;
|
|
1844
|
+
if (y + th > window.innerHeight - 10) y = event.clientY - th + 10;
|
|
1845
|
+
if (y < 5) y = 5;
|
|
1846
|
+
tooltipEl.style.left = x + 'px';
|
|
1847
|
+
tooltipEl.style.top = y + 'px';
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function hideTooltip() {
|
|
1851
|
+
tooltipEl.classList.add('hidden');
|
|
1852
|
+
tooltipCurrentKey = null;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Escape HTML for tooltip content
|
|
1856
|
+
function escapeHtml(str) {
|
|
1857
|
+
if (!str) return '';
|
|
1858
|
+
return str
|
|
1859
|
+
.replace(/&/g, '&')
|
|
1860
|
+
.replace(/</g, '<')
|
|
1861
|
+
.replace(/>/g, '>')
|
|
1862
|
+
.replace(/"/g, '"');
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Compute a stable file index from the DOM
|
|
1866
|
+
function getFileIndex(lineEl) {
|
|
1867
|
+
var section = lineEl.closest('.file-section');
|
|
1868
|
+
if (!section) return -1;
|
|
1869
|
+
var id = section.id;
|
|
1870
|
+
if (id && id.startsWith('file-')) {
|
|
1871
|
+
return parseInt(id.substring(5));
|
|
1872
|
+
}
|
|
1873
|
+
return -1;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Deep linking — scroll to file or line on page load
|
|
1877
|
+
function handleDeepLink() {
|
|
1878
|
+
var hash = window.location.hash;
|
|
1879
|
+
if (!hash) return;
|
|
1880
|
+
hash = hash.substring(1); // remove #
|
|
1881
|
+
|
|
1882
|
+
if (hash.startsWith('file-')) {
|
|
1883
|
+
setTimeout(function() {
|
|
1884
|
+
scrollToFile(hash);
|
|
1885
|
+
}, 100);
|
|
1886
|
+
} else if (hash.startsWith('L-')) {
|
|
1887
|
+
// #L-42 or #L-fileIdx-42
|
|
1888
|
+
var parts = hash.substring(2).split('-');
|
|
1889
|
+
if (parts.length === 2) {
|
|
1890
|
+
var fileIdx = parseInt(parts[0]);
|
|
1891
|
+
var lineNo = parseInt(parts[1]);
|
|
1892
|
+
var section = document.getElementById('file-' + fileIdx);
|
|
1893
|
+
if (section) {
|
|
1894
|
+
scrollToFile('file-' + fileIdx);
|
|
1895
|
+
// Try to find the line
|
|
1896
|
+
setTimeout(function() {
|
|
1897
|
+
var lines = section.querySelectorAll('.diff-line');
|
|
1898
|
+
for (var i = 0; i < lines.length; i++) {
|
|
1899
|
+
var oldAttr = lines[i].getAttribute('data-old');
|
|
1900
|
+
var newAttr = lines[i].getAttribute('data-new');
|
|
1901
|
+
if (oldAttr == lineNo || newAttr == lineNo) {
|
|
1902
|
+
lines[i].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1903
|
+
lines[i].style.outline = '2px solid var(--accent)';
|
|
1904
|
+
setTimeout(function() { lines[i].style.outline = ''; }, 2000);
|
|
1905
|
+
break;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}, 200);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Attach hover and click handlers to all diff lines
|
|
1915
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
1916
|
+
document.querySelectorAll('.diff-line').forEach(function(el) {
|
|
1917
|
+
var fileIdx = getFileIndex(el);
|
|
1918
|
+
if (fileIdx < 0) return;
|
|
1919
|
+
|
|
1920
|
+
var lineType = 'context';
|
|
1921
|
+
if (el.classList.contains('diff-addition')) lineType = 'addition';
|
|
1922
|
+
else if (el.classList.contains('diff-deletion')) lineType = 'deletion';
|
|
1923
|
+
|
|
1924
|
+
var oldNo = el.getAttribute('data-old');
|
|
1925
|
+
var newNo = el.getAttribute('data-new');
|
|
1926
|
+
|
|
1927
|
+
// Tooltip on hover
|
|
1928
|
+
el.addEventListener('mouseenter', function(e) {
|
|
1929
|
+
showTooltip(e, fileIdx, lineType, oldNo, newNo);
|
|
1930
|
+
tooltipEl.style.pointerEvents = 'none';
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
el.addEventListener('mousemove', function(e) {
|
|
1934
|
+
positionTooltip(e);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
el.addEventListener('mouseleave', function() {
|
|
1938
|
+
hideTooltip();
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
// Commit drawer on click
|
|
1942
|
+
el.addEventListener('click', function(e) {
|
|
1943
|
+
var key = null;
|
|
1944
|
+
if (lineType === 'deletion' && oldNo) {
|
|
1945
|
+
key = fileIdx + ':' + oldNo;
|
|
1946
|
+
} else if (newNo) {
|
|
1947
|
+
key = fileIdx + ':' + newNo;
|
|
1948
|
+
}
|
|
1949
|
+
if (key && blameData[key]) {
|
|
1950
|
+
openDrawer(blameData[key].commit);
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
// Handle deep linking after all handlers are attached
|
|
1956
|
+
handleDeepLink();
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
// Also handle hash changes dynamically
|
|
1960
|
+
window.addEventListener('hashchange', function() {
|
|
1961
|
+
handleDeepLink();
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
// View Switching
|
|
1965
|
+
let currentView = 'unified';
|
|
1966
|
+
|
|
1967
|
+
function switchView(viewName) {
|
|
1968
|
+
currentView = viewName;
|
|
1969
|
+
document.querySelectorAll('.view-btn').forEach(function(btn) {
|
|
1970
|
+
btn.classList.toggle('active', btn.dataset.view === viewName);
|
|
1971
|
+
});
|
|
1972
|
+
document.querySelectorAll('.diff-view').forEach(function(view) {
|
|
1973
|
+
view.classList.toggle('active-view', view.classList.contains(viewName + '-view'));
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Theme Toggle
|
|
1978
|
+
function toggleTheme() {
|
|
1979
|
+
var html = document.documentElement;
|
|
1980
|
+
var isDark = html.getAttribute('data-theme') === 'dark';
|
|
1981
|
+
html.setAttribute('data-theme', isDark ? 'light' : 'dark');
|
|
1982
|
+
document.getElementById('theme-btn').textContent = isDark ? '\\u{1F319}' : '\\u{1F31E}';
|
|
1983
|
+
localStorage.setItem('diffstory-theme', isDark ? 'light' : 'dark');
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Load saved theme
|
|
1987
|
+
(function() {
|
|
1988
|
+
var saved = localStorage.getItem('diffstory-theme');
|
|
1989
|
+
if (saved) {
|
|
1990
|
+
document.documentElement.setAttribute('data-theme', saved);
|
|
1991
|
+
document.getElementById('theme-btn').textContent = saved === 'dark' ? '\\u{1F319}' : '\\u{1F31E}';
|
|
1992
|
+
}
|
|
1993
|
+
})();
|
|
1994
|
+
|
|
1995
|
+
// File Toggle (collapse/expand)
|
|
1996
|
+
function toggleFile(header) {
|
|
1997
|
+
var section = header.closest('.file-section');
|
|
1998
|
+
section.classList.toggle('collapsed');
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Sidebar Toggle
|
|
2002
|
+
var sidebarVisible = true;
|
|
2003
|
+
|
|
2004
|
+
function toggleSidebar() {
|
|
2005
|
+
sidebarVisible = !sidebarVisible;
|
|
2006
|
+
document.getElementById('sidebar').classList.toggle('hidden', !sidebarVisible);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Stats Panel
|
|
2010
|
+
function toggleStats() {
|
|
2011
|
+
document.getElementById('stats-panel').classList.toggle('hidden');
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// Scroll to File
|
|
2015
|
+
function scrollToFile(fileId) {
|
|
2016
|
+
var fileSection = document.getElementById(fileId);
|
|
2017
|
+
if (fileSection) {
|
|
2018
|
+
fileSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
2019
|
+
}
|
|
2020
|
+
document.querySelectorAll('.sidebar-file').forEach(function(el) {
|
|
2021
|
+
el.classList.remove('active');
|
|
2022
|
+
});
|
|
2023
|
+
var sidebarEl = document.querySelector('.sidebar-file[onclick*="' + fileId + '"]');
|
|
2024
|
+
if (sidebarEl) sidebarEl.classList.add('active');
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Commit Drawer
|
|
2028
|
+
function openDrawer(commitHash) {
|
|
2029
|
+
var info = commitData[commitHash];
|
|
2030
|
+
if (!info) return;
|
|
2031
|
+
|
|
2032
|
+
var drawer = document.getElementById('commit-drawer');
|
|
2033
|
+
var overlay = document.getElementById('drawer-overlay');
|
|
2034
|
+
var content = document.getElementById('drawer-content');
|
|
2035
|
+
|
|
2036
|
+
var filesChanged = info.files_changed !== undefined ? info.files_changed : '?';
|
|
2037
|
+
var insertions = info.insertions !== undefined ? info.insertions : '?';
|
|
2038
|
+
var deletions = info.deletions !== undefined ? info.deletions : '?';
|
|
2039
|
+
|
|
2040
|
+
var bodyHtml = '';
|
|
2041
|
+
if (info.body) {
|
|
2042
|
+
bodyHtml = '<div class="drawer-section"><div class="drawer-body">' + escapeHtml(info.body) + '</div></div>';
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
var parentHtml = '';
|
|
2046
|
+
if (info.parents && info.parents.length > 0) {
|
|
2047
|
+
parentHtml = '<div class="drawer-meta-grid"><div class="drawer-meta-label">Parents</div><div class="drawer-meta-value drawer-commit-hash">' +
|
|
2048
|
+
info.parents.map(function(p) { return shortHash(p); }).join(', ') + '</div></div>';
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
content.innerHTML = '' +
|
|
2052
|
+
'<div class="drawer-section">' +
|
|
2053
|
+
' <div class="drawer-commit-hash">' + commitHash + '</div>' +
|
|
2054
|
+
' <div class="drawer-subject">' + escapeHtml(info.subject || 'No subject') + '</div>' +
|
|
2055
|
+
bodyHtml +
|
|
2056
|
+
'</div>' +
|
|
2057
|
+
'<div class="drawer-section">' +
|
|
2058
|
+
' <div class="drawer-section-title">Meta</div>' +
|
|
2059
|
+
' <div class="drawer-meta-grid">' +
|
|
2060
|
+
' <div class="drawer-meta-label">Author</div><div class="drawer-meta-value">' + escapeHtml(info.author || 'Unknown') + '</div>' +
|
|
2061
|
+
' <div class="drawer-meta-label">Email</div><div class="drawer-meta-value">' + escapeHtml(info.author_email || '') + '</div>' +
|
|
2062
|
+
' <div class="drawer-meta-label">Date</div><div class="drawer-meta-value">' + formatDate(info.author_date || '') + '</div>' +
|
|
2063
|
+
' <div class="drawer-meta-label">Committer</div><div class="drawer-meta-value">' + escapeHtml(info.committer || '') + '</div>' +
|
|
2064
|
+
parentHtml +
|
|
2065
|
+
' </div>' +
|
|
2066
|
+
'</div>' +
|
|
2067
|
+
'<div class="drawer-section">' +
|
|
2068
|
+
' <div class="drawer-section-title">Stats</div>' +
|
|
2069
|
+
' <div class="drawer-stats">' +
|
|
2070
|
+
' <div class="drawer-stat"><div class="drawer-stat-value">' + filesChanged + '</div><div class="drawer-stat-label">Files</div></div>' +
|
|
2071
|
+
' <div class="drawer-stat"><div class="drawer-stat-value stat-add">+' + insertions + '</div><div class="drawer-stat-label">Additions</div></div>' +
|
|
2072
|
+
' <div class="drawer-stat"><div class="drawer-stat-value stat-del">-' + deletions + '</div><div class="drawer-stat-label">Deletions</div></div>' +
|
|
2073
|
+
' </div>' +
|
|
2074
|
+
'</div>';
|
|
2075
|
+
|
|
2076
|
+
drawer.classList.remove('hidden');
|
|
2077
|
+
overlay.classList.remove('hidden');
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function closeDrawer() {
|
|
2081
|
+
document.getElementById('commit-drawer').classList.add('hidden');
|
|
2082
|
+
document.getElementById('drawer-overlay').classList.add('hidden');
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// Global Search
|
|
2086
|
+
function focusSearch() {
|
|
2087
|
+
var bar = document.getElementById('search-bar');
|
|
2088
|
+
bar.classList.remove('hidden');
|
|
2089
|
+
var input = document.getElementById('global-search');
|
|
2090
|
+
input.focus();
|
|
2091
|
+
input.select();
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function doGlobalSearch() {
|
|
2095
|
+
var query = document.getElementById('global-search').value.toLowerCase().trim();
|
|
2096
|
+
var countEl = document.getElementById('search-count');
|
|
2097
|
+
var matchedFiles = [];
|
|
2098
|
+
|
|
2099
|
+
document.querySelectorAll('.file-section').forEach(function(section) {
|
|
2100
|
+
section.classList.remove('hidden-by-search');
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
if (!query) {
|
|
2104
|
+
countEl.textContent = '';
|
|
2105
|
+
document.querySelectorAll('.search-match').forEach(function(el) {
|
|
2106
|
+
var parent = el.parentNode;
|
|
2107
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
2108
|
+
parent.removeChild(el);
|
|
2109
|
+
});
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Check each file section
|
|
2114
|
+
document.querySelectorAll('.file-section').forEach(function(section) {
|
|
2115
|
+
var fileName = section.dataset.file || '';
|
|
2116
|
+
var fileIdx = parseInt(section.id.replace('file-', ''));
|
|
2117
|
+
var match = false;
|
|
2118
|
+
|
|
2119
|
+
// Check file name
|
|
2120
|
+
if (fileName.toLowerCase().includes(query)) match = true;
|
|
2121
|
+
|
|
2122
|
+
// Check authors from search data
|
|
2123
|
+
if (!match && searchData.authors) {
|
|
2124
|
+
for (var i = 0; i < searchData.authors.length; i++) {
|
|
2125
|
+
if (searchData.authors[i].toLowerCase().includes(query)) { match = true; break; }
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// Check commit subjects from search data
|
|
2130
|
+
if (!match && searchData.subjects) {
|
|
2131
|
+
for (var i = 0; i < searchData.subjects.length; i++) {
|
|
2132
|
+
if (searchData.subjects[i].toLowerCase().includes(query)) { match = true; break; }
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Check code content in the diff lines
|
|
2137
|
+
if (!match) {
|
|
2138
|
+
var lines = section.querySelectorAll('.line-content');
|
|
2139
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2140
|
+
if (lines[i].textContent.toLowerCase().includes(query)) {
|
|
2141
|
+
match = true;
|
|
2142
|
+
break;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
if (match) {
|
|
2148
|
+
matchedFiles.push(fileName);
|
|
2149
|
+
section.classList.remove('hidden-by-search');
|
|
2150
|
+
} else {
|
|
2151
|
+
section.classList.add('hidden-by-search');
|
|
2152
|
+
}
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
countEl.textContent = matchedFiles.length + ' file' + (matchedFiles.length !== 1 ? 's' : '') + ' match';
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function clearGlobalSearch() {
|
|
2159
|
+
document.getElementById('global-search').value = '';
|
|
2160
|
+
document.getElementById('search-count').textContent = '';
|
|
2161
|
+
document.querySelectorAll('.file-section').forEach(function(section) {
|
|
2162
|
+
section.classList.remove('hidden-by-search');
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Filter Chips
|
|
2167
|
+
var activeExtFilters = [];
|
|
2168
|
+
var activeTypeFilters = [];
|
|
2169
|
+
|
|
2170
|
+
function applyFilters() {
|
|
2171
|
+
var hasExtFilter = activeExtFilters.length > 0;
|
|
2172
|
+
var hasTypeFilter = activeTypeFilters.length > 0;
|
|
2173
|
+
|
|
2174
|
+
if (!hasExtFilter && !hasTypeFilter) {
|
|
2175
|
+
document.querySelectorAll('.file-section').forEach(function(s) { s.classList.remove('hidden-by-filter'); });
|
|
2176
|
+
document.getElementById('active-filters').textContent = '';
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
var visibleCount = 0;
|
|
2181
|
+
document.querySelectorAll('.file-section').forEach(function(section) {
|
|
2182
|
+
var fileName = section.dataset.file || '';
|
|
2183
|
+
var statusIcon = section.querySelector('.file-status-icon');
|
|
2184
|
+
var hide = false;
|
|
2185
|
+
|
|
2186
|
+
if (hasExtFilter) {
|
|
2187
|
+
var ext = '';
|
|
2188
|
+
var dotIdx = fileName.lastIndexOf('.');
|
|
2189
|
+
if (dotIdx >= 0) ext = fileName.substring(dotIdx).toLowerCase();
|
|
2190
|
+
if (activeExtFilters.indexOf(ext) === -1) hide = true;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (!hide && hasTypeFilter) {
|
|
2194
|
+
var status = 'modified';
|
|
2195
|
+
if (statusIcon) {
|
|
2196
|
+
if (statusIcon.classList.contains('file-status-added')) status = 'added';
|
|
2197
|
+
else if (statusIcon.classList.contains('file-status-deleted')) status = 'deleted';
|
|
2198
|
+
else if (statusIcon.classList.contains('file-status-renamed')) status = 'renamed';
|
|
2199
|
+
}
|
|
2200
|
+
if (activeTypeFilters.indexOf(status) === -1) hide = true;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
if (hide) {
|
|
2204
|
+
section.classList.add('hidden-by-filter');
|
|
2205
|
+
} else {
|
|
2206
|
+
section.classList.remove('hidden-by-filter');
|
|
2207
|
+
visibleCount++;
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
var label = '';
|
|
2212
|
+
if (hasTypeFilter) label += activeTypeFilters.join(', ');
|
|
2213
|
+
if (hasExtFilter) label += (label ? ' | ' : '') + activeExtFilters.join(', ');
|
|
2214
|
+
document.getElementById('active-filters').textContent = label ? 'Filtered: ' + label : '';
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function toggleFilterExt(ext) {
|
|
2218
|
+
var btn = document.querySelector('.filter-ext[data-ext="' + ext + '"]');
|
|
2219
|
+
var idx = activeExtFilters.indexOf(ext);
|
|
2220
|
+
if (idx >= 0) {
|
|
2221
|
+
activeExtFilters.splice(idx, 1);
|
|
2222
|
+
btn.classList.remove('active');
|
|
2223
|
+
} else {
|
|
2224
|
+
activeExtFilters.push(ext);
|
|
2225
|
+
btn.classList.add('active');
|
|
2226
|
+
}
|
|
2227
|
+
applyFilters();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function toggleFilterType(type) {
|
|
2231
|
+
var btn = document.querySelector('.filter-type[data-type="' + type + '"]');
|
|
2232
|
+
var idx = activeTypeFilters.indexOf(type);
|
|
2233
|
+
if (idx >= 0) {
|
|
2234
|
+
activeTypeFilters.splice(idx, 1);
|
|
2235
|
+
btn.classList.remove('active');
|
|
2236
|
+
} else {
|
|
2237
|
+
activeTypeFilters.push(type);
|
|
2238
|
+
btn.classList.add('active');
|
|
2239
|
+
}
|
|
2240
|
+
applyFilters();
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function clearFilters() {
|
|
2244
|
+
activeExtFilters = [];
|
|
2245
|
+
activeTypeFilters = [];
|
|
2246
|
+
document.querySelectorAll('.filter-chip').forEach(function(btn) { btn.classList.remove('active'); });
|
|
2247
|
+
document.querySelectorAll('.file-section').forEach(function(s) { s.classList.remove('hidden-by-filter'); });
|
|
2248
|
+
document.getElementById('active-filters').textContent = '';
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// Keyboard Navigation
|
|
2252
|
+
document.addEventListener('keydown', function(e) {
|
|
2253
|
+
// Allow typing in inputs
|
|
2254
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
2255
|
+
if (e.key === 'Escape') {
|
|
2256
|
+
e.target.blur();
|
|
2257
|
+
document.getElementById('search-bar').classList.add('hidden');
|
|
2258
|
+
e.preventDefault();
|
|
2259
|
+
}
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
switch (e.key) {
|
|
2263
|
+
case 'j': case 'J': scrollToNextFile(1); e.preventDefault(); break;
|
|
2264
|
+
case 'k': case 'K': scrollToNextFile(-1); e.preventDefault(); break;
|
|
2265
|
+
case 'f': case 'F': focusSearch(); e.preventDefault(); break;
|
|
2266
|
+
case '/': focusSearch(); e.preventDefault(); break;
|
|
2267
|
+
case 'd': case 'D': toggleTheme(); e.preventDefault(); break;
|
|
2268
|
+
case 'u': case 'U': switchView('unified'); e.preventDefault(); break;
|
|
2269
|
+
case 's': case 'S': switchView('sidebyside'); e.preventDefault(); break;
|
|
2270
|
+
case 'i': case 'I': switchView('inline'); e.preventDefault(); break;
|
|
2271
|
+
case 'Escape':
|
|
2272
|
+
if (!document.getElementById('commit-drawer').classList.contains('hidden')) {
|
|
2273
|
+
closeDrawer();
|
|
2274
|
+
} else if (!document.getElementById('search-bar').classList.contains('hidden')) {
|
|
2275
|
+
document.getElementById('search-bar').classList.add('hidden');
|
|
2276
|
+
clearGlobalSearch();
|
|
2277
|
+
} else {
|
|
2278
|
+
document.getElementById('stats-panel').classList.add('hidden');
|
|
2279
|
+
}
|
|
2280
|
+
e.preventDefault();
|
|
2281
|
+
break;
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
// J/K Scroll to next/previous file
|
|
2286
|
+
function scrollToNextFile(direction) {
|
|
2287
|
+
var sections = document.querySelectorAll('.file-section:not(.hidden-by-search):not(.hidden-by-filter)');
|
|
2288
|
+
if (sections.length === 0) return;
|
|
2289
|
+
var container = document.getElementById('diff-content');
|
|
2290
|
+
var scrollTop = container.scrollTop;
|
|
2291
|
+
var containerHeight = container.clientHeight;
|
|
2292
|
+
var viewCenter = scrollTop + containerHeight / 2;
|
|
2293
|
+
|
|
2294
|
+
var bestIdx = -1;
|
|
2295
|
+
if (direction > 0) {
|
|
2296
|
+
// Find the first section whose top is below the view center
|
|
2297
|
+
var minTop = Infinity;
|
|
2298
|
+
for (var i = 0; i < sections.length; i++) {
|
|
2299
|
+
var top = sections[i].offsetTop;
|
|
2300
|
+
if (top > viewCenter + 10 && top < minTop) {
|
|
2301
|
+
minTop = top;
|
|
2302
|
+
bestIdx = i;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (bestIdx === -1) bestIdx = 0; // wrap to first
|
|
2306
|
+
} else {
|
|
2307
|
+
// Find the last section whose top is above the view center
|
|
2308
|
+
var maxTop = -Infinity;
|
|
2309
|
+
for (var i = 0; i < sections.length; i++) {
|
|
2310
|
+
var top = sections[i].offsetTop;
|
|
2311
|
+
if (top < viewCenter - 10 && top > maxTop) {
|
|
2312
|
+
maxTop = top;
|
|
2313
|
+
bestIdx = i;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
if (bestIdx === -1) bestIdx = sections.length - 1; // wrap to last
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
if (bestIdx >= 0) {
|
|
2320
|
+
sections[bestIdx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
2321
|
+
scrollToFile(sections[bestIdx].id);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// File Filtering
|
|
2326
|
+
function filterFiles() {
|
|
2327
|
+
var query = document.getElementById('file-search').value.toLowerCase();
|
|
2328
|
+
document.querySelectorAll('.sidebar-file').forEach(function(el) {
|
|
2329
|
+
var name = el.querySelector('.sidebar-file-name').textContent.toLowerCase();
|
|
2330
|
+
el.style.display = name.includes(query) ? '' : 'none';
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Sidebar file click tracking for active state
|
|
2335
|
+
document.querySelectorAll('.sidebar-file').forEach(function(el) {
|
|
2336
|
+
el.addEventListener('click', function() {
|
|
2337
|
+
document.querySelectorAll('.sidebar-file').forEach(function(f) {
|
|
2338
|
+
f.classList.remove('active');
|
|
2339
|
+
});
|
|
2340
|
+
el.classList.add('active');
|
|
2341
|
+
});
|
|
2342
|
+
});
|
|
2343
|
+
"""
|