semantic-code-review 0.24.2__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.
Files changed (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,937 @@
1
+ // Diff renderer + fold-state machinery.
2
+ //
3
+ // Owns the layout pass that turns DATA into the on-page DOM: PR
4
+ // panel, file blocks, hunk headers, the side-by-side row grid, gap
5
+ // chips for unchanged context, segment-folded summaries, refs, smell
6
+ // pills. Carries the fold state too (STATE.fold / overrides /
7
+ // renderedDiffs cache) because all of that exists to feed the
8
+ // renderer, and binds the user inputs that drive it (fold-slider
9
+ // buttons, keyboard 1-4, hash sync).
10
+ //
11
+ // Other modules attach to surfaces this module creates:
12
+ // - sidebar.ts mutates pill state but reads from .file / .hunk
13
+ // - folds.ts attaches chevrons to the per-half row elements stashed
14
+ // on each .diff and .gap-expansion container
15
+ // - comments.ts replays its comment rows after each renderAll
16
+ // - annotations.ts hosts the row-annotation DOM
17
+
18
+ import { Annotations } from "./annotations";
19
+ import { Comments } from "./comments";
20
+ import { FileRows } from "./file_rows";
21
+ import { Folds } from "./folds";
22
+ import { Progress } from "./progress";
23
+ import { Sidebar } from "./sidebar";
24
+ import { blockDiff, matchRanges, wrapRanges, type CharRange } from "./text_highlight";
25
+
26
+ // --- Module state --------------------------------------------------------
27
+
28
+ type FoldMode = "files" | "hunks" | "segments" | "off";
29
+
30
+ interface RenderState {
31
+ fold: FoldMode;
32
+ overrides: Record<string, boolean>;
33
+ renderedDiffs: Record<string, HTMLElement>;
34
+ }
35
+
36
+ let _data: ViewerData = { version: "1", pr: {} as PRBlock, smells_catalogue: {}, files: [], groups: [], symbols: [] };
37
+ let _smells: Record<string, SmellCatalogueEntry> = {};
38
+ // The focused symbol's name, highlighted search-style across every diff
39
+ // line, or null when no symbol pill is active. Newly rendered cells pick
40
+ // it up in _renderContent; setSymbolSearch repaints cells already in the
41
+ // DOM. See setSymbolSearch / sidebar's active-pill callback.
42
+ let _symbolSearch: string | null = null;
43
+ const _state: RenderState = {
44
+ fold: "hunks",
45
+ overrides: Object.create(null),
46
+ renderedDiffs: Object.create(null),
47
+ };
48
+
49
+ // --- Public API ----------------------------------------------------------
50
+
51
+ /** Wire input handlers + restore state from URL hash + run initial
52
+ * render. Called once at boot from viewer.js. Resets the rendered-
53
+ * diff cache + fold overrides so a re-boot (tests, future hot
54
+ * reload) starts fresh. */
55
+ function renderInit(data: ViewerData): void {
56
+ _data = data;
57
+ _smells = data.smells_catalogue || {};
58
+ _state.fold = "hunks";
59
+ _state.overrides = Object.create(null);
60
+ _state.renderedDiffs = Object.create(null);
61
+ _wireInputs();
62
+ _restoreHash();
63
+ render();
64
+ }
65
+
66
+ /** Re-render the entire app DOM. Cheap-ish — STATE.renderedDiffs
67
+ * caches the per-hunk .diff so this isn't quadratic on revisits. */
68
+ function render(): void {
69
+ const app = document.getElementById("app");
70
+ if (!app) return;
71
+ app.innerHTML = "";
72
+ app.appendChild(_renderPRPanel(_data.pr));
73
+ for (const f of _data.files) app.appendChild(_renderFile(f));
74
+ Sidebar.render();
75
+ Sidebar.applyFilter();
76
+ _updateStatus();
77
+ _syncHash();
78
+ _updateSliderButtons();
79
+ Comments.renderAll();
80
+ // Annotation arrows attached during render were sized while the
81
+ // tree was still detached. The viewport watcher hooks
82
+ // window-resize + fonts.ready for post-mount reflow; double-RAF a
83
+ // fresh pass for the first paint.
84
+ Annotations.watchViewport();
85
+ requestAnimationFrame(() => {
86
+ Annotations.reflowAll();
87
+ requestAnimationFrame(() => Annotations.reflowAll());
88
+ });
89
+ }
90
+
91
+ /** Replace one hunk's DOM in place. Drops the renderedDiffs cache
92
+ * entry first so attachLineNotes / fold detection re-run against
93
+ * the (possibly different) row set. Called from the SSE patchers
94
+ * in viewer.js when a `hunk` event arrives. */
95
+ function renderHunkReplace(file: FileBlock, hunkIdx: number): void {
96
+ const h = file.hunks[hunkIdx];
97
+ if (!h) return;
98
+ delete _state.renderedDiffs[h.id];
99
+ const fresh = _renderHunk(h, file);
100
+ const existing = document.querySelector(
101
+ '.hunk[data-id="' + _cssEscape(h.id) + '"]',
102
+ );
103
+ if (existing && existing.parentNode) {
104
+ existing.parentNode.replaceChild(fresh, existing);
105
+ }
106
+ }
107
+
108
+ /** Re-render just the header of one hunk (intent slot + meta).
109
+ * Used by the hunk-start SSE handler to flip the "queued"
110
+ * placeholder to "analysing…" without rebuilding the diff body. */
111
+ function repaintHunkHeader(hunkId: string): void {
112
+ const node = document.querySelector(
113
+ '.hunk[data-id="' + _cssEscape(hunkId) + '"]',
114
+ );
115
+ if (!node) return;
116
+ const oldHdr = node.querySelector(".hunk-header");
117
+ if (!oldHdr) return;
118
+ const parts = hunkId.replace("H", "").split("_").map(Number);
119
+ const [fi, hi] = parts;
120
+ const f = _data.files && _data.files[fi];
121
+ const h = f && f.hunks && f.hunks[hi];
122
+ if (!h) return;
123
+ const folded = _isFolded(h.id, _defaultHunkFolded());
124
+ const fresh = _renderHunkHeader(h, folded, f);
125
+ oldHdr.replaceWith(fresh);
126
+ }
127
+
128
+ /** Drop the cached `.diff` element for a hunk. Called by SSE
129
+ * patchers before they replace the surrounding hunk DOM. */
130
+ function clearRenderedDiffCache(hunkId: string): void {
131
+ delete _state.renderedDiffs[hunkId];
132
+ }
133
+
134
+ // --- Fold state ---------------------------------------------------------
135
+
136
+ function _defaultFileFolded(): boolean { return _state.fold === "files"; }
137
+ function _defaultHunkFolded(): boolean { return _state.fold === "files" || _state.fold === "hunks"; }
138
+ function _defaultSegmentFolded(): boolean { return _state.fold !== "off"; }
139
+
140
+ function _isFolded(id: string, fallback: boolean): boolean {
141
+ return Object.prototype.hasOwnProperty.call(_state.overrides, id)
142
+ ? _state.overrides[id] : fallback;
143
+ }
144
+
145
+ function _toggleFold(id: string, currentDefault: boolean): void {
146
+ const current = _isFolded(id, currentDefault);
147
+ _state.overrides[id] = !current;
148
+ render();
149
+ }
150
+
151
+ function _setGlobalFold(fold: FoldMode): void {
152
+ _state.fold = fold;
153
+ _state.overrides = Object.create(null);
154
+ render();
155
+ }
156
+
157
+ // --- DOM helpers (private) ----------------------------------------------
158
+
159
+ const _SVG_NS = "http://www.w3.org/2000/svg";
160
+
161
+ function _el(tag: string, className: string | null, text?: string): HTMLElement {
162
+ const n = document.createElement(tag);
163
+ if (className) n.className = className;
164
+ if (text !== undefined) n.textContent = text;
165
+ return n;
166
+ }
167
+
168
+ function _chev(folded: boolean, extraClass?: string): SVGElement {
169
+ const svg = document.createElementNS(_SVG_NS, "svg") as unknown as SVGElement;
170
+ svg.setAttribute("viewBox", "0 0 12 12");
171
+ svg.setAttribute("aria-hidden", "true");
172
+ svg.classList.add("chevron");
173
+ if (extraClass) svg.classList.add(extraClass);
174
+ if (!folded) svg.classList.add("open");
175
+ const path = document.createElementNS(_SVG_NS, "path");
176
+ path.setAttribute("d", "M4.25 2.75 L8 6 L4.25 9.25");
177
+ path.setAttribute("fill", "none");
178
+ path.setAttribute("stroke", "currentColor");
179
+ path.setAttribute("stroke-width", "1.75");
180
+ path.setAttribute("stroke-linecap", "round");
181
+ path.setAttribute("stroke-linejoin", "round");
182
+ svg.appendChild(path);
183
+ return svg;
184
+ }
185
+
186
+ interface SmellPromotion {
187
+ /** Stable id of the source smell — "<container_id>:smell:<tag>". */
188
+ smellId: string;
189
+ file: string;
190
+ side: "old" | "new";
191
+ line: number;
192
+ }
193
+
194
+ /** Bucket the LLM's 0-100 confidence into a subtle three-star
195
+ * indicator that sits at the top-right of a hunk header. Returns
196
+ * null when no confidence was emitted (so the slot is invisible
197
+ * rather than rendering an empty rating). */
198
+ function _confidenceStars(confidence: number | null | undefined): HTMLElement | null {
199
+ if (confidence == null) return null;
200
+ // Buckets chosen so a model that hedges (<50) gets one star and a
201
+ // confident answer (≥80) gets three. The middle band (50-79) is the
202
+ // most common "I think so, not 100%" outcome.
203
+ const filled = confidence >= 80 ? 3 : confidence >= 50 ? 2 : 1;
204
+ const wrap = _el("span", "hunk-confidence");
205
+ wrap.dataset.level = String(filled);
206
+ wrap.title = `Model confidence ${confidence}/100`
207
+ + (filled === 1 ? " — low, review carefully" : "");
208
+ for (let i = 0; i < 3; i++) {
209
+ const star = _el("span", "conf-star" + (i < filled ? " on" : ""));
210
+ star.textContent = i < filled ? "★" : "☆";
211
+ wrap.appendChild(star);
212
+ }
213
+ return wrap;
214
+ }
215
+
216
+ function _smellPill(smell: Smell, promotion?: SmellPromotion): HTMLElement {
217
+ const def = _smells[smell.tag];
218
+ const sev = def ? def.severity : "minor";
219
+ const p = _el("span", `smell sev-${sev}`, smell.tag);
220
+ p.title = smell.note || (def ? def.label : smell.tag);
221
+ if (promotion) {
222
+ // Skip rendering at all if the user has already promoted this smell
223
+ // — the renderer treats a non-attached element as a no-op.
224
+ if (Comments.isPromoted(promotion.smellId)) {
225
+ p.style.display = "none";
226
+ }
227
+ p.dataset.smellId = promotion.smellId;
228
+ p.classList.add("smell-promotable");
229
+ p.title = `${smell.tag}${smell.note ? ` — ${smell.note}` : ""} (click to add as comment)`;
230
+ p.addEventListener("click", (e) => {
231
+ e.stopPropagation();
232
+ const body = smell.note
233
+ ? `${smell.tag}: ${smell.note}`
234
+ : smell.tag;
235
+ Comments.promoteSmell({
236
+ ...promotion, body, smellId: promotion.smellId,
237
+ });
238
+ });
239
+ }
240
+ return p;
241
+ }
242
+
243
+ function _esc(s: string): string {
244
+ return String(s).replace(/[&<>"']/g, (c) => ({
245
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
246
+ }[c] || c));
247
+ }
248
+
249
+ function _cssEscape(s: string): string {
250
+ const w = window as unknown as { CSS?: { escape?: (s: string) => string } };
251
+ if (w.CSS && typeof w.CSS.escape === "function") return w.CSS.escape(s);
252
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => "\\" + c);
253
+ }
254
+
255
+ // --- Renderers ----------------------------------------------------------
256
+
257
+ function _renderPRPanel(pr: PRBlock): HTMLElement {
258
+ const panel = _el("section", "pr-panel");
259
+ panel.appendChild(_el("h2", null, "PR summary"));
260
+ panel.appendChild(_el("p", null, pr.summary || "(no summary)"));
261
+ if (pr.themes && pr.themes.length) {
262
+ const themes = _el("div", "themes");
263
+ for (const t of pr.themes) themes.appendChild(_el("span", null, t));
264
+ panel.appendChild(themes);
265
+ }
266
+ return panel;
267
+ }
268
+
269
+ function _renderFile(f: FileBlock): HTMLElement {
270
+ const div = _el("div", "file");
271
+ div.dataset.id = f.id;
272
+ const folded = _isFolded(f.id, _defaultFileFolded());
273
+ div.classList.toggle("folded", folded);
274
+ div.appendChild(_renderFileHeader(f, folded));
275
+ if (!folded) {
276
+ const body = _el("div", "file-body");
277
+ const overview = _renderFileOverview(f);
278
+ if (overview) body.appendChild(overview);
279
+ const top = _gapBeforeFirstHunk(f);
280
+ if (top) body.appendChild(_renderGapChip(f, top));
281
+ for (let i = 0; i < f.hunks.length; i++) {
282
+ body.appendChild(_renderHunk(f.hunks[i], f));
283
+ const mid = _gapAfterHunk(f, i);
284
+ if (mid) body.appendChild(_renderGapChip(f, mid));
285
+ }
286
+ div.appendChild(body);
287
+ // Run a file-level fold pass once the body is assembled.
288
+ Folds.attachFileFolds(div, f);
289
+ }
290
+ return div;
291
+ }
292
+
293
+ function _renderFileHeader(f: FileBlock, folded: boolean): HTMLElement {
294
+ const hdr = _el("div", "file-header");
295
+ hdr.appendChild(_chev(folded));
296
+ hdr.appendChild(_el("span", "file-path", f.path));
297
+ hdr.appendChild(_el("span", "file-summary", f.summary || ""));
298
+ const meta = _el("div", "file-meta");
299
+ meta.appendChild(_el("span", "adds", `+${f.adds}`));
300
+ meta.appendChild(_el("span", "dels", `-${f.dels}`));
301
+ hdr.appendChild(meta);
302
+ const smells = _uniqueFileSmells(f);
303
+ if (smells.length) {
304
+ const badge = _el("div", "file-meta");
305
+ for (const sm of smells) badge.appendChild(_smellPill({ tag: sm, note: "" }));
306
+ hdr.appendChild(badge);
307
+ }
308
+ hdr.addEventListener("click", () => _toggleFold(f.id, _defaultFileFolded()));
309
+ return hdr;
310
+ }
311
+
312
+ function _uniqueFileSmells(f: FileBlock): string[] {
313
+ const s = new Set<string>();
314
+ for (const h of f.hunks) {
315
+ for (const sm of h.smells || []) s.add(sm.tag);
316
+ for (const seg of h.segments || []) for (const sm of seg.smells || []) s.add(sm.tag);
317
+ }
318
+ return Array.from(s);
319
+ }
320
+
321
+ function _renderFileOverview(f: FileBlock): HTMLElement | null {
322
+ const sym = f.symbols || { added: [], modified: [], removed: [] };
323
+ const parts: string[] = [];
324
+ if (sym.added && sym.added.length) parts.push(`<span class="label">added:</span>${_esc(sym.added.join(", "))}`);
325
+ if (sym.modified && sym.modified.length) parts.push(`<span class="label">modified:</span>${_esc(sym.modified.join(", "))}`);
326
+ if (sym.removed && sym.removed.length) parts.push(`<span class="label">removed:</span>${_esc(sym.removed.join(", "))}`);
327
+ if (parts.length === 0) return null;
328
+ const div = _el("div", "file-overview");
329
+ div.innerHTML = parts.join("&nbsp;&nbsp;");
330
+ return div;
331
+ }
332
+
333
+ // --- Inter-hunk context expansion ---------------------------------------
334
+
335
+ interface GapDescriptor {
336
+ position: "top" | "between" | "bottom";
337
+ new_start: number;
338
+ new_end: number;
339
+ old_start: number;
340
+ old_end: number;
341
+ }
342
+
343
+ function _gapBeforeFirstHunk(f: FileBlock): GapDescriptor | null {
344
+ if (!f.head_lines || f.hunks.length === 0) return null;
345
+ const h = f.hunks[0];
346
+ const newStart = 1, newEnd = h.new_start - 1;
347
+ if (newEnd < newStart) return null;
348
+ return {
349
+ position: "top",
350
+ new_start: newStart, new_end: newEnd,
351
+ old_start: 1, old_end: h.old_start - 1,
352
+ };
353
+ }
354
+
355
+ function _gapAfterHunk(f: FileBlock, i: number): GapDescriptor | null {
356
+ if (!f.head_lines) return null;
357
+ const h = f.hunks[i];
358
+ const newStart = h.new_start + h.new_count;
359
+ const oldStart = h.old_start + h.old_count;
360
+ if (i + 1 < f.hunks.length) {
361
+ const n = f.hunks[i + 1];
362
+ const newEnd = n.new_start - 1;
363
+ if (newEnd < newStart) return null;
364
+ return {
365
+ position: "between",
366
+ new_start: newStart, new_end: newEnd,
367
+ old_start: oldStart, old_end: n.old_start - 1,
368
+ };
369
+ }
370
+ const total = f.head_lines.length;
371
+ if (newStart > total) return null;
372
+ return {
373
+ position: "bottom",
374
+ new_start: newStart, new_end: total,
375
+ old_start: oldStart, old_end: oldStart + (total - newStart),
376
+ };
377
+ }
378
+
379
+ function _renderGapChip(f: FileBlock, gap: GapDescriptor): HTMLElement {
380
+ const chip = _el("div", "gap-chip");
381
+ const count = gap.new_end - gap.new_start + 1;
382
+ const icon = gap.position === "top" ? "⬆" : gap.position === "bottom" ? "⬇" : "⋯";
383
+ const word = count === 1 ? "line" : "lines";
384
+ const label = gap.position === "top" ? `expand ${count} ${word} above`
385
+ : gap.position === "bottom" ? `expand ${count} ${word} below`
386
+ : `expand ${count} hidden ${word}`;
387
+ chip.innerHTML = `<span class="gap-icon">${icon}</span> <span class="gap-label">${label}</span>`;
388
+ chip.title = `lines ${gap.new_start}–${gap.new_end}`;
389
+ chip.addEventListener("click", () => {
390
+ chip.replaceWith(_renderGapExpansion(f, gap));
391
+ _refreshFileFolds(f);
392
+ });
393
+ return chip;
394
+ }
395
+
396
+ function _renderGapExpansion(f: FileBlock, gap: GapDescriptor): HTMLElement {
397
+ const container = _el("div", "gap-expansion");
398
+ const collapse = _el("button", "gap-collapse", "× collapse");
399
+ collapse.title = "Hide these lines again";
400
+ collapse.addEventListener("click", () => {
401
+ container.replaceWith(_renderGapChip(f, gap));
402
+ _refreshFileFolds(f);
403
+ });
404
+ container.appendChild(collapse);
405
+
406
+ const diff = _el("div", "diff");
407
+ const halfOld = _el("div", "half half-old");
408
+ const halfNew = _el("div", "half half-new");
409
+ diff.appendChild(halfOld);
410
+ diff.appendChild(halfNew);
411
+
412
+ const rows: RowBlock[] = [];
413
+ const rowElsOld: HTMLElement[] = [];
414
+ const rowElsNew: HTMLElement[] = [];
415
+ const count = gap.new_end - gap.new_start + 1;
416
+ const headLines = f.head_lines || [];
417
+ for (let i = 0; i < count; i++) {
418
+ const ol = gap.old_start + i;
419
+ const nl = gap.new_start + i;
420
+ const text = headLines[nl - 1] ?? "";
421
+ const rowRecord: RowBlock = {
422
+ kind: "ctx", old_line: ol, new_line: nl,
423
+ old_text: text, new_text: text,
424
+ };
425
+ rows.push(rowRecord);
426
+ const pair = _renderRow(rowRecord, f);
427
+ (pair.old as { _scrPair?: HTMLElement })._scrPair = pair.new;
428
+ (pair.new as { _scrPair?: HTMLElement })._scrPair = pair.old;
429
+ halfOld.appendChild(pair.old);
430
+ halfNew.appendChild(pair.new);
431
+ rowElsOld.push(pair.old);
432
+ rowElsNew.push(pair.new);
433
+ }
434
+
435
+ // The file-level fold walker (folds.ts) needs to recover the row
436
+ // stream + per-side DOM elements; hand them off through FileRows.
437
+ FileRows.record(container, { rows, oldEls: rowElsOld, newEls: rowElsNew });
438
+
439
+ container.appendChild(diff);
440
+ return container;
441
+ }
442
+
443
+ function _refreshFileFolds(f: FileBlock): void {
444
+ const fileEl = document.querySelector(
445
+ '.file[data-id="' + _cssEscape(f.id) + '"]',
446
+ ) as HTMLElement | null;
447
+ if (fileEl) Folds.attachFileFolds(fileEl, f);
448
+ }
449
+
450
+ // --- Hunk + diff body ---------------------------------------------------
451
+
452
+ function _renderHunk(h: HunkBlock, f: FileBlock): HTMLElement {
453
+ const div = _el("div", "hunk");
454
+ div.dataset.id = h.id;
455
+ const folded = _isFolded(h.id, _defaultHunkFolded());
456
+ div.classList.toggle("folded", folded);
457
+ div.style.borderLeftColor = _maxSeverityColor(h);
458
+ div.appendChild(_renderHunkHeader(h, folded, f));
459
+ if (!folded) {
460
+ if (
461
+ h.segments && h.segments.length > 0
462
+ && _defaultSegmentFolded()
463
+ && !_anySegmentOverridden(h, false)
464
+ ) {
465
+ const list = _el("div", "seg-list");
466
+ for (const s of h.segments) list.appendChild(_renderSegmentFolded(s, f));
467
+ div.appendChild(list);
468
+ } else {
469
+ div.appendChild(_renderHunkDiff(h, f));
470
+ }
471
+ if (h.context) {
472
+ const c = _el("div", "context-note");
473
+ c.innerHTML = `<strong>context:</strong> ${_esc(h.context)}`;
474
+ div.appendChild(c);
475
+ }
476
+ if (h.refs && h.refs.length) {
477
+ div.appendChild(_renderRefs(h.refs));
478
+ }
479
+ // line_notes used to render as a bottom-of-hunk block; they're
480
+ // attached inline by _attachLineNotes() in _renderHunkDiff.
481
+ }
482
+ return div;
483
+ }
484
+
485
+ function _renderRefs(refs: Ref[]): HTMLElement {
486
+ const div = _el("div", "refs");
487
+ div.appendChild(_el("strong", null, "refs: "));
488
+ for (const ref of refs) {
489
+ div.appendChild(_buildRefLink(ref));
490
+ if (ref.reason) div.appendChild(_el("span", "ref-reason", " " + ref.reason + " "));
491
+ }
492
+ return div;
493
+ }
494
+
495
+ function _buildRefLink(ref: Ref): HTMLElement {
496
+ const pr = _data.pr || ({} as PRBlock);
497
+ const sha = pr.head_sha || pr.base_sha || "HEAD";
498
+ const a = document.createElement("a");
499
+ a.className = "ref-link";
500
+ a.href = pr.repo
501
+ ? `https://github.com/${pr.repo}/blob/${sha}/${ref.path}#L${ref.line}`
502
+ : "#";
503
+ a.target = "_blank";
504
+ a.rel = "noopener";
505
+ a.textContent = `${ref.path}:${ref.line}`;
506
+ a.title = ref.reason || "";
507
+ return a;
508
+ }
509
+
510
+ function _anySegmentOverridden(h: HunkBlock, toValue: boolean): boolean {
511
+ return (h.segments || []).some((s) => {
512
+ const val = _isFolded(s.id, _defaultSegmentFolded());
513
+ return val === toValue;
514
+ });
515
+ }
516
+
517
+ function _renderHunkHeader(h: HunkBlock, folded: boolean, f: FileBlock): HTMLElement {
518
+ const hdr = _el("div", "hunk-header");
519
+ hdr.appendChild(_chev(folded));
520
+ hdr.appendChild(_el("span", "hunk-pos", h.header));
521
+ let intent: HTMLElement;
522
+ if (h.intent) {
523
+ intent = _el("span", "hunk-intent", h.intent);
524
+ } else if (_data.pending && !h._failed) {
525
+ // Still streaming. Distinguish "queued, model hasn't looked yet"
526
+ // (static, dim) from "running, model is working on it right now"
527
+ // (pulse). State comes from the Progress module.
528
+ const st = Progress.getHunkState(h.id);
529
+ if (st === "running") {
530
+ intent = _el("span", "hunk-intent pending", "analysing…");
531
+ } else {
532
+ intent = _el("span", "hunk-intent queued", "queued");
533
+ }
534
+ } else {
535
+ intent = _el("span", "hunk-intent empty", "(no intent — may need re-run)");
536
+ }
537
+ hdr.appendChild(intent);
538
+ const meta = _el("span", "hunk-meta");
539
+ for (const sm of h.smells || []) meta.appendChild(_smellPill(sm, {
540
+ smellId: `${h.id}:smell:${sm.tag}`,
541
+ file: f.path, side: "new", line: h.new_start,
542
+ }));
543
+ if (h.context) {
544
+ const icon = _el("span", "context-icon", "ⓘ");
545
+ icon.title = h.context;
546
+ meta.appendChild(icon);
547
+ }
548
+ const stars = _confidenceStars(h.confidence);
549
+ if (stars) meta.appendChild(stars);
550
+ hdr.appendChild(meta);
551
+ hdr.addEventListener("click", (e) => {
552
+ e.stopPropagation();
553
+ _toggleFold(h.id, _defaultHunkFolded());
554
+ });
555
+ return hdr;
556
+ }
557
+
558
+ function _renderSegmentFolded(s: SegmentBlock, f: FileBlock): HTMLElement {
559
+ const div = _el("div", "segment");
560
+ div.dataset.id = s.id;
561
+ div.appendChild(_chev(true));
562
+ div.appendChild(_el("span", "segment-range", `+${s.new_start}..+${s.new_start + s.new_count - 1}`));
563
+ div.appendChild(_el("span", s.intent ? "segment-intent" : "segment-intent empty", s.intent || "(no intent)"));
564
+ for (const sm of s.smells || []) div.appendChild(_smellPill(sm, {
565
+ smellId: `${s.id}:smell:${sm.tag}`,
566
+ file: f.path, side: "new", line: s.new_start,
567
+ }));
568
+ div.addEventListener("click", (e) => {
569
+ e.stopPropagation();
570
+ _toggleFold(s.id, _defaultSegmentFolded());
571
+ });
572
+ return div;
573
+ }
574
+
575
+ function _renderHunkDiff(h: HunkBlock, file: FileBlock): HTMLElement {
576
+ const cached = _state.renderedDiffs[h.id];
577
+ if (cached) return cached;
578
+ const container = _el("div", "diff");
579
+ const halfOld = _el("div", "half half-old");
580
+ const halfNew = _el("div", "half half-new");
581
+ container.appendChild(halfOld);
582
+ container.appendChild(halfNew);
583
+
584
+ const rowElsOld: HTMLElement[] = [];
585
+ const rowElsNew: HTMLElement[] = [];
586
+ const rows = h.rows || [];
587
+ const marks = _blockMarks(rows);
588
+ for (let idx = 0; idx < rows.length; idx++) {
589
+ const pair = _renderRow(rows[idx], file, marks[idx]?.old, marks[idx]?.new);
590
+ (pair.old as { _scrPair?: HTMLElement })._scrPair = pair.new;
591
+ (pair.new as { _scrPair?: HTMLElement })._scrPair = pair.old;
592
+ halfOld.appendChild(pair.old);
593
+ halfNew.appendChild(pair.new);
594
+ rowElsOld.push(pair.old);
595
+ rowElsNew.push(pair.new);
596
+ }
597
+ _attachLineNotes(rowElsOld, rowElsNew, h.rows || [], h.line_notes || [], h.id, file.path);
598
+ // Record this hunk's rows so folds.ts can build a unified row stream
599
+ // across the hunk and adjacent expanded context.
600
+ FileRows.record(container, {
601
+ rows: h.rows || [], oldEls: rowElsOld, newEls: rowElsNew,
602
+ });
603
+ _state.renderedDiffs[h.id] = container;
604
+ return container;
605
+ }
606
+
607
+ function _attachLineNotes(
608
+ rowElsOld: HTMLElement[], rowElsNew: HTMLElement[],
609
+ rows: RowBlock[], notes: LineNote[],
610
+ hunkId: string, filePath: string,
611
+ ): void {
612
+ if (!notes.length || !rows.length) return;
613
+ const byNewLine = new Map<number, number>();
614
+ for (let i = 0; i < rows.length; i++) {
615
+ const ln = rows[i].new_line;
616
+ if (ln !== null && ln !== undefined) byNewLine.set(ln, i);
617
+ }
618
+ for (const note of notes) {
619
+ const idx = byNewLine.get(note.line);
620
+ if (idx === undefined) continue;
621
+ const noteId = `${hunkId}:line_note:${note.line}`;
622
+ // If this line_note has already been promoted to a local comment,
623
+ // skip rendering it — the comment now stands in its place. Keeps
624
+ // a re-augment from resurrecting an observation the reviewer has
625
+ // already turned into a comment.
626
+ if (Comments.isPromoted(noteId)) continue;
627
+ Annotations.attach({
628
+ anchor: rowElsNew[idx],
629
+ shadowAnchor: rowElsOld[idx],
630
+ variant: "note",
631
+ content: _buildLineNoteContent(note, noteId, filePath, rowElsNew[idx]),
632
+ onInsert: (el) => { el.dataset.lineNoteId = noteId; },
633
+ });
634
+ }
635
+ }
636
+
637
+ /** Compose a line_note's annotation body: the LLM's text plus a small
638
+ * "Add as comment" affordance that hands the body to the comment
639
+ * editor pre-filled and anchored at the same row. */
640
+ function _buildLineNoteContent(
641
+ note: LineNote, noteId: string, filePath: string, rowEl: HTMLElement,
642
+ ): HTMLElement {
643
+ const wrap = document.createElement("div");
644
+ wrap.className = "line-note-body";
645
+ const text = document.createElement("div");
646
+ text.className = "line-note-text";
647
+ text.textContent = note.body || "";
648
+ wrap.appendChild(text);
649
+
650
+ const actions = document.createElement("div");
651
+ actions.className = "line-note-actions";
652
+ const promote = document.createElement("button");
653
+ promote.className = "comment-btn comment-btn-promote";
654
+ promote.type = "button";
655
+ promote.textContent = "Add as comment";
656
+ promote.title = "Open the comment editor pre-filled with this observation";
657
+ promote.addEventListener("click", (e) => {
658
+ e.stopPropagation();
659
+ Comments.openPromotionEditor({
660
+ rowEl, side: "new", line: note.line,
661
+ file: filePath, body: note.body || "",
662
+ derivedFrom: noteId,
663
+ });
664
+ });
665
+ actions.appendChild(promote);
666
+ wrap.appendChild(actions);
667
+ return wrap;
668
+ }
669
+
670
+ function _renderRow(
671
+ row: RowBlock,
672
+ file: FileBlock,
673
+ oldMarks?: CharRange[],
674
+ newMarks?: CharRange[],
675
+ ): { old: HTMLElement; new: HTMLElement } {
676
+ const hasOld = row.old_line !== null && row.old_line !== undefined;
677
+ const hasNew = row.new_line !== null && row.new_line !== undefined;
678
+ const oldRow = _el("div", `row row-${row.kind}`);
679
+ oldRow.appendChild(_renderLineno(row.old_line, "old", hasOld));
680
+ oldRow.appendChild(_renderContent(row.old_text, "old", hasOld, file, oldMarks));
681
+ const newRow = _el("div", `row row-${row.kind}`);
682
+ newRow.appendChild(_renderLineno(row.new_line, "new", hasNew));
683
+ newRow.appendChild(_renderContent(row.new_text, "new", hasNew, file, newMarks));
684
+ return { old: oldRow, new: newRow };
685
+ }
686
+
687
+ interface _RowMarks { old?: CharRange[]; new?: CharRange[]; }
688
+
689
+ /** Per-row intra-line change marks for a hunk's rows. Consecutive changed
690
+ * rows form a block; a block that has *both* deleted and inserted lines (a
691
+ * replacement) is token-diffed across line boundaries so a change spanning
692
+ * several old lines is marked as one deletion + insertion. Pure
693
+ * deletions / insertions and context get no marks (the row tint suffices).
694
+ */
695
+ function _blockMarks(rows: RowBlock[]): (_RowMarks | undefined)[] {
696
+ const out: (_RowMarks | undefined)[] = new Array(rows.length).fill(undefined);
697
+ let i = 0;
698
+ while (i < rows.length) {
699
+ if (rows[i].kind === "ctx") { i++; continue; }
700
+ const oldRows: number[] = [];
701
+ const newRows: number[] = [];
702
+ const oldLines: string[] = [];
703
+ const newLines: string[] = [];
704
+ let j = i;
705
+ for (; j < rows.length && rows[j].kind !== "ctx"; j++) {
706
+ const r = rows[j];
707
+ if (r.old_line !== null && r.old_line !== undefined) { oldRows.push(j); oldLines.push(r.old_text); }
708
+ if (r.new_line !== null && r.new_line !== undefined) { newRows.push(j); newLines.push(r.new_text); }
709
+ }
710
+ if (oldLines.length > 0 && newLines.length > 0) {
711
+ const d = blockDiff(oldLines, newLines);
712
+ oldRows.forEach((ri, k) => { (out[ri] ??= {}).old = d.old[k]; });
713
+ newRows.forEach((ri, k) => { (out[ri] ??= {}).new = d.new[k]; });
714
+ }
715
+ i = j;
716
+ }
717
+ return out;
718
+ }
719
+
720
+ function _renderLineno(line: number | null, side: "old" | "new", present: boolean): HTMLElement {
721
+ const c = _el("span", `cell cell-lineno cell-lineno-${side}`);
722
+ if (!present || line === null) {
723
+ c.classList.add("empty");
724
+ return c;
725
+ }
726
+ c.textContent = String(line);
727
+ return c;
728
+ }
729
+
730
+ function _renderContent(
731
+ text: string,
732
+ side: "old" | "new",
733
+ present: boolean,
734
+ file: FileBlock,
735
+ markRanges?: CharRange[],
736
+ ): HTMLElement {
737
+ const c = _el("span", `cell cell-content cell-content-${side}`);
738
+ if (!present) {
739
+ c.classList.add("empty");
740
+ return c;
741
+ }
742
+ const code = _el("code", "hljs");
743
+ const lang = file && file.language;
744
+ const hljs = (window as unknown as {
745
+ hljs?: { highlight(text: string, opts: { language: string; ignoreIllegals: boolean }): { value: string } };
746
+ }).hljs;
747
+ if (hljs && lang) {
748
+ try {
749
+ code.innerHTML = hljs.highlight(text || " ", { language: lang, ignoreIllegals: true }).value;
750
+ } catch (_) {
751
+ code.textContent = text;
752
+ }
753
+ } else {
754
+ code.textContent = text;
755
+ }
756
+ // Paint the intra-line change marks over the (possibly highlighted)
757
+ // text. Offsets are over the raw line, which highlight.js preserves.
758
+ if (markRanges && markRanges.length) wrapRanges(code, markRanges, "char-chg");
759
+ // Search-highlight the focused symbol on this fresh cell.
760
+ if (_symbolSearch) _applySymbolHits(code);
761
+ c.appendChild(code);
762
+ return c;
763
+ }
764
+
765
+ // --- Symbol-focus search highlight ---------------------------------------
766
+
767
+ /** Set (or clear, with null) the symbol name highlighted across the diff,
768
+ * then repaint every cell already in the DOM. Driven by the sidebar when
769
+ * a Symbols-axis pill is focused. */
770
+ function setSymbolSearch(term: string | null): void {
771
+ const next = term && term.trim() ? term : null;
772
+ if (next === _symbolSearch) return;
773
+ _symbolSearch = next;
774
+ for (const code of document.querySelectorAll<HTMLElement>("#app .cell-content code")) {
775
+ _clearSymbolHits(code);
776
+ if (_symbolSearch) _applySymbolHits(code);
777
+ }
778
+ }
779
+
780
+ function _applySymbolHits(code: HTMLElement): void {
781
+ if (!_symbolSearch) return;
782
+ const ranges = matchRanges(code.textContent || "", _symbolSearch);
783
+ if (ranges.length) wrapRanges(code, ranges, "symbol-hit");
784
+ }
785
+
786
+ /** Unwrap this cell's `symbol-hit` spans back to plain text, leaving any
787
+ * highlight.js / char-chg markup untouched. */
788
+ function _clearSymbolHits(code: HTMLElement): void {
789
+ const hits = code.querySelectorAll("span.symbol-hit");
790
+ if (hits.length === 0) return;
791
+ for (const hit of Array.from(hits)) {
792
+ hit.replaceWith(document.createTextNode(hit.textContent || ""));
793
+ }
794
+ code.normalize(); // merge the text nodes the unwrap left adjacent
795
+ }
796
+
797
+ // --- Severity color ----------------------------------------------------
798
+
799
+ const _SEV_ORDER: Record<string, number> = {
800
+ info: 1, minor: 2, major: 3, critical: 4,
801
+ };
802
+
803
+ function _maxSeverityColor(h: HunkBlock): string {
804
+ let worst = 0;
805
+ let color = "var(--border)";
806
+ const check = (sm: Smell): void => {
807
+ const def = _smells[sm.tag];
808
+ if (!def) return;
809
+ const s = _SEV_ORDER[def.severity] || 0;
810
+ if (s > worst) { worst = s; color = def.color; }
811
+ };
812
+ for (const sm of h.smells || []) check(sm);
813
+ for (const seg of h.segments || []) for (const sm of seg.smells || []) check(sm);
814
+ return color;
815
+ }
816
+
817
+ // --- Slider / status / hash / keyboard ---------------------------------
818
+
819
+ function _updateSliderButtons(): void {
820
+ document.querySelectorAll(".fold-slider button").forEach((b) => {
821
+ const btn = b as HTMLElement;
822
+ btn.classList.toggle("active", btn.dataset.fold === _state.fold);
823
+ });
824
+ }
825
+
826
+ function _updateStatus(): void {
827
+ // Prefer the dedicated counts span (the console bar shares the footer
828
+ // with it); fall back to the footer itself for the static-render path
829
+ // where no console bar is mounted.
830
+ const s = document.getElementById("status-counts")
831
+ || document.getElementById("status-bar");
832
+ if (!s) return;
833
+ let smells = 0, critical = 0;
834
+ for (const f of _data.files) {
835
+ for (const h of f.hunks) {
836
+ for (const sm of h.smells || []) {
837
+ smells++;
838
+ if ((_smells[sm.tag] || {} as SmellCatalogueEntry).severity === "critical") critical++;
839
+ }
840
+ for (const seg of h.segments || []) {
841
+ for (const sm of seg.smells || []) {
842
+ smells++;
843
+ if ((_smells[sm.tag] || {} as SmellCatalogueEntry).severity === "critical") critical++;
844
+ }
845
+ }
846
+ }
847
+ }
848
+ s.textContent = `${_data.files.length} files · ${smells} smells · ${critical} critical · keys 1-4 fold · space toggle · ? help`;
849
+ }
850
+
851
+ function _syncHash(): void {
852
+ const parts = [`fold=${_state.fold}`];
853
+ for (const [id, folded] of Object.entries(_state.overrides)) {
854
+ parts.push(`${id}=${folded ? "f" : "o"}`);
855
+ }
856
+ const newHash = "#" + parts.join("&");
857
+ if (window.location.hash !== newHash) {
858
+ history.replaceState(null, "", newHash);
859
+ }
860
+ }
861
+
862
+ function _restoreHash(): void {
863
+ const h = window.location.hash.slice(1);
864
+ if (!h) return;
865
+ for (const kv of h.split("&")) {
866
+ const [k, v] = kv.split("=");
867
+ if (k === "fold" && ["files", "hunks", "segments", "off"].includes(v)) {
868
+ _state.fold = v as FoldMode;
869
+ } else if (k && v != null) {
870
+ _state.overrides[k] = (v === "f");
871
+ }
872
+ }
873
+ }
874
+
875
+ function _onKeydown(e: KeyboardEvent): void {
876
+ const target = e.target as HTMLElement | null;
877
+ const tag = ((target && target.tagName) || "").toLowerCase();
878
+ if (tag === "input" || tag === "textarea") return;
879
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
880
+ switch (e.key) {
881
+ case "1": _setGlobalFold("files"); e.preventDefault(); break;
882
+ case "2": _setGlobalFold("hunks"); e.preventDefault(); break;
883
+ case "3": _setGlobalFold("segments"); e.preventDefault(); break;
884
+ case "4": _setGlobalFold("off"); e.preventDefault(); break;
885
+ case "?": _toggleHelp(); e.preventDefault(); break;
886
+ case "Escape": _closeHelp(); break;
887
+ }
888
+ }
889
+
890
+ function _toggleHelp(): void {
891
+ const o = document.getElementById("help-overlay");
892
+ if (o) o.classList.toggle("hidden");
893
+ }
894
+ function _closeHelp(): void {
895
+ const o = document.getElementById("help-overlay");
896
+ if (o) o.classList.add("hidden");
897
+ }
898
+
899
+ function _wireInputs(): void {
900
+ document.querySelectorAll(".fold-slider button").forEach((b) => {
901
+ const btn = b as HTMLElement;
902
+ btn.addEventListener("click", () => {
903
+ const f = btn.dataset.fold as FoldMode | undefined;
904
+ if (f) _setGlobalFold(f);
905
+ });
906
+ });
907
+ const reset = document.getElementById("reset-btn");
908
+ if (reset) {
909
+ reset.addEventListener("click", () => {
910
+ _state.overrides = Object.create(null);
911
+ render();
912
+ });
913
+ }
914
+ const help = document.getElementById("help-btn");
915
+ if (help) help.addEventListener("click", _toggleHelp);
916
+ const overlay = document.getElementById("help-overlay");
917
+ if (overlay) overlay.addEventListener("click", (e) => {
918
+ if (e.target === overlay) _closeHelp();
919
+ });
920
+ document.addEventListener("keydown", _onKeydown);
921
+ window.addEventListener("hashchange", () => {
922
+ _state.overrides = Object.create(null);
923
+ _restoreHash();
924
+ render();
925
+ });
926
+ }
927
+
928
+ // --- Public surface -----------------------------------------------------
929
+
930
+ export const Render = {
931
+ init: renderInit,
932
+ render,
933
+ renderHunkReplace,
934
+ repaintHunkHeader,
935
+ clearRenderedDiffCache,
936
+ setSymbolSearch,
937
+ };