diffback-review 1.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.
Files changed (3) hide show
  1. package/README.md +144 -0
  2. package/dist/cli.js +1761 -0
  3. package/package.json +35 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1761 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import http from "http";
5
+ import { execSync } from "child_process";
6
+ import { createHash } from "crypto";
7
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "fs";
8
+ import { resolve, basename } from "path";
9
+ var cwd = process.cwd();
10
+ var projectName = basename(cwd);
11
+ function getBranchName() {
12
+ try {
13
+ return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim();
14
+ } catch {
15
+ return "unknown";
16
+ }
17
+ }
18
+ function getStateDir() {
19
+ const branch = getBranchName();
20
+ return resolve(cwd, ".diffback-local-diffs", branch);
21
+ }
22
+ function getStateFile() {
23
+ return resolve(getStateDir(), "state.json");
24
+ }
25
+ function isGitRepo() {
26
+ try {
27
+ execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function hasCommits() {
34
+ try {
35
+ execSync("git rev-parse HEAD", { cwd, stdio: "pipe" });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function getChangedFiles() {
42
+ const files = [];
43
+ if (hasCommits()) {
44
+ const diff = execSync("git diff --name-status HEAD", { cwd, encoding: "utf-8" }).trim();
45
+ if (diff) {
46
+ for (const line of diff.split("\n")) {
47
+ const parts = line.split(" ");
48
+ const code = parts[0];
49
+ if (code.startsWith("R")) {
50
+ files.push({ path: parts[2], status: "renamed", oldPath: parts[1] });
51
+ } else if (code === "M") {
52
+ files.push({ path: parts[1], status: "modified" });
53
+ } else if (code === "D") {
54
+ files.push({ path: parts[1], status: "deleted" });
55
+ } else if (code === "A") {
56
+ files.push({ path: parts[1], status: "added" });
57
+ }
58
+ }
59
+ }
60
+ }
61
+ const untracked = execSync("git ls-files --others --exclude-standard", {
62
+ cwd,
63
+ encoding: "utf-8"
64
+ }).trim();
65
+ if (untracked) {
66
+ for (const path of untracked.split("\n")) {
67
+ if (path.startsWith(".diffback-local-diffs/")) continue;
68
+ if (!files.some((f) => f.path === path)) {
69
+ files.push({ path, status: "added" });
70
+ }
71
+ }
72
+ }
73
+ if (hasCommits()) {
74
+ const staged = execSync("git diff --name-status --cached HEAD", { cwd, encoding: "utf-8" }).trim();
75
+ if (staged) {
76
+ for (const line of staged.split("\n")) {
77
+ const parts = line.split(" ");
78
+ const code = parts[0];
79
+ const path = code.startsWith("R") ? parts[2] : parts[1];
80
+ if (!files.some((f) => f.path === path)) {
81
+ if (code.startsWith("R")) {
82
+ files.push({ path, status: "renamed", oldPath: parts[1] });
83
+ } else if (code === "M") {
84
+ files.push({ path, status: "modified" });
85
+ } else if (code === "D") {
86
+ files.push({ path, status: "deleted" });
87
+ } else if (code === "A") {
88
+ files.push({ path, status: "added" });
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return files.sort((a, b) => a.path.localeCompare(b.path));
95
+ }
96
+ function getFileDiff(filePath) {
97
+ const absPath = resolve(cwd, filePath);
98
+ try {
99
+ const numstat = execSync(`git diff --numstat HEAD -- "${filePath}"`, {
100
+ cwd,
101
+ encoding: "utf-8"
102
+ }).trim();
103
+ if (numstat && numstat.startsWith("- - ")) {
104
+ return `Binary file ${filePath} has changed`;
105
+ }
106
+ } catch {
107
+ }
108
+ if (hasCommits()) {
109
+ const diff = execSync(`git diff HEAD -- "${filePath}"`, { cwd, encoding: "utf-8" });
110
+ if (diff.trim()) return diff;
111
+ const stagedDiff = execSync(`git diff --cached HEAD -- "${filePath}"`, { cwd, encoding: "utf-8" });
112
+ if (stagedDiff.trim()) return stagedDiff;
113
+ }
114
+ if (existsSync(absPath)) {
115
+ const content = readFileSync(absPath, "utf-8");
116
+ const lines = content.split("\n");
117
+ const diffLines = [
118
+ `--- /dev/null`,
119
+ `+++ b/${filePath}`,
120
+ `@@ -0,0 +1,${lines.length} @@`,
121
+ ...lines.map((l) => `+${l}`)
122
+ ];
123
+ return diffLines.join("\n");
124
+ }
125
+ if (hasCommits()) {
126
+ try {
127
+ const content = execSync(`git show HEAD:"${filePath}"`, { cwd, encoding: "utf-8" });
128
+ const lines = content.split("\n");
129
+ const diffLines = [
130
+ `--- a/${filePath}`,
131
+ `+++ /dev/null`,
132
+ `@@ -1,${lines.length} +0,0 @@`,
133
+ ...lines.map((l) => `-${l}`)
134
+ ];
135
+ return diffLines.join("\n");
136
+ } catch {
137
+ return `File ${filePath} was deleted`;
138
+ }
139
+ }
140
+ return "";
141
+ }
142
+ function hashFile(filePath) {
143
+ const absPath = resolve(cwd, filePath);
144
+ try {
145
+ const content = readFileSync(absPath);
146
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
147
+ } catch {
148
+ return "deleted";
149
+ }
150
+ }
151
+ function loadState() {
152
+ try {
153
+ const data = readFileSync(getStateFile(), "utf-8");
154
+ return JSON.parse(data);
155
+ } catch {
156
+ return { files: {}, generalComments: [] };
157
+ }
158
+ }
159
+ function saveState(state) {
160
+ mkdirSync(getStateDir(), { recursive: true });
161
+ writeFileSync(getStateFile(), JSON.stringify(state, null, 2));
162
+ }
163
+ function reconcileState(state, changedFiles) {
164
+ const currentPaths = new Set(changedFiles.map((f) => f.path));
165
+ for (const path of Object.keys(state.files)) {
166
+ if (!currentPaths.has(path)) {
167
+ delete state.files[path];
168
+ }
169
+ }
170
+ for (const path of currentPaths) {
171
+ const currentHash = hashFile(path);
172
+ const existing = state.files[path];
173
+ if (existing) {
174
+ if (existing.hash !== currentHash) {
175
+ existing.status = "pending";
176
+ existing.hash = currentHash;
177
+ existing.changedSinceReview = true;
178
+ }
179
+ }
180
+ }
181
+ return state;
182
+ }
183
+ function generateFeedback(state) {
184
+ const lines = [];
185
+ const filesWithFeedback = [];
186
+ for (const [path, review] of Object.entries(state.files)) {
187
+ if (review.comments.length > 0) {
188
+ filesWithFeedback.push(path);
189
+ }
190
+ }
191
+ const totalComments = filesWithFeedback.reduce((sum, p) => sum + state.files[p].comments.length, 0) + state.generalComments.length;
192
+ lines.push("# Code Review Feedback");
193
+ lines.push("");
194
+ lines.push(
195
+ `${filesWithFeedback.length} files need changes. ${totalComments} comments total.`
196
+ );
197
+ for (const path of filesWithFeedback) {
198
+ const review = state.files[path];
199
+ lines.push("");
200
+ lines.push(`## ${path}`);
201
+ for (const comment of review.comments) {
202
+ const lineRef = comment.line ? `L${comment.line}` : "General";
203
+ if (comment.suggestion) {
204
+ lines.push(`- ${lineRef}: ${comment.text}`);
205
+ lines.push(" ```");
206
+ lines.push(` ${comment.suggestion}`);
207
+ lines.push(" ```");
208
+ } else {
209
+ lines.push(`- ${lineRef}: ${comment.text}`);
210
+ }
211
+ }
212
+ }
213
+ if (state.generalComments.length > 0) {
214
+ lines.push("");
215
+ lines.push("## General");
216
+ for (const comment of state.generalComments) {
217
+ lines.push(`- ${comment.text}`);
218
+ }
219
+ }
220
+ return lines.join("\n");
221
+ }
222
+ function copyToClipboard(text) {
223
+ try {
224
+ const platform = process.platform;
225
+ if (platform === "darwin") {
226
+ execSync("pbcopy", { input: text });
227
+ } else if (platform === "linux") {
228
+ execSync("xclip -selection clipboard", { input: text });
229
+ } else if (platform === "win32") {
230
+ execSync("clip", { input: text });
231
+ }
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+ function parseBody(req) {
238
+ return new Promise((resolve2) => {
239
+ let body = "";
240
+ req.on("data", (chunk) => body += chunk.toString());
241
+ req.on("end", () => resolve2(body));
242
+ });
243
+ }
244
+ function json(res, data, status = 200) {
245
+ res.writeHead(status, {
246
+ "Content-Type": "application/json",
247
+ "Access-Control-Allow-Origin": "*"
248
+ });
249
+ res.end(JSON.stringify(data));
250
+ }
251
+ function startServer(port) {
252
+ const changedFiles = getChangedFiles();
253
+ if (changedFiles.length === 0) {
254
+ console.log("No uncommitted changes found. Nothing to review.");
255
+ process.exit(0);
256
+ }
257
+ let state = loadState();
258
+ state = reconcileState(state, changedFiles);
259
+ saveState(state);
260
+ const server = http.createServer(async (req, res) => {
261
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
262
+ const path = url.pathname;
263
+ if (req.method === "OPTIONS") {
264
+ res.writeHead(204, {
265
+ "Access-Control-Allow-Origin": "*",
266
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
267
+ "Access-Control-Allow-Headers": "Content-Type"
268
+ });
269
+ res.end();
270
+ return;
271
+ }
272
+ try {
273
+ if (path === "/" && req.method === "GET") {
274
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
275
+ res.end(`<!DOCTYPE html>
276
+ <html lang="en">
277
+ <head>
278
+ <meta charset="UTF-8">
279
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
280
+ <title>Code Review</title>
281
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>\u{1F50D}</text></svg>">
282
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">
283
+ <script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
284
+ <style>
285
+ /* Solarized Dark */
286
+ :root {
287
+ --bg: #002b36;
288
+ --bg-secondary: #073642;
289
+ --bg-tertiary: #0a4050;
290
+ --border: #586e75;
291
+ --text: #93a1a1;
292
+ --text-bright: #eee8d5;
293
+ --text-muted: #657b83;
294
+ --accent: #268bd2;
295
+ --cyan: #2aa198;
296
+ --green: #859900;
297
+ --yellow: #b58900;
298
+ --orange: #cb4b16;
299
+ --red: #dc322f;
300
+ --magenta: #d33682;
301
+ --violet: #6c71c4;
302
+ --sidebar-width: 280px;
303
+ }
304
+
305
+ * { margin: 0; padding: 0; box-sizing: border-box; }
306
+
307
+ body {
308
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
309
+ background: var(--bg);
310
+ color: var(--text);
311
+ height: 100vh;
312
+ display: flex;
313
+ flex-direction: column;
314
+ overflow: hidden;
315
+ }
316
+
317
+ /* Toolbar */
318
+ .toolbar {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 12px;
322
+ padding: 8px 16px;
323
+ background: var(--bg-secondary);
324
+ border-bottom: 1px solid var(--border);
325
+ flex-shrink: 0;
326
+ }
327
+ .toolbar-title {
328
+ font-weight: 600;
329
+ font-size: 14px;
330
+ color: var(--text-bright);
331
+ flex: 1;
332
+ }
333
+ .toolbar-stats {
334
+ font-size: 13px;
335
+ color: var(--text-muted);
336
+ }
337
+ .toolbar-stats .count { color: var(--cyan); font-weight: 600; }
338
+ .btn {
339
+ padding: 5px 12px;
340
+ border-radius: 6px;
341
+ border: 1px solid var(--border);
342
+ background: var(--bg-tertiary);
343
+ color: var(--text);
344
+ font-size: 12px;
345
+ cursor: pointer;
346
+ font-weight: 500;
347
+ }
348
+ .btn:hover { border-color: var(--accent); }
349
+ .btn-primary {
350
+ background: var(--accent);
351
+ color: var(--bg);
352
+ border-color: var(--accent);
353
+ font-weight: 600;
354
+ }
355
+ .btn-primary:hover { opacity: 0.9; }
356
+ .btn-danger {
357
+ border-color: var(--orange);
358
+ color: var(--orange);
359
+ }
360
+ .btn-danger:hover { background: var(--orange); color: var(--bg); }
361
+
362
+ /* Main layout */
363
+ .main {
364
+ display: flex;
365
+ flex: 1;
366
+ overflow: hidden;
367
+ }
368
+
369
+ /* Sidebar */
370
+ .sidebar {
371
+ width: var(--sidebar-width);
372
+ border-right: 1px solid var(--border);
373
+ display: flex;
374
+ flex-direction: column;
375
+ flex-shrink: 0;
376
+ overflow: hidden;
377
+ }
378
+ .sidebar-header {
379
+ padding: 8px 12px;
380
+ font-size: 12px;
381
+ font-weight: 600;
382
+ color: var(--text-muted);
383
+ text-transform: uppercase;
384
+ letter-spacing: 0.5px;
385
+ border-bottom: 1px solid var(--border);
386
+ background: var(--bg-secondary);
387
+ }
388
+ .file-list {
389
+ flex: 1;
390
+ overflow-y: auto;
391
+ }
392
+ .file-item {
393
+ display: flex;
394
+ align-items: center;
395
+ gap: 8px;
396
+ padding: 6px 12px;
397
+ cursor: pointer;
398
+ font-size: 13px;
399
+ border-bottom: 1px solid var(--border);
400
+ transition: background 0.1s;
401
+ }
402
+ .file-item:hover { background: var(--bg-tertiary); }
403
+ .file-item.active { background: var(--bg-tertiary); border-left: 2px solid var(--cyan); }
404
+ .file-status-icon {
405
+ width: 18px;
406
+ text-align: center;
407
+ flex-shrink: 0;
408
+ font-size: 12px;
409
+ font-weight: 700;
410
+ }
411
+ .file-review-icon {
412
+ width: 18px;
413
+ text-align: center;
414
+ flex-shrink: 0;
415
+ font-size: 11px;
416
+ }
417
+ .file-path {
418
+ flex: 1;
419
+ overflow: hidden;
420
+ text-overflow: ellipsis;
421
+ white-space: nowrap;
422
+ font-family: "SF Mono", "Fira Code", monospace;
423
+ font-size: 12px;
424
+ }
425
+ .file-dir { color: var(--text-muted); }
426
+
427
+ /* Shortcuts legend in sidebar */
428
+ .shortcuts-legend {
429
+ padding: 8px 12px;
430
+ border-top: 1px solid var(--border);
431
+ background: var(--bg-secondary);
432
+ font-size: 11px;
433
+ color: var(--text-muted);
434
+ flex-shrink: 0;
435
+ }
436
+ .shortcuts-legend div {
437
+ display: flex;
438
+ justify-content: space-between;
439
+ padding: 2px 0;
440
+ }
441
+ .shortcuts-legend kbd {
442
+ background: var(--bg-tertiary);
443
+ border: 1px solid var(--border);
444
+ border-radius: 3px;
445
+ padding: 0 4px;
446
+ font-family: "SF Mono", "Fira Code", monospace;
447
+ font-size: 10px;
448
+ color: var(--text-bright);
449
+ }
450
+
451
+ /* Content area */
452
+ .content {
453
+ flex: 1;
454
+ display: flex;
455
+ flex-direction: column;
456
+ overflow: hidden;
457
+ position: relative;
458
+ }
459
+
460
+ /* File header */
461
+ .file-header {
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 8px;
465
+ padding: 8px 16px;
466
+ background: var(--bg-secondary);
467
+ border-bottom: 1px solid var(--border);
468
+ flex-shrink: 0;
469
+ z-index: 10;
470
+ }
471
+ .file-nav-btn {
472
+ background: none;
473
+ border: 1px solid var(--border);
474
+ color: var(--text);
475
+ cursor: pointer;
476
+ padding: 2px 8px;
477
+ border-radius: 4px;
478
+ font-size: 14px;
479
+ line-height: 1;
480
+ }
481
+ .file-nav-btn:hover { border-color: var(--cyan); color: var(--cyan); }
482
+ .file-header-path {
483
+ font-family: "SF Mono", "Fira Code", monospace;
484
+ font-size: 13px;
485
+ flex: 1;
486
+ color: var(--text-bright);
487
+ }
488
+ .file-header .badge {
489
+ font-size: 11px;
490
+ padding: 2px 8px;
491
+ border-radius: 10px;
492
+ font-weight: 500;
493
+ }
494
+ .badge-added { background: rgba(133, 153, 0, 0.2); color: var(--green); }
495
+ .badge-modified { background: rgba(181, 137, 0, 0.2); color: var(--yellow); }
496
+ .badge-deleted { background: rgba(203, 75, 22, 0.2); color: var(--orange); }
497
+ .badge-renamed { background: rgba(108, 113, 196, 0.2); color: var(--violet); }
498
+ .badge-changed { background: rgba(203, 75, 22, 0.15); color: var(--orange); font-size: 10px; }
499
+
500
+ /* Diff viewer */
501
+ .diff-container {
502
+ flex: 1;
503
+ overflow-y: auto;
504
+ overflow-x: auto;
505
+ position: relative;
506
+ z-index: 1;
507
+ }
508
+ .diff-container .d2h-wrapper { background: var(--bg); }
509
+ .empty-state {
510
+ display: flex;
511
+ align-items: center;
512
+ justify-content: center;
513
+ flex: 1;
514
+ color: var(--text-muted);
515
+ font-size: 14px;
516
+ }
517
+
518
+ /* Review section */
519
+ .review-section {
520
+ border-top: 1px solid var(--border);
521
+ background: var(--bg-secondary);
522
+ flex-shrink: 0;
523
+ max-height: 40%;
524
+ overflow-y: auto;
525
+ position: relative;
526
+ z-index: 10;
527
+ }
528
+ .review-header {
529
+ display: flex;
530
+ align-items: center;
531
+ gap: 8px;
532
+ padding: 8px 16px;
533
+ font-size: 12px;
534
+ font-weight: 600;
535
+ color: var(--text-muted);
536
+ text-transform: uppercase;
537
+ border-bottom: 1px solid var(--border);
538
+ }
539
+ .comments-list {
540
+ padding: 8px 16px;
541
+ }
542
+ .comment-item {
543
+ display: flex;
544
+ align-items: flex-start;
545
+ gap: 8px;
546
+ padding: 8px 0;
547
+ border-bottom: 1px solid var(--border);
548
+ font-size: 13px;
549
+ }
550
+ .comment-item:last-child { border-bottom: none; }
551
+ .comment-line-ref {
552
+ font-family: "SF Mono", "Fira Code", monospace;
553
+ font-size: 11px;
554
+ color: var(--cyan);
555
+ background: rgba(42, 161, 152, 0.15);
556
+ padding: 2px 6px;
557
+ border-radius: 4px;
558
+ white-space: nowrap;
559
+ flex-shrink: 0;
560
+ }
561
+ .comment-text { flex: 1; line-height: 1.4; }
562
+ .comment-suggestion {
563
+ margin-top: 4px;
564
+ background: var(--bg);
565
+ border: 1px solid var(--border);
566
+ border-radius: 4px;
567
+ padding: 6px 8px;
568
+ font-family: "SF Mono", "Fira Code", monospace;
569
+ font-size: 12px;
570
+ white-space: pre-wrap;
571
+ }
572
+ .comment-delete {
573
+ color: var(--text-muted);
574
+ cursor: pointer;
575
+ font-size: 14px;
576
+ padding: 0 4px;
577
+ flex-shrink: 0;
578
+ }
579
+ .comment-delete:hover { color: var(--orange); }
580
+
581
+ /* Add comment form */
582
+ .add-comment {
583
+ padding: 8px 16px;
584
+ border-top: 1px solid var(--border);
585
+ }
586
+ .add-comment-row {
587
+ display: flex;
588
+ gap: 8px;
589
+ align-items: flex-start;
590
+ }
591
+ .add-comment input[type="number"] {
592
+ width: 60px;
593
+ padding: 6px 8px;
594
+ background: var(--bg);
595
+ border: 1px solid var(--border);
596
+ border-radius: 4px;
597
+ color: var(--text);
598
+ font-size: 12px;
599
+ font-family: "SF Mono", "Fira Code", monospace;
600
+ }
601
+ .add-comment textarea {
602
+ flex: 1;
603
+ padding: 6px 8px;
604
+ background: var(--bg);
605
+ border: 1px solid var(--border);
606
+ border-radius: 4px;
607
+ color: var(--text);
608
+ font-size: 13px;
609
+ font-family: inherit;
610
+ resize: vertical;
611
+ min-height: 32px;
612
+ max-height: 120px;
613
+ }
614
+ .add-comment input:focus,
615
+ .add-comment textarea:focus {
616
+ outline: none;
617
+ border-color: var(--accent);
618
+ }
619
+ .suggestion-toggle {
620
+ font-size: 11px;
621
+ color: var(--accent);
622
+ cursor: pointer;
623
+ margin-top: 4px;
624
+ display: inline-block;
625
+ }
626
+ .suggestion-input {
627
+ margin-top: 4px;
628
+ }
629
+ .suggestion-input textarea {
630
+ width: 100%;
631
+ font-family: "SF Mono", "Fira Code", monospace;
632
+ font-size: 12px;
633
+ }
634
+
635
+ /* General comments section */
636
+ .general-section {
637
+ border-top: 2px solid var(--border);
638
+ background: var(--bg-secondary);
639
+ flex-shrink: 0;
640
+ position: relative;
641
+ z-index: 10;
642
+ }
643
+ .general-header {
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 8px;
647
+ padding: 8px 16px;
648
+ font-size: 12px;
649
+ font-weight: 600;
650
+ color: var(--text-muted);
651
+ text-transform: uppercase;
652
+ border-bottom: 1px solid var(--border);
653
+ cursor: pointer;
654
+ }
655
+ .general-header:hover { color: var(--text); }
656
+ .general-body {
657
+ max-height: 200px;
658
+ overflow-y: auto;
659
+ }
660
+
661
+ /* Modal */
662
+ .modal-overlay {
663
+ position: fixed;
664
+ inset: 0;
665
+ background: rgba(0, 0, 0, 0.6);
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ z-index: 100;
670
+ }
671
+ .modal {
672
+ background: var(--bg-secondary);
673
+ border: 1px solid var(--border);
674
+ border-radius: 8px;
675
+ width: 80%;
676
+ max-width: 800px;
677
+ max-height: 80vh;
678
+ display: flex;
679
+ flex-direction: column;
680
+ }
681
+ .modal-header {
682
+ display: flex;
683
+ align-items: center;
684
+ gap: 12px;
685
+ padding: 12px 16px;
686
+ border-bottom: 1px solid var(--border);
687
+ font-weight: 600;
688
+ }
689
+ .modal-header .modal-title { flex: 1; }
690
+ .modal-body {
691
+ flex: 1;
692
+ overflow-y: auto;
693
+ padding: 16px;
694
+ }
695
+ .modal-body pre {
696
+ font-family: "SF Mono", "Fira Code", monospace;
697
+ font-size: 13px;
698
+ line-height: 1.5;
699
+ white-space: pre-wrap;
700
+ word-break: break-word;
701
+ }
702
+ .modal-footer {
703
+ display: flex;
704
+ align-items: center;
705
+ gap: 8px;
706
+ padding: 12px 16px;
707
+ border-top: 1px solid var(--border);
708
+ justify-content: flex-end;
709
+ }
710
+ .copied-msg {
711
+ color: var(--cyan);
712
+ font-size: 13px;
713
+ font-weight: 500;
714
+ }
715
+
716
+ /* Scrollbar */
717
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
718
+ ::-webkit-scrollbar-track { background: transparent; }
719
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
720
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
721
+
722
+ /* Line click hint */
723
+ .d2h-code-linenumber { cursor: pointer; }
724
+ .d2h-code-linenumber:hover {
725
+ background: rgba(38, 139, 210, 0.25) !important;
726
+ }
727
+
728
+ /* ---- diff2html Solarized Dark overrides ---- */
729
+ .d2h-file-header, .d2h-file-wrapper {
730
+ border-color: var(--border) !important;
731
+ }
732
+ .d2h-wrapper, .d2h-file-wrapper {
733
+ background: var(--bg) !important;
734
+ }
735
+ .d2h-file-diff .d2h-code-pane {
736
+ background: var(--bg) !important;
737
+ }
738
+ /* Force table to fill width */
739
+ .d2h-diff-table {
740
+ width: 100% !important;
741
+ }
742
+ .d2h-code-line, .d2h-code-side-line {
743
+ background: var(--bg) !important;
744
+ color: var(--text) !important;
745
+ width: 100% !important;
746
+ }
747
+ .d2h-code-line-ctn {
748
+ color: var(--text) !important;
749
+ width: 100% !important;
750
+ }
751
+ /* Added lines */
752
+ .d2h-ins, .d2h-ins .d2h-code-line-ctn {
753
+ background: #0a3d0a !important;
754
+ color: #eee8d5 !important;
755
+ }
756
+ .d2h-ins .d2h-code-line-ctn ins,
757
+ .d2h-ins.d2h-change .d2h-code-line-ctn ins {
758
+ background: #1a5c1a !important;
759
+ color: #eee8d5 !important;
760
+ text-decoration: none !important;
761
+ }
762
+ /* Deleted lines */
763
+ .d2h-del, .d2h-del .d2h-code-line-ctn {
764
+ background: #3d0a0a !important;
765
+ color: #eee8d5 !important;
766
+ }
767
+ .d2h-del .d2h-code-line-ctn del,
768
+ .d2h-del.d2h-change .d2h-code-line-ctn del {
769
+ background: #5c1a1a !important;
770
+ color: #eee8d5 !important;
771
+ text-decoration: none !important;
772
+ }
773
+ /* Info/hunk headers - visually hidden but in DOM for fold indicator positioning */
774
+ .d2h-info {
775
+ height: 0 !important;
776
+ padding: 0 !important;
777
+ overflow: hidden !important;
778
+ border: none !important;
779
+ line-height: 0 !important;
780
+ font-size: 0 !important;
781
+ }
782
+ /* Line numbers */
783
+ .d2h-code-linenumber {
784
+ background: #073642 !important;
785
+ color: #657b83 !important;
786
+ border-color: #586e75 !important;
787
+ }
788
+ .d2h-ins .d2h-code-linenumber {
789
+ background: #082e08 !important;
790
+ color: #839496 !important;
791
+ }
792
+ .d2h-del .d2h-code-linenumber {
793
+ background: #2e0808 !important;
794
+ color: #839496 !important;
795
+ }
796
+ /* Empty placeholder cells */
797
+ .d2h-code-side-emptyplaceholder, .d2h-emptyplaceholder {
798
+ background: var(--bg-tertiary) !important;
799
+ border-color: var(--border) !important;
800
+ }
801
+
802
+ /* ---- Code fold/expand indicator ---- */
803
+ .fold-indicator {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 8px;
807
+ padding: 4px 16px 4px 60px;
808
+ background: var(--bg-secondary);
809
+ border-top: 1px solid var(--border);
810
+ border-bottom: 1px solid var(--border);
811
+ color: var(--accent);
812
+ font-size: 12px;
813
+ cursor: pointer;
814
+ user-select: none;
815
+ font-family: "SF Mono", "Fira Code", monospace;
816
+ }
817
+ .fold-indicator:hover {
818
+ background: var(--bg-tertiary);
819
+ color: var(--cyan);
820
+ }
821
+ .fold-indicator .fold-icon {
822
+ transition: transform 0.15s;
823
+ }
824
+ .fold-indicator.expanded .fold-icon {
825
+ transform: rotate(90deg);
826
+ }
827
+ .fold-lines {
828
+ background: var(--bg);
829
+ font-family: "SF Mono", "Fira Code", monospace;
830
+ font-size: 13px;
831
+ color: var(--text-muted);
832
+ overflow: hidden;
833
+ }
834
+ .fold-lines.collapsed {
835
+ display: none;
836
+ }
837
+ .fold-line {
838
+ display: flex;
839
+ padding: 0 8px;
840
+ line-height: 20px;
841
+ }
842
+ .fold-line-num {
843
+ width: 50px;
844
+ text-align: right;
845
+ padding-right: 12px;
846
+ color: #586e75;
847
+ flex-shrink: 0;
848
+ user-select: none;
849
+ }
850
+ .fold-line-content {
851
+ flex: 1;
852
+ white-space: pre;
853
+ }
854
+
855
+ /* ---- Inline comment markers & bubbles ---- */
856
+ .has-comment .d2h-code-linenumber {
857
+ position: relative;
858
+ }
859
+ .comment-marker {
860
+ position: absolute;
861
+ left: 2px;
862
+ top: 50%;
863
+ transform: translateY(-50%);
864
+ width: 8px;
865
+ height: 8px;
866
+ border-radius: 50%;
867
+ background: var(--yellow);
868
+ }
869
+ .inline-comment-row td {
870
+ padding: 0 !important;
871
+ border: none !important;
872
+ background: var(--bg-secondary) !important;
873
+ }
874
+ .inline-comment-bubble {
875
+ margin: 4px 8px 4px 60px;
876
+ padding: 6px 10px;
877
+ background: var(--bg-tertiary);
878
+ border: 1px solid var(--border);
879
+ border-left: 3px solid var(--yellow);
880
+ border-radius: 4px;
881
+ font-size: 12px;
882
+ color: var(--text-bright);
883
+ line-height: 1.4;
884
+ }
885
+ .inline-comment-bubble .inline-line-ref {
886
+ color: var(--cyan);
887
+ font-family: "SF Mono", "Fira Code", monospace;
888
+ font-size: 11px;
889
+ margin-right: 6px;
890
+ }
891
+ .inline-comment-bubble .inline-suggestion {
892
+ margin-top: 4px;
893
+ padding: 4px 6px;
894
+ background: var(--bg);
895
+ border: 1px solid var(--border);
896
+ border-radius: 3px;
897
+ font-family: "SF Mono", "Fira Code", monospace;
898
+ font-size: 11px;
899
+ white-space: pre-wrap;
900
+ color: var(--text);
901
+ }
902
+ </style>
903
+ </head>
904
+ <body>
905
+ <div class="toolbar">
906
+ <div class="toolbar-title" id="toolbar-title">diffback</div>
907
+ <div class="toolbar-stats" id="toolbar-stats"></div>
908
+ <button class="btn btn-primary" id="btn-generate" title="Generate feedback prompt">Generate Feedback</button>
909
+ <button class="btn btn-danger" id="btn-finish" title="Finish review and clean up state">Finish Review</button>
910
+ </div>
911
+
912
+ <div class="main">
913
+ <div class="sidebar">
914
+ <div class="sidebar-header">Files</div>
915
+ <div class="file-list" id="file-list"></div>
916
+ <div class="shortcuts-legend">
917
+ <div><span>Prev/Next file</span> <span><kbd>k</kbd> <kbd>j</kbd></span></div>
918
+ <div><span>Viewed file</span> <kbd>a</kbd></div>
919
+ <div><span>Add comment</span> <kbd>c</kbd></div>
920
+ <div><span>Generate feedback</span> <kbd>g</kbd></div>
921
+ <div><span>Send comment</span> <kbd>Cmd+Enter</kbd></div>
922
+ </div>
923
+ </div>
924
+
925
+ <div class="content" id="content">
926
+ <div class="empty-state" id="empty-state">Select a file to review</div>
927
+ </div>
928
+ </div>
929
+
930
+ <script>
931
+ // --- State ---
932
+ let appState = {
933
+ files: [],
934
+ generalComments: [],
935
+ summary: null,
936
+ projectName: '',
937
+ selectedFile: null,
938
+ currentDiff: null,
939
+ fileContents: {}, // cache for expanded fold content
940
+ };
941
+
942
+ // --- API ---
943
+ async function api(path, opts = {}) {
944
+ const res = await fetch(path, {
945
+ headers: { 'Content-Type': 'application/json' },
946
+ ...opts,
947
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
948
+ });
949
+ return res.json();
950
+ }
951
+
952
+ async function loadFiles() {
953
+ const data = await api('/api/files');
954
+ appState.files = data.files;
955
+ appState.generalComments = data.generalComments || [];
956
+ appState.summary = data.summary;
957
+ appState.projectName = data.projectName;
958
+ renderToolbar();
959
+ renderFileList();
960
+ }
961
+
962
+ async function loadDiff(filePath) {
963
+ const data = await api(\`/api/diff?path=\${encodeURIComponent(filePath)}\`);
964
+ appState.currentDiff = data;
965
+ appState.selectedFile = filePath;
966
+ renderContent();
967
+ document.querySelectorAll('.file-item').forEach(el => {
968
+ el.classList.toggle('active', el.dataset.path === filePath);
969
+ });
970
+ }
971
+
972
+ async function saveReview(filePath, status, comments) {
973
+ await api('/api/review', {
974
+ method: 'POST',
975
+ body: { path: filePath, status, comments },
976
+ });
977
+ await loadFiles();
978
+ }
979
+
980
+ async function saveGeneralComments() {
981
+ await api('/api/general-comments', {
982
+ method: 'POST',
983
+ body: { comments: appState.generalComments },
984
+ });
985
+ }
986
+
987
+ async function getFileContent(filePath) {
988
+ if (appState.fileContents[filePath]) return appState.fileContents[filePath];
989
+ const data = await api(\`/api/file-content?path=\${encodeURIComponent(filePath)}\`);
990
+ if (data.content) {
991
+ appState.fileContents[filePath] = data.content;
992
+ }
993
+ return data.content || '';
994
+ }
995
+
996
+ // --- Navigation helpers ---
997
+ function navigateFile(direction) {
998
+ const files = appState.files;
999
+ if (files.length === 0) return;
1000
+ const currentIdx = files.findIndex(f => f.path === appState.selectedFile);
1001
+ let next;
1002
+ if (direction === 'next') {
1003
+ next = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
1004
+ } else {
1005
+ next = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
1006
+ }
1007
+ loadDiff(files[next].path);
1008
+ }
1009
+
1010
+ // --- Render: Toolbar ---
1011
+ function renderToolbar() {
1012
+ document.getElementById('toolbar-title').textContent = \`diffback: \${appState.projectName}\`;
1013
+ document.title = \`Review: \${appState.projectName}\`;
1014
+ const s = appState.summary;
1015
+ if (s) {
1016
+ document.getElementById('toolbar-stats').innerHTML =
1017
+ \`<span class="count">\${s.reviewed + s.hasFeedback}</span>/\${s.total} reviewed\`;
1018
+ }
1019
+ }
1020
+
1021
+ // --- Render: File List ---
1022
+ function renderFileList() {
1023
+ const container = document.getElementById('file-list');
1024
+ container.innerHTML = '';
1025
+
1026
+ for (const file of appState.files) {
1027
+ const el = document.createElement('div');
1028
+ el.className = 'file-item' + (appState.selectedFile === file.path ? ' active' : '');
1029
+ el.dataset.path = file.path;
1030
+
1031
+ const statusIcon = document.createElement('span');
1032
+ statusIcon.className = 'file-status-icon';
1033
+ if (file.status === 'added') { statusIcon.textContent = 'A'; statusIcon.style.color = 'var(--accent)'; }
1034
+ else if (file.status === 'modified') { statusIcon.textContent = 'M'; statusIcon.style.color = 'var(--yellow)'; }
1035
+ else if (file.status === 'deleted') { statusIcon.textContent = 'D'; statusIcon.style.color = 'var(--orange)'; }
1036
+ else if (file.status === 'renamed') { statusIcon.textContent = 'R'; statusIcon.style.color = 'var(--violet)'; }
1037
+
1038
+ const reviewIcon = document.createElement('span');
1039
+ reviewIcon.className = 'file-review-icon';
1040
+ if (file.review?.status === 'reviewed') {
1041
+ reviewIcon.textContent = '\\u2713';
1042
+ reviewIcon.style.color = 'var(--cyan)';
1043
+ } else if (file.review?.status === 'has-feedback') {
1044
+ reviewIcon.textContent = '\\u25CF';
1045
+ reviewIcon.style.color = 'var(--yellow)';
1046
+ } else {
1047
+ reviewIcon.textContent = '\\u25CB';
1048
+ reviewIcon.style.color = 'var(--text-muted)';
1049
+ }
1050
+
1051
+ const pathEl = document.createElement('span');
1052
+ pathEl.className = 'file-path';
1053
+ const parts = file.path.split('/');
1054
+ if (parts.length > 1) {
1055
+ const dir = document.createElement('span');
1056
+ dir.className = 'file-dir';
1057
+ dir.textContent = parts.slice(0, -1).join('/') + '/';
1058
+ pathEl.appendChild(dir);
1059
+ pathEl.appendChild(document.createTextNode(parts[parts.length - 1]));
1060
+ } else {
1061
+ pathEl.textContent = file.path;
1062
+ }
1063
+
1064
+ el.appendChild(statusIcon);
1065
+ el.appendChild(reviewIcon);
1066
+ el.appendChild(pathEl);
1067
+ el.addEventListener('click', () => loadDiff(file.path));
1068
+ container.appendChild(el);
1069
+ }
1070
+ }
1071
+
1072
+ // --- Parse hunk ranges from @@ header ---
1073
+ function parseHunkHeader(headerText) {
1074
+ // @@ -oldStart,oldCount +newStart,newCount @@
1075
+ const match = headerText.match(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/);
1076
+ if (!match) return null;
1077
+ return {
1078
+ oldStart: parseInt(match[1]),
1079
+ oldCount: match[2] !== undefined ? parseInt(match[2]) : 1,
1080
+ newStart: parseInt(match[3]),
1081
+ newCount: match[4] !== undefined ? parseInt(match[4]) : 1,
1082
+ };
1083
+ }
1084
+
1085
+ // --- Insert fold indicators between hunks ---
1086
+ function insertFoldIndicators(diffContainer, diffText, filePath) {
1087
+ const hunkHeaders = [];
1088
+ const lines = diffText.split('\\n');
1089
+ for (const line of lines) {
1090
+ const parsed = parseHunkHeader(line);
1091
+ if (parsed) hunkHeaders.push(parsed);
1092
+ }
1093
+
1094
+ if (hunkHeaders.length === 0) return;
1095
+
1096
+ const infoElements = diffContainer.querySelectorAll('.d2h-info');
1097
+
1098
+ // For each pair of consecutive hunks, add a fold between them
1099
+ for (let i = 1; i < hunkHeaders.length && i < infoElements.length; i++) {
1100
+ const prevHunk = hunkHeaders[i - 1];
1101
+ const currHunk = hunkHeaders[i];
1102
+ const prevEnd = prevHunk.newStart + prevHunk.newCount - 1;
1103
+ const currStart = currHunk.newStart;
1104
+ const hiddenLines = currStart - prevEnd - 1;
1105
+
1106
+ if (hiddenLines <= 0) continue;
1107
+
1108
+ insertFoldAtElement(infoElements[i], filePath, prevEnd + 1, currStart - 1, hiddenLines, \`fold-\${i}\`);
1109
+ }
1110
+ }
1111
+
1112
+ function insertFoldAtElement(infoEl, filePath, startLine, endLine, hiddenLines, foldId) {
1113
+ const parentRow = infoEl.closest('tr');
1114
+ if (!parentRow) return;
1115
+
1116
+ const fold = document.createElement('div');
1117
+ fold.className = 'fold-indicator';
1118
+ fold.innerHTML = \`<span class="fold-icon">\\u25B6</span> \${hiddenLines} lines hidden (\${startLine}\\u2013\${endLine})\`;
1119
+
1120
+ const foldContent = document.createElement('div');
1121
+ foldContent.className = 'fold-lines collapsed';
1122
+
1123
+ const tr = document.createElement('tr');
1124
+ const td = document.createElement('td');
1125
+ td.colSpan = 99;
1126
+ td.style.padding = '0';
1127
+ td.style.border = 'none';
1128
+ td.appendChild(fold);
1129
+ td.appendChild(foldContent);
1130
+ tr.appendChild(td);
1131
+
1132
+ parentRow.parentElement.insertBefore(tr, parentRow);
1133
+
1134
+ fold.addEventListener('click', async () => {
1135
+ const isExpanded = !foldContent.classList.contains('collapsed');
1136
+ if (isExpanded) {
1137
+ foldContent.classList.add('collapsed');
1138
+ fold.classList.remove('expanded');
1139
+ } else {
1140
+ if (!foldContent.dataset.loaded) {
1141
+ const fileContent = await getFileContent(filePath);
1142
+ const allLines = fileContent.split('\\n');
1143
+ const start = startLine - 1;
1144
+ const end = endLine;
1145
+ const slice = allLines.slice(start, end);
1146
+
1147
+ foldContent.innerHTML = slice.map((line, idx) => {
1148
+ const lineNum = start + idx + 1;
1149
+ return \`<div class="fold-line"><span class="fold-line-num">\${lineNum}</span><span class="fold-line-content">\${escapeHtml(line)}</span></div>\`;
1150
+ }).join('');
1151
+ foldContent.dataset.loaded = 'true';
1152
+ }
1153
+ foldContent.classList.remove('collapsed');
1154
+ fold.classList.add('expanded');
1155
+ }
1156
+ });
1157
+ }
1158
+
1159
+ // --- Insert inline comment markers & bubbles ---
1160
+ function insertInlineComments(diffContainer, comments) {
1161
+ const lineComments = comments.filter(c => c.line !== null);
1162
+ if (lineComments.length === 0) return;
1163
+
1164
+ // Group comments by line number
1165
+ const byLine = {};
1166
+ for (const c of lineComments) {
1167
+ if (!byLine[c.line]) byLine[c.line] = [];
1168
+ byLine[c.line].push(c);
1169
+ }
1170
+
1171
+ // Find all line number cells and match
1172
+ diffContainer.querySelectorAll('.d2h-code-linenumber').forEach(el => {
1173
+ const lineNum2 = el.querySelector('.line-num2')?.textContent?.trim();
1174
+ const num = parseInt(lineNum2);
1175
+ if (isNaN(num) || !byLine[num]) return;
1176
+
1177
+ // Add marker dot
1178
+ el.style.position = 'relative';
1179
+ const marker = document.createElement('span');
1180
+ marker.className = 'comment-marker';
1181
+ el.appendChild(marker);
1182
+
1183
+ // Insert comment bubble row after this line's <tr>
1184
+ const tr = el.closest('tr');
1185
+ if (!tr) return;
1186
+
1187
+ for (const comment of byLine[num]) {
1188
+ const commentTr = document.createElement('tr');
1189
+ commentTr.className = 'inline-comment-row';
1190
+ const td = document.createElement('td');
1191
+ td.colSpan = 99;
1192
+ td.innerHTML = \`
1193
+ <div class="inline-comment-bubble">
1194
+ <span class="inline-line-ref">L\${comment.line}</span>
1195
+ \${escapeHtml(comment.text)}
1196
+ \${comment.suggestion ? \`<div class="inline-suggestion">\${escapeHtml(comment.suggestion)}</div>\` : ''}
1197
+ </div>
1198
+ \`;
1199
+ commentTr.appendChild(td);
1200
+ tr.after(commentTr);
1201
+ }
1202
+
1203
+ // Remove from map so we don't double-insert
1204
+ delete byLine[num];
1205
+ });
1206
+ }
1207
+
1208
+ // --- Render: Content (diff + review) ---
1209
+ function renderContent() {
1210
+ const container = document.getElementById('content');
1211
+ const file = appState.files.find(f => f.path === appState.selectedFile);
1212
+ if (!file || !appState.currentDiff) {
1213
+ container.innerHTML = '<div class="empty-state">Select a file to review</div>';
1214
+ return;
1215
+ }
1216
+
1217
+ const review = file.review || { status: 'pending', comments: [], hash: '' };
1218
+ const comments = review.comments || [];
1219
+
1220
+ container.innerHTML = '';
1221
+
1222
+ // File header with navigation arrows
1223
+ const currentIdx = appState.files.findIndex(f => f.path === appState.selectedFile);
1224
+ const header = document.createElement('div');
1225
+ header.className = 'file-header';
1226
+ header.innerHTML = \`
1227
+ <button class="file-nav-btn" id="btn-prev" title="Previous file (k)">\\u25B2</button>
1228
+ <button class="file-nav-btn" id="btn-next" title="Next file (j)">\\u25BC</button>
1229
+ <span class="file-header-path">\${escapeHtml(file.path)}</span>
1230
+ <span style="color: var(--text-muted); font-size: 12px;">\${currentIdx + 1}/\${appState.files.length}</span>
1231
+ \${file.review?.changedSinceReview ? '<span class="badge badge-changed">Changed since review</span>' : ''}
1232
+ <span class="badge badge-\${file.status}">\${file.status}</span>
1233
+ <button class="btn \${review.status === 'reviewed' ? 'btn-primary' : ''}" id="btn-approve">
1234
+ \${review.status === 'reviewed' ? '\\u2713 Viewed' : 'Mark Viewed'}
1235
+ </button>
1236
+ \`;
1237
+ container.appendChild(header);
1238
+
1239
+ // Nav button handlers
1240
+ header.querySelector('#btn-prev').addEventListener('click', () => navigateFile('prev'));
1241
+ header.querySelector('#btn-next').addEventListener('click', () => navigateFile('next'));
1242
+
1243
+ // Diff viewer
1244
+ const diffContainer = document.createElement('div');
1245
+ diffContainer.className = 'diff-container';
1246
+ if (appState.currentDiff.diff) {
1247
+ try {
1248
+ const diff2htmlUi = new Diff2HtmlUI(diffContainer, appState.currentDiff.diff, {
1249
+ drawFileList: false,
1250
+ matching: 'lines',
1251
+ outputFormat: 'line-by-line',
1252
+ highlight: true,
1253
+ fileListToggle: false,
1254
+ fileListStartVisible: false,
1255
+ fileContentToggle: false,
1256
+ });
1257
+ diff2htmlUi.draw();
1258
+
1259
+ // Hide diff2html's own file header
1260
+ const d2hHeader = diffContainer.querySelector('.d2h-file-header');
1261
+ if (d2hHeader) d2hHeader.style.display = 'none';
1262
+
1263
+ // Post-render: folds, inline comments, line click handlers
1264
+ setTimeout(() => {
1265
+ insertFoldIndicators(diffContainer, appState.currentDiff.diff, file.path);
1266
+ insertInlineComments(diffContainer, comments);
1267
+
1268
+ diffContainer.querySelectorAll('.d2h-code-linenumber').forEach(el => {
1269
+ el.addEventListener('click', () => {
1270
+ const lineNum = el.querySelector('.line-num2')?.textContent?.trim()
1271
+ || el.querySelector('.line-num1')?.textContent?.trim()
1272
+ || el.textContent?.trim();
1273
+ const num = parseInt(lineNum);
1274
+ if (!isNaN(num)) {
1275
+ const lineInput = document.getElementById('comment-line');
1276
+ if (lineInput) {
1277
+ lineInput.value = num;
1278
+ document.getElementById('comment-text')?.focus();
1279
+ }
1280
+ }
1281
+ });
1282
+ });
1283
+ }, 80);
1284
+ } catch (err) {
1285
+ diffContainer.innerHTML = \`<pre style="padding: 16px; font-size: 13px; white-space: pre-wrap;">\${escapeHtml(appState.currentDiff.diff)}</pre>\`;
1286
+ }
1287
+ } else {
1288
+ diffContainer.innerHTML = '<div class="empty-state">No diff available</div>';
1289
+ }
1290
+ container.appendChild(diffContainer);
1291
+
1292
+ // Review section
1293
+ const reviewSection = document.createElement('div');
1294
+ reviewSection.className = 'review-section';
1295
+
1296
+ const reviewHeader = document.createElement('div');
1297
+ reviewHeader.className = 'review-header';
1298
+ reviewHeader.textContent = \`File Comments (\${comments.length})\`;
1299
+ reviewSection.appendChild(reviewHeader);
1300
+
1301
+ if (comments.length > 0) {
1302
+ const commentsList = document.createElement('div');
1303
+ commentsList.className = 'comments-list';
1304
+ for (const comment of comments) {
1305
+ const item = document.createElement('div');
1306
+ item.className = 'comment-item';
1307
+ item.innerHTML = \`
1308
+ <span class="comment-line-ref">\${comment.line ? 'L' + comment.line : 'General'}</span>
1309
+ <div class="comment-text">
1310
+ \${escapeHtml(comment.text)}
1311
+ \${comment.suggestion ? \`<div class="comment-suggestion">\${escapeHtml(comment.suggestion)}</div>\` : ''}
1312
+ </div>
1313
+ <span class="comment-delete" data-id="\${comment.id}" title="Delete comment">&times;</span>
1314
+ \`;
1315
+ commentsList.appendChild(item);
1316
+ }
1317
+ reviewSection.appendChild(commentsList);
1318
+
1319
+ commentsList.querySelectorAll('.comment-delete').forEach(el => {
1320
+ el.addEventListener('click', () => {
1321
+ const id = el.dataset.id;
1322
+ const newComments = comments.filter(c => c.id !== id);
1323
+ const newStatus = newComments.length > 0 ? 'has-feedback' : 'pending';
1324
+ saveReview(file.path, newStatus, newComments).then(() => {
1325
+ if (appState.selectedFile === file.path) loadDiff(file.path);
1326
+ });
1327
+ });
1328
+ });
1329
+ }
1330
+
1331
+ const addComment = document.createElement('div');
1332
+ addComment.className = 'add-comment';
1333
+ addComment.innerHTML = \`
1334
+ <div class="add-comment-row">
1335
+ <input type="number" id="comment-line" placeholder="Line" min="1" title="Line number (leave empty for general file comment)">
1336
+ <textarea id="comment-text" placeholder="Add a comment (line optional)..." rows="1"></textarea>
1337
+ <button class="btn" id="btn-add-comment">Add</button>
1338
+ </div>
1339
+ <span class="suggestion-toggle" id="toggle-suggestion">+ Add code suggestion</span>
1340
+ <div class="suggestion-input" id="suggestion-input" style="display: none;">
1341
+ <textarea id="comment-suggestion" placeholder="Suggested code..." rows="3"></textarea>
1342
+ </div>
1343
+ \`;
1344
+ reviewSection.appendChild(addComment);
1345
+ container.appendChild(reviewSection);
1346
+
1347
+ // General comments section
1348
+ const generalSection = document.createElement('div');
1349
+ generalSection.className = 'general-section';
1350
+ generalSection.innerHTML = \`
1351
+ <div class="general-header" id="toggle-general">
1352
+ General Comments (\${appState.generalComments.length}) \\u25BE
1353
+ </div>
1354
+ <div class="general-body" id="general-body">
1355
+ <div class="comments-list" id="general-comments-list"></div>
1356
+ <div class="add-comment">
1357
+ <div class="add-comment-row">
1358
+ <textarea id="general-comment-text" placeholder="Add a general comment (not tied to any file)..." rows="1" style="flex:1;"></textarea>
1359
+ <button class="btn" id="btn-add-general">Add</button>
1360
+ </div>
1361
+ </div>
1362
+ </div>
1363
+ \`;
1364
+ container.appendChild(generalSection);
1365
+
1366
+ renderGeneralComments();
1367
+
1368
+ // --- Event handlers ---
1369
+
1370
+ document.getElementById('btn-approve').addEventListener('click', () => {
1371
+ if (review.status === 'reviewed') {
1372
+ // Un-view: stay on same file
1373
+ saveReview(file.path, 'pending', comments).then(() => loadDiff(file.path));
1374
+ } else {
1375
+ // Viewed and advance to next file
1376
+ saveReview(file.path, 'reviewed', comments).then(() => navigateFile('next'));
1377
+ }
1378
+ });
1379
+
1380
+ document.getElementById('btn-add-comment').addEventListener('click', addFileComment);
1381
+ document.getElementById('comment-text').addEventListener('keydown', (e) => {
1382
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addFileComment();
1383
+ });
1384
+
1385
+ document.getElementById('toggle-suggestion').addEventListener('click', () => {
1386
+ const input = document.getElementById('suggestion-input');
1387
+ input.style.display = input.style.display === 'none' ? 'block' : 'none';
1388
+ });
1389
+
1390
+ document.getElementById('btn-add-general').addEventListener('click', addGeneralComment);
1391
+ document.getElementById('general-comment-text').addEventListener('keydown', (e) => {
1392
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addGeneralComment();
1393
+ });
1394
+
1395
+ document.getElementById('toggle-general').addEventListener('click', () => {
1396
+ const body = document.getElementById('general-body');
1397
+ body.style.display = body.style.display === 'none' ? 'block' : 'none';
1398
+ });
1399
+
1400
+ function addFileComment() {
1401
+ const text = document.getElementById('comment-text').value.trim();
1402
+ if (!text) return;
1403
+
1404
+ const line = parseInt(document.getElementById('comment-line').value) || null;
1405
+ const suggestion = document.getElementById('comment-suggestion')?.value.trim() || null;
1406
+
1407
+ const newComment = {
1408
+ id: 'c-' + Date.now(),
1409
+ line,
1410
+ text,
1411
+ suggestion,
1412
+ };
1413
+
1414
+ // Save scroll position before re-render
1415
+ const diffEl = document.querySelector('.diff-container');
1416
+ const scrollTop = diffEl ? diffEl.scrollTop : 0;
1417
+
1418
+ const newComments = [...comments, newComment];
1419
+ saveReview(file.path, 'has-feedback', newComments).then(() => {
1420
+ loadDiff(file.path).then(() => {
1421
+ // Restore scroll position
1422
+ const newDiffEl = document.querySelector('.diff-container');
1423
+ if (newDiffEl) newDiffEl.scrollTop = scrollTop;
1424
+ });
1425
+ });
1426
+ }
1427
+
1428
+ function addGeneralComment() {
1429
+ const text = document.getElementById('general-comment-text').value.trim();
1430
+ if (!text) return;
1431
+
1432
+ appState.generalComments.push({
1433
+ id: 'g-' + Date.now(),
1434
+ text,
1435
+ });
1436
+ saveGeneralComments().then(() => {
1437
+ document.getElementById('general-comment-text').value = '';
1438
+ renderGeneralComments();
1439
+ loadFiles();
1440
+ });
1441
+ }
1442
+ }
1443
+
1444
+ function renderGeneralComments() {
1445
+ const list = document.getElementById('general-comments-list');
1446
+ if (!list) return;
1447
+
1448
+ list.innerHTML = '';
1449
+ for (const comment of appState.generalComments) {
1450
+ const item = document.createElement('div');
1451
+ item.className = 'comment-item';
1452
+ item.innerHTML = \`
1453
+ <span class="comment-text">\${escapeHtml(comment.text)}</span>
1454
+ <span class="comment-delete" data-id="\${comment.id}" title="Delete">&times;</span>
1455
+ \`;
1456
+ list.appendChild(item);
1457
+ }
1458
+
1459
+ list.querySelectorAll('.comment-delete').forEach(el => {
1460
+ el.addEventListener('click', () => {
1461
+ appState.generalComments = appState.generalComments.filter(c => c.id !== el.dataset.id);
1462
+ saveGeneralComments().then(() => {
1463
+ renderGeneralComments();
1464
+ loadFiles();
1465
+ });
1466
+ });
1467
+ });
1468
+
1469
+ const header = document.getElementById('toggle-general');
1470
+ if (header) {
1471
+ header.textContent = \`General Comments (\${appState.generalComments.length}) \\u25BE\`;
1472
+ }
1473
+ }
1474
+
1475
+ // --- Generate Feedback ---
1476
+ document.getElementById('btn-generate').addEventListener('click', async () => {
1477
+ const data = await api('/api/generate', { method: 'POST', body: {} });
1478
+ showFeedbackModal(data.prompt);
1479
+ });
1480
+
1481
+ function showFeedbackModal(prompt) {
1482
+ const overlay = document.createElement('div');
1483
+ overlay.className = 'modal-overlay';
1484
+ overlay.innerHTML = \`
1485
+ <div class="modal">
1486
+ <div class="modal-header">
1487
+ <span class="modal-title">Generated Feedback Prompt</span>
1488
+ <button class="btn" id="modal-close">&times;</button>
1489
+ </div>
1490
+ <div class="modal-body">
1491
+ <pre id="modal-prompt">\${escapeHtml(prompt)}</pre>
1492
+ </div>
1493
+ <div class="modal-footer">
1494
+ <span class="copied-msg" id="copied-msg" style="display: none;">Copied!</span>
1495
+ <button class="btn btn-primary" id="modal-copy">Copy to Clipboard</button>
1496
+ </div>
1497
+ </div>
1498
+ \`;
1499
+ document.body.appendChild(overlay);
1500
+
1501
+ overlay.querySelector('#modal-close').addEventListener('click', () => overlay.remove());
1502
+ overlay.addEventListener('click', (e) => {
1503
+ if (e.target === overlay) overlay.remove();
1504
+ });
1505
+ document.addEventListener('keydown', function handler(e) {
1506
+ if (e.key === 'Escape') {
1507
+ overlay.remove();
1508
+ document.removeEventListener('keydown', handler);
1509
+ }
1510
+ });
1511
+
1512
+ overlay.querySelector('#modal-copy').addEventListener('click', async () => {
1513
+ await api('/api/clipboard', { method: 'POST', body: { text: prompt } });
1514
+ const msg = overlay.querySelector('#copied-msg');
1515
+ msg.style.display = 'inline';
1516
+ setTimeout(() => msg.style.display = 'none', 2000);
1517
+ });
1518
+
1519
+ // Auto-copy on open
1520
+ api('/api/clipboard', { method: 'POST', body: { text: prompt } });
1521
+ }
1522
+
1523
+ // --- Finish Review ---
1524
+ document.getElementById('btn-finish').addEventListener('click', async () => {
1525
+ if (!confirm('Finish review and clear all state? This cannot be undone.')) return;
1526
+ await api('/api/reset', { method: 'POST', body: {} });
1527
+
1528
+ // Show goodbye screen with countdown
1529
+ document.body.innerHTML = \`
1530
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;background:#002b36;color:#93a1a1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;text-align:center;gap:16px;">
1531
+ <div style="font-size:48px;">&#128640;</div>
1532
+ <div style="font-size:22px;color:#eee8d5;font-weight:600;">Thanks for using diffback!</div>
1533
+ <div style="font-size:14px;color:#657b83;">Review state cleared. Go ship that feedback.</div>
1534
+ <div style="margin-top:24px;font-size:13px;color:#586e75;">Closing in <span id="countdown">5</span>s...</div>
1535
+ </div>
1536
+ \`;
1537
+
1538
+ let seconds = 5;
1539
+ const interval = setInterval(() => {
1540
+ seconds--;
1541
+ const el = document.getElementById('countdown');
1542
+ if (el) el.textContent = String(seconds);
1543
+ if (seconds <= 0) {
1544
+ clearInterval(interval);
1545
+ window.close();
1546
+ }
1547
+ }, 1000);
1548
+ });
1549
+
1550
+ // --- Helpers ---
1551
+ function escapeHtml(str) {
1552
+ const div = document.createElement('div');
1553
+ div.textContent = str;
1554
+ return div.innerHTML;
1555
+ }
1556
+
1557
+ // --- Keyboard shortcuts ---
1558
+ document.addEventListener('keydown', (e) => {
1559
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1560
+
1561
+ if (e.key === 'j' || e.key === 'ArrowDown') {
1562
+ e.preventDefault();
1563
+ navigateFile('next');
1564
+ } else if (e.key === 'k' || e.key === 'ArrowUp') {
1565
+ e.preventDefault();
1566
+ navigateFile('prev');
1567
+ } else if (e.key === 'a') {
1568
+ if (appState.selectedFile) {
1569
+ document.getElementById('btn-approve')?.click();
1570
+ }
1571
+ } else if (e.key === 'c') {
1572
+ e.preventDefault();
1573
+ document.getElementById('comment-text')?.focus();
1574
+ } else if (e.key === 'g') {
1575
+ document.getElementById('btn-generate').click();
1576
+ }
1577
+ });
1578
+
1579
+ // --- Polling: refresh file list every 3s to detect external changes ---
1580
+ setInterval(async () => {
1581
+ const prev = JSON.stringify(appState.files.map(f => ({ p: f.path, s: f.review?.status, h: f.review?.hash, ch: f.review?.changedSinceReview })));
1582
+ await loadFiles();
1583
+ const curr = JSON.stringify(appState.files.map(f => ({ p: f.path, s: f.review?.status, h: f.review?.hash, ch: f.review?.changedSinceReview })));
1584
+ // If the currently selected file changed, refresh its diff
1585
+ if (prev !== curr && appState.selectedFile) {
1586
+ const file = appState.files.find(f => f.path === appState.selectedFile);
1587
+ if (file?.review?.changedSinceReview) {
1588
+ // Preserve scroll
1589
+ const diffEl = document.querySelector('.diff-container');
1590
+ const scrollTop = diffEl ? diffEl.scrollTop : 0;
1591
+ await loadDiff(appState.selectedFile);
1592
+ const newDiffEl = document.querySelector('.diff-container');
1593
+ if (newDiffEl) newDiffEl.scrollTop = scrollTop;
1594
+ }
1595
+ }
1596
+ }, 3000);
1597
+
1598
+ // --- Init ---
1599
+ loadFiles();
1600
+ </script>
1601
+ </body>
1602
+ </html>
1603
+ `);
1604
+ return;
1605
+ }
1606
+ if (path === "/api/files" && req.method === "GET") {
1607
+ const currentFiles = getChangedFiles();
1608
+ state = reconcileState(state, currentFiles);
1609
+ saveState(state);
1610
+ const filesWithReview = currentFiles.map((f) => ({
1611
+ ...f,
1612
+ review: state.files[f.path] || null
1613
+ }));
1614
+ const reviewed = Object.values(state.files).filter((f) => f.status === "reviewed").length;
1615
+ const hasFeedback = Object.values(state.files).filter((f) => f.status === "has-feedback").length;
1616
+ json(res, {
1617
+ files: filesWithReview,
1618
+ generalComments: state.generalComments,
1619
+ summary: {
1620
+ total: currentFiles.length,
1621
+ reviewed,
1622
+ hasFeedback,
1623
+ pending: currentFiles.length - reviewed - hasFeedback
1624
+ },
1625
+ projectName
1626
+ });
1627
+ return;
1628
+ }
1629
+ if (path === "/api/diff" && req.method === "GET") {
1630
+ const filePath = url.searchParams.get("path");
1631
+ if (!filePath) {
1632
+ json(res, { error: "Missing path parameter" }, 400);
1633
+ return;
1634
+ }
1635
+ const diff = getFileDiff(filePath);
1636
+ const file = changedFiles.find((f) => f.path === filePath);
1637
+ json(res, {
1638
+ path: filePath,
1639
+ diff,
1640
+ status: file?.status || "modified"
1641
+ });
1642
+ return;
1643
+ }
1644
+ if (path === "/api/file-content" && req.method === "GET") {
1645
+ const filePath = url.searchParams.get("path");
1646
+ if (!filePath) {
1647
+ json(res, { error: "Missing path parameter" }, 400);
1648
+ return;
1649
+ }
1650
+ const absPath = resolve(cwd, filePath);
1651
+ try {
1652
+ const content = readFileSync(absPath, "utf-8");
1653
+ json(res, { path: filePath, content });
1654
+ } catch {
1655
+ try {
1656
+ const content = execSync(`git show HEAD:"${filePath}"`, { cwd, encoding: "utf-8" });
1657
+ json(res, { path: filePath, content });
1658
+ } catch {
1659
+ json(res, { error: "File not found" }, 404);
1660
+ }
1661
+ }
1662
+ return;
1663
+ }
1664
+ if (path === "/api/review" && req.method === "POST") {
1665
+ const body = JSON.parse(await parseBody(req));
1666
+ const { path: filePath, status, comments } = body;
1667
+ state.files[filePath] = {
1668
+ status,
1669
+ hash: hashFile(filePath),
1670
+ comments: comments || [],
1671
+ changedSinceReview: false
1672
+ };
1673
+ saveState(state);
1674
+ json(res, { ok: true });
1675
+ return;
1676
+ }
1677
+ if (path === "/api/general-comments" && req.method === "POST") {
1678
+ const body = JSON.parse(await parseBody(req));
1679
+ state.generalComments = body.comments;
1680
+ saveState(state);
1681
+ json(res, { ok: true });
1682
+ return;
1683
+ }
1684
+ if (path === "/api/generate" && req.method === "POST") {
1685
+ state = loadState();
1686
+ const prompt = generateFeedback(state);
1687
+ json(res, { prompt });
1688
+ return;
1689
+ }
1690
+ if (path === "/api/clipboard" && req.method === "POST") {
1691
+ const body = JSON.parse(await parseBody(req));
1692
+ const ok = copyToClipboard(body.text);
1693
+ json(res, { ok });
1694
+ return;
1695
+ }
1696
+ if (path === "/api/reset" && req.method === "POST") {
1697
+ try {
1698
+ rmSync(getStateDir(), { recursive: true, force: true });
1699
+ } catch {
1700
+ }
1701
+ json(res, { ok: true });
1702
+ setTimeout(() => {
1703
+ console.log("\n Review finished. State cleared. Bye!");
1704
+ server.close();
1705
+ process.exit(0);
1706
+ }, 6e3);
1707
+ return;
1708
+ }
1709
+ res.writeHead(404);
1710
+ res.end("Not found");
1711
+ } catch (err) {
1712
+ console.error("Server error:", err);
1713
+ json(res, { error: String(err) }, 500);
1714
+ }
1715
+ });
1716
+ server.listen(port, () => {
1717
+ console.log(`
1718
+ diffback: ${projectName}`);
1719
+ console.log(` ${changedFiles.length} files with changes`);
1720
+ console.log(` http://localhost:${port}
1721
+ `);
1722
+ console.log(" Press Ctrl+C to stop\n");
1723
+ import("open").then((mod) => mod.default(`http://localhost:${port}`));
1724
+ });
1725
+ process.on("SIGINT", () => {
1726
+ console.log("\n Stopped.");
1727
+ server.close();
1728
+ process.exit(0);
1729
+ });
1730
+ process.on("SIGTERM", () => {
1731
+ server.close();
1732
+ process.exit(0);
1733
+ });
1734
+ }
1735
+ function main() {
1736
+ if (!isGitRepo()) {
1737
+ console.error("Error: not a git repository. Run diffback from a git project directory.");
1738
+ process.exit(1);
1739
+ }
1740
+ const args = process.argv.slice(2);
1741
+ let port = 3847;
1742
+ for (let i = 0; i < args.length; i++) {
1743
+ if (args[i] === "--port" && args[i + 1]) {
1744
+ port = parseInt(args[i + 1], 10);
1745
+ i++;
1746
+ } else if (args[i] === "--help" || args[i] === "-h") {
1747
+ console.log(`
1748
+ diffback - Review AI-generated code changes
1749
+
1750
+ Usage: diffback [options]
1751
+
1752
+ Options:
1753
+ --port <number> Port to use (default: 3847)
1754
+ --help, -h Show this help
1755
+ `);
1756
+ process.exit(0);
1757
+ }
1758
+ }
1759
+ startServer(port);
1760
+ }
1761
+ main();