@bugabinga/pi-ext-diff-review 0.1.0

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,1184 @@
1
+ import "./style.css";
2
+ import type { DiffLineAnnotation, FileDiff, FileDiffMetadata, SelectedLineRange } from "@pierre/diffs";
3
+ import type { FileTree, FileTreeRowDecorationRenderer, GitStatusEntry } from "@pierre/trees";
4
+ import { DIFF_FONT_CSS, TREE_CSS } from "./shadow-css.js";
5
+ import { NUGU_CODE_THEME, registerNuguCodeTheme } from "./theme.js";
6
+ import { HEARTBEAT_INTERVAL_MS, REVIEW_POLL_INTERVAL_MS } from "../constants.js";
7
+ import type { FindingDraft, ReviewCategory, ReviewPayload, ReviewSeverity, ReviewSide } from "../types.js";
8
+
9
+ type FindingMeta = { findingId: string; severity: ReviewSeverity; category: ReviewCategory; comment: string };
10
+ type ThemeMode = "system" | "dark" | "light";
11
+ type PaneSide = "left" | "right";
12
+ type ReviewSelection = { file: string; side: ReviewSide; startLine: number; endLine: number };
13
+ type ViewPoint = { x: number; y: number };
14
+ type SearchEntry = { file: string; side: "additions" | "deletions"; line: number; text: string };
15
+ type SearchMatch = SearchEntry & { matchStart: number; matchEnd: number };
16
+
17
+ type Elements = {
18
+ app: HTMLElement;
19
+ meta: HTMLElement;
20
+ summary: HTMLElement;
21
+ findingCount: HTMLElement;
22
+ drawerCount: HTMLElement;
23
+ tree: HTMLElement;
24
+ diffs: HTMLElement;
25
+ inlineFinding: HTMLElement;
26
+ selection: HTMLElement;
27
+ severity: HTMLSelectElement;
28
+ severitySub: HTMLElement;
29
+ category: HTMLSelectElement;
30
+ categorySub: HTMLElement;
31
+ comment: HTMLTextAreaElement;
32
+ add: HTMLButtonElement;
33
+ inlineCancel: HTMLButtonElement;
34
+ findings: HTMLElement;
35
+ notes: HTMLTextAreaElement;
36
+ search: HTMLInputElement;
37
+ searchClear: HTMLButtonElement;
38
+ searchResults: HTMLElement;
39
+ cancel: HTMLButtonElement;
40
+ submit: HTMLButtonElement;
41
+ status: HTMLElement;
42
+ themeMode: HTMLSelectElement;
43
+ leftSplitter: HTMLElement;
44
+ rightSplitter: HTMLElement;
45
+ toggleLeft: HTMLButtonElement;
46
+ toggleRight: HTMLButtonElement;
47
+ };
48
+
49
+ const ROUTES = {
50
+ review: "/api/review",
51
+ heartbeat: "/api/heartbeat",
52
+ cancel: "/api/cancel",
53
+ validateFinding: "/api/finding/validate",
54
+ submit: "/api/submit",
55
+ } as const;
56
+ const THEME_STORAGE_KEY = "diff-review-theme";
57
+ const LAYOUT_STORAGE_KEY = "diff-review-layout";
58
+ const REVIEW_CATEGORIES = ["bug", "style", "perf", "question"] as const satisfies readonly ReviewCategory[];
59
+ const REVIEW_SEVERITIES = ["high", "medium", "low"] as const satisfies readonly ReviewSeverity[];
60
+ const THEME_MODES = ["system", "dark", "light"] as const satisfies readonly ThemeMode[];
61
+ const SEVERITY_SUBS: Record<string, string> = { high: "ship it to prod, coward", medium: "suspicious little gremlin", low: "tiny crime, still crime" };
62
+ const CATEGORY_SUBS: Record<string, string> = { bug: "exquisite disaster", style: "crimes against taste", perf: "artisanal slowness", question: "explain yourself" };
63
+ const PANE_LIMITS = {
64
+ left: { min: 180, max: 560, fallback: 288 },
65
+ right: { min: 280, max: 680, fallback: 382 },
66
+ } as const;
67
+
68
+ type LayoutState = {
69
+ left: number;
70
+ right: number;
71
+ leftCollapsed: boolean;
72
+ rightCollapsed: boolean;
73
+ };
74
+
75
+ const token = new URLSearchParams(location.search).get("token") ?? "";
76
+ const els: Elements = {
77
+ app: must("app"),
78
+ meta: must("meta"),
79
+ summary: must("summary"),
80
+ findingCount: must("finding-count"),
81
+ drawerCount: must("drawer-count"),
82
+ tree: must("tree"),
83
+ diffs: must("diffs"),
84
+ inlineFinding: must("inline-finding"),
85
+ selection: must("selection"),
86
+ severity: mustSelect("severity"),
87
+ severitySub: must("severity-sub"),
88
+ category: mustSelect("category"),
89
+ categorySub: must("category-sub"),
90
+ comment: mustText("comment"),
91
+ add: mustButton("add"),
92
+ inlineCancel: mustButton("inline-cancel"),
93
+ findings: must("findings"),
94
+ notes: mustText("notes"),
95
+ search: must("search") as HTMLInputElement,
96
+ searchClear: mustButton("search-clear"),
97
+ searchResults: must("search-results"),
98
+ cancel: mustButton("cancel"),
99
+ submit: mustButton("submit"),
100
+ status: must("status"),
101
+ themeMode: mustSelect("theme-mode"),
102
+ leftSplitter: must("left-splitter"),
103
+ rightSplitter: must("right-splitter"),
104
+ toggleLeft: mustButton("toggle-left"),
105
+ toggleRight: mustButton("toggle-right"),
106
+ };
107
+
108
+ const findings: FindingDraft[] = [];
109
+ const diffViews = new Map<string, FileDiff<FindingMeta>>();
110
+ const systemTheme = window.matchMedia("(prefers-color-scheme: light)");
111
+ let fileTree: FileTree | undefined;
112
+ let selectedFile: string | undefined;
113
+ let selected: ReviewSelection | undefined;
114
+ let lastDiffPointer: ViewPoint | undefined;
115
+ let searchIndex: SearchEntry[] = [];
116
+ let searchResults: SearchMatch[] = [];
117
+ let searchActiveIndex = -1;
118
+ let searchDebounce: number | undefined;
119
+ let diffsModule: typeof import("@pierre/diffs") | undefined;
120
+ let treesModule: typeof import("@pierre/trees") | undefined;
121
+ let heartbeat: number | undefined;
122
+ let reviewPoll: number | undefined;
123
+ let reviewHash = "";
124
+ let renderGeneration = 0;
125
+ let searchIndexing = false;
126
+ let notesGrowFrame: number | undefined;
127
+ let closing = false;
128
+ let themeMode: ThemeMode = readThemeMode();
129
+
130
+ applyThemeMode(themeMode);
131
+ els.themeMode.value = themeMode;
132
+ applyLayout(readLayout());
133
+
134
+ main().catch((err) => setDisconnectedStatus(err));
135
+ window.addEventListener("pagehide", closeFromBrowser);
136
+ systemTheme.addEventListener("change", () => {
137
+ if (themeMode === "system") applyThemeMode(themeMode);
138
+ });
139
+
140
+ async function main() {
141
+ if (!token) throw new Error("Missing review token");
142
+ setStatus("Loading review…");
143
+ const reviewPromise = api<ReviewPayload>(ROUTES.review);
144
+ setStatus("Loading diff renderer…");
145
+ await loadRendererModules();
146
+ const review = await reviewPromise;
147
+ startHeartbeat();
148
+ startReviewPolling();
149
+ wireActions();
150
+ await renderReview(review);
151
+ setStatus("Ready");
152
+ }
153
+
154
+ async function loadRendererModules() {
155
+ if (diffsModule && treesModule) return;
156
+ const [diffs, trees] = await Promise.all([import("@pierre/diffs"), import("@pierre/trees")]);
157
+ diffsModule = diffs;
158
+ treesModule = trees;
159
+ registerNuguCodeTheme(diffs.registerCustomCSSVariableTheme);
160
+ }
161
+
162
+ function diffs() {
163
+ if (!diffsModule) throw new Error("Diff renderer not loaded");
164
+ return diffsModule;
165
+ }
166
+
167
+ function trees() {
168
+ if (!treesModule) throw new Error("File tree renderer not loaded");
169
+ return treesModule;
170
+ }
171
+
172
+ async function renderReview(review: ReviewPayload) {
173
+ const generation = ++renderGeneration;
174
+ reviewHash = hashReview(review);
175
+ renderMeta(review);
176
+ renderTree(review);
177
+ const fileDiffs = await renderDiffs(review, generation);
178
+ if (!fileDiffs || generation !== renderGeneration) return;
179
+ renderAnnotations();
180
+ void buildSearchIndex(fileDiffs, generation);
181
+ }
182
+
183
+ function renderMeta(review: ReviewPayload) {
184
+ const totals = review.files.reduce((sum, file) => ({ additions: sum.additions + file.additions, deletions: sum.deletions + file.deletions }), { additions: 0, deletions: 0 });
185
+ els.meta.replaceChildren(metaChip("round", String(review.round)), metaChip("reviewing", sourceLabel(review.source)));
186
+
187
+ els.summary.replaceChildren();
188
+ const stats = document.createElement("span");
189
+ stats.className = "summary-stats";
190
+ stats.textContent = `${review.files.length} files · +${totals.additions} −${totals.deletions}`;
191
+ els.summary.appendChild(stats);
192
+ if (review.skippedFiles.length > 0) {
193
+ const skipped = document.createElement("span");
194
+ skipped.className = "summary-skipped";
195
+ skipped.textContent = `skipped ${review.skippedFiles.length} oversized/binary file${review.skippedFiles.length === 1 ? "" : "s"}`;
196
+ els.summary.appendChild(skipped);
197
+ }
198
+ const hint = document.createElement("span");
199
+ hint.className = "summary-hint";
200
+ hint.textContent = "Click a changed line, describe issue, send findings to Pi.";
201
+ els.summary.appendChild(hint);
202
+ updateFindingCount();
203
+ }
204
+
205
+ function sourceLabel(source: ReviewPayload["source"]): string {
206
+ const scope = source.files?.length ? ` · ${source.files.length} path${source.files.length === 1 ? "" : "s"}` : "";
207
+ if (source.kind === "working-tree") return `unstaged changes${scope}`;
208
+ if (source.kind === "staged") return `staged changes${scope}`;
209
+ return `changes vs ${source.base}${scope}`;
210
+ }
211
+
212
+ function metaChip(label: string, value: string) {
213
+ const chip = document.createElement("span");
214
+ chip.className = `meta-chip meta-${label}`;
215
+ const labelNode = document.createElement("span");
216
+ labelNode.className = "meta-label";
217
+ labelNode.textContent = label;
218
+ const valueNode = document.createElement("span");
219
+ valueNode.className = "meta-value";
220
+ valueNode.textContent = value;
221
+ chip.append(labelNode, valueNode);
222
+ return chip;
223
+ }
224
+
225
+ function renderTree(review: ReviewPayload) {
226
+ fileTree?.cleanUp();
227
+ const skipped = skippedFilesByPath(review);
228
+ const reviewPaths = review.files.flatMap((file) => file.prevPath && file.prevPath !== file.path ? [file.prevPath, file.path] : [file.path]);
229
+ const paths = [...reviewPaths, ...[...skipped.keys()].filter((path) => !reviewPaths.includes(path))];
230
+ fileTree = new (trees().FileTree)({
231
+ paths,
232
+ search: true,
233
+ unsafeCSS: TREE_CSS,
234
+ initialExpansion: "open",
235
+ initialSelectedPaths: selectedFile && review.files.some((file) => file.path === selectedFile) ? [selectedFile] : [],
236
+ gitStatus: [
237
+ ...review.files.flatMap((file) => treeGitStatusEntries(file)),
238
+ ...[...skipped.keys()].map((path) => ({ path, status: "ignored" as const })),
239
+ ] satisfies GitStatusEntry[],
240
+ renderRowDecoration: treeRowDecoration(review, skipped),
241
+ onSelectionChange: ([path]) => {
242
+ const renamedTarget = path ? renamedTargetForPrevPath(review, path) : undefined;
243
+ if (renamedTarget) {
244
+ fileTree?.getItem(path)?.deselect();
245
+ fileTree?.getItem(renamedTarget)?.select();
246
+ selectFile(renamedTarget, true);
247
+ return;
248
+ }
249
+ if (path && skipped.has(path)) {
250
+ fileTree?.getItem(path)?.deselect();
251
+ if (selectedFile) fileTree?.getItem(selectedFile)?.select();
252
+ setStatus(`Skipped ${path}: ${skipped.get(path)}`);
253
+ return;
254
+ }
255
+ selectFile(path, true);
256
+ },
257
+ });
258
+ fileTree.render({ fileTreeContainer: els.tree });
259
+ wireTreeSearchClear(fileTree);
260
+ }
261
+
262
+ function treeGitStatusEntries(file: ReviewPayload["files"][number]): GitStatusEntry[] {
263
+ if (file.status === "renamed" && file.prevPath && file.prevPath !== file.path) return [
264
+ { path: file.prevPath, status: "deleted" },
265
+ { path: file.path, status: "renamed" },
266
+ ];
267
+ return [{ path: file.path, status: toGitStatus(file.status) }];
268
+ }
269
+
270
+ function renamedTargetForPrevPath(review: ReviewPayload, path: string) {
271
+ return review.files.find((file) => file.status === "renamed" && file.prevPath === path)?.path;
272
+ }
273
+
274
+ function treeRowDecoration(review: ReviewPayload, skipped: ReadonlyMap<string, string>): FileTreeRowDecorationRenderer {
275
+ return ({ row }) => {
276
+ if (row.kind !== "file") return null;
277
+ const reason = skipped.get(row.path);
278
+ if (reason) return { text: "skip", title: reason };
279
+ const renameSource = review.files.find((file) => file.status === "renamed" && file.prevPath === row.path);
280
+ if (renameSource) return { text: "from", title: `renamed to ${renameSource.path}` };
281
+ const renameTarget = review.files.find((file) => file.status === "renamed" && file.path === row.path && file.prevPath);
282
+ if (renameTarget?.prevPath) return { text: "to", title: `renamed from ${renameTarget.prevPath}` };
283
+ return null;
284
+ };
285
+ }
286
+
287
+ function wireTreeSearchClear(tree: FileTree) {
288
+ const root = els.tree.shadowRoot;
289
+ if (!root) return;
290
+
291
+ const sync = () => {
292
+ const container = root.querySelector<HTMLElement>("[data-file-tree-search-container]");
293
+ const input = root.querySelector<HTMLInputElement>("[data-file-tree-search-input]");
294
+ if (!container || !input || container.querySelector(".tree-search-clear")) return;
295
+ const clear = document.createElement("button");
296
+ clear.type = "button";
297
+ clear.className = "tree-search-clear";
298
+ clear.textContent = "×";
299
+ clear.setAttribute("aria-label", "Clear file search");
300
+ clear.addEventListener("pointerdown", (event) => event.preventDefault());
301
+ clear.addEventListener("click", (event) => {
302
+ event.preventDefault();
303
+ tree.setSearch("");
304
+ input.value = "";
305
+ input.focus();
306
+ });
307
+ container.appendChild(clear);
308
+ };
309
+
310
+ sync();
311
+ const observer = new MutationObserver(sync);
312
+ observer.observe(root, { childList: true, subtree: true });
313
+ const cleanup = tree.cleanUp.bind(tree);
314
+ tree.cleanUp = () => {
315
+ observer.disconnect();
316
+ cleanup();
317
+ };
318
+ }
319
+
320
+ function skippedFilesByPath(review: ReviewPayload): Map<string, string> {
321
+ const skipped = new Map<string, string>();
322
+ for (const entry of review.skippedFiles) {
323
+ const { path, reason } = parseSkippedFile(entry);
324
+ skipped.set(path, reason);
325
+ }
326
+ return skipped;
327
+ }
328
+
329
+ function parseSkippedFile(entry: string): { path: string; reason: string } {
330
+ const match = /^(.*) \(([^()]*)\)$/.exec(entry);
331
+ if (!match) return { path: entry, reason: "oversized/binary" };
332
+ return { path: match[1], reason: match[2] };
333
+ }
334
+
335
+ async function renderDiffs(review: ReviewPayload, generation: number): Promise<FileDiffMetadata[] | undefined> {
336
+ for (const view of diffViews.values()) view.cleanUp();
337
+ diffViews.clear();
338
+ els.diffs.replaceChildren();
339
+ clearSelection(false);
340
+
341
+ const files = diffs().parsePatchFiles(review.diffText, `diff-review-${review.round}`, true).flatMap((patch) => patch.files);
342
+ for (let index = 0; index < files.length; index++) {
343
+ if (generation !== renderGeneration) return undefined;
344
+ renderFileDiff(files[index]);
345
+ if (files.length > 1) setStatus(`Rendering ${index + 1}/${files.length} files…`);
346
+ await yieldToBrowser();
347
+ }
348
+ return files;
349
+ }
350
+
351
+ function renderFileDiff(fileDiff: FileDiffMetadata) {
352
+ const section = fileSection(fileDiff);
353
+ const view = new (diffs().FileDiff)<FindingMeta>({
354
+ theme: { dark: NUGU_CODE_THEME, light: NUGU_CODE_THEME },
355
+ diffStyle: "split",
356
+ overflow: "scroll",
357
+ enableLineSelection: true,
358
+ enableGutterUtility: true,
359
+ lineHoverHighlight: "both",
360
+ themeType: themeMode,
361
+ unsafeCSS: DIFF_FONT_CSS,
362
+ onLineSelected: (range) => setSelection(fileDiff.name, range, lastDiffPointer),
363
+ onGutterUtilityClick: (range) => setSelection(fileDiff.name, range, lastDiffPointer),
364
+ onLineClick: ({ lineNumber, annotationSide, event }) => setSelection(fileDiff.name, singleLineRange(lineNumber, annotationSide), eventPoint(event)),
365
+ onLineNumberClick: ({ lineNumber, annotationSide, event }) => setSelection(fileDiff.name, singleLineRange(lineNumber, annotationSide), eventPoint(event)),
366
+ renderAnnotation: renderAnnotation,
367
+ });
368
+ view.render({ fileDiff, containerWrapper: section, lineAnnotations: [] });
369
+ diffViews.set(fileDiff.name, view);
370
+ }
371
+
372
+ function fileSection(fileDiff: FileDiffMetadata) {
373
+ const section = document.createElement("section");
374
+ section.className = "file-section";
375
+ section.dataset.fileSection = fileDiff.name;
376
+ if (fileDiff.name === selectedFile) section.dataset.selectedFile = "true";
377
+
378
+ const title = document.createElement("h2");
379
+ title.className = "file-title";
380
+ title.textContent = fileTitle(fileDiff);
381
+ section.appendChild(title);
382
+ els.diffs.appendChild(section);
383
+ return section;
384
+ }
385
+
386
+ function fileTitle(fileDiff: FileDiffMetadata) {
387
+ return fileDiff.prevName && fileDiff.prevName !== fileDiff.name ? `${fileDiff.prevName} → ${fileDiff.name}` : fileDiff.name;
388
+ }
389
+
390
+ function renderAnnotation(annotation: DiffLineAnnotation<FindingMeta>) {
391
+ const chip = document.createElement("div");
392
+ chip.className = "finding-chip";
393
+ chip.textContent = `[${annotation.metadata.severity}][${annotation.metadata.category}] ${annotation.metadata.comment}`;
394
+ return chip;
395
+ }
396
+
397
+ function setSelection(file: string, range: SelectedLineRange | null, point?: ViewPoint) {
398
+ const next = normalizeSelection(file, range);
399
+ if (!next) {
400
+ clearSelection(false);
401
+ return;
402
+ }
403
+ selected = next;
404
+ els.add.disabled = false;
405
+ els.selection.className = "selection-active";
406
+ els.selection.textContent = selectionLabel(next);
407
+ showFindingForm(next, point);
408
+ }
409
+
410
+ function showFindingForm(selection: ReviewSelection, point?: ViewPoint) {
411
+ const anchor = point ?? lineAnchor(selection) ?? centerPoint();
412
+ els.inlineFinding.hidden = false;
413
+ els.inlineFinding.dataset.open = "true";
414
+ positionFindingForm(anchor);
415
+ requestAnimationFrame(() => {
416
+ positionFindingForm(anchor);
417
+ els.comment.focus();
418
+ });
419
+ }
420
+
421
+ function hideFindingForm() {
422
+ els.inlineFinding.hidden = true;
423
+ delete els.inlineFinding.dataset.open;
424
+ }
425
+
426
+ function positionFindingForm(point: ViewPoint) {
427
+ const margin = 12;
428
+ const gap = 14;
429
+ const rect = els.inlineFinding.getBoundingClientRect();
430
+ const width = rect.width || 360;
431
+ const height = rect.height || 280;
432
+ let left = point.x + gap;
433
+ let top = point.y - 22;
434
+ if (left + width + margin > window.innerWidth) left = point.x - width - gap;
435
+ left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
436
+ top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
437
+ els.inlineFinding.style.left = `${Math.round(left)}px`;
438
+ els.inlineFinding.style.top = `${Math.round(top)}px`;
439
+ }
440
+
441
+ function lineAnchor(selection: ReviewSelection): ViewPoint | undefined {
442
+ const section = document.querySelector<HTMLElement>(`[data-file-section="${cssEscape(selection.file)}"]`);
443
+ const container = section?.querySelector("diffs-container") as HTMLElement | null;
444
+ const root = container?.shadowRoot;
445
+ const line = root?.querySelector<HTMLElement>(`[data-${selection.side}] [data-line="${selection.startLine}"]`);
446
+ if (!line) return undefined;
447
+ const rect = line.getBoundingClientRect();
448
+ return { x: rect.left + 24, y: rect.top + rect.height / 2 };
449
+ }
450
+
451
+ function centerPoint(): ViewPoint {
452
+ return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
453
+ }
454
+
455
+ function eventPoint(event: MouseEvent | PointerEvent): ViewPoint {
456
+ return { x: event.clientX, y: event.clientY };
457
+ }
458
+
459
+ function normalizeSelection(file: string, range: SelectedLineRange | null): ReviewSelection | undefined {
460
+ if (!range) return undefined;
461
+ const side = range.side;
462
+ const endSide = range.endSide ?? side;
463
+ if (!isReviewSide(side) || endSide !== side) return undefined;
464
+ return { file, side, startLine: Math.min(range.start, range.end), endLine: Math.max(range.start, range.end) };
465
+ }
466
+
467
+ function singleLineRange(lineNumber: number, side: SelectedLineRange["side"]): SelectedLineRange | null {
468
+ return side ? { start: lineNumber, end: lineNumber, side, endSide: side } : null;
469
+ }
470
+
471
+ function selectionLabel(selection: ReviewSelection) {
472
+ const { file, startLine, endLine, side } = selection;
473
+ return `${file}:${startLine}${startLine === endLine ? "" : `-${endLine}`} ${side}`;
474
+ }
475
+
476
+ function wireActions() {
477
+ els.themeMode.addEventListener("change", updateThemeMode);
478
+ els.severity.addEventListener("change", () => { els.severitySub.textContent = SEVERITY_SUBS[els.severity.value] ?? ""; });
479
+ els.category.addEventListener("change", () => { els.categorySub.textContent = CATEGORY_SUBS[els.category.value] ?? ""; });
480
+ els.add.addEventListener("click", addFinding);
481
+ els.submit.addEventListener("click", submitReview);
482
+ els.cancel.addEventListener("click", cancelReview);
483
+ els.toggleLeft.addEventListener("click", () => togglePane("left"));
484
+ els.toggleRight.addEventListener("click", () => togglePane("right"));
485
+ els.leftSplitter.addEventListener("pointerdown", (event) => startPaneResize("left", event));
486
+ els.rightSplitter.addEventListener("pointerdown", (event) => startPaneResize("right", event));
487
+ els.diffs.addEventListener("pointerdown", (event) => { lastDiffPointer = eventPoint(event); }, true);
488
+ els.diffs.addEventListener("pointerup", (event) => { lastDiffPointer = eventPoint(event); }, true);
489
+ els.inlineCancel.addEventListener("click", () => {
490
+ clearFindingForm();
491
+ clearSelection(true);
492
+ });
493
+ els.comment.addEventListener("keydown", (event) => {
494
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void addFinding();
495
+ if (event.key === "Escape") clearSelection(true);
496
+ });
497
+ els.notes.addEventListener("input", () => scheduleNotesAutoGrow());
498
+ scheduleNotesAutoGrow();
499
+ els.search.addEventListener("input", () => {
500
+ window.clearTimeout(searchDebounce);
501
+ searchDebounce = window.setTimeout(runSearch, 120);
502
+ });
503
+ els.searchClear.addEventListener("click", () => clearCodeSearch(true));
504
+ els.search.addEventListener("search", () => {
505
+ if (!els.search.value.trim()) clearCodeSearch(false);
506
+ });
507
+ els.search.addEventListener("keydown", (event) => {
508
+ if (isEscapeKey(event)) {
509
+ event.preventDefault();
510
+ event.stopPropagation();
511
+ closeCodeSearchPopup();
512
+ return;
513
+ }
514
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
515
+ event.preventDefault();
516
+ navigateSearch(event.key === "ArrowDown" ? 1 : -1);
517
+ return;
518
+ }
519
+ if (event.key === "Enter") {
520
+ event.preventDefault();
521
+ if (searchResults.length === 0) return;
522
+ const idx = searchActiveIndex >= 0 ? searchActiveIndex : 0;
523
+ jumpToMatch(searchResults[idx], idx);
524
+ hideSearch();
525
+ }
526
+ });
527
+ document.addEventListener("keydown", (event) => {
528
+ if (!isEscapeKey(event) || els.searchResults.hidden) return;
529
+ event.preventDefault();
530
+ event.stopPropagation();
531
+ closeCodeSearchPopup();
532
+ }, true);
533
+ document.addEventListener("click", (event) => {
534
+ if (!els.searchResults.contains(event.target as Node) && event.target !== els.search) hideSearch();
535
+ });
536
+ }
537
+
538
+ function scheduleNotesAutoGrow() {
539
+ if (notesGrowFrame !== undefined) return;
540
+ notesGrowFrame = requestAnimationFrame(() => {
541
+ notesGrowFrame = undefined;
542
+ autoGrowTextarea(els.notes);
543
+ });
544
+ }
545
+
546
+ function autoGrowTextarea(textarea: HTMLTextAreaElement) {
547
+ textarea.style.height = "auto";
548
+ textarea.style.height = `${textarea.scrollHeight}px`;
549
+ }
550
+
551
+ function yieldToBrowser() {
552
+ return new Promise<void>((resolve) => {
553
+ const requestIdle = window.requestIdleCallback as ((callback: () => void, options?: { timeout: number }) => number) | undefined;
554
+ if (requestIdle) requestIdle(() => resolve(), { timeout: 80 });
555
+ else requestAnimationFrame(() => resolve());
556
+ });
557
+ }
558
+
559
+ function updateThemeMode() {
560
+ themeMode = themeModeFromValue(els.themeMode.value);
561
+ localStorage.setItem(THEME_STORAGE_KEY, themeMode);
562
+ applyThemeMode(themeMode);
563
+ for (const view of diffViews.values()) view.setThemeType(themeMode);
564
+ }
565
+
566
+ function togglePane(side: PaneSide) {
567
+ const layout = readLayout();
568
+ if (side === "left") layout.leftCollapsed = !layout.leftCollapsed;
569
+ else layout.rightCollapsed = !layout.rightCollapsed;
570
+ applyLayout(layout);
571
+ writeLayout(layout);
572
+ }
573
+
574
+ function startPaneResize(side: PaneSide, event: PointerEvent) {
575
+ if ((event.target as HTMLElement).closest("button")) return;
576
+ event.preventDefault();
577
+ const layout = readLayout();
578
+ if (side === "left") layout.leftCollapsed = false;
579
+ else layout.rightCollapsed = false;
580
+ els.app.dataset.resizing = side;
581
+ try { els.app.setPointerCapture?.(event.pointerId); } catch {}
582
+
583
+ const move = (moveEvent: PointerEvent) => {
584
+ const next = { ...layout };
585
+ if (side === "left") next.left = clampPane("left", moveEvent.clientX);
586
+ else next.right = clampPane("right", window.innerWidth - moveEvent.clientX);
587
+ applyLayout(next);
588
+ writeLayout(next);
589
+ };
590
+ const stop = () => {
591
+ delete els.app.dataset.resizing;
592
+ window.removeEventListener("pointermove", move);
593
+ window.removeEventListener("pointerup", stop);
594
+ window.removeEventListener("pointercancel", stop);
595
+ };
596
+
597
+ window.addEventListener("pointermove", move);
598
+ window.addEventListener("pointerup", stop, { once: true });
599
+ window.addEventListener("pointercancel", stop, { once: true });
600
+ }
601
+
602
+ function readLayout(): LayoutState {
603
+ const stored = localStorage.getItem(LAYOUT_STORAGE_KEY);
604
+ if (!stored) return defaultLayout();
605
+ try {
606
+ const value = JSON.parse(stored) as Partial<LayoutState>;
607
+ return {
608
+ left: clampPane("left", value.left),
609
+ right: clampPane("right", value.right),
610
+ leftCollapsed: value.leftCollapsed === true,
611
+ rightCollapsed: value.rightCollapsed === true,
612
+ };
613
+ } catch {
614
+ return defaultLayout();
615
+ }
616
+ }
617
+
618
+ function writeLayout(layout: LayoutState) {
619
+ localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout));
620
+ }
621
+
622
+ function defaultLayout(): LayoutState {
623
+ return { left: PANE_LIMITS.left.fallback, right: PANE_LIMITS.right.fallback, leftCollapsed: false, rightCollapsed: false };
624
+ }
625
+
626
+ function applyLayout(layout: LayoutState) {
627
+ els.app.style.setProperty("--left-pane", `${layout.leftCollapsed ? 0 : layout.left}px`);
628
+ els.app.style.setProperty("--right-pane", `${layout.rightCollapsed ? 0 : layout.right}px`);
629
+ els.app.dataset.leftCollapsed = String(layout.leftCollapsed);
630
+ els.app.dataset.rightCollapsed = String(layout.rightCollapsed);
631
+ els.toggleLeft.textContent = layout.leftCollapsed ? "›" : "‹";
632
+ els.toggleRight.textContent = layout.rightCollapsed ? "‹" : "›";
633
+ els.toggleLeft.setAttribute("aria-label", layout.leftCollapsed ? "Expand file tree" : "Collapse file tree");
634
+ els.toggleRight.setAttribute("aria-label", layout.rightCollapsed ? "Expand review panel" : "Collapse review panel");
635
+ }
636
+
637
+ function clampPane(side: PaneSide, value: unknown) {
638
+ const limits = PANE_LIMITS[side];
639
+ const width = typeof value === "number" && Number.isFinite(value) ? value : limits.fallback;
640
+ return Math.min(limits.max, Math.max(limits.min, Math.round(width)));
641
+ }
642
+
643
+ async function addFinding() {
644
+ const draft = draftFromSelection();
645
+ if (!draft) return;
646
+ els.add.disabled = true;
647
+ try {
648
+ await api(ROUTES.validateFinding, postJson(draft));
649
+ findings.push(draft);
650
+ clearFindingForm();
651
+ clearSelection(true);
652
+ renderFindings();
653
+ renderAnnotations();
654
+ setStatus("Finding added");
655
+ } catch (err) {
656
+ setStatus(errorMessage(err), "error");
657
+ } finally {
658
+ els.add.disabled = !selected;
659
+ }
660
+ }
661
+
662
+ function draftFromSelection(): FindingDraft | undefined {
663
+ if (!selected) return undefined;
664
+ const comment = els.comment.value.trim();
665
+ if (!comment) {
666
+ setStatus("Comment required", "error");
667
+ return undefined;
668
+ }
669
+ return {
670
+ ...selected,
671
+ category: categoryFromValue(els.category.value),
672
+ severity: severityFromValue(els.severity.value),
673
+ comment,
674
+ };
675
+ }
676
+
677
+ async function submitReview() {
678
+ if (findings.length === 0 && !els.notes.value.trim() && !confirm("Send empty review to Pi?")) return;
679
+ setDoneButtonsDisabled(true);
680
+ try {
681
+ await api(ROUTES.submit, postJson({ notes: els.notes.value, findings }));
682
+ findings.splice(0);
683
+ els.notes.value = "";
684
+ renderFindings();
685
+ renderAnnotations();
686
+ setStatus("Sent to Pi — keep reviewing or close tab to end", "ok");
687
+ } catch (err) {
688
+ setStatus(errorMessage(err), "error");
689
+ } finally {
690
+ setDoneButtonsDisabled(false);
691
+ }
692
+ }
693
+
694
+ async function cancelReview() {
695
+ closing = true;
696
+ setDoneButtonsDisabled(true);
697
+ await api(ROUTES.cancel, postJson({})).catch(() => undefined);
698
+ cleanup();
699
+ setStatus("Closed — return to Pi");
700
+ }
701
+
702
+ function setDoneButtonsDisabled(disabled: boolean) {
703
+ els.submit.disabled = disabled;
704
+ els.cancel.disabled = disabled;
705
+ }
706
+
707
+ function clearFindingForm() {
708
+ els.comment.value = "";
709
+ }
710
+
711
+ function clearSelection(clearViews: boolean) {
712
+ selected = undefined;
713
+ els.add.disabled = true;
714
+ els.selection.className = "selection-empty";
715
+ els.selection.textContent = "Click an added or deleted line.";
716
+ hideFindingForm();
717
+ if (clearViews) for (const view of diffViews.values()) view.setSelectedLines(null);
718
+ }
719
+
720
+ function renderFindings() {
721
+ els.findings.replaceChildren();
722
+ els.findings.className = findings.length === 0 ? "empty-list" : "";
723
+ if (findings.length === 0) {
724
+ els.findings.textContent = "No findings yet.";
725
+ updateFindingCount();
726
+ return;
727
+ }
728
+ findings.forEach((finding, index) => els.findings.appendChild(findingItem(finding, index)));
729
+ updateFindingCount();
730
+ }
731
+
732
+ function findingItem(finding: FindingDraft, index: number) {
733
+ const item = document.createElement("div");
734
+ item.className = `finding-item severity-${finding.severity}`;
735
+ item.tabIndex = 0;
736
+ item.addEventListener("click", () => selectFile(finding.file, true));
737
+ item.addEventListener("keydown", (event) => {
738
+ if (event.key === "Enter") selectFile(finding.file, true);
739
+ });
740
+
741
+ const head = document.createElement("div");
742
+ head.className = "finding-item-head";
743
+
744
+ const number = document.createElement("span");
745
+ number.className = "finding-number";
746
+ number.textContent = String(index + 1);
747
+
748
+ const severity = document.createElement("span");
749
+ severity.className = `finding-pill ${finding.severity}`;
750
+ severity.textContent = finding.severity;
751
+
752
+ const category = document.createElement("span");
753
+ category.className = "finding-pill category";
754
+ category.textContent = finding.category;
755
+
756
+ const remove = document.createElement("button");
757
+ remove.className = "remove-finding";
758
+ remove.type = "button";
759
+ remove.textContent = "×";
760
+ remove.title = "Remove finding";
761
+ remove.addEventListener("click", (event) => {
762
+ event.stopPropagation();
763
+ removeFinding(index);
764
+ });
765
+
766
+ const location = document.createElement("div");
767
+ location.className = "finding-location";
768
+ location.textContent = selectionLabel(finding);
769
+
770
+ const text = document.createElement("div");
771
+ text.className = "finding-comment";
772
+ text.textContent = finding.comment;
773
+
774
+ head.append(number, severity, category, remove);
775
+ item.append(head, location, text);
776
+ return item;
777
+ }
778
+
779
+ function removeFinding(index: number) {
780
+ findings.splice(index, 1);
781
+ renderFindings();
782
+ renderAnnotations();
783
+ }
784
+
785
+ function updateFindingCount() {
786
+ els.findingCount.textContent = `${findings.length} finding${findings.length === 1 ? "" : "s"}`;
787
+ els.drawerCount.textContent = String(findings.length);
788
+ }
789
+
790
+ function renderAnnotations() {
791
+ for (const [file, view] of diffViews) {
792
+ view.setLineAnnotations(annotationsFor(file));
793
+ view.rerender();
794
+ }
795
+ }
796
+
797
+ async function buildSearchIndex(fileDiffs: FileDiffMetadata[], generation: number) {
798
+ searchIndexing = true;
799
+ searchIndex = [];
800
+ const restoreStatus = els.status.textContent === "Ready" ? "Ready" : undefined;
801
+ const entries: SearchEntry[] = [];
802
+ for (let index = 0; index < fileDiffs.length; index++) {
803
+ if (generation !== renderGeneration) { searchIndexing = false; return; }
804
+ entries.push(...searchEntriesForFile(fileDiffs[index]));
805
+ if (fileDiffs.length > 1) setStatus(`Indexing search ${index + 1}/${fileDiffs.length} files…`);
806
+ await yieldToBrowser();
807
+ }
808
+ if (generation !== renderGeneration) { searchIndexing = false; return; }
809
+ searchIndex = entries;
810
+ searchIndexing = false;
811
+ if (els.search.value.trim()) runSearch();
812
+ if (restoreStatus && els.status.textContent.startsWith("Indexing search")) setStatus(restoreStatus);
813
+ }
814
+
815
+ function searchEntriesForFile(fileDiff: FileDiffMetadata): SearchEntry[] {
816
+ const entries: SearchEntry[] = [];
817
+ for (const hunk of fileDiff.hunks) {
818
+ let additionLine = hunk.additionStart;
819
+ let deletionLine = hunk.deletionStart;
820
+ for (const content of hunk.hunkContent) {
821
+ if (content.type === "context") {
822
+ for (let i = 0; i < content.lines; i++) {
823
+ const text = fileDiff.additionLines[content.additionLineIndex + i];
824
+ if (text !== undefined) entries.push({ file: fileDiff.name, side: "additions", line: additionLine + i, text });
825
+ }
826
+ additionLine += content.lines;
827
+ deletionLine += content.lines;
828
+ } else {
829
+ for (let i = 0; i < content.deletions; i++) {
830
+ const text = fileDiff.deletionLines[content.deletionLineIndex + i];
831
+ if (text !== undefined) entries.push({ file: fileDiff.name, side: "deletions", line: deletionLine + i, text });
832
+ }
833
+ for (let i = 0; i < content.additions; i++) {
834
+ const text = fileDiff.additionLines[content.additionLineIndex + i];
835
+ if (text !== undefined) entries.push({ file: fileDiff.name, side: "additions", line: additionLine + i, text });
836
+ }
837
+ deletionLine += content.deletions;
838
+ additionLine += content.additions;
839
+ }
840
+ }
841
+ }
842
+ return entries;
843
+ }
844
+
845
+ function runSearch() {
846
+ const query = els.search.value.trim();
847
+ if (!query) { searchResults = []; searchActiveIndex = -1; hideSearch(); return; }
848
+ if (searchIndexing) {
849
+ els.searchResults.hidden = false;
850
+ els.searchResults.replaceChildren();
851
+ const empty = document.createElement("div");
852
+ empty.className = "search-empty";
853
+ empty.textContent = "Indexing search…";
854
+ els.searchResults.appendChild(empty);
855
+ return;
856
+ }
857
+ searchResults = searchCode(query);
858
+ searchActiveIndex = -1;
859
+ if (searchResults.length === 0) {
860
+ els.searchResults.hidden = false;
861
+ els.searchResults.replaceChildren();
862
+ const empty = document.createElement("div");
863
+ empty.className = "search-empty";
864
+ empty.textContent = "No matches.";
865
+ els.searchResults.appendChild(empty);
866
+ updateSearchBadge();
867
+ return;
868
+ }
869
+ renderSearchResults();
870
+ updateSearchBadge();
871
+ }
872
+
873
+ function renderSearchResults() {
874
+ els.searchResults.hidden = false;
875
+ els.searchResults.replaceChildren();
876
+ const max = Math.min(searchResults.length, 200);
877
+ for (let i = 0; i < max; i++) els.searchResults.appendChild(searchResultItem(searchResults[i], i));
878
+ if (searchResults.length > max) {
879
+ const more = document.createElement("div");
880
+ more.className = "search-more";
881
+ more.textContent = `${searchResults.length - max} more matches`;
882
+ els.searchResults.appendChild(more);
883
+ }
884
+ }
885
+
886
+ function navigateSearch(delta: 1 | -1) {
887
+ if (searchResults.length === 0) return;
888
+ searchActiveIndex = searchActiveIndex < 0 ? 0 : Math.max(0, Math.min(searchResults.length - 1, searchActiveIndex + delta));
889
+ const items = els.searchResults.querySelectorAll<HTMLElement>(".search-result");
890
+ items.forEach((el, i) => el.classList.toggle("search-result-active", i === searchActiveIndex));
891
+ const active = items[searchActiveIndex];
892
+ active?.scrollIntoView({ block: "nearest" });
893
+ jumpToMatch(searchResults[searchActiveIndex], searchActiveIndex);
894
+ updateSearchBadge();
895
+ }
896
+
897
+ function updateSearchBadge() {
898
+ const badge = document.querySelector<HTMLElement>("#search-badge");
899
+ if (!badge) return;
900
+ if (searchResults.length === 0) { badge.hidden = true; return; }
901
+ badge.hidden = false;
902
+ badge.textContent = searchActiveIndex >= 0 ? `${searchActiveIndex + 1}/${searchResults.length}` : `${searchResults.length}`;
903
+ }
904
+
905
+ function searchCode(query: string): SearchMatch[] {
906
+ const hits: SearchMatch[] = [];
907
+ const isRegex = query.startsWith("/") && query.endsWith("/");
908
+ const pattern = isRegex ? query.slice(1, -1) : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
909
+ let re: RegExp;
910
+ try { re = new RegExp(pattern, "i"); } catch { return hits; }
911
+ for (const entry of searchIndex) {
912
+ const m = re.exec(entry.text);
913
+ if (m && m.index >= 0) {
914
+ hits.push({ ...entry, matchStart: m.index, matchEnd: m.index + m[0].length });
915
+ if (hits.length >= 500) break;
916
+ }
917
+ }
918
+ return hits;
919
+ }
920
+
921
+ function searchResultItem(match: SearchMatch, index: number) {
922
+ const item = document.createElement("div");
923
+ item.className = "search-result";
924
+ item.tabIndex = -1;
925
+ item.addEventListener("click", () => {
926
+ searchActiveIndex = index;
927
+ jumpToMatch(match, index);
928
+ renderSearchResults();
929
+ updateSearchBadge();
930
+ });
931
+
932
+ const file = document.createElement("span");
933
+ file.className = "search-result-file";
934
+ file.textContent = match.file;
935
+
936
+ const loc = document.createElement("span");
937
+ loc.className = "search-result-loc";
938
+ loc.textContent = `:${match.line} ${match.side === "additions" ? "+" : "-"}`;
939
+
940
+ const preview = document.createElement("div");
941
+ preview.className = "search-result-preview";
942
+ const before = match.text.slice(Math.max(0, match.matchStart - 20), match.matchStart);
943
+ const matched = match.text.slice(match.matchStart, match.matchEnd);
944
+ const after = match.text.slice(match.matchEnd, match.matchEnd + 40);
945
+ preview.textContent = before;
946
+ const mark = document.createElement("mark");
947
+ mark.textContent = matched;
948
+ preview.appendChild(mark);
949
+ preview.append(after);
950
+
951
+ item.append(file, loc, preview);
952
+ return item;
953
+ }
954
+
955
+ function jumpToMatch(match: SearchMatch, index: number) {
956
+ selectFile(match.file, false);
957
+ requestAnimationFrame(() => {
958
+ const section = document.querySelector<HTMLElement>(`[data-file-section="${cssEscape(match.file)}"]`);
959
+ const container = section?.querySelector("diffs-container") as HTMLElement | null;
960
+ const root = container?.shadowRoot;
961
+ const sideRoot = root?.querySelector<HTMLElement>(match.side === "additions" ? "[data-additions]" : "[data-deletions]");
962
+ const line = sideRoot?.querySelector<HTMLElement>(`[data-line="${match.line}"]`);
963
+ if (line) {
964
+ const lineIndex = line.getAttribute("data-line-index");
965
+ const gutter = lineIndex
966
+ ? sideRoot?.querySelector<HTMLElement>(`[data-column-number][data-line-index="${cssEscape(lineIndex)}"]`)
967
+ : sideRoot?.querySelector<HTMLElement>(`[data-column-number="${match.line}"]`);
968
+ scrollDiffLineIntoView(line);
969
+ flashSearchHighlight([line, gutter]);
970
+ }
971
+ });
972
+ }
973
+
974
+ function scrollDiffLineIntoView(line: HTMLElement) {
975
+ const scroller = els.diffs;
976
+ const lineRect = line.getBoundingClientRect();
977
+ const scrollerRect = scroller.getBoundingClientRect();
978
+ const nextTop = scroller.scrollTop + lineRect.top - scrollerRect.top - (scrollerRect.height / 2) + (lineRect.height / 2);
979
+ scroller.scrollTo({ top: Math.max(0, nextTop), behavior: "smooth" });
980
+ }
981
+
982
+ function flashSearchHighlight(elements: Array<HTMLElement | undefined | null>) {
983
+ const targets = elements.filter((element): element is HTMLElement => Boolean(element));
984
+ for (const target of targets) target.classList.remove("search-highlight");
985
+ if (targets[0]) void targets[0].offsetWidth;
986
+ for (const target of targets) target.classList.add("search-highlight");
987
+ }
988
+
989
+ function isEscapeKey(event: KeyboardEvent) {
990
+ return event.key === "Escape" || event.key === "Esc";
991
+ }
992
+
993
+ function cancelPendingSearch() {
994
+ window.clearTimeout(searchDebounce);
995
+ searchDebounce = undefined;
996
+ }
997
+
998
+ function closeCodeSearchPopup() {
999
+ cancelPendingSearch();
1000
+ searchActiveIndex = -1;
1001
+ hideSearch();
1002
+ }
1003
+
1004
+ function clearCodeSearch(refocus: boolean) {
1005
+ cancelPendingSearch();
1006
+ els.search.value = "";
1007
+ searchResults = [];
1008
+ searchActiveIndex = -1;
1009
+ hideSearch();
1010
+ updateSearchBadge();
1011
+ if (refocus) els.search.focus();
1012
+ }
1013
+
1014
+ function hideSearch() {
1015
+ els.searchResults.hidden = true;
1016
+ updateSearchBadge();
1017
+ }
1018
+
1019
+ function annotationsFor(file: string): DiffLineAnnotation<FindingMeta>[] {
1020
+ return findings
1021
+ .filter((finding) => finding.file === file)
1022
+ .map((finding, index) => ({
1023
+ side: finding.side,
1024
+ lineNumber: finding.startLine,
1025
+ metadata: { findingId: String(index), severity: finding.severity, category: finding.category, comment: finding.comment },
1026
+ }));
1027
+ }
1028
+
1029
+ async function api<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
1030
+ const res = await fetch(path, {
1031
+ ...init,
1032
+ headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
1033
+ });
1034
+ const body = await res.text();
1035
+ const data = body ? JSON.parse(body) : undefined;
1036
+ if (!res.ok) throw new Error(errorPayload(data) ?? `${res.status} ${res.statusText}`);
1037
+ return data as T;
1038
+ }
1039
+
1040
+ function postJson(body: unknown): RequestInit {
1041
+ return { method: "POST", body: JSON.stringify(body) };
1042
+ }
1043
+
1044
+ function errorPayload(value: unknown) {
1045
+ return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : undefined;
1046
+ }
1047
+
1048
+ function startHeartbeat() {
1049
+ heartbeat = window.setInterval(() => api(ROUTES.heartbeat, postJson({})).catch(() => undefined), HEARTBEAT_INTERVAL_MS);
1050
+ }
1051
+
1052
+ function startReviewPolling() {
1053
+ reviewPoll = window.setInterval(refreshReview, REVIEW_POLL_INTERVAL_MS);
1054
+ }
1055
+
1056
+ async function refreshReview() {
1057
+ try {
1058
+ const review = await api<ReviewPayload>(ROUTES.review);
1059
+ const nextHash = hashReview(review);
1060
+ if (nextHash === reviewHash) return;
1061
+ await renderReview(review);
1062
+ setStatus("Diff updated", "ok");
1063
+ } catch (err) {
1064
+ setDisconnectedStatus(err);
1065
+ }
1066
+ }
1067
+
1068
+ function hashReview(review: ReviewPayload) {
1069
+ return `${review.diffText}\0${review.skippedFiles.join("\0")}`;
1070
+ }
1071
+
1072
+ function stopHeartbeat() {
1073
+ if (heartbeat !== undefined) window.clearInterval(heartbeat);
1074
+ heartbeat = undefined;
1075
+ }
1076
+
1077
+ function stopReviewPolling() {
1078
+ if (reviewPoll !== undefined) window.clearInterval(reviewPoll);
1079
+ reviewPoll = undefined;
1080
+ }
1081
+
1082
+ function cleanup() {
1083
+ stopHeartbeat();
1084
+ stopReviewPolling();
1085
+ fileTree?.cleanUp();
1086
+ for (const view of diffViews.values()) view.cleanUp();
1087
+ }
1088
+
1089
+ function closeFromBrowser() {
1090
+ if (closing) return;
1091
+ closing = true;
1092
+ navigator.sendBeacon?.(ROUTES.cancel, new Blob(["{}"], { type: "application/json" }));
1093
+ cleanup();
1094
+ }
1095
+
1096
+ function setStatus(text: string, tone: "neutral" | "error" | "ok" = "neutral") {
1097
+ els.status.textContent = text;
1098
+ els.status.className = tone === "neutral" ? "" : tone;
1099
+ }
1100
+
1101
+ function setDisconnectedStatus(err: unknown) {
1102
+ const message = errorMessage(err);
1103
+ const disconnected = /networkerror|failed to fetch|load failed|fetch resource/i.test(message);
1104
+ setStatus(disconnected ? "Disconnected. Run /diff-review, then refresh." : message, "error");
1105
+ }
1106
+
1107
+ function readThemeMode(): ThemeMode {
1108
+ return themeModeFromValue(localStorage.getItem(THEME_STORAGE_KEY));
1109
+ }
1110
+
1111
+ function applyThemeMode(mode: ThemeMode) {
1112
+ document.documentElement.dataset.theme = mode === "system" ? (systemTheme.matches ? "light" : "dark") : mode;
1113
+ document.documentElement.dataset.themeMode = mode;
1114
+ const icon = document.querySelector<HTMLElement>(".theme-icon");
1115
+ if (icon) icon.textContent = mode === "dark" ? "☾" : mode === "light" ? "☼" : "◐";
1116
+ }
1117
+
1118
+ function selectFile(path: string | undefined, scroll: boolean) {
1119
+ selectedFile = path;
1120
+ applySelectedFileEmphasis();
1121
+ if (scroll) scrollToFile(path);
1122
+ }
1123
+
1124
+ function applySelectedFileEmphasis() {
1125
+ for (const section of els.diffs.querySelectorAll<HTMLElement>("[data-file-section]")) {
1126
+ const active = section.dataset.fileSection === selectedFile;
1127
+ if (active) section.dataset.selectedFile = "true";
1128
+ else delete section.dataset.selectedFile;
1129
+ }
1130
+ }
1131
+
1132
+ function scrollToFile(path: string | undefined) {
1133
+ if (!path) return;
1134
+ const section = document.querySelector<HTMLElement>(`[data-file-section="${cssEscape(path)}"]`);
1135
+ if (!section) return;
1136
+ const scroller = els.diffs;
1137
+ const top = section.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
1138
+ scroller.scrollTo({ top: Math.max(0, top - 2), behavior: "smooth" });
1139
+ }
1140
+
1141
+ function toGitStatus(status: ReviewPayload["files"][number]["status"]): GitStatusEntry["status"] {
1142
+ if (status === "added" || status === "deleted" || status === "renamed") return status;
1143
+ return "modified";
1144
+ }
1145
+
1146
+ function themeModeFromValue(value: unknown): ThemeMode {
1147
+ return isOneOf(value, THEME_MODES) ? value : "system";
1148
+ }
1149
+
1150
+ function categoryFromValue(value: unknown): ReviewCategory {
1151
+ if (isOneOf(value, REVIEW_CATEGORIES)) return value;
1152
+ throw new Error(`Invalid category: ${String(value)}`);
1153
+ }
1154
+
1155
+ function severityFromValue(value: unknown): ReviewSeverity {
1156
+ if (isOneOf(value, REVIEW_SEVERITIES)) return value;
1157
+ throw new Error(`Invalid severity: ${String(value)}`);
1158
+ }
1159
+
1160
+ function isReviewSide(value: unknown): value is ReviewSide {
1161
+ return value === "additions" || value === "deletions";
1162
+ }
1163
+
1164
+ function isOneOf<T extends string>(value: unknown, choices: readonly T[]): value is T {
1165
+ return typeof value === "string" && choices.includes(value as T);
1166
+ }
1167
+
1168
+ function must(id: keyof Elements | string) {
1169
+ const el = document.getElementById(id);
1170
+ if (!el) throw new Error(`Missing #${id}`);
1171
+ return el;
1172
+ }
1173
+
1174
+ function mustButton(id: string) { return must(id) as HTMLButtonElement; }
1175
+ function mustSelect(id: string) { return must(id) as HTMLSelectElement; }
1176
+ function mustText(id: string) { return must(id) as HTMLTextAreaElement; }
1177
+
1178
+ function cssEscape(value: string) {
1179
+ return CSS.escape(value);
1180
+ }
1181
+
1182
+ function errorMessage(err: unknown) {
1183
+ return err instanceof Error ? err.message : String(err);
1184
+ }