diffback-review 1.1.0 → 1.3.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 +43 -16
  2. package/dist/cli.js +866 -148
  3. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -6,6 +6,85 @@ import { execSync } from "child_process";
6
6
  import { createHash } from "crypto";
7
7
  import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "fs";
8
8
  import { resolve, basename } from "path";
9
+
10
+ // src/feedback.ts
11
+ function generateFeedback(state) {
12
+ const lines = [];
13
+ const filesWithFeedback = [];
14
+ for (const [path, review] of Object.entries(state.files)) {
15
+ if (review.comments.length > 0) {
16
+ filesWithFeedback.push(path);
17
+ }
18
+ }
19
+ const totalComments = filesWithFeedback.reduce((sum, p) => sum + state.files[p].comments.length, 0) + state.generalComments.length;
20
+ lines.push("# Code Review Feedback");
21
+ lines.push("");
22
+ lines.push(
23
+ `${filesWithFeedback.length} files need changes. ${totalComments} comments total.`
24
+ );
25
+ for (const path of filesWithFeedback) {
26
+ const review = state.files[path];
27
+ lines.push("");
28
+ lines.push(`## ${path}`);
29
+ for (const comment of review.comments) {
30
+ const lineRef = comment.line ? `L${comment.line}` : "General";
31
+ if (comment.suggestion) {
32
+ lines.push(`- ${lineRef}: ${comment.text}`);
33
+ lines.push(" ```");
34
+ lines.push(` ${comment.suggestion}`);
35
+ lines.push(" ```");
36
+ } else {
37
+ lines.push(`- ${lineRef}: ${comment.text}`);
38
+ }
39
+ }
40
+ }
41
+ if (state.generalComments.length > 0) {
42
+ lines.push("");
43
+ lines.push("## General");
44
+ for (const comment of state.generalComments) {
45
+ lines.push(`- ${comment.text}`);
46
+ }
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ // src/state.ts
52
+ function reconcileState(state, changedFiles, hashFile2) {
53
+ const currentPaths = new Set(changedFiles.map((f) => f.path));
54
+ let hasChanges = false;
55
+ for (const path of Object.keys(state.files)) {
56
+ if (!currentPaths.has(path)) {
57
+ delete state.files[path];
58
+ }
59
+ }
60
+ for (const path of currentPaths) {
61
+ const currentHash = hashFile2(path);
62
+ const existing = state.files[path];
63
+ if (existing) {
64
+ if (existing.hash !== currentHash) {
65
+ if (existing.comments.length > 0) {
66
+ const archived = existing.comments.map((c) => ({
67
+ ...c,
68
+ archivedAt: (/* @__PURE__ */ new Date()).toISOString(),
69
+ round: state.round
70
+ }));
71
+ existing.archivedComments = [...existing.archivedComments || [], ...archived];
72
+ }
73
+ existing.status = "pending";
74
+ existing.hash = currentHash;
75
+ existing.comments = [];
76
+ existing.changedSinceReview = true;
77
+ hasChanges = true;
78
+ }
79
+ }
80
+ }
81
+ if (hasChanges) {
82
+ state.round = (state.round || 1) + 1;
83
+ }
84
+ return state;
85
+ }
86
+
87
+ // src/cli.ts
9
88
  var cwd = process.cwd();
10
89
  var projectName = basename(cwd);
11
90
  function getBranchName() {
@@ -91,6 +170,22 @@ function getChangedFiles() {
91
170
  }
92
171
  }
93
172
  }
173
+ if (hasCommits()) {
174
+ try {
175
+ const numstat = execSync("git diff --numstat HEAD", { cwd, encoding: "utf-8" }).trim();
176
+ if (numstat) {
177
+ for (const line of numstat.split("\n")) {
178
+ const [add, del, path] = line.split(" ");
179
+ const file = files.find((f) => f.path === path);
180
+ if (file && add !== "-") {
181
+ file.additions = parseInt(add) || 0;
182
+ file.deletions = parseInt(del) || 0;
183
+ }
184
+ }
185
+ }
186
+ } catch {
187
+ }
188
+ }
94
189
  return files.sort((a, b) => a.path.localeCompare(b.path));
95
190
  }
96
191
  function getFileDiff(filePath) {
@@ -151,74 +246,17 @@ function hashFile(filePath) {
151
246
  function loadState() {
152
247
  try {
153
248
  const data = readFileSync(getStateFile(), "utf-8");
154
- return JSON.parse(data);
249
+ const state = JSON.parse(data);
250
+ if (!state.round) state.round = 1;
251
+ return state;
155
252
  } catch {
156
- return { files: {}, generalComments: [] };
253
+ return { round: 1, files: {}, generalComments: [] };
157
254
  }
158
255
  }
159
256
  function saveState(state) {
160
257
  mkdirSync(getStateDir(), { recursive: true });
161
258
  writeFileSync(getStateFile(), JSON.stringify(state, null, 2));
162
259
  }
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
260
  function copyToClipboard(text) {
223
261
  try {
224
262
  const platform = process.platform;
@@ -255,7 +293,7 @@ function startServer(port) {
255
293
  process.exit(0);
256
294
  }
257
295
  let state = loadState();
258
- state = reconcileState(state, changedFiles);
296
+ state = reconcileState(state, changedFiles, hashFile);
259
297
  saveState(state);
260
298
  const server = http.createServer(async (req, res) => {
261
299
  const url = new URL(req.url || "/", `http://localhost:${port}`);
@@ -280,10 +318,12 @@ function startServer(port) {
280
318
  <title>Code Review</title>
281
319
  <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
320
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">
321
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/base16/solarized-dark.min.css">
283
322
  <script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
284
323
  <style>
285
- /* Solarized Dark */
286
324
  :root {
325
+ --sidebar-width: 280px;
326
+ /* Theme colors - overridden by theme classes */
287
327
  --bg: #002b36;
288
328
  --bg-secondary: #073642;
289
329
  --bg-tertiary: #0a4050;
@@ -299,8 +339,133 @@ function startServer(port) {
299
339
  --red: #dc322f;
300
340
  --magenta: #d33682;
301
341
  --violet: #6c71c4;
302
- --sidebar-width: 280px;
303
- }
342
+ /* Diff backgrounds */
343
+ --diff-ins-bg: #0a3d0a;
344
+ --diff-ins-highlight: #1a5c1a;
345
+ --diff-del-bg: #3d0a0a;
346
+ --diff-del-highlight: #5c1a1a;
347
+ --diff-ins-linenumber: #082e08;
348
+ --diff-del-linenumber: #2e0808;
349
+ /* Syntax highlighting */
350
+ --hl-keyword: #859900;
351
+ --hl-string: #2aa198;
352
+ --hl-type: #b58900;
353
+ --hl-function: #268bd2;
354
+ --hl-number: #d33682;
355
+ --hl-comment: #586e75;
356
+ --hl-attr: #b58900;
357
+ --hl-meta: #cb4b16;
358
+ --hl-variable: #268bd2;
359
+ --hl-regexp: #dc322f;
360
+ --hl-symbol: #6c71c4;
361
+ }
362
+
363
+ /* Solarized Dark (default) */
364
+ .theme-solarized-dark {
365
+ --bg: #002b36; --bg-secondary: #073642; --bg-tertiary: #0a4050;
366
+ --border: #586e75; --text: #93a1a1; --text-bright: #eee8d5; --text-muted: #657b83;
367
+ --accent: #268bd2; --cyan: #2aa198; --green: #859900; --yellow: #b58900;
368
+ --orange: #cb4b16; --red: #dc322f; --magenta: #d33682; --violet: #6c71c4;
369
+ --diff-ins-bg: #0a3d0a; --diff-ins-highlight: #1a5c1a;
370
+ --diff-del-bg: #3d0a0a; --diff-del-highlight: #5c1a1a;
371
+ --diff-ins-linenumber: #082e08; --diff-del-linenumber: #2e0808;
372
+ --hl-keyword: #859900; --hl-string: #2aa198; --hl-type: #b58900;
373
+ --hl-function: #268bd2; --hl-number: #d33682; --hl-comment: #586e75;
374
+ --hl-attr: #b58900; --hl-meta: #cb4b16; --hl-variable: #268bd2;
375
+ --hl-regexp: #dc322f; --hl-symbol: #6c71c4;
376
+ }
377
+
378
+ /* Monokai */
379
+ .theme-monokai {
380
+ --bg: #272822; --bg-secondary: #1e1f1c; --bg-tertiary: #3e3d32;
381
+ --border: #49483e; --text: #f8f8f2; --text-bright: #f8f8f0; --text-muted: #75715e;
382
+ --accent: #66d9ef; --cyan: #66d9ef; --green: #a6e22e; --yellow: #e6db74;
383
+ --orange: #fd971f; --red: #f92672; --magenta: #f92672; --violet: #ae81ff;
384
+ --diff-ins-bg: #2b3d0a; --diff-ins-highlight: #3d5c1a;
385
+ --diff-del-bg: #3d0a1a; --diff-del-highlight: #5c1a2a;
386
+ --diff-ins-linenumber: #232e08; --diff-del-linenumber: #2e0815;
387
+ --hl-keyword: #f92672; --hl-string: #e6db74; --hl-type: #66d9ef;
388
+ --hl-function: #a6e22e; --hl-number: #ae81ff; --hl-comment: #75715e;
389
+ --hl-attr: #a6e22e; --hl-meta: #f92672; --hl-variable: #f8f8f2;
390
+ --hl-regexp: #e6db74; --hl-symbol: #ae81ff;
391
+ }
392
+
393
+ /* GitHub Light */
394
+ .theme-github-light {
395
+ --bg: #ffffff; --bg-secondary: #f6f8fa; --bg-tertiary: #eaeef2;
396
+ --border: #d0d7de; --text: #1f2328; --text-bright: #1f2328; --text-muted: #656d76;
397
+ --accent: #0969da; --cyan: #0969da; --green: #1a7f37; --yellow: #9a6700;
398
+ --orange: #bc4c00; --red: #cf222e; --magenta: #8250df; --violet: #8250df;
399
+ --diff-ins-bg: #e6ffec; --diff-ins-highlight: #ccffd8;
400
+ --diff-del-bg: #ffebe9; --diff-del-highlight: #ffd7d5;
401
+ --diff-ins-linenumber: #e6ffec; --diff-del-linenumber: #ffebe9;
402
+ --hl-keyword: #cf222e; --hl-string: #0a3069; --hl-type: #8250df;
403
+ --hl-function: #6639ba; --hl-number: #0550ae; --hl-comment: #6e7781;
404
+ --hl-attr: #953800; --hl-meta: #cf222e; --hl-variable: #953800;
405
+ --hl-regexp: #0550ae; --hl-symbol: #8250df;
406
+ }
407
+
408
+ /* GitHub Light: force all diff2html backgrounds to white */
409
+ .theme-github-light .d2h-wrapper,
410
+ .theme-github-light .d2h-file-wrapper,
411
+ .theme-github-light .d2h-file-diff .d2h-code-pane,
412
+ .theme-github-light .d2h-code-line,
413
+ .theme-github-light .d2h-code-side-line,
414
+ .theme-github-light .d2h-diff-table,
415
+ .theme-github-light .d2h-diff-tbody,
416
+ .theme-github-light .d2h-code-line-ctn,
417
+ .theme-github-light td.d2h-code-line-ctn,
418
+ .theme-github-light tr {
419
+ background: #ffffff !important;
420
+ }
421
+ .theme-github-light .d2h-code-line-ctn,
422
+ .theme-github-light .d2h-code-line-ctn * {
423
+ color: #1f2328 !important;
424
+ }
425
+ /* Re-apply syntax colors over the forced text color */
426
+ .theme-github-light .d2h-code-line-ctn .hljs-keyword,
427
+ .theme-github-light .d2h-code-line-ctn .hljs-selector-tag { color: #cf222e !important; }
428
+ .theme-github-light .d2h-code-line-ctn .hljs-string { color: #0a3069 !important; }
429
+ .theme-github-light .d2h-code-line-ctn .hljs-built_in,
430
+ .theme-github-light .d2h-code-line-ctn .hljs-type,
431
+ .theme-github-light .d2h-code-line-ctn .hljs-title.class_ { color: #8250df !important; }
432
+ .theme-github-light .d2h-code-line-ctn .hljs-function,
433
+ .theme-github-light .d2h-code-line-ctn .hljs-title { color: #6639ba !important; }
434
+ .theme-github-light .d2h-code-line-ctn .hljs-number,
435
+ .theme-github-light .d2h-code-line-ctn .hljs-literal { color: #0550ae !important; }
436
+ .theme-github-light .d2h-code-line-ctn .hljs-comment,
437
+ .theme-github-light .d2h-code-line-ctn .hljs-quote { color: #6e7781 !important; }
438
+ .theme-github-light .d2h-code-line-ctn .hljs-attr,
439
+ .theme-github-light .d2h-code-line-ctn .hljs-variable { color: #953800 !important; }
440
+ .theme-github-light .d2h-code-line-ctn .hljs-meta { color: #cf222e !important; }
441
+ /* Light theme diff backgrounds */
442
+ .theme-github-light .d2h-ins,
443
+ .theme-github-light .d2h-ins .d2h-code-line-ctn { background: #e6ffec !important; }
444
+ .theme-github-light .d2h-ins .d2h-code-line-ctn ins { background: #ccffd8 !important; }
445
+ .theme-github-light .d2h-del,
446
+ .theme-github-light .d2h-del .d2h-code-line-ctn { background: #ffebe9 !important; }
447
+ .theme-github-light .d2h-del .d2h-code-line-ctn del { background: #ffd7d5 !important; }
448
+ .theme-github-light .d2h-ins .d2h-code-linenumber { background: #e6ffec !important; }
449
+ .theme-github-light .d2h-del .d2h-code-linenumber { background: #ffebe9 !important; }
450
+ .theme-github-light .d2h-file-header,
451
+ .theme-github-light .d2h-file-wrapper {
452
+ border-color: #d0d7de !important;
453
+ }
454
+ .theme-github-light .d2h-code-linenumber {
455
+ background: #f6f8fa !important;
456
+ color: #8c959f !important;
457
+ border-color: #d0d7de !important;
458
+ }
459
+
460
+ /* GitHub Light: scrollbar and fold overrides */
461
+ .theme-github-light ::-webkit-scrollbar-thumb { background: #d0d7de; }
462
+ .theme-github-light ::-webkit-scrollbar-thumb:hover { background: #8c959f; }
463
+ .theme-github-light .fold-indicator { color: #0969da; background: #f6f8fa; }
464
+ .theme-github-light .fold-indicator:hover { background: #eaeef2; }
465
+ .theme-github-light .fold-lines { color: #656d76; }
466
+ .theme-github-light .fold-line-num { color: #8c959f; }
467
+ .theme-github-light .comment-marker { background: #9a6700; }
468
+ .theme-github-light .inline-comment-bubble { background: #f6f8fa; border-left-color: #9a6700; color: #1f2328; }
304
469
 
305
470
  * { margin: 0; padding: 0; box-sizing: border-box; }
306
471
 
@@ -318,8 +483,8 @@ function startServer(port) {
318
483
  .toolbar {
319
484
  display: flex;
320
485
  align-items: center;
321
- gap: 12px;
322
- padding: 8px 16px;
486
+ gap: 8px;
487
+ padding: 6px 12px;
323
488
  background: var(--bg-secondary);
324
489
  border-bottom: 1px solid var(--border);
325
490
  flex-shrink: 0;
@@ -328,11 +493,22 @@ function startServer(port) {
328
493
  font-weight: 600;
329
494
  font-size: 14px;
330
495
  color: var(--text-bright);
331
- flex: 1;
496
+ white-space: nowrap;
497
+ overflow: hidden;
498
+ text-overflow: ellipsis;
499
+ min-width: 0;
500
+ }
501
+ .toolbar-spacer { flex: 1; }
502
+ .toolbar-actions {
503
+ display: flex;
504
+ align-items: center;
505
+ gap: 8px;
506
+ flex-shrink: 0;
332
507
  }
333
508
  .toolbar-stats {
334
509
  font-size: 13px;
335
510
  color: var(--text-muted);
511
+ white-space: nowrap;
336
512
  }
337
513
  .toolbar-stats .count { color: var(--cyan); font-weight: 600; }
338
514
  .btn {
@@ -346,6 +522,17 @@ function startServer(port) {
346
522
  font-weight: 500;
347
523
  }
348
524
  .btn:hover { border-color: var(--accent); }
525
+ select.btn {
526
+ appearance: none;
527
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6'%3E%3Cpath d='M0 0l4 6 4-6z' fill='%2393a1a1'/%3E%3C/svg%3E");
528
+ background-repeat: no-repeat;
529
+ background-position: right 8px center;
530
+ padding-right: 22px;
531
+ }
532
+ select.btn option {
533
+ background: var(--bg-secondary);
534
+ color: var(--text);
535
+ }
349
536
  .btn-primary {
350
537
  background: var(--accent);
351
538
  color: var(--bg);
@@ -358,6 +545,7 @@ function startServer(port) {
358
545
  color: var(--orange);
359
546
  }
360
547
  .btn-danger:hover { background: var(--orange); color: var(--bg); }
548
+ .btn { white-space: nowrap; }
361
549
 
362
550
  /* Main layout */
363
551
  .main {
@@ -369,14 +557,30 @@ function startServer(port) {
369
557
  /* Sidebar */
370
558
  .sidebar {
371
559
  width: var(--sidebar-width);
560
+ min-width: 180px;
561
+ max-width: 600px;
372
562
  border-right: 1px solid var(--border);
373
563
  display: flex;
374
564
  flex-direction: column;
375
565
  flex-shrink: 0;
376
566
  overflow: hidden;
567
+ position: relative;
568
+ }
569
+ .sidebar-resize {
570
+ position: absolute;
571
+ top: 0;
572
+ right: 0;
573
+ width: 4px;
574
+ height: 100%;
575
+ cursor: col-resize;
576
+ z-index: 20;
577
+ }
578
+ .sidebar-resize:hover,
579
+ .sidebar-resize.dragging {
580
+ background: var(--accent);
377
581
  }
378
582
  .sidebar-header {
379
- padding: 8px 12px;
583
+ padding: 6px 12px;
380
584
  font-size: 12px;
381
585
  font-weight: 600;
382
586
  color: var(--text-muted);
@@ -384,7 +588,29 @@ function startServer(port) {
384
588
  letter-spacing: 0.5px;
385
589
  border-bottom: 1px solid var(--border);
386
590
  background: var(--bg-secondary);
591
+ display: flex;
592
+ align-items: center;
593
+ justify-content: space-between;
594
+ gap: 8px;
595
+ }
596
+ .filter-bar {
597
+ display: flex;
598
+ gap: 2px;
387
599
  }
600
+ .filter-btn {
601
+ background: none;
602
+ border: 1px solid transparent;
603
+ color: var(--text-muted);
604
+ font-size: 10px;
605
+ padding: 2px 6px;
606
+ border-radius: 3px;
607
+ cursor: pointer;
608
+ text-transform: none;
609
+ letter-spacing: 0;
610
+ font-weight: 400;
611
+ }
612
+ .filter-btn:hover { color: var(--text); border-color: var(--border); }
613
+ .filter-btn.active { color: var(--cyan); border-color: var(--cyan); font-weight: 600; }
388
614
  .file-list {
389
615
  flex: 1;
390
616
  overflow-y: auto;
@@ -423,16 +649,35 @@ function startServer(port) {
423
649
  font-size: 12px;
424
650
  }
425
651
  .file-dir { color: var(--text-muted); }
652
+ .file-stats {
653
+ font-family: "SF Mono", "Fira Code", monospace;
654
+ font-size: 10px;
655
+ flex-shrink: 0;
656
+ display: flex;
657
+ gap: 4px;
658
+ }
659
+ .file-stats .stat-add { color: var(--green); }
660
+ .file-stats .stat-del { color: var(--orange); }
426
661
 
427
662
  /* Shortcuts legend in sidebar */
428
663
  .shortcuts-legend {
429
- padding: 8px 12px;
664
+ padding: 0;
430
665
  border-top: 1px solid var(--border);
431
666
  background: var(--bg-secondary);
432
667
  font-size: 11px;
433
668
  color: var(--text-muted);
434
669
  flex-shrink: 0;
435
670
  }
671
+ .shortcuts-toggle {
672
+ padding: 6px 12px;
673
+ cursor: pointer;
674
+ display: flex;
675
+ justify-content: space-between;
676
+ }
677
+ .shortcuts-toggle:hover { color: var(--text); }
678
+ .shortcuts-body {
679
+ padding: 0 12px 6px;
680
+ }
436
681
  .shortcuts-legend div {
437
682
  display: flex;
438
683
  justify-content: space-between;
@@ -467,6 +712,7 @@ function startServer(port) {
467
712
  border-bottom: 1px solid var(--border);
468
713
  flex-shrink: 0;
469
714
  z-index: 10;
715
+ overflow-x: auto;
470
716
  }
471
717
  .file-nav-btn {
472
718
  background: none;
@@ -588,8 +834,7 @@ function startServer(port) {
588
834
  gap: 8px;
589
835
  align-items: flex-start;
590
836
  }
591
- .add-comment input[type="number"] {
592
- width: 60px;
837
+ .add-comment input[type="text"] {
593
838
  padding: 6px 8px;
594
839
  background: var(--bg);
595
840
  border: 1px solid var(--border);
@@ -623,6 +868,79 @@ function startServer(port) {
623
868
  margin-top: 4px;
624
869
  display: inline-block;
625
870
  }
871
+ .quick-comments {
872
+ display: flex;
873
+ flex-wrap: wrap;
874
+ gap: 4px;
875
+ padding: 6px 16px;
876
+ border-top: 1px solid var(--border);
877
+ }
878
+ .quick-btn {
879
+ background: var(--bg);
880
+ border: 1px solid var(--border);
881
+ color: var(--text-muted);
882
+ font-size: 11px;
883
+ padding: 2px 8px;
884
+ border-radius: 12px;
885
+ cursor: pointer;
886
+ }
887
+ .quick-btn:hover { color: var(--text); border-color: var(--accent); }
888
+ .archived-section {
889
+ border-top: 1px dashed var(--border);
890
+ padding: 6px 16px;
891
+ }
892
+ .archived-header {
893
+ font-size: 11px;
894
+ color: var(--text-muted);
895
+ cursor: pointer;
896
+ padding: 4px 0;
897
+ }
898
+ .archived-header:hover { color: var(--text); }
899
+ .archived-item {
900
+ display: flex;
901
+ align-items: flex-start;
902
+ gap: 8px;
903
+ padding: 6px 0;
904
+ font-size: 12px;
905
+ color: var(--text-muted);
906
+ border-bottom: 1px solid var(--border);
907
+ }
908
+ .archived-item:last-child { border-bottom: none; }
909
+ .archived-item .archived-round {
910
+ font-size: 10px;
911
+ background: var(--violet);
912
+ color: var(--bg);
913
+ border-radius: 3px;
914
+ padding: 1px 6px;
915
+ flex-shrink: 0;
916
+ font-weight: 600;
917
+ }
918
+ .archived-item .archived-text { flex: 1; }
919
+ /* Inline archived comment bubble (dimmed, dashed border) */
920
+ .inline-archived-bubble {
921
+ margin: 4px 8px 4px 60px;
922
+ padding: 5px 10px;
923
+ background: transparent;
924
+ border: 1px dashed var(--border);
925
+ border-left: 3px dashed var(--violet);
926
+ border-radius: 4px;
927
+ font-size: 11px;
928
+ color: var(--text-muted);
929
+ line-height: 1.4;
930
+ display: flex;
931
+ align-items: flex-start;
932
+ gap: 6px;
933
+ opacity: 0.7;
934
+ }
935
+ .inline-archived-bubble .archived-round {
936
+ font-size: 9px;
937
+ background: var(--violet);
938
+ color: var(--bg);
939
+ border-radius: 3px;
940
+ padding: 1px 5px;
941
+ flex-shrink: 0;
942
+ font-weight: 600;
943
+ }
626
944
  .suggestion-input {
627
945
  margin-top: 4px;
628
946
  }
@@ -719,11 +1037,13 @@ function startServer(port) {
719
1037
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
720
1038
  ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
721
1039
 
722
- /* Line click hint */
723
- .d2h-code-linenumber { cursor: pointer; }
1040
+ /* Line number click hint - only the number column is clickable */
1041
+ .d2h-code-linenumber { cursor: pointer; user-select: none; }
724
1042
  .d2h-code-linenumber:hover {
725
1043
  background: rgba(38, 139, 210, 0.25) !important;
726
1044
  }
1045
+ /* Code content is freely selectable */
1046
+ .d2h-code-line-ctn { cursor: text; user-select: text; }
727
1047
 
728
1048
  /* ---- diff2html Solarized Dark overrides ---- */
729
1049
  .d2h-file-header, .d2h-file-wrapper {
@@ -741,33 +1061,58 @@ function startServer(port) {
741
1061
  }
742
1062
  .d2h-code-line, .d2h-code-side-line {
743
1063
  background: var(--bg) !important;
744
- color: var(--text) !important;
745
1064
  width: 100% !important;
746
1065
  }
747
1066
  .d2h-code-line-ctn {
748
- color: var(--text) !important;
749
1067
  width: 100% !important;
750
1068
  }
1069
+ /* Default text color for non-highlighted code */
1070
+ .d2h-code-line-ctn, .d2h-code-line-ctn * {
1071
+ color: var(--text);
1072
+ }
1073
+ /* Syntax highlighting via CSS variables (theme-aware) */
1074
+ .d2h-code-line-ctn .hljs-keyword,
1075
+ .d2h-code-line-ctn .hljs-selector-tag,
1076
+ .d2h-code-line-ctn .hljs-deletion { color: var(--hl-keyword) !important; }
1077
+ .d2h-code-line-ctn .hljs-string,
1078
+ .d2h-code-line-ctn .hljs-addition { color: var(--hl-string) !important; }
1079
+ .d2h-code-line-ctn .hljs-built_in,
1080
+ .d2h-code-line-ctn .hljs-type { color: var(--hl-type) !important; }
1081
+ .d2h-code-line-ctn .hljs-function,
1082
+ .d2h-code-line-ctn .hljs-title { color: var(--hl-function) !important; }
1083
+ .d2h-code-line-ctn .hljs-number,
1084
+ .d2h-code-line-ctn .hljs-literal { color: var(--hl-number) !important; }
1085
+ .d2h-code-line-ctn .hljs-comment,
1086
+ .d2h-code-line-ctn .hljs-quote { color: var(--hl-comment) !important; font-style: italic; }
1087
+ .d2h-code-line-ctn .hljs-attr,
1088
+ .d2h-code-line-ctn .hljs-attribute { color: var(--hl-attr) !important; }
1089
+ .d2h-code-line-ctn .hljs-params { color: var(--text) !important; }
1090
+ .d2h-code-line-ctn .hljs-meta,
1091
+ .d2h-code-line-ctn .hljs-meta .hljs-keyword { color: var(--hl-meta) !important; }
1092
+ .d2h-code-line-ctn .hljs-class .hljs-title,
1093
+ .d2h-code-line-ctn .hljs-title.class_ { color: var(--hl-type) !important; }
1094
+ .d2h-code-line-ctn .hljs-variable,
1095
+ .d2h-code-line-ctn .hljs-template-variable { color: var(--hl-variable) !important; }
1096
+ .d2h-code-line-ctn .hljs-regexp { color: var(--hl-regexp) !important; }
1097
+ .d2h-code-line-ctn .hljs-symbol { color: var(--hl-symbol) !important; }
1098
+ .d2h-code-line-ctn .hljs-selector-class,
1099
+ .d2h-code-line-ctn .hljs-selector-id { color: var(--hl-function) !important; }
751
1100
  /* Added lines */
752
1101
  .d2h-ins, .d2h-ins .d2h-code-line-ctn {
753
- background: #0a3d0a !important;
754
- color: #eee8d5 !important;
1102
+ background: var(--diff-ins-bg) !important;
755
1103
  }
756
1104
  .d2h-ins .d2h-code-line-ctn ins,
757
1105
  .d2h-ins.d2h-change .d2h-code-line-ctn ins {
758
- background: #1a5c1a !important;
759
- color: #eee8d5 !important;
1106
+ background: var(--diff-ins-highlight) !important;
760
1107
  text-decoration: none !important;
761
1108
  }
762
1109
  /* Deleted lines */
763
1110
  .d2h-del, .d2h-del .d2h-code-line-ctn {
764
- background: #3d0a0a !important;
765
- color: #eee8d5 !important;
1111
+ background: var(--diff-del-bg) !important;
766
1112
  }
767
1113
  .d2h-del .d2h-code-line-ctn del,
768
1114
  .d2h-del.d2h-change .d2h-code-line-ctn del {
769
- background: #5c1a1a !important;
770
- color: #eee8d5 !important;
1115
+ background: var(--diff-del-highlight) !important;
771
1116
  text-decoration: none !important;
772
1117
  }
773
1118
  /* Info/hunk headers - visually hidden but in DOM for fold indicator positioning */
@@ -781,17 +1126,17 @@ function startServer(port) {
781
1126
  }
782
1127
  /* Line numbers */
783
1128
  .d2h-code-linenumber {
784
- background: #073642 !important;
785
- color: #657b83 !important;
786
- border-color: #586e75 !important;
1129
+ background: var(--bg-secondary) !important;
1130
+ color: var(--text-muted) !important;
1131
+ border-color: var(--border) !important;
787
1132
  }
788
1133
  .d2h-ins .d2h-code-linenumber {
789
- background: #082e08 !important;
790
- color: #839496 !important;
1134
+ background: var(--diff-ins-linenumber) !important;
1135
+ color: var(--text-muted) !important;
791
1136
  }
792
1137
  .d2h-del .d2h-code-linenumber {
793
- background: #2e0808 !important;
794
- color: #839496 !important;
1138
+ background: var(--diff-del-linenumber) !important;
1139
+ color: var(--text-muted) !important;
795
1140
  }
796
1141
  /* Empty placeholder cells */
797
1142
  .d2h-code-side-emptyplaceholder, .d2h-emptyplaceholder {
@@ -864,7 +1209,25 @@ function startServer(port) {
864
1209
  width: 8px;
865
1210
  height: 8px;
866
1211
  border-radius: 50%;
867
- background: var(--yellow);
1212
+ cursor: pointer;
1213
+ z-index: 5;
1214
+ }
1215
+ .comment-marker.marker-current {
1216
+ background: var(--orange);
1217
+ }
1218
+ .comment-marker.marker-archived {
1219
+ background: transparent;
1220
+ border: 2px solid var(--violet);
1221
+ }
1222
+ .comment-marker:hover { opacity: 0.8; }
1223
+ /* Selected line range highlighting */
1224
+ tr.line-selected td {
1225
+ outline: 1px solid var(--accent);
1226
+ outline-offset: -1px;
1227
+ }
1228
+ tr.line-selected .d2h-code-linenumber {
1229
+ background: rgba(38, 139, 210, 0.25) !important;
1230
+ color: var(--text-bright) !important;
868
1231
  }
869
1232
  .inline-comment-row td {
870
1233
  padding: 0 !important;
@@ -881,7 +1244,20 @@ function startServer(port) {
881
1244
  font-size: 12px;
882
1245
  color: var(--text-bright);
883
1246
  line-height: 1.4;
1247
+ display: flex;
1248
+ align-items: flex-start;
1249
+ gap: 6px;
1250
+ }
1251
+ .inline-comment-bubble .inline-content { flex: 1; }
1252
+ .inline-comment-delete {
1253
+ color: var(--text-muted);
1254
+ cursor: pointer;
1255
+ font-size: 15px;
1256
+ line-height: 1;
1257
+ padding: 0 2px;
1258
+ flex-shrink: 0;
884
1259
  }
1260
+ .inline-comment-delete:hover { color: var(--orange); }
885
1261
  .inline-comment-bubble .inline-line-ref {
886
1262
  color: var(--cyan);
887
1263
  font-family: "SF Mono", "Fira Code", monospace;
@@ -904,21 +1280,43 @@ function startServer(port) {
904
1280
  <body>
905
1281
  <div class="toolbar">
906
1282
  <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>
1283
+ <div class="toolbar-spacer"></div>
1284
+ <div class="toolbar-actions">
1285
+ <div class="toolbar-stats" id="toolbar-stats"></div>
1286
+ <select id="theme-selector" class="btn" title="Change theme" style="padding:4px 8px;cursor:pointer;">
1287
+ <option value="solarized-dark">Solarized Dark</option>
1288
+ <option value="monokai">Monokai</option>
1289
+ <option value="github-light">GitHub Light</option>
1290
+ </select>
1291
+ <button class="btn btn-primary" id="btn-generate" title="Generate feedback prompt">Generate</button>
1292
+ <button class="btn btn-danger" id="btn-finish" title="Finish review and clean up state">Finish</button>
1293
+ </div>
910
1294
  </div>
911
1295
 
912
1296
  <div class="main">
913
- <div class="sidebar">
914
- <div class="sidebar-header">Files</div>
1297
+ <div class="sidebar" id="sidebar">
1298
+ <div class="sidebar-resize" id="sidebar-resize"></div>
1299
+ <div class="sidebar-header">
1300
+ <span>Files</span>
1301
+ <div class="filter-bar" id="filter-bar">
1302
+ <button class="filter-btn active" data-filter="all">All</button>
1303
+ <button class="filter-btn" data-filter="pending">Pending</button>
1304
+ <button class="filter-btn" data-filter="viewed">Viewed</button>
1305
+ <button class="filter-btn" data-filter="feedback">Feedback</button>
1306
+ </div>
1307
+ </div>
915
1308
  <div class="file-list" id="file-list"></div>
916
1309
  <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>
1310
+ <div class="shortcuts-toggle" id="shortcuts-toggle">
1311
+ <span>Shortcuts</span> <span id="shortcuts-arrow">&blacktriangleright;</span>
1312
+ </div>
1313
+ <div class="shortcuts-body" id="shortcuts-body" style="display:none;">
1314
+ <div><span>Prev/Next file</span> <span><kbd>k</kbd> <kbd>j</kbd></span></div>
1315
+ <div><span>Viewed file</span> <kbd>a</kbd></div>
1316
+ <div><span>Add comment</span> <kbd>c</kbd></div>
1317
+ <div><span>Generate feedback</span> <kbd>g</kbd></div>
1318
+ <div><span>Send comment</span> <kbd>Cmd+Enter</kbd></div>
1319
+ </div>
922
1320
  </div>
923
1321
  </div>
924
1322
 
@@ -927,8 +1325,7 @@ function startServer(port) {
927
1325
  </div>
928
1326
  </div>
929
1327
 
930
- <script>
931
- // --- State ---
1328
+ <script> // --- State ---
932
1329
  let appState = {
933
1330
  files: [],
934
1331
  generalComments: [],
@@ -936,7 +1333,9 @@ function startServer(port) {
936
1333
  projectName: '',
937
1334
  selectedFile: null,
938
1335
  currentDiff: null,
939
- fileContents: {}, // cache for expanded fold content
1336
+ fileContents: {},
1337
+ filter: 'all',
1338
+ round: 1,
940
1339
  };
941
1340
 
942
1341
  // --- API ---
@@ -955,6 +1354,7 @@ function startServer(port) {
955
1354
  appState.generalComments = data.generalComments || [];
956
1355
  appState.summary = data.summary;
957
1356
  appState.projectName = data.projectName;
1357
+ appState.round = data.round || 1;
958
1358
  renderToolbar();
959
1359
  renderFileList();
960
1360
  }
@@ -994,8 +1394,19 @@ function startServer(port) {
994
1394
  }
995
1395
 
996
1396
  // --- Navigation helpers ---
1397
+ function getFilteredFiles() {
1398
+ return appState.files.filter(f => {
1399
+ if (appState.filter === 'all') return true;
1400
+ const status = f.review?.status;
1401
+ if (appState.filter === 'pending') return !status || status === 'pending';
1402
+ if (appState.filter === 'viewed') return status === 'reviewed';
1403
+ if (appState.filter === 'feedback') return status === 'has-feedback' || (f.review?.comments?.length > 0);
1404
+ return true;
1405
+ });
1406
+ }
1407
+
997
1408
  function navigateFile(direction) {
998
- const files = appState.files;
1409
+ const files = getFilteredFiles();
999
1410
  if (files.length === 0) return;
1000
1411
  const currentIdx = files.findIndex(f => f.path === appState.selectedFile);
1001
1412
  let next;
@@ -1023,7 +1434,9 @@ function startServer(port) {
1023
1434
  const container = document.getElementById('file-list');
1024
1435
  container.innerHTML = '';
1025
1436
 
1026
- for (const file of appState.files) {
1437
+ const filtered = getFilteredFiles();
1438
+
1439
+ for (const file of filtered) {
1027
1440
  const el = document.createElement('div');
1028
1441
  el.className = 'file-item' + (appState.selectedFile === file.path ? ' active' : '');
1029
1442
  el.dataset.path = file.path;
@@ -1064,11 +1477,36 @@ function startServer(port) {
1064
1477
  el.appendChild(statusIcon);
1065
1478
  el.appendChild(reviewIcon);
1066
1479
  el.appendChild(pathEl);
1480
+
1481
+ // Line stats (+/-)
1482
+ if (file.additions !== undefined || file.deletions !== undefined) {
1483
+ const stats = document.createElement('span');
1484
+ stats.className = 'file-stats';
1485
+ const add = file.additions || 0;
1486
+ const del = file.deletions || 0;
1487
+ if (add > 0) stats.innerHTML += \`<span class="stat-add">+\${add}</span>\`;
1488
+ if (del > 0) stats.innerHTML += \`<span class="stat-del">-\${del}</span>\`;
1489
+ el.appendChild(stats);
1490
+ }
1491
+
1067
1492
  el.addEventListener('click', () => loadDiff(file.path));
1068
1493
  container.appendChild(el);
1069
1494
  }
1495
+
1496
+ // Update filter button counts
1497
+ document.querySelectorAll('.filter-btn').forEach(btn => {
1498
+ btn.classList.toggle('active', btn.dataset.filter === appState.filter);
1499
+ });
1070
1500
  }
1071
1501
 
1502
+ // --- Filter handlers ---
1503
+ document.getElementById('filter-bar').addEventListener('click', (e) => {
1504
+ const btn = e.target.closest('.filter-btn');
1505
+ if (!btn) return;
1506
+ appState.filter = btn.dataset.filter;
1507
+ renderFileList();
1508
+ });
1509
+
1072
1510
  // --- Parse hunk ranges from @@ header ---
1073
1511
  function parseHunkHeader(headerText) {
1074
1512
  // @@ -oldStart,oldCount +newStart,newCount @@
@@ -1157,51 +1595,171 @@ function startServer(port) {
1157
1595
  }
1158
1596
 
1159
1597
  // --- 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);
1598
+ function getCommentEndLine(comment) {
1599
+ // Returns the last line number for a comment (for range "15-22" returns 22, for "15" returns 15)
1600
+ if (!comment.line) return null;
1601
+ const parts = String(comment.line).split('-');
1602
+ return parseInt(parts[parts.length - 1]);
1603
+ }
1604
+ function getCommentStartLine(comment) {
1605
+ if (!comment.line) return null;
1606
+ return parseInt(String(comment.line).split('-')[0]);
1607
+ }
1608
+
1609
+ function insertInlineComments(diffContainer, comments, archivedComments) {
1610
+ // Build a map of line -> { current: [...], archived: [...] }
1611
+ const lineData = {};
1612
+
1613
+ for (const c of comments.filter(c => c.line !== null)) {
1614
+ const endLine = getCommentEndLine(c);
1615
+ const startLine = getCommentStartLine(c);
1616
+ if (!endLine) continue;
1617
+ if (!lineData[endLine]) lineData[endLine] = { current: [], archived: [], markerLines: new Set() };
1618
+ lineData[endLine].current.push(c);
1619
+ for (let l = startLine; l <= endLine; l++) lineData[endLine].markerLines.add(l);
1620
+ }
1621
+
1622
+ for (const c of (archivedComments || []).filter(c => c.line !== null)) {
1623
+ const endLine = getCommentEndLine(c);
1624
+ if (!endLine) continue;
1625
+ if (!lineData[endLine]) lineData[endLine] = { current: [], archived: [], markerLines: new Set() };
1626
+ lineData[endLine].archived.push(c);
1627
+ }
1628
+
1629
+ // Collect all marker lines (for range highlighting)
1630
+ const allMarkerLines = new Set();
1631
+ for (const data of Object.values(lineData)) {
1632
+ for (const l of data.markerLines) allMarkerLines.add(l);
1169
1633
  }
1170
1634
 
1171
- // Find all line number cells and match
1172
1635
  diffContainer.querySelectorAll('.d2h-code-linenumber').forEach(el => {
1173
1636
  const lineNum2 = el.querySelector('.line-num2')?.textContent?.trim();
1174
1637
  const num = parseInt(lineNum2);
1175
- if (isNaN(num) || !byLine[num]) return;
1638
+ if (isNaN(num)) return;
1639
+
1640
+ const data = lineData[num];
1641
+ const hasCurrent = data?.current.length > 0;
1642
+ const hasArchived = data?.archived.length > 0;
1643
+ const isInRange = allMarkerLines.has(num) && !data; // part of a range but not the end line
1644
+
1645
+ if (!hasCurrent && !hasArchived && !isInRange) return;
1176
1646
 
1177
- // Add marker dot
1178
1647
  el.style.position = 'relative';
1179
- const marker = document.createElement('span');
1180
- marker.className = 'comment-marker';
1181
- el.appendChild(marker);
1182
1648
 
1183
- // Insert comment bubble row after this line's <tr>
1649
+ // Add marker dots (can have both current and archived on same line)
1650
+ if (hasCurrent) {
1651
+ const marker = document.createElement('span');
1652
+ marker.className = 'comment-marker marker-current';
1653
+ marker.title = 'Toggle comments';
1654
+ el.appendChild(marker);
1655
+ }
1656
+ if (hasArchived) {
1657
+ const marker = document.createElement('span');
1658
+ marker.className = 'comment-marker marker-archived';
1659
+ marker.style.left = hasCurrent ? '12px' : '2px'; // offset if both
1660
+ marker.title = 'Toggle archived comments';
1661
+ el.appendChild(marker);
1662
+ }
1663
+ if (isInRange) {
1664
+ const marker = document.createElement('span');
1665
+ marker.className = 'comment-marker marker-current';
1666
+ marker.style.opacity = '0.4';
1667
+ el.appendChild(marker);
1668
+ }
1669
+
1670
+ if (!data) return;
1184
1671
  const tr = el.closest('tr');
1185
1672
  if (!tr) return;
1186
1673
 
1187
- for (const comment of byLine[num]) {
1674
+ // Create current comment bubbles (visible by default)
1675
+ const currentRows = [];
1676
+ for (const comment of data.current) {
1188
1677
  const commentTr = document.createElement('tr');
1189
- commentTr.className = 'inline-comment-row';
1678
+ commentTr.className = 'inline-comment-row inline-current';
1190
1679
  const td = document.createElement('td');
1191
1680
  td.colSpan = 99;
1192
1681
  td.innerHTML = \`
1193
1682
  <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>\` : ''}
1683
+ <div class="inline-content">
1684
+ <span class="inline-line-ref">L\${comment.line}</span>
1685
+ \${escapeHtml(comment.text)}
1686
+ \${comment.suggestion ? \`<div class="inline-suggestion">\${escapeHtml(comment.suggestion)}</div>\` : ''}
1687
+ </div>
1688
+ <span class="inline-comment-delete" data-id="\${comment.id}" title="Delete comment">&times;</span>
1197
1689
  </div>
1198
1690
  \`;
1691
+ td.querySelector('.inline-comment-delete').addEventListener('click', () => {
1692
+ const file = appState.files.find(f => f.path === appState.selectedFile);
1693
+ if (!file) return;
1694
+ const review = file.review || { comments: [] };
1695
+ const newComments = (review.comments || []).filter(c => c.id !== comment.id);
1696
+ const newStatus = newComments.length > 0 ? 'has-feedback' : 'pending';
1697
+ const diffEl2 = document.querySelector('.diff-container');
1698
+ const scrollTop = diffEl2 ? diffEl2.scrollTop : 0;
1699
+ saveReview(file.path, newStatus, newComments).then(() => {
1700
+ loadDiff(file.path).then(() => {
1701
+ const newDiffEl = document.querySelector('.diff-container');
1702
+ if (newDiffEl) newDiffEl.scrollTop = scrollTop;
1703
+ });
1704
+ });
1705
+ });
1199
1706
  commentTr.appendChild(td);
1200
- tr.after(commentTr);
1707
+ currentRows.push(commentTr);
1708
+ }
1709
+
1710
+ // Create archived comment bubbles (hidden by default)
1711
+ const archivedRows = [];
1712
+ for (const ac of data.archived) {
1713
+ const archTr = document.createElement('tr');
1714
+ archTr.className = 'inline-comment-row inline-archived';
1715
+ archTr.style.display = 'none';
1716
+ const td = document.createElement('td');
1717
+ td.colSpan = 99;
1718
+ td.innerHTML = \`
1719
+ <div class="inline-archived-bubble">
1720
+ <span class="archived-round">R\${ac.round}</span>
1721
+ <span class="inline-line-ref">L\${ac.line}</span>
1722
+ <span>\${escapeHtml(ac.text)}\${ac.suggestion ? \`<div class="inline-suggestion">\${escapeHtml(ac.suggestion)}</div>\` : ''}</span>
1723
+ </div>
1724
+ \`;
1725
+ archTr.appendChild(td);
1726
+ archivedRows.push(archTr);
1727
+ }
1728
+
1729
+ // Insert all rows after tr
1730
+ let insertAfter = tr;
1731
+ for (const row of currentRows) {
1732
+ insertAfter.after(row);
1733
+ insertAfter = row;
1734
+ }
1735
+ for (const row of archivedRows) {
1736
+ insertAfter.after(row);
1737
+ insertAfter = row;
1201
1738
  }
1202
1739
 
1203
- // Remove from map so we don't double-insert
1204
- delete byLine[num];
1740
+ // Click orange marker to toggle current bubbles
1741
+ const currentMarker = el.querySelector('.marker-current');
1742
+ if (currentMarker && currentRows.length > 0) {
1743
+ currentMarker.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); });
1744
+ currentMarker.addEventListener('mouseup', (ev) => {
1745
+ ev.stopPropagation();
1746
+ const visible = currentRows[0].style.display !== 'none';
1747
+ currentRows.forEach(r => r.style.display = visible ? 'none' : '');
1748
+ });
1749
+ }
1750
+
1751
+ // Click violet marker to toggle archived bubbles
1752
+ const archivedMarker = el.querySelector('.marker-archived');
1753
+ if (archivedMarker && archivedRows.length > 0) {
1754
+ archivedMarker.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); });
1755
+ archivedMarker.addEventListener('mouseup', (ev) => {
1756
+ ev.stopPropagation();
1757
+ const visible = archivedRows[0].style.display !== 'none';
1758
+ archivedRows.forEach(r => r.style.display = visible ? 'none' : '');
1759
+ });
1760
+ }
1761
+
1762
+ delete lineData[num];
1205
1763
  });
1206
1764
  }
1207
1765
 
@@ -1263,20 +1821,61 @@ function startServer(port) {
1263
1821
  // Post-render: folds, inline comments, line click handlers
1264
1822
  setTimeout(() => {
1265
1823
  insertFoldIndicators(diffContainer, appState.currentDiff.diff, file.path);
1266
- insertInlineComments(diffContainer, comments);
1824
+ insertInlineComments(diffContainer, comments, review.archivedComments || []);
1825
+
1826
+ // Line selection state
1827
+ let rangeStart = null;
1828
+
1829
+ function highlightRange(s, e) {
1830
+ diffContainer.querySelectorAll('tr.line-selected').forEach(r => r.classList.remove('line-selected'));
1831
+ if (s === null) return;
1832
+ diffContainer.querySelectorAll('.d2h-code-linenumber').forEach(numEl => {
1833
+ const ln = parseInt(numEl.querySelector('.line-num2')?.textContent?.trim());
1834
+ if (!isNaN(ln) && ln >= s && ln <= e) {
1835
+ const row = numEl.closest('tr');
1836
+ if (row) row.classList.add('line-selected');
1837
+ }
1838
+ });
1839
+ }
1267
1840
 
1841
+ // Click line numbers: click to select/deselect, shift+click to extend range
1842
+ // Use mousedown+mouseup to ignore drags
1268
1843
  diffContainer.querySelectorAll('.d2h-code-linenumber').forEach(el => {
1269
- el.addEventListener('click', () => {
1844
+ let mouseDownPos = null;
1845
+ el.addEventListener('mousedown', (ev) => {
1846
+ mouseDownPos = { x: ev.clientX, y: ev.clientY };
1847
+ ev.preventDefault(); // Prevent text selection starting from line numbers
1848
+ });
1849
+ el.addEventListener('mouseup', (ev) => {
1850
+ // Ignore if dragged more than 5px (user was selecting text)
1851
+ if (!mouseDownPos) return;
1852
+ const dx = Math.abs(ev.clientX - mouseDownPos.x);
1853
+ const dy = Math.abs(ev.clientY - mouseDownPos.y);
1854
+ mouseDownPos = null;
1855
+ if (dx > 5 || dy > 5) return;
1856
+
1270
1857
  const lineNum = el.querySelector('.line-num2')?.textContent?.trim()
1271
- || el.querySelector('.line-num1')?.textContent?.trim()
1272
- || el.textContent?.trim();
1858
+ || el.querySelector('.line-num1')?.textContent?.trim();
1273
1859
  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
- }
1860
+ if (isNaN(num)) return;
1861
+
1862
+ const lineInput = document.getElementById('comment-line');
1863
+ const currentVal = lineInput?.value.trim() || '';
1864
+
1865
+ if (ev.shiftKey && rangeStart !== null) {
1866
+ const s = Math.min(rangeStart, num);
1867
+ const e = Math.max(rangeStart, num);
1868
+ if (lineInput) lineInput.value = s === e ? String(s) : \`\${s}-\${e}\`;
1869
+ highlightRange(s, e);
1870
+ } else if (currentVal && (rangeStart === num || currentVal.includes('-'))) {
1871
+ rangeStart = null;
1872
+ if (lineInput) lineInput.value = '';
1873
+ highlightRange(null, null);
1874
+ } else {
1875
+ rangeStart = num;
1876
+ if (lineInput) lineInput.value = String(num);
1877
+ highlightRange(num, num);
1878
+ document.getElementById('comment-text')?.focus();
1280
1879
  }
1281
1880
  });
1282
1881
  });
@@ -1332,7 +1931,7 @@ function startServer(port) {
1332
1931
  addComment.className = 'add-comment';
1333
1932
  addComment.innerHTML = \`
1334
1933
  <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)">
1934
+ <input type="text" id="comment-line" placeholder="L# or L#-#" style="width:80px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;font-family:'SF Mono','Fira Code',monospace;" title="Line number or range (e.g. 42 or 15-22). Leave empty for general file comment.">
1336
1935
  <textarea id="comment-text" placeholder="Add a comment (line optional)..." rows="1"></textarea>
1337
1936
  <button class="btn" id="btn-add-comment">Add</button>
1338
1937
  </div>
@@ -1342,6 +1941,49 @@ function startServer(port) {
1342
1941
  </div>
1343
1942
  \`;
1344
1943
  reviewSection.appendChild(addComment);
1944
+
1945
+ // Quick comment buttons
1946
+ const quickComments = document.createElement('div');
1947
+ quickComments.className = 'quick-comments';
1948
+ const presets = ['Naming', 'Missing test', 'Unnecessary change', 'Delete this file', 'Needs refactor', 'Wrong approach'];
1949
+ for (const preset of presets) {
1950
+ const btn = document.createElement('button');
1951
+ btn.className = 'quick-btn';
1952
+ btn.textContent = preset;
1953
+ btn.addEventListener('click', () => {
1954
+ const textArea = document.getElementById('comment-text');
1955
+ if (textArea) {
1956
+ textArea.value = preset;
1957
+ textArea.focus();
1958
+ }
1959
+ });
1960
+ quickComments.appendChild(btn);
1961
+ }
1962
+ reviewSection.appendChild(quickComments);
1963
+
1964
+ // Archived comments from previous rounds
1965
+ const archived = review.archivedComments || [];
1966
+ if (archived.length > 0) {
1967
+ const archivedSection = document.createElement('div');
1968
+ archivedSection.className = 'archived-section';
1969
+ archivedSection.innerHTML = \`<div class="archived-header" id="toggle-archived">Previous round comments (\${archived.length}) \\u25B8</div>\`;
1970
+ const archivedList = document.createElement('div');
1971
+ archivedList.id = 'archived-list';
1972
+ archivedList.style.display = 'none';
1973
+ for (const ac of archived) {
1974
+ const item = document.createElement('div');
1975
+ item.className = 'archived-item';
1976
+ item.innerHTML = \`
1977
+ <span class="archived-round">R\${ac.round}</span>
1978
+ <span class="comment-line-ref">\${ac.line ? 'L' + ac.line : 'General'}</span>
1979
+ <span class="archived-text">\${escapeHtml(ac.text)}\${ac.suggestion ? \`<div class="comment-suggestion">\${escapeHtml(ac.suggestion)}</div>\` : ''}</span>
1980
+ \`;
1981
+ archivedList.appendChild(item);
1982
+ }
1983
+ archivedSection.appendChild(archivedList);
1984
+ reviewSection.appendChild(archivedSection);
1985
+ }
1986
+
1345
1987
  container.appendChild(reviewSection);
1346
1988
 
1347
1989
  // General comments section
@@ -1397,11 +2039,23 @@ function startServer(port) {
1397
2039
  body.style.display = body.style.display === 'none' ? 'block' : 'none';
1398
2040
  });
1399
2041
 
2042
+ // Archived comments toggle
2043
+ const toggleArchived = document.getElementById('toggle-archived');
2044
+ if (toggleArchived) {
2045
+ toggleArchived.addEventListener('click', () => {
2046
+ const list = document.getElementById('archived-list');
2047
+ const isHidden = list.style.display === 'none';
2048
+ list.style.display = isHidden ? 'block' : 'none';
2049
+ toggleArchived.textContent = \`Previous round comments (\${archived.length}) \${isHidden ? '\\u25BE' : '\\u25B8'}\`;
2050
+ });
2051
+ }
2052
+
1400
2053
  function addFileComment() {
1401
2054
  const text = document.getElementById('comment-text').value.trim();
1402
2055
  if (!text) return;
1403
2056
 
1404
- const line = parseInt(document.getElementById('comment-line').value) || null;
2057
+ const lineRaw = document.getElementById('comment-line').value.trim();
2058
+ const line = lineRaw || null; // Keep as string for ranges like "15-22"
1405
2059
  const suggestion = document.getElementById('comment-suggestion')?.value.trim() || null;
1406
2060
 
1407
2061
  const newComment = {
@@ -1557,11 +2211,12 @@ function startServer(port) {
1557
2211
  // --- Keyboard shortcuts ---
1558
2212
  document.addEventListener('keydown', (e) => {
1559
2213
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
2214
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1560
2215
 
1561
- if (e.key === 'j' || e.key === 'ArrowDown') {
2216
+ if (e.key === 'j') {
1562
2217
  e.preventDefault();
1563
2218
  navigateFile('next');
1564
- } else if (e.key === 'k' || e.key === 'ArrowUp') {
2219
+ } else if (e.key === 'k') {
1565
2220
  e.preventDefault();
1566
2221
  navigateFile('prev');
1567
2222
  } else if (e.key === 'a') {
@@ -1595,9 +2250,69 @@ function startServer(port) {
1595
2250
  }
1596
2251
  }, 3000);
1597
2252
 
2253
+ // --- Shortcuts toggle ---
2254
+ document.getElementById('shortcuts-toggle').addEventListener('click', () => {
2255
+ const body = document.getElementById('shortcuts-body');
2256
+ const arrow = document.getElementById('shortcuts-arrow');
2257
+ const hidden = body.style.display === 'none';
2258
+ body.style.display = hidden ? 'block' : 'none';
2259
+ arrow.innerHTML = hidden ? '\\u25BE' : '\\u25B8';
2260
+ });
2261
+
2262
+ // --- Sidebar resize ---
2263
+ (() => {
2264
+ const sidebar = document.getElementById('sidebar');
2265
+ const handle = document.getElementById('sidebar-resize');
2266
+ let dragging = false;
2267
+
2268
+ handle.addEventListener('mousedown', (e) => {
2269
+ e.preventDefault();
2270
+ dragging = true;
2271
+ handle.classList.add('dragging');
2272
+ document.body.style.cursor = 'col-resize';
2273
+ document.body.style.userSelect = 'none';
2274
+ });
2275
+
2276
+ document.addEventListener('mousemove', (e) => {
2277
+ if (!dragging) return;
2278
+ const newWidth = Math.min(600, Math.max(180, e.clientX));
2279
+ sidebar.style.width = newWidth + 'px';
2280
+ });
2281
+
2282
+ document.addEventListener('mouseup', () => {
2283
+ if (!dragging) return;
2284
+ dragging = false;
2285
+ handle.classList.remove('dragging');
2286
+ document.body.style.cursor = '';
2287
+ document.body.style.userSelect = '';
2288
+ localStorage.setItem('diffback-sidebar-width', sidebar.style.width);
2289
+ });
2290
+
2291
+ // Restore saved width
2292
+ const saved = localStorage.getItem('diffback-sidebar-width');
2293
+ if (saved) sidebar.style.width = saved;
2294
+ })();
2295
+
2296
+ // --- Theme switcher ---
2297
+ function setTheme(name) {
2298
+ document.documentElement.className = \`theme-\${name}\`;
2299
+ localStorage.setItem('diffback-theme', name);
2300
+ document.getElementById('theme-selector').value = name;
2301
+ }
2302
+
2303
+ document.getElementById('theme-selector').addEventListener('change', (e) => {
2304
+ setTheme(e.target.value);
2305
+ // Re-render diff to apply new colors
2306
+ if (appState.selectedFile) loadDiff(appState.selectedFile);
2307
+ });
2308
+
2309
+ // Load saved theme
2310
+ const savedTheme = localStorage.getItem('diffback-theme') || 'solarized-dark';
2311
+ setTheme(savedTheme);
2312
+
1598
2313
  // --- Init ---
1599
2314
  loadFiles();
1600
- </script>
2315
+ </script>
1601
2316
  </body>
1602
2317
  </html>
1603
2318
  `);
@@ -1605,7 +2320,7 @@ function startServer(port) {
1605
2320
  }
1606
2321
  if (path === "/api/files" && req.method === "GET") {
1607
2322
  const currentFiles = getChangedFiles();
1608
- state = reconcileState(state, currentFiles);
2323
+ state = reconcileState(state, currentFiles, hashFile);
1609
2324
  saveState(state);
1610
2325
  const filesWithReview = currentFiles.map((f) => ({
1611
2326
  ...f,
@@ -1622,7 +2337,8 @@ function startServer(port) {
1622
2337
  hasFeedback,
1623
2338
  pending: currentFiles.length - reviewed - hasFeedback
1624
2339
  },
1625
- projectName
2340
+ projectName,
2341
+ round: state.round
1626
2342
  });
1627
2343
  return;
1628
2344
  }
@@ -1664,10 +2380,12 @@ function startServer(port) {
1664
2380
  if (path === "/api/review" && req.method === "POST") {
1665
2381
  const body = JSON.parse(await parseBody(req));
1666
2382
  const { path: filePath, status, comments } = body;
2383
+ const existing = state.files[filePath];
1667
2384
  state.files[filePath] = {
1668
2385
  status,
1669
2386
  hash: hashFile(filePath),
1670
2387
  comments: comments || [],
2388
+ archivedComments: existing?.archivedComments || [],
1671
2389
  changedSinceReview: false
1672
2390
  };
1673
2391
  saveState(state);