codeclone 1.1.0__py3-none-any.whl → 1.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.
- codeclone/baseline.py +13 -9
- codeclone/cache.py +12 -6
- codeclone/cfg.py +166 -1
- codeclone/cli.py +305 -75
- codeclone/html_report.py +569 -586
- codeclone/normalize.py +29 -6
- codeclone/report.py +6 -6
- {codeclone-1.1.0.dist-info → codeclone-1.2.0.dist-info}/METADATA +15 -5
- codeclone-1.2.0.dist-info/RECORD +19 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.0.dist-info}/WHEEL +1 -1
- codeclone-1.1.0.dist-info/RECORD +0 -19
- {codeclone-1.1.0.dist-info → codeclone-1.2.0.dist-info}/entry_points.txt +0 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codeclone-1.1.0.dist-info → codeclone-1.2.0.dist-info}/top_level.txt +0 -0
codeclone/html_report.py
CHANGED
|
@@ -12,14 +12,16 @@ import html
|
|
|
12
12
|
import itertools
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from pathlib import Path
|
|
15
|
+
from string import Template
|
|
15
16
|
from typing import Any, Optional, Iterable
|
|
16
17
|
|
|
17
18
|
from codeclone import __version__
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
# ============================
|
|
21
|
+
# ============================
|
|
21
22
|
# Pairwise
|
|
22
|
-
# ============================
|
|
23
|
+
# ============================
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
|
25
27
|
a, b = itertools.tee(iterable)
|
|
@@ -27,9 +29,10 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
|
|
|
27
29
|
return zip(a, b)
|
|
28
30
|
|
|
29
31
|
|
|
30
|
-
# ============================
|
|
32
|
+
# ============================
|
|
31
33
|
# Code snippet infrastructure
|
|
32
|
-
# ============================
|
|
34
|
+
# ============================
|
|
35
|
+
|
|
33
36
|
|
|
34
37
|
@dataclass
|
|
35
38
|
class _Snippet:
|
|
@@ -101,10 +104,14 @@ def _prefix_css(css: str, prefix: str) -> str:
|
|
|
101
104
|
if not stripped:
|
|
102
105
|
out_lines.append(line)
|
|
103
106
|
continue
|
|
104
|
-
if
|
|
107
|
+
if (
|
|
108
|
+
stripped.startswith("/*")
|
|
109
|
+
or stripped.startswith("*")
|
|
110
|
+
or stripped.startswith("*/")
|
|
111
|
+
):
|
|
105
112
|
out_lines.append(line)
|
|
106
113
|
continue
|
|
107
|
-
# Selector lines usually end with `{
|
|
114
|
+
# Selector lines usually end with `{
|
|
108
115
|
if "{" in line:
|
|
109
116
|
# naive prefix: split at "{", prefix selector part
|
|
110
117
|
before, after = line.split("{", 1)
|
|
@@ -119,13 +126,13 @@ def _prefix_css(css: str, prefix: str) -> str:
|
|
|
119
126
|
|
|
120
127
|
|
|
121
128
|
def _render_code_block(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
*,
|
|
130
|
+
filepath: str,
|
|
131
|
+
start_line: int,
|
|
132
|
+
end_line: int,
|
|
133
|
+
file_cache: _FileCache,
|
|
134
|
+
context: int,
|
|
135
|
+
max_lines: int,
|
|
129
136
|
) -> _Snippet:
|
|
130
137
|
lines = file_cache.get_lines(filepath)
|
|
131
138
|
|
|
@@ -161,9 +168,10 @@ def _render_code_block(
|
|
|
161
168
|
)
|
|
162
169
|
|
|
163
170
|
|
|
164
|
-
# ============================
|
|
171
|
+
# ============================
|
|
165
172
|
# HTML report builder
|
|
166
|
-
# ============================
|
|
173
|
+
# ============================
|
|
174
|
+
|
|
167
175
|
|
|
168
176
|
def _escape(v: Any) -> str:
|
|
169
177
|
return html.escape("" if v is None else str(v))
|
|
@@ -176,701 +184,494 @@ def _group_sort_key(items: list[dict[str, Any]]) -> tuple[int, int]:
|
|
|
176
184
|
)
|
|
177
185
|
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
func_groups: dict[str, list[dict[str, Any]]],
|
|
182
|
-
block_groups: dict[str, list[dict[str, Any]]],
|
|
183
|
-
title: str = "CodeClone Report",
|
|
184
|
-
context_lines: int = 3,
|
|
185
|
-
max_snippet_lines: int = 220,
|
|
186
|
-
) -> str:
|
|
187
|
-
file_cache = _FileCache()
|
|
188
|
-
|
|
189
|
-
func_sorted = sorted(func_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
190
|
-
block_sorted = sorted(block_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
191
|
-
|
|
192
|
-
has_any = bool(func_sorted) or bool(block_sorted)
|
|
193
|
-
|
|
194
|
-
# Pygments CSS (scoped). Use modern GitHub-like styles when available.
|
|
195
|
-
# We scope per theme to support toggle without reloading.
|
|
196
|
-
pyg_dark_raw = _pygments_css("github-dark")
|
|
197
|
-
if not pyg_dark_raw:
|
|
198
|
-
pyg_dark_raw = _pygments_css("monokai")
|
|
199
|
-
pyg_light_raw = _pygments_css("github-light")
|
|
200
|
-
if not pyg_light_raw:
|
|
201
|
-
pyg_light_raw = _pygments_css("friendly")
|
|
202
|
-
|
|
203
|
-
pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
|
|
204
|
-
pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
|
|
205
|
-
|
|
206
|
-
# ----------------------------
|
|
207
|
-
# Section renderer
|
|
208
|
-
# ----------------------------
|
|
209
|
-
|
|
210
|
-
def render_section(
|
|
211
|
-
section_id: str,
|
|
212
|
-
section_title: str,
|
|
213
|
-
groups: list[tuple[str, list[dict[str, Any]]]],
|
|
214
|
-
pill_cls: str,
|
|
215
|
-
) -> str:
|
|
216
|
-
if not groups:
|
|
217
|
-
return ""
|
|
218
|
-
|
|
219
|
-
# build group DOM with data-search (for fast client-side search)
|
|
220
|
-
out: list[str] = [
|
|
221
|
-
f'<section id="{section_id}" class="section" data-section="{section_id}">',
|
|
222
|
-
'<div class="section-head">',
|
|
223
|
-
f"<h2>{_escape(section_title)} "
|
|
224
|
-
f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
|
|
225
|
-
f"""
|
|
226
|
-
<div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
|
|
227
|
-
<div class="toolbar-left">
|
|
228
|
-
<div class="search-wrap">
|
|
229
|
-
<span class="search-ico">⌕</span>
|
|
230
|
-
<input class="search" id="search-{section_id}" placeholder="Search by qualname / path / fingerprint…" autocomplete="off" />
|
|
231
|
-
<button class="btn ghost" type="button" data-clear="{section_id}" title="Clear search">×</button>
|
|
232
|
-
</div>
|
|
233
|
-
<div class="segmented">
|
|
234
|
-
<button class="btn seg" type="button" data-collapse-all="{section_id}">Collapse</button>
|
|
235
|
-
<button class="btn seg" type="button" data-expand-all="{section_id}">Expand</button>
|
|
236
|
-
</div>
|
|
237
|
-
</div>
|
|
238
|
-
|
|
239
|
-
<div class="toolbar-right">
|
|
240
|
-
<div class="pager">
|
|
241
|
-
<button class="btn" type="button" data-prev="{section_id}">‹</button>
|
|
242
|
-
<span class="page-meta" data-page-meta="{section_id}">Page 1</span>
|
|
243
|
-
<button class="btn" type="button" data-next="{section_id}">›</button>
|
|
244
|
-
</div>
|
|
245
|
-
<select class="select" data-pagesize="{section_id}" title="Groups per page">
|
|
246
|
-
<option value="5">5 / page</option>
|
|
247
|
-
<option value="10" selected>10 / page</option>
|
|
248
|
-
<option value="20">20 / page</option>
|
|
249
|
-
<option value="50">50 / page</option>
|
|
250
|
-
</select>
|
|
251
|
-
</div>
|
|
252
|
-
</div>
|
|
253
|
-
""",
|
|
254
|
-
"</div>", # section-head
|
|
255
|
-
'<div class="section-body">',
|
|
256
|
-
]
|
|
257
|
-
|
|
258
|
-
for idx, (gkey, items) in enumerate(groups, start=1):
|
|
259
|
-
# Create search blob for group:
|
|
260
|
-
# - gkey (fingerprint)
|
|
261
|
-
# - all qualnames + filepaths
|
|
262
|
-
# This is used by JS filtering (no heavy DOM scans on each keystroke).
|
|
263
|
-
search_parts: list[str] = [str(gkey)]
|
|
264
|
-
for it in items:
|
|
265
|
-
search_parts.append(str(it.get("qualname", "")))
|
|
266
|
-
search_parts.append(str(it.get("filepath", "")))
|
|
267
|
-
search_blob = " ".join(search_parts).lower()
|
|
268
|
-
search_blob_escaped = html.escape(search_blob, quote=True)
|
|
269
|
-
|
|
270
|
-
out.append(
|
|
271
|
-
f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
out.append(
|
|
275
|
-
f'<div class="group-head">'
|
|
276
|
-
f'<div class="group-left">'
|
|
277
|
-
f'<button class="chev" type="button" aria-label="Toggle group" data-toggle-group="{section_id}-{idx}">▾</button>'
|
|
278
|
-
f'<div class="group-title">Group #{idx}</div>'
|
|
279
|
-
f'<span class="pill small {pill_cls}">{len(items)} items</span>'
|
|
280
|
-
f"</div>"
|
|
281
|
-
f'<div class="group-right">'
|
|
282
|
-
f'<code class="gkey">{_escape(gkey)}</code>'
|
|
283
|
-
f"</div>"
|
|
284
|
-
f"</div>"
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
|
|
288
|
-
|
|
289
|
-
for a, b in pairwise(items):
|
|
290
|
-
out.append('<div class="item-pair">')
|
|
291
|
-
|
|
292
|
-
for item in (a, b):
|
|
293
|
-
snippet = _render_code_block(
|
|
294
|
-
filepath=item["filepath"],
|
|
295
|
-
start_line=int(item["start_line"]),
|
|
296
|
-
end_line=int(item["end_line"]),
|
|
297
|
-
file_cache=file_cache,
|
|
298
|
-
context=context_lines,
|
|
299
|
-
max_lines=max_snippet_lines,
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
out.append(
|
|
303
|
-
'<div class="item">'
|
|
304
|
-
f'<div class="item-head">{_escape(item["qualname"])}</div>'
|
|
305
|
-
f'<div class="item-file">'
|
|
306
|
-
f'{_escape(item["filepath"])}:'
|
|
307
|
-
f'{item["start_line"]}-{item["end_line"]}'
|
|
308
|
-
f'</div>'
|
|
309
|
-
f'{snippet.code_html}'
|
|
310
|
-
'</div>'
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
out.append("</div>") # item-pair
|
|
314
|
-
|
|
315
|
-
out.append("</div>") # items
|
|
316
|
-
out.append("</div>") # group
|
|
317
|
-
|
|
318
|
-
out.append("</div>") # section-body
|
|
319
|
-
out.append("</section>")
|
|
320
|
-
return "\n".join(out)
|
|
321
|
-
|
|
322
|
-
# ============================
|
|
323
|
-
# HTML
|
|
324
|
-
# ============================
|
|
325
|
-
|
|
326
|
-
empty_state_html = ""
|
|
327
|
-
if not has_any:
|
|
328
|
-
empty_state_html = """
|
|
329
|
-
<div class="empty">
|
|
330
|
-
<div class="empty-card">
|
|
331
|
-
<div class="empty-icon">✓</div>
|
|
332
|
-
<h2>No code clones detected</h2>
|
|
333
|
-
<p>No structural or block-level duplication was found above configured thresholds.</p>
|
|
334
|
-
<p class="muted">This usually indicates healthy abstraction boundaries.</p>
|
|
335
|
-
</div>
|
|
336
|
-
</div>
|
|
337
|
-
"""
|
|
338
|
-
|
|
339
|
-
return f"""<!doctype html>
|
|
187
|
+
REPORT_TEMPLATE = Template(r"""
|
|
188
|
+
<!doctype html>
|
|
340
189
|
<html lang="en" data-theme="dark">
|
|
341
190
|
<head>
|
|
342
191
|
<meta charset="utf-8">
|
|
343
192
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
344
|
-
<title
|
|
193
|
+
<title>${title}</title>
|
|
345
194
|
|
|
346
195
|
<style>
|
|
347
|
-
/* ============================
|
|
196
|
+
/* ============================
|
|
348
197
|
CodeClone UI/UX
|
|
349
198
|
============================ */
|
|
350
199
|
|
|
351
|
-
:root {
|
|
352
|
-
--bg: #
|
|
353
|
-
--panel:
|
|
354
|
-
--panel2:
|
|
355
|
-
--text:
|
|
356
|
-
--muted:
|
|
357
|
-
--border:
|
|
358
|
-
--border2:
|
|
359
|
-
--accent: #
|
|
360
|
-
--accent2: rgba(
|
|
361
|
-
--good: #
|
|
362
|
-
--shadow: 0
|
|
363
|
-
--shadow2: 0
|
|
364
|
-
--radius:
|
|
365
|
-
--radius2:
|
|
366
|
-
--mono: ui-monospace, SFMono-Regular,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
--
|
|
372
|
-
--
|
|
373
|
-
--
|
|
374
|
-
--
|
|
375
|
-
--
|
|
376
|
-
--
|
|
377
|
-
--
|
|
378
|
-
--
|
|
379
|
-
--
|
|
380
|
-
--
|
|
381
|
-
--
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
200
|
+
:root {
|
|
201
|
+
--bg: #0d1117;
|
|
202
|
+
--panel: #161b22;
|
|
203
|
+
--panel2: #21262d;
|
|
204
|
+
--text: #c9d1d9;
|
|
205
|
+
--muted: #8b949e;
|
|
206
|
+
--border: #30363d;
|
|
207
|
+
--border2: #6e7681;
|
|
208
|
+
--accent: #58a6ff;
|
|
209
|
+
--accent2: rgba(56, 139, 253, 0.15);
|
|
210
|
+
--good: #3fb950;
|
|
211
|
+
--shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
212
|
+
--shadow2: 0 4px 12px rgba(0,0,0,0.2);
|
|
213
|
+
--radius: 6px;
|
|
214
|
+
--radius2: 8px;
|
|
215
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
216
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
html[data-theme="light"] {
|
|
220
|
+
--bg: #ffffff;
|
|
221
|
+
--panel: #f6f8fa;
|
|
222
|
+
--panel2: #eaeef2;
|
|
223
|
+
--text: #24292f;
|
|
224
|
+
--muted: #57606a;
|
|
225
|
+
--border: #d0d7de;
|
|
226
|
+
--border2: #afb8c1;
|
|
227
|
+
--accent: #0969da;
|
|
228
|
+
--accent2: rgba(84, 174, 255, 0.2);
|
|
229
|
+
--good: #1a7f37;
|
|
230
|
+
--shadow: 0 8px 24px rgba(140,149,159,0.2);
|
|
231
|
+
--shadow2: 0 4px 12px rgba(140,149,159,0.1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
* { box-sizing: border-box; }
|
|
235
|
+
|
|
236
|
+
body {
|
|
387
237
|
margin: 0;
|
|
388
|
-
background:
|
|
389
|
-
radial-gradient(900px 600px at 80% 0%, rgba(124,255,160,.10), transparent 35%),
|
|
390
|
-
var(--bg);
|
|
238
|
+
background: var(--bg);
|
|
391
239
|
color: var(--text);
|
|
392
|
-
font-family:
|
|
393
|
-
line-height: 1.
|
|
394
|
-
}
|
|
240
|
+
font-family: var(--font);
|
|
241
|
+
line-height: 1.5;
|
|
242
|
+
}
|
|
395
243
|
|
|
396
|
-
.container {
|
|
397
|
-
max-width:
|
|
244
|
+
.container {
|
|
245
|
+
max-width: 1400px;
|
|
398
246
|
margin: 0 auto;
|
|
399
|
-
padding:
|
|
400
|
-
}
|
|
247
|
+
padding: 20px 20px 80px;
|
|
248
|
+
}
|
|
401
249
|
|
|
402
|
-
.topbar {
|
|
250
|
+
.topbar {
|
|
403
251
|
position: sticky;
|
|
404
252
|
top: 0;
|
|
405
|
-
z-index:
|
|
406
|
-
backdrop-filter: blur(
|
|
407
|
-
-webkit-backdrop-filter: blur(
|
|
408
|
-
background:
|
|
253
|
+
z-index: 100;
|
|
254
|
+
backdrop-filter: blur(8px);
|
|
255
|
+
-webkit-backdrop-filter: blur(8px);
|
|
256
|
+
background: var(--bg);
|
|
409
257
|
border-bottom: 1px solid var(--border);
|
|
410
|
-
|
|
411
|
-
}
|
|
258
|
+
opacity: 0.98;
|
|
259
|
+
}
|
|
412
260
|
|
|
413
|
-
.topbar-inner {
|
|
261
|
+
.topbar-inner {
|
|
414
262
|
display: flex;
|
|
415
263
|
align-items: center;
|
|
416
264
|
justify-content: space-between;
|
|
417
|
-
|
|
418
|
-
|
|
265
|
+
height: 60px;
|
|
266
|
+
padding: 0 20px;
|
|
267
|
+
max-width: 1400px;
|
|
268
|
+
margin: 0 auto;
|
|
269
|
+
}
|
|
419
270
|
|
|
420
|
-
.brand {
|
|
271
|
+
.brand {
|
|
421
272
|
display: flex;
|
|
422
|
-
|
|
423
|
-
gap:
|
|
424
|
-
}
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 12px;
|
|
275
|
+
}
|
|
425
276
|
|
|
426
|
-
.brand h1 {
|
|
277
|
+
.brand h1 {
|
|
427
278
|
margin: 0;
|
|
428
|
-
font-size:
|
|
429
|
-
|
|
430
|
-
}
|
|
279
|
+
font-size: 18px;
|
|
280
|
+
font-weight: 600;
|
|
281
|
+
}
|
|
431
282
|
|
|
432
|
-
.brand .sub {
|
|
283
|
+
.brand .sub {
|
|
433
284
|
color: var(--muted);
|
|
434
|
-
font-size:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
gap: 10px;
|
|
441
|
-
}}
|
|
285
|
+
font-size: 13px;
|
|
286
|
+
background: var(--panel2);
|
|
287
|
+
padding: 2px 8px;
|
|
288
|
+
border-radius: 99px;
|
|
289
|
+
font-weight: 500;
|
|
290
|
+
}
|
|
442
291
|
|
|
443
|
-
.btn {
|
|
292
|
+
.btn {
|
|
444
293
|
display: inline-flex;
|
|
445
294
|
align-items: center;
|
|
446
295
|
justify-content: center;
|
|
447
|
-
gap:
|
|
448
|
-
padding:
|
|
449
|
-
border-radius:
|
|
296
|
+
gap: 6px;
|
|
297
|
+
padding: 6px 12px;
|
|
298
|
+
border-radius: 6px;
|
|
450
299
|
border: 1px solid var(--border);
|
|
451
300
|
background: var(--panel);
|
|
452
301
|
color: var(--text);
|
|
453
302
|
cursor: pointer;
|
|
454
|
-
|
|
455
|
-
font-weight:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
303
|
+
font-size: 13px;
|
|
304
|
+
font-weight: 500;
|
|
305
|
+
transition: 0.2s;
|
|
306
|
+
height: 32px;
|
|
307
|
+
}
|
|
459
308
|
|
|
460
|
-
.btn:hover {
|
|
309
|
+
.btn:hover {
|
|
461
310
|
border-color: var(--border2);
|
|
462
311
|
background: var(--panel2);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
.btn:active {{
|
|
466
|
-
transform: translateY(1px);
|
|
467
|
-
}}
|
|
312
|
+
}
|
|
468
313
|
|
|
469
|
-
.btn.ghost {
|
|
314
|
+
.btn.ghost {
|
|
470
315
|
background: transparent;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
316
|
+
border-color: transparent;
|
|
317
|
+
padding: 4px;
|
|
318
|
+
width: 28px;
|
|
319
|
+
height: 28px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.select {
|
|
323
|
+
padding: 0 24px 0 8px;
|
|
324
|
+
height: 32px;
|
|
325
|
+
border-radius: 6px;
|
|
477
326
|
border: 1px solid var(--border);
|
|
478
327
|
background: var(--panel);
|
|
479
328
|
color: var(--text);
|
|
480
|
-
font-
|
|
481
|
-
|
|
482
|
-
}}
|
|
329
|
+
font-size: 13px;
|
|
330
|
+
}
|
|
483
331
|
|
|
484
|
-
.section {
|
|
485
|
-
margin-top:
|
|
486
|
-
}
|
|
332
|
+
.section {
|
|
333
|
+
margin-top: 32px;
|
|
334
|
+
}
|
|
487
335
|
|
|
488
|
-
.section-head {
|
|
336
|
+
.section-head {
|
|
489
337
|
display: flex;
|
|
490
338
|
flex-direction: column;
|
|
491
|
-
gap:
|
|
492
|
-
margin-
|
|
493
|
-
}
|
|
339
|
+
gap: 16px;
|
|
340
|
+
margin-bottom: 16px;
|
|
341
|
+
}
|
|
494
342
|
|
|
495
|
-
.section-head h2 {
|
|
343
|
+
.section-head h2 {
|
|
496
344
|
margin: 0;
|
|
497
|
-
font-size:
|
|
498
|
-
|
|
499
|
-
}}
|
|
500
|
-
|
|
501
|
-
.section-toolbar {{
|
|
345
|
+
font-size: 20px;
|
|
346
|
+
font-weight: 600;
|
|
502
347
|
display: flex;
|
|
503
|
-
justify-content: space-between;
|
|
504
348
|
align-items: center;
|
|
505
349
|
gap: 12px;
|
|
506
|
-
|
|
507
|
-
}}
|
|
350
|
+
}
|
|
508
351
|
|
|
509
|
-
.
|
|
352
|
+
.section-toolbar {
|
|
510
353
|
display: flex;
|
|
354
|
+
justify-content: space-between;
|
|
511
355
|
align-items: center;
|
|
512
|
-
gap:
|
|
513
|
-
|
|
356
|
+
gap: 16px;
|
|
357
|
+
flex-wrap: wrap;
|
|
358
|
+
padding: 12px;
|
|
359
|
+
background: var(--panel);
|
|
360
|
+
border: 1px solid var(--border);
|
|
361
|
+
border-radius: 6px;
|
|
362
|
+
}
|
|
514
363
|
|
|
515
|
-
.search-wrap {
|
|
364
|
+
.search-wrap {
|
|
516
365
|
display: flex;
|
|
517
366
|
align-items: center;
|
|
518
367
|
gap: 8px;
|
|
519
|
-
padding: 8px
|
|
520
|
-
border-radius:
|
|
368
|
+
padding: 4px 8px;
|
|
369
|
+
border-radius: 6px;
|
|
521
370
|
border: 1px solid var(--border);
|
|
522
|
-
background: var(--
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
371
|
+
background: var(--bg);
|
|
372
|
+
min-width: 300px;
|
|
373
|
+
height: 32px;
|
|
374
|
+
}
|
|
375
|
+
.search-wrap:focus-within {
|
|
376
|
+
border-color: var(--accent);
|
|
377
|
+
box-shadow: 0 0 0 2px var(--accent2);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.search-ico {
|
|
381
|
+
color: var(--muted);
|
|
382
|
+
display: flex;
|
|
383
|
+
}
|
|
531
384
|
|
|
532
|
-
.search {
|
|
385
|
+
.search {
|
|
533
386
|
width: 100%;
|
|
534
387
|
border: none;
|
|
535
388
|
outline: none;
|
|
536
389
|
background: transparent;
|
|
537
390
|
color: var(--text);
|
|
538
391
|
font-size: 13px;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
.search::placeholder {{
|
|
542
|
-
color: var(--muted);
|
|
543
|
-
}}
|
|
392
|
+
}
|
|
544
393
|
|
|
545
|
-
.segmented {
|
|
394
|
+
.segmented {
|
|
546
395
|
display: inline-flex;
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
border:
|
|
550
|
-
}
|
|
396
|
+
background: var(--panel2);
|
|
397
|
+
padding: 2px;
|
|
398
|
+
border-radius: 6px;
|
|
399
|
+
}
|
|
551
400
|
|
|
552
|
-
.btn.seg {
|
|
401
|
+
.btn.seg {
|
|
553
402
|
border: none;
|
|
554
|
-
border-radius: 0;
|
|
555
|
-
box-shadow: none;
|
|
556
403
|
background: transparent;
|
|
557
|
-
|
|
404
|
+
height: 28px;
|
|
405
|
+
font-size: 12px;
|
|
406
|
+
}
|
|
407
|
+
.btn.seg:hover {
|
|
408
|
+
background: var(--bg);
|
|
409
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
410
|
+
}
|
|
558
411
|
|
|
559
|
-
.pager {
|
|
412
|
+
.pager {
|
|
560
413
|
display: inline-flex;
|
|
561
414
|
align-items: center;
|
|
562
415
|
gap: 8px;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
.page-meta {{
|
|
566
|
-
color: var(--muted);
|
|
567
|
-
font-size: 12.5px;
|
|
568
|
-
min-width: 120px;
|
|
569
|
-
text-align: center;
|
|
570
|
-
}}
|
|
416
|
+
font-size: 13px;
|
|
417
|
+
}
|
|
571
418
|
|
|
572
|
-
.pill {
|
|
573
|
-
padding:
|
|
574
|
-
border-radius:
|
|
419
|
+
.pill {
|
|
420
|
+
padding: 2px 10px;
|
|
421
|
+
border-radius: 99px;
|
|
575
422
|
background: var(--accent2);
|
|
576
|
-
border: 1px solid rgba(
|
|
423
|
+
border: 1px solid rgba(56, 139, 253, 0.3);
|
|
577
424
|
font-size: 12px;
|
|
578
|
-
font-weight:
|
|
579
|
-
color: var(--
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
padding: 3px 8px;
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
color: var(--accent);
|
|
427
|
+
}
|
|
428
|
+
.pill.small {
|
|
429
|
+
padding: 1px 8px;
|
|
584
430
|
font-size: 11px;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
background:
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}}
|
|
596
|
-
|
|
597
|
-
.section-body {{
|
|
598
|
-
margin-top: 12px;
|
|
599
|
-
}}
|
|
431
|
+
}
|
|
432
|
+
.pill-func {
|
|
433
|
+
color: var(--accent);
|
|
434
|
+
background: var(--accent2);
|
|
435
|
+
}
|
|
436
|
+
.pill-block {
|
|
437
|
+
color: var(--good);
|
|
438
|
+
background: rgba(63, 185, 80, 0.15);
|
|
439
|
+
border-color: rgba(63, 185, 80, 0.3);
|
|
440
|
+
}
|
|
600
441
|
|
|
601
|
-
.group {
|
|
602
|
-
margin-
|
|
442
|
+
.group {
|
|
443
|
+
margin-bottom: 16px;
|
|
603
444
|
border: 1px solid var(--border);
|
|
604
|
-
border-radius:
|
|
605
|
-
background: var(--
|
|
606
|
-
box-shadow: var(--
|
|
607
|
-
|
|
608
|
-
}}
|
|
445
|
+
border-radius: 6px;
|
|
446
|
+
background: var(--bg);
|
|
447
|
+
box-shadow: var(--shadow2);
|
|
448
|
+
}
|
|
609
449
|
|
|
610
|
-
.group-head {
|
|
450
|
+
.group-head {
|
|
611
451
|
display: flex;
|
|
612
452
|
justify-content: space-between;
|
|
613
453
|
align-items: center;
|
|
614
|
-
|
|
615
|
-
|
|
454
|
+
padding: 12px 16px;
|
|
455
|
+
background: var(--panel);
|
|
616
456
|
border-bottom: 1px solid var(--border);
|
|
617
|
-
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
}
|
|
618
459
|
|
|
619
|
-
.group-left {
|
|
460
|
+
.group-left {
|
|
620
461
|
display: flex;
|
|
621
462
|
align-items: center;
|
|
622
|
-
gap:
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
.group-title {{
|
|
626
|
-
font-weight: 900;
|
|
627
|
-
font-size: 13px;
|
|
628
|
-
}}
|
|
463
|
+
gap: 12px;
|
|
464
|
+
}
|
|
629
465
|
|
|
630
|
-
.group-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
max-width: 60%;
|
|
635
|
-
}}
|
|
466
|
+
.group-title {
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
font-size: 14px;
|
|
469
|
+
}
|
|
636
470
|
|
|
637
|
-
.gkey {
|
|
471
|
+
.gkey {
|
|
638
472
|
font-family: var(--mono);
|
|
639
|
-
font-size:
|
|
473
|
+
font-size: 12px;
|
|
640
474
|
color: var(--muted);
|
|
641
|
-
overflow: hidden;
|
|
642
|
-
text-overflow: ellipsis;
|
|
643
|
-
white-space: nowrap;
|
|
644
|
-
}}
|
|
645
|
-
|
|
646
|
-
.chev {{
|
|
647
|
-
width: 30px;
|
|
648
|
-
height: 30px;
|
|
649
|
-
border-radius: 10px;
|
|
650
|
-
border: 1px solid var(--border);
|
|
651
475
|
background: var(--panel2);
|
|
476
|
+
padding: 2px 6px;
|
|
477
|
+
border-radius: 4px;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.chev {
|
|
481
|
+
display: flex;
|
|
482
|
+
align-items: center;
|
|
483
|
+
justify-content: center;
|
|
484
|
+
width: 24px;
|
|
485
|
+
height: 24px;
|
|
486
|
+
border-radius: 4px;
|
|
487
|
+
border: 1px solid var(--border);
|
|
488
|
+
background: var(--bg);
|
|
489
|
+
color: var(--muted);
|
|
490
|
+
padding: 0;
|
|
491
|
+
}
|
|
492
|
+
.chev:hover {
|
|
652
493
|
color: var(--text);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}}
|
|
494
|
+
border-color: var(--border2);
|
|
495
|
+
}
|
|
656
496
|
|
|
657
|
-
.items {
|
|
658
|
-
padding:
|
|
659
|
-
}
|
|
497
|
+
.items {
|
|
498
|
+
padding: 16px;
|
|
499
|
+
}
|
|
660
500
|
|
|
661
|
-
.item-pair {
|
|
501
|
+
.item-pair {
|
|
662
502
|
display: grid;
|
|
663
503
|
grid-template-columns: 1fr 1fr;
|
|
664
|
-
gap:
|
|
665
|
-
margin-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
504
|
+
gap: 16px;
|
|
505
|
+
margin-bottom: 16px;
|
|
506
|
+
}
|
|
507
|
+
.item-pair:last-child {
|
|
508
|
+
margin-bottom: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@media (max-width: 1000px) {
|
|
512
|
+
.item-pair {
|
|
670
513
|
grid-template-columns: 1fr;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
min-width: 260px;
|
|
674
|
-
}}
|
|
675
|
-
}}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
676
516
|
|
|
677
|
-
.item {
|
|
517
|
+
.item {
|
|
678
518
|
border: 1px solid var(--border);
|
|
679
|
-
border-radius:
|
|
519
|
+
border-radius: 6px;
|
|
680
520
|
overflow: hidden;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
html[data-theme="light"] .item {{
|
|
685
|
-
background: rgba(255,255,255,0.60);
|
|
686
|
-
}}
|
|
521
|
+
display: flex;
|
|
522
|
+
flex-direction: column;
|
|
523
|
+
}
|
|
687
524
|
|
|
688
|
-
.item-head {
|
|
689
|
-
padding:
|
|
690
|
-
|
|
691
|
-
font-size: 12.8px;
|
|
525
|
+
.item-head {
|
|
526
|
+
padding: 8px 12px;
|
|
527
|
+
background: var(--panel);
|
|
692
528
|
border-bottom: 1px solid var(--border);
|
|
693
|
-
|
|
529
|
+
font-size: 13px;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
color: var(--accent);
|
|
532
|
+
}
|
|
694
533
|
|
|
695
|
-
.item-file {
|
|
534
|
+
.item-file {
|
|
696
535
|
padding: 6px 12px;
|
|
536
|
+
background: var(--panel2);
|
|
537
|
+
border-bottom: 1px solid var(--border);
|
|
697
538
|
font-family: var(--mono);
|
|
698
|
-
font-size:
|
|
539
|
+
font-size: 11px;
|
|
699
540
|
color: var(--muted);
|
|
700
|
-
|
|
701
|
-
}}
|
|
541
|
+
}
|
|
702
542
|
|
|
703
|
-
.codebox {
|
|
543
|
+
.codebox {
|
|
704
544
|
margin: 0;
|
|
705
545
|
padding: 12px;
|
|
706
546
|
font-family: var(--mono);
|
|
707
|
-
font-size:
|
|
547
|
+
font-size: 12px;
|
|
548
|
+
line-height: 1.5;
|
|
708
549
|
overflow: auto;
|
|
709
|
-
background:
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
html[data-theme="light"] .codebox {{
|
|
713
|
-
background: rgba(0,0,0,0.03);
|
|
714
|
-
}}
|
|
715
|
-
|
|
716
|
-
.line {{
|
|
717
|
-
white-space: pre;
|
|
718
|
-
}}
|
|
550
|
+
background: var(--bg);
|
|
551
|
+
flex: 1;
|
|
552
|
+
}
|
|
719
553
|
|
|
720
|
-
.
|
|
721
|
-
|
|
722
|
-
background: rgba(255, 184, 107, .18);
|
|
723
|
-
}}
|
|
724
|
-
|
|
725
|
-
.empty {{
|
|
726
|
-
margin-top: 34px;
|
|
554
|
+
.empty {
|
|
555
|
+
padding: 60px 0;
|
|
727
556
|
display: flex;
|
|
728
557
|
justify-content: center;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
.empty-card {{
|
|
732
|
-
max-width: 640px;
|
|
733
|
-
border-radius: 22px;
|
|
734
|
-
border: 1px solid var(--border);
|
|
735
|
-
background: var(--panel);
|
|
736
|
-
box-shadow: var(--shadow);
|
|
737
|
-
padding: 26px 26px;
|
|
558
|
+
}
|
|
559
|
+
.empty-card {
|
|
738
560
|
text-align: center;
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
561
|
+
padding: 40px;
|
|
562
|
+
background: var(--panel);
|
|
563
|
+
border: 1px solid var(--border);
|
|
564
|
+
border-radius: 12px;
|
|
565
|
+
max-width: 500px;
|
|
566
|
+
}
|
|
567
|
+
.empty-icon {
|
|
743
568
|
color: var(--good);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
569
|
+
margin-bottom: 16px;
|
|
570
|
+
display: flex;
|
|
571
|
+
justify-content: center;
|
|
572
|
+
}
|
|
747
573
|
|
|
748
|
-
.footer {
|
|
749
|
-
margin-top:
|
|
574
|
+
.footer {
|
|
575
|
+
margin-top: 60px;
|
|
750
576
|
text-align: center;
|
|
751
577
|
color: var(--muted);
|
|
752
578
|
font-size: 12px;
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
color: var(--muted);
|
|
757
|
-
}}
|
|
579
|
+
border-top: 1px solid var(--border);
|
|
580
|
+
padding-top: 24px;
|
|
581
|
+
}
|
|
758
582
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
IMPORTANT: without this, `nowrap=True` output won't be colored.
|
|
762
|
-
============================ */
|
|
763
|
-
{pyg_dark}
|
|
764
|
-
{pyg_light}
|
|
583
|
+
${pyg_dark}
|
|
584
|
+
${pyg_light}
|
|
765
585
|
</style>
|
|
766
586
|
</head>
|
|
767
587
|
|
|
768
588
|
<body>
|
|
769
589
|
<div class="topbar">
|
|
770
|
-
<div class="
|
|
771
|
-
<div class="
|
|
772
|
-
<
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
<div class="top-actions">
|
|
778
|
-
<button class="btn" type="button" id="theme-toggle" title="Toggle theme">🌓 Theme</button>
|
|
779
|
-
</div>
|
|
590
|
+
<div class="topbar-inner">
|
|
591
|
+
<div class="brand">
|
|
592
|
+
<h1>${title}</h1>
|
|
593
|
+
<div class="sub">v${version}</div>
|
|
594
|
+
</div>
|
|
595
|
+
<div class="top-actions">
|
|
596
|
+
<button class="btn" type="button" id="theme-toggle" title="Toggle theme">${icon_theme} Theme</button>
|
|
780
597
|
</div>
|
|
781
598
|
</div>
|
|
782
599
|
</div>
|
|
783
600
|
|
|
784
601
|
<div class="container">
|
|
785
|
-
{empty_state_html}
|
|
602
|
+
${empty_state_html}
|
|
786
603
|
|
|
787
|
-
{
|
|
788
|
-
{
|
|
604
|
+
${func_section}
|
|
605
|
+
${block_section}
|
|
789
606
|
|
|
790
|
-
<div class="footer">Generated by CodeClone v{
|
|
607
|
+
<div class="footer">Generated by CodeClone v${version}</div>
|
|
791
608
|
</div>
|
|
792
609
|
|
|
793
610
|
<script>
|
|
794
|
-
(() => {
|
|
795
|
-
// ----------------------------
|
|
796
|
-
// Theme toggle
|
|
797
|
-
// ----------------------------
|
|
611
|
+
(() => {
|
|
798
612
|
const htmlEl = document.documentElement;
|
|
799
613
|
const btnTheme = document.getElementById("theme-toggle");
|
|
800
614
|
|
|
801
615
|
const stored = localStorage.getItem("codeclone_theme");
|
|
802
|
-
if (stored === "light" || stored === "dark") {
|
|
616
|
+
if (stored === "light" || stored === "dark") {
|
|
803
617
|
htmlEl.setAttribute("data-theme", stored);
|
|
804
|
-
}
|
|
618
|
+
}
|
|
805
619
|
|
|
806
|
-
btnTheme?.addEventListener("click", () => {
|
|
620
|
+
btnTheme?.addEventListener("click", () => {
|
|
807
621
|
const cur = htmlEl.getAttribute("data-theme") || "dark";
|
|
808
622
|
const next = cur === "dark" ? "light" : "dark";
|
|
809
623
|
htmlEl.setAttribute("data-theme", next);
|
|
810
624
|
localStorage.setItem("codeclone_theme", next);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Toggle group visibility via header click
|
|
628
|
+
document.querySelectorAll(".group-head").forEach((head) => {
|
|
629
|
+
head.addEventListener("click", (e) => {
|
|
630
|
+
if (e.target.closest("button")) return;
|
|
631
|
+
const btn = head.querySelector("[data-toggle-group]");
|
|
632
|
+
if (btn) btn.click();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
document.querySelectorAll("[data-toggle-group]").forEach((btn) => {
|
|
637
|
+
btn.addEventListener("click", () => {
|
|
818
638
|
const id = btn.getAttribute("data-toggle-group");
|
|
819
639
|
const body = document.getElementById("group-body-" + id);
|
|
820
640
|
if (!body) return;
|
|
821
641
|
|
|
822
642
|
const isHidden = body.style.display === "none";
|
|
823
643
|
body.style.display = isHidden ? "" : "none";
|
|
824
|
-
btn.
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// ----------------------------
|
|
829
|
-
// Search + Pagination ("soft virtualization")
|
|
830
|
-
// ----------------------------
|
|
831
|
-
function initSection(sectionId) {{
|
|
832
|
-
const section = document.querySelector(`section[data-section='${{sectionId}}']`);
|
|
833
|
-
if (!section) return;
|
|
834
|
-
|
|
835
|
-
const groups = Array.from(section.querySelectorAll(`.group[data-group='${{sectionId}}']`));
|
|
836
|
-
const searchInput = document.getElementById(`search-${{sectionId}}`);
|
|
837
|
-
|
|
838
|
-
const btnPrev = section.querySelector(`[data-prev='${{sectionId}}']`);
|
|
839
|
-
const btnNext = section.querySelector(`[data-next='${{sectionId}}']`);
|
|
840
|
-
const meta = section.querySelector(`[data-page-meta='${{sectionId}}']`);
|
|
841
|
-
const selPageSize = section.querySelector(`[data-pagesize='${{sectionId}}']`);
|
|
644
|
+
btn.style.transform = isHidden ? "rotate(0deg)" : "rotate(-90deg)";
|
|
645
|
+
});
|
|
646
|
+
});
|
|
842
647
|
|
|
843
|
-
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
const pill = section.querySelector(`[data-count-pill='${{sectionId}}']`);
|
|
648
|
+
function initSection(sectionId) {
|
|
649
|
+
const section = document.querySelector(`section[data-section='$${sectionId}']`);
|
|
650
|
+
if (!section) return;
|
|
847
651
|
|
|
848
|
-
const
|
|
652
|
+
const groups = Array.from(section.querySelectorAll(`.group[data-group='$${sectionId}']`));
|
|
653
|
+
const searchInput = document.getElementById(`search-$${sectionId}`);
|
|
654
|
+
const btnPrev = section.querySelector(`[data-prev='$${sectionId}']`);
|
|
655
|
+
const btnNext = section.querySelector(`[data-next='$${sectionId}']`);
|
|
656
|
+
const meta = section.querySelector(`[data-page-meta='$${sectionId}']`);
|
|
657
|
+
const selPageSize = section.querySelector(`[data-pagesize='$${sectionId}']`);
|
|
658
|
+
const btnClear = section.querySelector(`[data-clear='$${sectionId}']`);
|
|
659
|
+
const btnCollapseAll = section.querySelector(`[data-collapse-all='$${sectionId}']`);
|
|
660
|
+
const btnExpandAll = section.querySelector(`[data-expand-all='$${sectionId}']`);
|
|
661
|
+
const pill = section.querySelector(`[data-count-pill='$${sectionId}']`);
|
|
662
|
+
|
|
663
|
+
const state = {
|
|
849
664
|
q: "",
|
|
850
665
|
page: 1,
|
|
851
666
|
pageSize: parseInt(selPageSize?.value || "10", 10),
|
|
852
667
|
filtered: groups
|
|
853
|
-
}
|
|
668
|
+
};
|
|
854
669
|
|
|
855
|
-
function setGroupVisible(el, yes) {
|
|
670
|
+
function setGroupVisible(el, yes) {
|
|
856
671
|
el.style.display = yes ? "" : "none";
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function applyFilter() {{
|
|
860
|
-
const q = (state.q || "").trim().toLowerCase();
|
|
861
|
-
if (!q) {{
|
|
862
|
-
state.filtered = groups;
|
|
863
|
-
}} else {{
|
|
864
|
-
state.filtered = groups.filter(g => {{
|
|
865
|
-
const blob = g.getAttribute("data-search") || "";
|
|
866
|
-
return blob.indexOf(q) !== -1;
|
|
867
|
-
}});
|
|
868
|
-
}}
|
|
869
|
-
state.page = 1;
|
|
870
|
-
render();
|
|
871
|
-
}}
|
|
672
|
+
}
|
|
872
673
|
|
|
873
|
-
function render() {
|
|
674
|
+
function render() {
|
|
874
675
|
const total = state.filtered.length;
|
|
875
676
|
const pageSize = Math.max(1, state.pageSize);
|
|
876
677
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
@@ -879,75 +680,257 @@ html[data-theme="light"] .codebox {{
|
|
|
879
680
|
const start = (state.page - 1) * pageSize;
|
|
880
681
|
const end = Math.min(total, start + pageSize);
|
|
881
682
|
|
|
882
|
-
// hide all (this is the "virtualization": only show the slice)
|
|
883
683
|
groups.forEach(g => setGroupVisible(g, false));
|
|
884
684
|
state.filtered.slice(start, end).forEach(g => setGroupVisible(g, true));
|
|
885
685
|
|
|
886
|
-
if (meta) {{
|
|
887
|
-
|
|
888
|
-
}}
|
|
889
|
-
if (pill) {{
|
|
890
|
-
pill.textContent = `${{total}} groups`;
|
|
891
|
-
}}
|
|
686
|
+
if (meta) meta.textContent = `Page $${state.page} / $${pages} • $${total} groups`;
|
|
687
|
+
if (pill) pill.textContent = `$${total} groups`;
|
|
892
688
|
|
|
893
689
|
if (btnPrev) btnPrev.disabled = state.page <= 1;
|
|
894
690
|
if (btnNext) btnNext.disabled = state.page >= pages;
|
|
895
|
-
}
|
|
691
|
+
}
|
|
896
692
|
|
|
897
|
-
|
|
898
|
-
|
|
693
|
+
function applyFilter() {
|
|
694
|
+
const q = (state.q || "").trim().toLowerCase();
|
|
695
|
+
if (!q) {
|
|
696
|
+
state.filtered = groups;
|
|
697
|
+
} else {
|
|
698
|
+
state.filtered = groups.filter(g => {
|
|
699
|
+
const blob = g.getAttribute("data-search") || "";
|
|
700
|
+
return blob.indexOf(q) !== -1;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
state.page = 1;
|
|
704
|
+
render();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
searchInput?.addEventListener("input", (e) => {
|
|
899
708
|
state.q = e.target.value || "";
|
|
900
709
|
applyFilter();
|
|
901
|
-
}
|
|
710
|
+
});
|
|
902
711
|
|
|
903
|
-
btnClear?.addEventListener("click", () => {
|
|
712
|
+
btnClear?.addEventListener("click", () => {
|
|
904
713
|
if (searchInput) searchInput.value = "";
|
|
905
714
|
state.q = "";
|
|
906
715
|
applyFilter();
|
|
907
|
-
}
|
|
716
|
+
});
|
|
908
717
|
|
|
909
|
-
selPageSize?.addEventListener("change", () => {
|
|
718
|
+
selPageSize?.addEventListener("change", () => {
|
|
910
719
|
state.pageSize = parseInt(selPageSize.value || "10", 10);
|
|
911
720
|
state.page = 1;
|
|
912
721
|
render();
|
|
913
|
-
}
|
|
722
|
+
});
|
|
914
723
|
|
|
915
|
-
btnPrev?.addEventListener("click", () => {
|
|
724
|
+
btnPrev?.addEventListener("click", () => {
|
|
916
725
|
state.page -= 1;
|
|
917
726
|
render();
|
|
918
|
-
}
|
|
727
|
+
});
|
|
919
728
|
|
|
920
|
-
btnNext?.addEventListener("click", () => {
|
|
729
|
+
btnNext?.addEventListener("click", () => {
|
|
921
730
|
state.page += 1;
|
|
922
731
|
render();
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
btnCollapseAll?.addEventListener("click", () => {
|
|
926
|
-
section.querySelectorAll(".items").forEach(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
section.querySelectorAll(".items").forEach((b) => {{
|
|
936
|
-
b.style.display = "";
|
|
937
|
-
}});
|
|
938
|
-
section.querySelectorAll("[data-toggle-group]").forEach((c) => {{
|
|
939
|
-
c.textContent = "▾";
|
|
940
|
-
}});
|
|
941
|
-
}});
|
|
942
|
-
|
|
943
|
-
// Initial render
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
btnCollapseAll?.addEventListener("click", () => {
|
|
735
|
+
section.querySelectorAll(".items").forEach(b => b.style.display = "none");
|
|
736
|
+
section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(-90deg)");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
btnExpandAll?.addEventListener("click", () => {
|
|
740
|
+
section.querySelectorAll(".items").forEach(b => b.style.display = "");
|
|
741
|
+
section.querySelectorAll("[data-toggle-group]").forEach(c => c.style.transform = "rotate(0deg)");
|
|
742
|
+
});
|
|
743
|
+
|
|
944
744
|
render();
|
|
945
|
-
}
|
|
745
|
+
}
|
|
946
746
|
|
|
947
747
|
initSection("functions");
|
|
948
748
|
initSection("blocks");
|
|
949
|
-
}
|
|
749
|
+
})();
|
|
950
750
|
</script>
|
|
951
751
|
</body>
|
|
952
752
|
</html>
|
|
753
|
+
""")
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def build_html_report(
|
|
757
|
+
*,
|
|
758
|
+
func_groups: dict[str, list[dict[str, Any]]],
|
|
759
|
+
block_groups: dict[str, list[dict[str, Any]]],
|
|
760
|
+
title: str = "CodeClone Report",
|
|
761
|
+
context_lines: int = 3,
|
|
762
|
+
max_snippet_lines: int = 220,
|
|
763
|
+
) -> str:
|
|
764
|
+
file_cache = _FileCache()
|
|
765
|
+
|
|
766
|
+
func_sorted = sorted(func_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
767
|
+
block_sorted = sorted(block_groups.items(), key=lambda kv: _group_sort_key(kv[1]))
|
|
768
|
+
|
|
769
|
+
has_any = bool(func_sorted) or bool(block_sorted)
|
|
770
|
+
|
|
771
|
+
# Pygments CSS (scoped). Use modern GitHub-like styles when available.
|
|
772
|
+
# We scope per theme to support toggle without reloading.
|
|
773
|
+
pyg_dark_raw = _pygments_css("github-dark")
|
|
774
|
+
if not pyg_dark_raw:
|
|
775
|
+
pyg_dark_raw = _pygments_css("monokai")
|
|
776
|
+
pyg_light_raw = _pygments_css("github-light")
|
|
777
|
+
if not pyg_light_raw:
|
|
778
|
+
pyg_light_raw = _pygments_css("friendly")
|
|
779
|
+
|
|
780
|
+
pyg_dark = _prefix_css(pyg_dark_raw, "html[data-theme='dark']")
|
|
781
|
+
pyg_light = _prefix_css(pyg_light_raw, "html[data-theme='light']")
|
|
782
|
+
|
|
783
|
+
# ============================
|
|
784
|
+
# Icons (Inline SVG)
|
|
785
|
+
# ============================
|
|
786
|
+
ICON_SEARCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>'
|
|
787
|
+
ICON_X = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
|
|
788
|
+
ICON_CHEV_DOWN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>'
|
|
789
|
+
# ICON_CHEV_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
|
|
790
|
+
ICON_THEME = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>'
|
|
791
|
+
ICON_CHECK = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
|
792
|
+
ICON_PREV = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>'
|
|
793
|
+
ICON_NEXT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
|
|
794
|
+
|
|
795
|
+
# ----------------------------
|
|
796
|
+
# Section renderer
|
|
797
|
+
# ----------------------------
|
|
798
|
+
|
|
799
|
+
def render_section(
|
|
800
|
+
section_id: str,
|
|
801
|
+
section_title: str,
|
|
802
|
+
groups: list[tuple[str, list[dict[str, Any]]]],
|
|
803
|
+
pill_cls: str,
|
|
804
|
+
) -> str:
|
|
805
|
+
if not groups:
|
|
806
|
+
return ""
|
|
807
|
+
|
|
808
|
+
# build group DOM with data-search (for fast client-side search)
|
|
809
|
+
out: list[str] = [
|
|
810
|
+
f'<section id="{section_id}" class="section" data-section="{section_id}">',
|
|
811
|
+
'<div class="section-head">',
|
|
812
|
+
f"<h2>{_escape(section_title)} "
|
|
813
|
+
f'<span class="pill {pill_cls}" data-count-pill="{section_id}">{len(groups)} groups</span></h2>',
|
|
814
|
+
f"""
|
|
815
|
+
<div class="section-toolbar" role="toolbar" aria-label="{_escape(section_title)} controls">
|
|
816
|
+
<div class="toolbar-left">
|
|
817
|
+
<div class="search-wrap">
|
|
818
|
+
<span class="search-ico">{ICON_SEARCH}</span>
|
|
819
|
+
<input class="search" id="search-{section_id}" placeholder="Search..." autocomplete="off" />
|
|
820
|
+
<button class="btn ghost" type="button" data-clear="{section_id}" title="Clear search">{ICON_X}</button>
|
|
821
|
+
</div>
|
|
822
|
+
<div class="segmented">
|
|
823
|
+
<button class="btn seg" type="button" data-collapse-all="{section_id}">Collapse</button>
|
|
824
|
+
<button class="btn seg" type="button" data-expand-all="{section_id}">Expand</button>
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<div class="toolbar-right">
|
|
829
|
+
<div class="pager">
|
|
830
|
+
<button class="btn" type="button" data-prev="{section_id}">{ICON_PREV}</button>
|
|
831
|
+
<span class="page-meta" data-page-meta="{section_id}">Page 1</span>
|
|
832
|
+
<button class="btn" type="button" data-next="{section_id}">{ICON_NEXT}</button>
|
|
833
|
+
</div>
|
|
834
|
+
<select class="select" data-pagesize="{section_id}" title="Groups per page">
|
|
835
|
+
<option value="5">5 / page</option>
|
|
836
|
+
<option value="10" selected>10 / page</option>
|
|
837
|
+
<option value="20">20 / page</option>
|
|
838
|
+
<option value="50">50 / page</option>
|
|
839
|
+
</select>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
""",
|
|
843
|
+
"</div>", # section-head
|
|
844
|
+
'<div class="section-body">',
|
|
845
|
+
]
|
|
846
|
+
|
|
847
|
+
for idx, (gkey, items) in enumerate(groups, start=1):
|
|
848
|
+
search_parts: list[str] = [str(gkey)]
|
|
849
|
+
for it in items:
|
|
850
|
+
search_parts.append(str(it.get("qualname", "")))
|
|
851
|
+
search_parts.append(str(it.get("filepath", "")))
|
|
852
|
+
search_blob = " ".join(search_parts).lower()
|
|
853
|
+
search_blob_escaped = html.escape(search_blob, quote=True)
|
|
854
|
+
|
|
855
|
+
out.append(
|
|
856
|
+
f'<div class="group" data-group="{section_id}" data-search="{search_blob_escaped}">'
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
out.append(
|
|
860
|
+
f'<div class="group-head">'
|
|
861
|
+
f'<div class="group-left">'
|
|
862
|
+
f'<button class="chev" type="button" aria-label="Toggle group" data-toggle-group="{section_id}-{idx}">{ICON_CHEV_DOWN}</button>'
|
|
863
|
+
f'<div class="group-title">Group #{idx}</div>'
|
|
864
|
+
f'<span class="pill small {pill_cls}">{len(items)} items</span>'
|
|
865
|
+
f"</div>"
|
|
866
|
+
f'<div class="group-right">'
|
|
867
|
+
f'<code class="gkey">{_escape(gkey)}</code>'
|
|
868
|
+
f"</div>"
|
|
869
|
+
f"</div>"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
out.append(f'<div class="items" id="group-body-{section_id}-{idx}">')
|
|
873
|
+
|
|
874
|
+
for a, b in pairwise(items):
|
|
875
|
+
out.append('<div class="item-pair">')
|
|
876
|
+
|
|
877
|
+
for item in (a, b):
|
|
878
|
+
snippet = _render_code_block(
|
|
879
|
+
filepath=item["filepath"],
|
|
880
|
+
start_line=int(item["start_line"]),
|
|
881
|
+
end_line=int(item["end_line"]),
|
|
882
|
+
file_cache=file_cache,
|
|
883
|
+
context=context_lines,
|
|
884
|
+
max_lines=max_snippet_lines,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
out.append(
|
|
888
|
+
'<div class="item">'
|
|
889
|
+
f'<div class="item-head">{_escape(item["qualname"])}</div>'
|
|
890
|
+
f'<div class="item-file">'
|
|
891
|
+
f"{_escape(item['filepath'])}:"
|
|
892
|
+
f"{item['start_line']}-{item['end_line']}"
|
|
893
|
+
f"</div>"
|
|
894
|
+
f"{snippet.code_html}"
|
|
895
|
+
"</div>"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
out.append("</div>") # item-pair
|
|
899
|
+
|
|
900
|
+
out.append("</div>") # items
|
|
901
|
+
out.append("</div>") # group
|
|
902
|
+
|
|
903
|
+
out.append("</div>") # section-body
|
|
904
|
+
out.append("</section>")
|
|
905
|
+
return "\n".join(out)
|
|
906
|
+
|
|
907
|
+
# ============================
|
|
908
|
+
# HTML Rendering
|
|
909
|
+
# ============================
|
|
910
|
+
|
|
911
|
+
empty_state_html = ""
|
|
912
|
+
if not has_any:
|
|
913
|
+
empty_state_html = f"""
|
|
914
|
+
<div class="empty">
|
|
915
|
+
<div class="empty-card">
|
|
916
|
+
<div class="empty-icon">{ICON_CHECK}</div>
|
|
917
|
+
<h2>No code clones detected</h2>
|
|
918
|
+
<p>No structural or block-level duplication was found above configured thresholds.</p>
|
|
919
|
+
<p class="muted">This usually indicates healthy abstraction boundaries.</p>
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
953
922
|
"""
|
|
923
|
+
|
|
924
|
+
func_section = render_section("functions", "Function clones", func_sorted, "pill-func")
|
|
925
|
+
block_section = render_section("blocks", "Block clones", block_sorted, "pill-block")
|
|
926
|
+
|
|
927
|
+
return REPORT_TEMPLATE.substitute(
|
|
928
|
+
title=_escape(title),
|
|
929
|
+
version=__version__,
|
|
930
|
+
pyg_dark=pyg_dark,
|
|
931
|
+
pyg_light=pyg_light,
|
|
932
|
+
empty_state_html=empty_state_html,
|
|
933
|
+
func_section=func_section,
|
|
934
|
+
block_section=block_section,
|
|
935
|
+
icon_theme=ICON_THEME,
|
|
936
|
+
)
|