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.
@@ -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": "&#43;",
266
+ "deleted": "&#8722;",
267
+ "renamed": "&#8594;",
268
+ "modified": "&#9679;",
269
+ }.get(file_status, "&#9679;")
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) + ' &#8594; </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">&#9660;</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": "&#43;",
476
+ "deleted": "&#8722;",
477
+ "renamed": "&#8594;",
478
+ "modified": "&#9679;",
479
+ }.get(file.status, "&#9679;")
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 &mdash; ' + 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()">&times;</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()">&times;</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()">&times;</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, '&amp;')
1860
+ .replace(/</g, '&lt;')
1861
+ .replace(/>/g, '&gt;')
1862
+ .replace(/"/g, '&quot;');
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
+ """