seendiff 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3175 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>seendiff</title>
7
+ <link rel="icon"
8
+ href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16' shape-rendering='crispEdges'><defs><filter id='g' x='-50%' y='-50%' width='200%' height='200%' color-interpolation-filters='linearRGB'><feGaussianBlur stdDeviation='0.9' result='b'/><feMerge><feMergeNode in='b'/><feMergeNode in='b'/><feMergeNode in='b'/></feMerge></filter><clipPath id='t'><rect width='16' height='16' rx='3'/></clipPath></defs><rect width='16' height='16' rx='3' fill='%23111113'/><g clip-path='url(%23t)'><g fill='%23a3be8c' filter='url(%23g)'><rect x='6' y='3' width='1' height='1'/><rect x='9' y='3' width='1' height='1'/><rect x='5' y='4' width='1' height='1'/><rect x='10' y='4' width='1' height='1'/><rect x='4' y='5' width='1' height='1'/><rect x='11' y='5' width='1' height='1'/><rect x='3' y='6' width='1' height='1'/><rect x='12' y='6' width='1' height='1'/><rect x='2' y='7' width='1' height='1'/><rect x='13' y='7' width='1' height='1'/><rect x='3' y='8' width='1' height='1'/><rect x='12' y='8' width='1' height='1'/><rect x='4' y='9' width='1' height='1'/><rect x='11' y='9' width='1' height='1'/><rect x='5' y='10' width='1' height='1'/><rect x='10' y='10' width='1' height='1'/><rect x='6' y='11' width='1' height='1'/><rect x='9' y='11' width='1' height='1'/></g><g fill='%23a3be8c'><rect x='6' y='3' width='1' height='1'/><rect x='9' y='3' width='1' height='1'/><rect x='5' y='4' width='1' height='1'/><rect x='10' y='4' width='1' height='1'/><rect x='4' y='5' width='1' height='1'/><rect x='11' y='5' width='1' height='1'/><rect x='3' y='6' width='1' height='1'/><rect x='12' y='6' width='1' height='1'/><rect x='2' y='7' width='1' height='1'/><rect x='13' y='7' width='1' height='1'/><rect x='3' y='8' width='1' height='1'/><rect x='12' y='8' width='1' height='1'/><rect x='4' y='9' width='1' height='1'/><rect x='11' y='9' width='1' height='1'/><rect x='5' y='10' width='1' height='1'/><rect x='10' y='10' width='1' height='1'/><rect x='6' y='11' width='1' height='1'/><rect x='9' y='11' width='1' height='1'/></g></g></svg>">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <script>
11
+ (function () {
12
+ var prefs = __PREFS__;
13
+ for (var k in prefs) localStorage.setItem(k, prefs[k]);
14
+ })();
15
+ (function () {
16
+ var t = localStorage.getItem("seendiff.theme");
17
+ if (t === "solarized") t = "solarized-dark";
18
+ if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
19
+ document.documentElement.dataset.theme = t;
20
+ var f = localStorage.getItem("seendiff.font") || "JetBrains Mono";
21
+ document.documentElement.style.setProperty("--mono-font", "'" + f + "'");
22
+ })();
23
+ </script>
24
+ <link rel="stylesheet" href="/theme.css">
25
+ <style>
26
+ @font-face {
27
+ font-family: 'JetBrains Mono';
28
+ font-weight: 400 700;
29
+ src: url('/fonts/JetBrainsMono.woff2') format('woff2');
30
+ }
31
+
32
+ @font-face {
33
+ font-family: 'Ubuntu Sans Mono';
34
+ font-weight: 400 700;
35
+ src: url('/fonts/UbuntuSansMono.woff2') format('woff2');
36
+ }
37
+
38
+ :root {
39
+ --mono-font: 'JetBrains Mono';
40
+ --line-height: 20px;
41
+ --font-size: 12.5px;
42
+ --neon: color-mix(in srgb, var(--add-sign) 82%, #fff);
43
+ --file-neon: var(--neon);
44
+ --trough: color-mix(in srgb, #000 58%, var(--bg));
45
+ }
46
+
47
+ :root[data-theme="light"] {
48
+ --bg: #ffffff;
49
+ --bg2: #f2f2f3;
50
+ --fg: #1d1d1f;
51
+ --fg-dim: #6e6e73;
52
+ --border: #d6d6da;
53
+ --accent: #5f6b7a;
54
+ --add-bg: rgba(26, 127, 55, .08);
55
+ --del-bg: rgba(207, 34, 46, .08);
56
+ --add-sign: #1a7f37;
57
+ --del-sign: #cf222e;
58
+ --mixed: #9a6700;
59
+ --sel-bg: rgba(95, 107, 122, .16);
60
+ --focus-ring: #5f6b7a;
61
+ --toast-bg: #3a3a3f;
62
+ --toast-fg: #ffffff;
63
+ --neon: var(--add-sign);
64
+ --trough: color-mix(in srgb, #000 12%, var(--bg));
65
+ }
66
+
67
+ :root[data-theme="dark"] {
68
+ --bg: #0d1117;
69
+ --bg2: #161b22;
70
+ --fg: #c9d1d9;
71
+ --fg-dim: #8b949e;
72
+ --border: #30363d;
73
+ --accent: #58a6ff;
74
+ --add-bg: rgba(63, 185, 80, .09);
75
+ --del-bg: rgba(248, 81, 73, .09);
76
+ --add-sign: #3fb950;
77
+ --del-sign: #f85149;
78
+ --mixed: #d29922;
79
+ --sel-bg: rgba(56, 139, 253, .25);
80
+ --focus-ring: #58a6ff;
81
+ --toast-bg: #c9d1d9;
82
+ --toast-fg: #0d1117;
83
+ }
84
+
85
+ :root[data-theme="solarized-dark"] {
86
+ --bg: #002b36;
87
+ --bg2: #073642;
88
+ --fg: #93a1a1;
89
+ --fg-dim: #657b83;
90
+ --border: #0a4552;
91
+ --accent: #268bd2;
92
+ --add-bg: rgba(133, 153, 0, .10);
93
+ --del-bg: rgba(220, 50, 47, .10);
94
+ --add-sign: #859900;
95
+ --del-sign: #dc322f;
96
+ --mixed: #b58900;
97
+ --sel-bg: rgba(38, 139, 210, .25);
98
+ --focus-ring: #268bd2;
99
+ --toast-bg: #eee8d5;
100
+ --toast-fg: #002b36;
101
+ --trough: color-mix(in srgb, #000 18%, var(--bg));
102
+ }
103
+
104
+ :root[data-theme="solarized-light"] {
105
+ --bg: #fdf6e3;
106
+ --bg2: #eee8d5;
107
+ --fg: #586e75;
108
+ --fg-dim: #93a1a1;
109
+ --border: #ded8c4;
110
+ --accent: #268bd2;
111
+ --add-bg: rgba(133, 153, 0, .14);
112
+ --del-bg: rgba(220, 50, 47, .12);
113
+ --add-sign: #859900;
114
+ --del-sign: #dc322f;
115
+ --mixed: #b58900;
116
+ --sel-bg: rgba(38, 139, 210, .18);
117
+ --focus-ring: #268bd2;
118
+ --toast-bg: #073642;
119
+ --toast-fg: #eee8d5;
120
+ --neon: var(--add-sign);
121
+ --trough: color-mix(in srgb, #000 12%, var(--bg));
122
+ }
123
+
124
+ :root[data-theme="space"] {
125
+ --bg: #111113;
126
+ --bg2: #1a1a1d;
127
+ --fg: #b8bbc2;
128
+ --fg-dim: #77797f;
129
+ --border: #2b2b30;
130
+ --accent: #9aa4ae;
131
+ --add-bg: rgba(163, 190, 140, .09);
132
+ --del-bg: rgba(191, 97, 106, .10);
133
+ --add-sign: #a3be8c;
134
+ --del-sign: #bf616a;
135
+ --mixed: #ebcb8b;
136
+ --sel-bg: rgba(154, 164, 174, .20);
137
+ --focus-ring: #9aa4ae;
138
+ --toast-bg: #d8d8dc;
139
+ --toast-fg: #111113;
140
+ }
141
+
142
+ :root[data-theme="hailmary"] {
143
+ --bg: #0c1210;
144
+ --bg2: #161e1a;
145
+ --fg: #c6c0b4;
146
+ --fg-dim: #7e786d;
147
+ --border: #2c322d;
148
+ --accent: #4e8f94;
149
+ --add-bg: rgba(143, 156, 94, .10);
150
+ --del-bg: rgba(184, 94, 58, .10);
151
+ --add-sign: #8f9c5e;
152
+ --del-sign: #b85e3a;
153
+ --mixed: #c19a51;
154
+ --sel-bg: rgba(78, 143, 148, .22);
155
+ --focus-ring: #4e8f94;
156
+ --toast-bg: #d2ccc0;
157
+ --toast-fg: #0c1210;
158
+ --neon: #ff4a1f;
159
+ --file-neon: #ffa53d;
160
+ }
161
+
162
+ :root[data-theme="hailmary"] .hl .na {
163
+ color: #c19a51;
164
+ }
165
+
166
+ :root[data-theme="hailmary"] .hl .k,
167
+ :root[data-theme="hailmary"] .hl .kc,
168
+ :root[data-theme="hailmary"] .hl .kd,
169
+ :root[data-theme="hailmary"] .hl .kn,
170
+ :root[data-theme="hailmary"] .hl .kr,
171
+ :root[data-theme="hailmary"] .hl .kt {
172
+ color: #b85e3a;
173
+ }
174
+
175
+ :root[data-theme="hailmary"] .hl .nf,
176
+ :root[data-theme="hailmary"] .hl .nd {
177
+ color: #d9a892;
178
+ }
179
+
180
+ :root[data-theme="hailmary"] .hl .c,
181
+ :root[data-theme="hailmary"] .hl .c1,
182
+ :root[data-theme="hailmary"] .hl .cm,
183
+ :root[data-theme="hailmary"] .hl .cs {
184
+ color: #6f6a60;
185
+ }
186
+
187
+ * {
188
+ box-sizing: border-box;
189
+ margin: 0;
190
+ padding: 0;
191
+ }
192
+
193
+ * {
194
+ scrollbar-color: var(--sb-thumb) transparent;
195
+ scrollbar-width: thin;
196
+ }
197
+
198
+ :root {
199
+ --sb-thumb: color-mix(in srgb, var(--fg) 26%, transparent);
200
+ }
201
+
202
+ ::-webkit-scrollbar {
203
+ width: 10px;
204
+ height: 10px;
205
+ }
206
+
207
+ ::-webkit-scrollbar-track {
208
+ background: transparent;
209
+ }
210
+
211
+ ::-webkit-scrollbar-thumb {
212
+ background: var(--sb-thumb);
213
+ border-radius: 5px;
214
+ border: 2px solid transparent;
215
+ background-clip: content-box;
216
+ }
217
+
218
+ ::-webkit-scrollbar-thumb:hover {
219
+ background: color-mix(in srgb, var(--accent) 65%, var(--fg));
220
+ background-clip: content-box;
221
+ }
222
+
223
+ ::-webkit-scrollbar-corner {
224
+ background: transparent;
225
+ }
226
+
227
+ html,
228
+ body {
229
+ height: 100%;
230
+ }
231
+
232
+ body {
233
+ background: var(--bg);
234
+ color: var(--fg);
235
+ font: 13px/1.45 system-ui, sans-serif;
236
+ display: flex;
237
+ overflow: hidden;
238
+ }
239
+
240
+ button {
241
+ font: inherit;
242
+ color: inherit;
243
+ background: none;
244
+ border: 1px solid var(--border);
245
+ border-radius: 4px;
246
+ padding: 2px 8px;
247
+ cursor: pointer;
248
+ }
249
+
250
+ button:hover {
251
+ border-color: var(--accent);
252
+ }
253
+
254
+ #sidebar {
255
+ width: 300px;
256
+ min-width: 160px;
257
+ max-width: 60vw;
258
+ background: var(--bg2);
259
+ border-right: 1px solid var(--border);
260
+ display: flex;
261
+ flex-direction: column;
262
+ flex: none;
263
+ position: relative;
264
+ }
265
+
266
+ #sb-resizer {
267
+ position: absolute;
268
+ top: 0;
269
+ right: -3px;
270
+ width: 6px;
271
+ height: 100%;
272
+ cursor: col-resize;
273
+ z-index: 5;
274
+ }
275
+
276
+ #sb-resizer:hover,
277
+ #sb-resizer.active {
278
+ background: var(--accent);
279
+ opacity: .5;
280
+ }
281
+
282
+ #sb-head {
283
+ padding: 10px 12px 8px;
284
+ border-bottom: 1px solid var(--border);
285
+ }
286
+
287
+ #sb-title {
288
+ font-weight: 600;
289
+ }
290
+
291
+ #sb-title .wm-seen {
292
+ color: color-mix(in srgb, var(--fg) 35%, #fff);
293
+ }
294
+
295
+ #sb-title .wm-diff {
296
+ color: color-mix(in srgb, var(--fg) 70%, var(--fg-dim));
297
+ }
298
+
299
+ :root[data-theme="light"] #sb-title .wm-seen,
300
+ :root[data-theme="solarized-light"] #sb-title .wm-seen {
301
+ color: var(--fg);
302
+ }
303
+
304
+ #sb-stats {
305
+ color: var(--fg-dim);
306
+ font-size: 12px;
307
+ margin-top: 2px;
308
+ }
309
+
310
+ #sb-base {
311
+ color: var(--fg-dim);
312
+ font-size: 11px;
313
+ margin-top: 2px;
314
+ font-family: var(--mono-font), monospace;
315
+ }
316
+
317
+ #dirty-banner {
318
+ margin-top: 6px;
319
+ font-size: 12px;
320
+ color: var(--mixed);
321
+ cursor: default;
322
+ display: none;
323
+ }
324
+
325
+ #sb-progress,
326
+ #sb-progress span {
327
+ --notches: repeating-linear-gradient(90deg, var(--notch) 0 var(--notch-w),
328
+ transparent var(--notch-w) var(--notch-p));
329
+ }
330
+
331
+ #sb-progress {
332
+ --notch: color-mix(in srgb, var(--fg) 20%, transparent);
333
+ --notch-w: 7px;
334
+ --notch-p: 10px;
335
+ position: relative;
336
+ height: 15px;
337
+ margin-top: 8px;
338
+ width: var(--pbar-w, 100%);
339
+ padding: 3px var(--pbar-pad, 3px);
340
+ border-radius: 3px;
341
+ background-image: var(--notches), linear-gradient(var(--trough), var(--trough));
342
+ background-origin: content-box, border-box;
343
+ background-clip: content-box, border-box;
344
+ background-repeat: repeat, no-repeat;
345
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, #000 35%, transparent),
346
+ inset 0 1px 3px color-mix(in srgb, #000 45%, transparent);
347
+ }
348
+
349
+ #sb-progress span {
350
+ position: absolute;
351
+ left: var(--pbar-pad, 3px);
352
+ top: 3px;
353
+ bottom: 3px;
354
+ background-image: var(--notches);
355
+ transition: width .3s;
356
+ }
357
+
358
+ #sb-progress .seen {
359
+ --notch: color-mix(in srgb, var(--neon) 72%, transparent);
360
+ }
361
+
362
+ #sb-progress .rev {
363
+ --notch: var(--neon);
364
+ z-index: 1;
365
+ transition: width .3s, filter .4s;
366
+ filter: drop-shadow(0 0 3px color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(2px * var(--glow, 0)) color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(5px * var(--glow, 0)) color-mix(in srgb, var(--neon) 45%, transparent)) drop-shadow(0 0 calc(9px * var(--glow, 0)) color-mix(in srgb, var(--neon) 30%, transparent));
367
+ }
368
+
369
+ :is(:root[data-theme="light"], :root[data-theme="dark"],
370
+ :root[data-theme="solarized-dark"], :root[data-theme="solarized-light"]):is(#sb-progress, #sb-progress span) {
371
+ --notches: linear-gradient(var(--notch), var(--notch));
372
+ }
373
+
374
+ :is(:root[data-theme="light"], :root[data-theme="dark"],
375
+ :root[data-theme="solarized-dark"], :root[data-theme="solarized-light"]) #sb-progress span {
376
+ border-radius: 2px;
377
+ }
378
+
379
+ :is(:root[data-theme="light"], :root[data-theme="solarized-light"]):is(#sb-progress, .f-bar) .rev {
380
+ filter: none;
381
+ }
382
+
383
+ :root[data-theme="space"] #sb-progress .rev {
384
+ filter: drop-shadow(0 0 3px color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(4px * var(--glow, 0)) color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(9px * var(--glow, 0)) color-mix(in srgb, var(--neon) 45%, transparent)) drop-shadow(0 0 calc(18px * var(--glow, 0)) color-mix(in srgb, var(--neon) 30%, transparent));
385
+ }
386
+
387
+ :root[data-theme="hailmary"] #sb-progress .rev {
388
+ filter: drop-shadow(0 0 3px color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(2px * var(--glow, 0)) #fff6) drop-shadow(0 0 calc(6px * var(--glow, 0)) color-mix(in srgb, var(--neon) 70%, transparent)) drop-shadow(0 0 calc(13px * var(--glow, 0)) color-mix(in srgb, var(--neon) 45%, transparent)) drop-shadow(0 0 calc(24px * var(--glow, 0)) color-mix(in srgb, var(--neon) 30%, transparent));
389
+ }
390
+
391
+ #confetti {
392
+ position: fixed;
393
+ inset: 0;
394
+ pointer-events: none;
395
+ z-index: 30;
396
+ }
397
+
398
+ #filter {
399
+ margin: 8px 10px;
400
+ padding: 4px 8px;
401
+ width: calc(100% - 20px);
402
+ background: var(--bg);
403
+ color: var(--fg);
404
+ border: 1px solid var(--border);
405
+ border-radius: 4px;
406
+ font: inherit;
407
+ }
408
+
409
+ #filter:focus {
410
+ outline: 1px solid var(--accent);
411
+ }
412
+
413
+ #filelist {
414
+ overflow-y: auto;
415
+ flex: 1;
416
+ padding-bottom: 12px;
417
+ }
418
+
419
+ .f-item,
420
+ .d-item {
421
+ padding: 4px 10px 4px 8px;
422
+ cursor: pointer;
423
+ display: flex;
424
+ align-items: center;
425
+ gap: 6px;
426
+ border-left: 2px solid transparent;
427
+ }
428
+
429
+ .d-item {
430
+ color: var(--fg-dim);
431
+ }
432
+
433
+ .d-item:hover {
434
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
435
+ }
436
+
437
+ .d-arrow {
438
+ width: 12px;
439
+ flex: none;
440
+ text-align: center;
441
+ font-size: 10px;
442
+ }
443
+
444
+ .d-name {
445
+ flex: 1;
446
+ min-width: 0;
447
+ overflow: hidden;
448
+ text-overflow: ellipsis;
449
+ white-space: nowrap;
450
+ font-family: var(--mono-font), monospace;
451
+ font-size: 12px;
452
+ }
453
+
454
+ .f-item:hover {
455
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
456
+ }
457
+
458
+ .f-item.cur {
459
+ border-left-color: var(--accent);
460
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
461
+ }
462
+
463
+ .f-glyph {
464
+ width: 14px;
465
+ text-align: center;
466
+ flex: none;
467
+ cursor: pointer;
468
+ }
469
+
470
+ .f-glyph.done {
471
+ color: var(--add-sign);
472
+ }
473
+
474
+ .f-name {
475
+ flex: 1;
476
+ min-width: 0;
477
+ overflow: hidden;
478
+ text-overflow: ellipsis;
479
+ white-space: nowrap;
480
+ direction: rtl;
481
+ text-align: left;
482
+ font-family: var(--mono-font), monospace;
483
+ font-size: 12px;
484
+ }
485
+
486
+ .f-name bdi {
487
+ unicode-bidi: plaintext;
488
+ }
489
+
490
+ .f-right {
491
+ flex: none;
492
+ display: flex;
493
+ flex-direction: column;
494
+ align-items: flex-end;
495
+ gap: 2px;
496
+ }
497
+
498
+ .f-counts {
499
+ font-size: 10.5px;
500
+ color: var(--fg-dim);
501
+ font-family: var(--mono-font), monospace;
502
+ white-space: nowrap;
503
+ }
504
+
505
+ .f-counts .plus {
506
+ color: var(--add-sign);
507
+ }
508
+
509
+ .f-counts .minus {
510
+ color: var(--del-sign);
511
+ }
512
+
513
+ .f-bar {
514
+ width: 64px;
515
+ height: 5px;
516
+ display: flex;
517
+ background: color-mix(in srgb, var(--fg) 14%, transparent);
518
+ border-radius: 2px;
519
+ }
520
+
521
+ .f-bar .rev {
522
+ background: var(--file-neon);
523
+ height: 100%;
524
+ border-radius: 2px;
525
+ filter: drop-shadow(0 0 3px color-mix(in srgb, var(--file-neon) 75%, transparent));
526
+ }
527
+
528
+ .f-bar .seen {
529
+ background: color-mix(in srgb, var(--file-neon) 40%, transparent);
530
+ height: 100%;
531
+ border-radius: 2px;
532
+ }
533
+
534
+ #main {
535
+ flex: 1;
536
+ display: flex;
537
+ flex-direction: column;
538
+ min-width: 0;
539
+ }
540
+
541
+ #topbar {
542
+ display: flex;
543
+ align-items: center;
544
+ gap: 10px;
545
+ padding: 6px 12px;
546
+ border-bottom: 1px solid var(--border);
547
+ background: var(--bg2);
548
+ flex: none;
549
+ }
550
+
551
+ #tb-path {
552
+ font-family: var(--mono-font), monospace;
553
+ font-weight: 600;
554
+ overflow: hidden;
555
+ text-overflow: ellipsis;
556
+ white-space: nowrap;
557
+ }
558
+
559
+ #tb-info {
560
+ color: var(--fg-dim);
561
+ font-size: 12px;
562
+ white-space: nowrap;
563
+ }
564
+
565
+ #tb-sp {
566
+ flex: 1;
567
+ }
568
+
569
+ #topbar button {
570
+ font-size: 12px;
571
+ }
572
+
573
+ #pane-wrap {
574
+ flex: 1;
575
+ display: flex;
576
+ min-height: 0;
577
+ position: relative;
578
+ }
579
+
580
+ #pane {
581
+ flex: 1;
582
+ min-width: 0;
583
+ overflow: auto;
584
+ position: relative;
585
+ font-family: var(--mono-font), monospace;
586
+ font-size: var(--font-size);
587
+ }
588
+
589
+ #spacer {
590
+ position: relative;
591
+ min-width: 100%;
592
+ }
593
+
594
+ .row {
595
+ position: absolute;
596
+ left: 0;
597
+ right: 0;
598
+ line-height: var(--line-height);
599
+ display: flex;
600
+ align-items: flex-start;
601
+ }
602
+
603
+ .row.add {
604
+ background: var(--add-bg);
605
+ }
606
+
607
+ .row.del {
608
+ background: var(--del-bg);
609
+ }
610
+
611
+ .row.focused-block {
612
+ box-shadow: inset 2px 0 0 var(--focus-ring);
613
+ }
614
+
615
+ .row.selected .ln.new,
616
+ .row.selected .content {
617
+ background: var(--sel-bg);
618
+ }
619
+
620
+ .ln {
621
+ flex: none;
622
+ width: 4.5em;
623
+ padding-right: 8px;
624
+ text-align: right;
625
+ color: var(--fg-dim);
626
+ cursor: pointer;
627
+ user-select: none;
628
+ font-size: 11px;
629
+ }
630
+
631
+ .ln:hover {
632
+ color: var(--accent);
633
+ }
634
+
635
+ .sign {
636
+ flex: none;
637
+ width: 1.2em;
638
+ text-align: center;
639
+ user-select: none;
640
+ font-weight: 700;
641
+ }
642
+
643
+ .row.add .sign {
644
+ color: var(--add-sign);
645
+ }
646
+
647
+ .row.del .sign {
648
+ color: var(--del-sign);
649
+ }
650
+
651
+ .chk {
652
+ flex: none;
653
+ width: 1.4em;
654
+ text-align: center;
655
+ cursor: pointer;
656
+ user-select: none;
657
+ color: var(--fg-dim);
658
+ font-size: 14px;
659
+ }
660
+
661
+ .chk.seen {
662
+ color: color-mix(in srgb, var(--add-sign) 60%, var(--fg-dim));
663
+ }
664
+
665
+ .chk.reviewed {
666
+ color: var(--add-sign);
667
+ font-weight: 700;
668
+ }
669
+
670
+ .chk:hover {
671
+ color: var(--accent);
672
+ }
673
+
674
+ .content {
675
+ flex: 1;
676
+ min-width: 0;
677
+ font-family: inherit;
678
+ font-size: inherit;
679
+ white-space: pre-wrap;
680
+ word-break: break-all;
681
+ tab-size: 4;
682
+ }
683
+
684
+ .row.split {
685
+ align-items: stretch;
686
+ }
687
+
688
+ .row.split .half {
689
+ flex: 1 1 0;
690
+ min-width: 0;
691
+ display: flex;
692
+ align-items: flex-start;
693
+ }
694
+
695
+ .row.split .half.left {
696
+ border-right: 1px solid var(--border);
697
+ }
698
+
699
+ .row.split .half.add {
700
+ background: var(--add-bg);
701
+ }
702
+
703
+ .row.split .half.del {
704
+ background: var(--del-bg);
705
+ }
706
+
707
+ .row.split .half.empty {
708
+ background: color-mix(in srgb, var(--fg) 5%, transparent);
709
+ }
710
+
711
+ .row.split .half.add .sign {
712
+ color: var(--add-sign);
713
+ }
714
+
715
+ .row.split .half.del .sign {
716
+ color: var(--del-sign);
717
+ }
718
+
719
+ .row.split.selected .half {
720
+ background: var(--sel-bg);
721
+ }
722
+
723
+ #empty-msg {
724
+ padding: 48px;
725
+ color: var(--fg-dim);
726
+ font-size: 14px;
727
+ font-family: system-ui, sans-serif;
728
+ }
729
+
730
+ #wt-bar {
731
+ flex: none;
732
+ height: var(--wt-height, 160px);
733
+ background: var(--bg2);
734
+ border-top: 1px solid var(--border);
735
+ display: flex;
736
+ flex-direction: column;
737
+ position: relative;
738
+ font-family: system-ui, sans-serif;
739
+ }
740
+
741
+ #wt-bar[hidden] {
742
+ display: none;
743
+ }
744
+
745
+ #wt-resizer {
746
+ position: absolute;
747
+ top: -3px;
748
+ left: 0;
749
+ right: 0;
750
+ height: 6px;
751
+ cursor: row-resize;
752
+ z-index: 5;
753
+ }
754
+
755
+ #wt-resizer:hover,
756
+ #wt-resizer.active {
757
+ background: var(--accent);
758
+ opacity: .5;
759
+ }
760
+
761
+ #wt-head {
762
+ display: flex;
763
+ align-items: baseline;
764
+ gap: 10px;
765
+ padding: 8px 14px 2px;
766
+ flex: none;
767
+ min-width: 0;
768
+ }
769
+
770
+ #wt-act {
771
+ color: var(--accent);
772
+ font-size: 11px;
773
+ font-weight: 700;
774
+ letter-spacing: .08em;
775
+ text-transform: uppercase;
776
+ white-space: nowrap;
777
+ }
778
+
779
+ #wt-count {
780
+ color: var(--fg-dim);
781
+ font-size: 11px;
782
+ white-space: nowrap;
783
+ font-family: var(--mono-font), monospace;
784
+ }
785
+
786
+ #wt-title {
787
+ font-weight: 600;
788
+ overflow: hidden;
789
+ text-overflow: ellipsis;
790
+ white-space: nowrap;
791
+ }
792
+
793
+ #wt-tag {
794
+ font-size: 10px;
795
+ color: var(--fg-dim);
796
+ border: 1px solid var(--border);
797
+ border-radius: 3px;
798
+ padding: 0 4px;
799
+ display: none;
800
+ }
801
+
802
+ #wt-bar.d1 #wt-tag {
803
+ display: inline;
804
+ }
805
+
806
+ #wt-bar.d1 #wt-head,
807
+ #wt-bar.d1 #wt-text,
808
+ #wt-bar.d1 #wt-refs {
809
+ padding-left: 28px;
810
+ }
811
+
812
+ #wt-sp {
813
+ flex: 1;
814
+ }
815
+
816
+ #wt-hint {
817
+ color: var(--fg-dim);
818
+ font-size: 11px;
819
+ white-space: nowrap;
820
+ }
821
+
822
+ #wt-dismiss {
823
+ font-size: 11px;
824
+ white-space: nowrap;
825
+ }
826
+
827
+ #wt-text {
828
+ flex: 1;
829
+ overflow-y: auto;
830
+ padding: 4px 14px;
831
+ font-size: 13px;
832
+ line-height: 1.5;
833
+ min-height: 0;
834
+ }
835
+
836
+ #wt-text.err {
837
+ color: var(--del-sign);
838
+ white-space: pre-wrap;
839
+ font-family: var(--mono-font), monospace;
840
+ font-size: 12px;
841
+ }
842
+
843
+ #wt-refs {
844
+ flex: none;
845
+ display: flex;
846
+ gap: 6px;
847
+ padding: 4px 14px;
848
+ flex-wrap: wrap;
849
+ align-items: center;
850
+ }
851
+
852
+ .wt-chip {
853
+ font-family: var(--mono-font), monospace;
854
+ font-size: 11px;
855
+ border: 1px solid var(--border);
856
+ border-radius: 4px;
857
+ padding: 1px 7px;
858
+ cursor: pointer;
859
+ color: var(--fg);
860
+ background: var(--bg);
861
+ }
862
+
863
+ .wt-chip:hover {
864
+ border-color: var(--accent);
865
+ }
866
+
867
+ .wt-chip.primary {
868
+ border-color: var(--accent);
869
+ color: var(--accent);
870
+ }
871
+
872
+ .wt-fwd {
873
+ font-size: 11px;
874
+ color: var(--fg-dim);
875
+ cursor: pointer;
876
+ margin-left: auto;
877
+ white-space: nowrap;
878
+ }
879
+
880
+ .wt-fwd:hover {
881
+ color: var(--accent);
882
+ }
883
+
884
+ #wt-pips {
885
+ flex: none;
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 4px;
889
+ padding: 6px 14px 8px;
890
+ overflow-x: auto;
891
+ }
892
+
893
+ .wt-pip {
894
+ width: 8px;
895
+ height: 8px;
896
+ border-radius: 50%;
897
+ flex: none;
898
+ cursor: pointer;
899
+ border: 1px solid var(--fg-dim);
900
+ background: transparent;
901
+ opacity: .45;
902
+ }
903
+
904
+ .wt-pip.visited {
905
+ opacity: 1;
906
+ }
907
+
908
+ .wt-pip.cur {
909
+ background: var(--accent);
910
+ border-color: var(--accent);
911
+ opacity: 1;
912
+ }
913
+
914
+ .wt-pip.d1 {
915
+ width: 5px;
916
+ height: 5px;
917
+ }
918
+
919
+ .wt-gap {
920
+ width: 8px;
921
+ flex: none;
922
+ }
923
+
924
+ .row.wt-hi {
925
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
926
+ box-shadow: inset 2px 0 0 var(--accent);
927
+ }
928
+
929
+ .row.split.wt-hi .half {
930
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
931
+ }
932
+
933
+ .row.wt-anchor .ln.new {
934
+ position: relative;
935
+ }
936
+
937
+ .row.wt-anchor .ln.new::before {
938
+ content: "";
939
+ position: absolute;
940
+ right: 1px;
941
+ top: 50%;
942
+ margin-top: -3px;
943
+ width: 6px;
944
+ height: 6px;
945
+ border-radius: 50%;
946
+ background: var(--accent);
947
+ opacity: .5;
948
+ }
949
+
950
+ #wt-outline {
951
+ border-bottom: 1px solid var(--border);
952
+ max-height: 40%;
953
+ overflow-y: auto;
954
+ flex: none;
955
+ font-size: 12px;
956
+ padding-bottom: 4px;
957
+ }
958
+
959
+ #wt-o-head {
960
+ padding: 7px 12px 3px;
961
+ font-weight: 600;
962
+ cursor: pointer;
963
+ }
964
+
965
+ #wt-o-head .d-arrow {
966
+ display: inline-block;
967
+ }
968
+
969
+ .wt-o-act {
970
+ padding: 5px 12px 2px;
971
+ color: var(--fg-dim);
972
+ font-size: 10.5px;
973
+ font-weight: 700;
974
+ letter-spacing: .06em;
975
+ text-transform: uppercase;
976
+ cursor: pointer;
977
+ }
978
+
979
+ .wt-o-step {
980
+ padding: 2px 12px 2px 22px;
981
+ cursor: pointer;
982
+ overflow: hidden;
983
+ text-overflow: ellipsis;
984
+ white-space: nowrap;
985
+ border-left: 2px solid transparent;
986
+ }
987
+
988
+ .wt-o-step:hover {
989
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
990
+ }
991
+
992
+ .wt-o-step.cur {
993
+ border-left-color: var(--accent);
994
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
995
+ }
996
+
997
+ .wt-o-step.d1 {
998
+ padding-left: 32px;
999
+ color: var(--fg-dim);
1000
+ }
1001
+
1002
+ .wt-files-head {
1003
+ padding: 8px 12px 2px;
1004
+ color: var(--fg-dim);
1005
+ font-size: 10.5px;
1006
+ font-weight: 700;
1007
+ letter-spacing: .06em;
1008
+ text-transform: uppercase;
1009
+ }
1010
+
1011
+ #searchbar {
1012
+ flex: none;
1013
+ display: flex;
1014
+ align-items: center;
1015
+ gap: 6px;
1016
+ padding: 5px 12px;
1017
+ background: var(--bg2);
1018
+ border-bottom: 1px solid var(--border);
1019
+ }
1020
+
1021
+ #searchbar[hidden] {
1022
+ display: none;
1023
+ }
1024
+
1025
+ #s-input {
1026
+ flex: 1;
1027
+ min-width: 0;
1028
+ padding: 3px 8px;
1029
+ background: var(--bg);
1030
+ color: var(--fg);
1031
+ border: 1px solid var(--border);
1032
+ border-radius: 4px;
1033
+ font-family: var(--mono-font), monospace;
1034
+ font-size: 12px;
1035
+ }
1036
+
1037
+ #s-input:focus {
1038
+ outline: 1px solid var(--accent);
1039
+ }
1040
+
1041
+ #s-input.bad {
1042
+ border-color: var(--del-sign);
1043
+ }
1044
+
1045
+ #s-count {
1046
+ color: var(--fg-dim);
1047
+ font-size: 11.5px;
1048
+ white-space: nowrap;
1049
+ font-family: var(--mono-font), monospace;
1050
+ min-width: 7em;
1051
+ text-align: right;
1052
+ }
1053
+
1054
+ #searchbar button {
1055
+ font-size: 11.5px;
1056
+ padding: 1px 7px;
1057
+ }
1058
+
1059
+ #searchbar button.on {
1060
+ border-color: var(--accent);
1061
+ color: var(--accent);
1062
+ }
1063
+
1064
+ mark {
1065
+ background: color-mix(in srgb, var(--mixed) 35%, transparent);
1066
+ color: inherit;
1067
+ border-radius: 2px;
1068
+ }
1069
+
1070
+ mark.cur {
1071
+ background: color-mix(in srgb, var(--mixed) 65%, transparent);
1072
+ box-shadow: 0 0 0 1px var(--mixed);
1073
+ }
1074
+
1075
+ #s-panel {
1076
+ width: 320px;
1077
+ min-width: 180px;
1078
+ max-width: 50vw;
1079
+ flex: none;
1080
+ background: var(--bg2);
1081
+ border-left: 1px solid var(--border);
1082
+ display: flex;
1083
+ flex-direction: column;
1084
+ position: relative;
1085
+ }
1086
+
1087
+ #s-panel[hidden] {
1088
+ display: none;
1089
+ }
1090
+
1091
+ #sp-resizer {
1092
+ position: absolute;
1093
+ top: 0;
1094
+ left: -3px;
1095
+ width: 6px;
1096
+ height: 100%;
1097
+ cursor: col-resize;
1098
+ z-index: 5;
1099
+ }
1100
+
1101
+ #sp-resizer:hover,
1102
+ #sp-resizer.active {
1103
+ background: var(--accent);
1104
+ opacity: .5;
1105
+ }
1106
+
1107
+ #sp-head {
1108
+ display: flex;
1109
+ align-items: baseline;
1110
+ gap: 8px;
1111
+ flex: none;
1112
+ padding: 8px 10px 6px;
1113
+ border-bottom: 1px solid var(--border);
1114
+ }
1115
+
1116
+ #sp-title {
1117
+ font-weight: 600;
1118
+ font-size: 12.5px;
1119
+ }
1120
+
1121
+ #sp-sub {
1122
+ color: var(--fg-dim);
1123
+ font-size: 11px;
1124
+ flex: 1;
1125
+ min-width: 0;
1126
+ overflow: hidden;
1127
+ text-overflow: ellipsis;
1128
+ white-space: nowrap;
1129
+ }
1130
+
1131
+ #sp-close {
1132
+ font-size: 11px;
1133
+ flex: none;
1134
+ padding: 0 5px;
1135
+ }
1136
+
1137
+ #s-results {
1138
+ overflow-y: auto;
1139
+ flex: 1;
1140
+ padding-bottom: 12px;
1141
+ }
1142
+
1143
+ .sr-file {
1144
+ padding: 7px 10px 2px;
1145
+ font-family: var(--mono-font), monospace;
1146
+ font-size: 11.5px;
1147
+ color: var(--fg-dim);
1148
+ overflow: hidden;
1149
+ text-overflow: ellipsis;
1150
+ white-space: nowrap;
1151
+ direction: rtl;
1152
+ text-align: left;
1153
+ }
1154
+
1155
+ .sr-file bdi {
1156
+ unicode-bidi: plaintext;
1157
+ }
1158
+
1159
+ .sr-hit {
1160
+ display: flex;
1161
+ gap: 6px;
1162
+ padding: 1px 10px 1px 14px;
1163
+ cursor: pointer;
1164
+ border-left: 2px solid transparent;
1165
+ }
1166
+
1167
+ .sr-hit:hover {
1168
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
1169
+ }
1170
+
1171
+ .sr-hit.cur {
1172
+ border-left-color: var(--accent);
1173
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
1174
+ }
1175
+
1176
+ .sr-ln {
1177
+ flex: none;
1178
+ width: 3.6em;
1179
+ text-align: right;
1180
+ color: var(--fg-dim);
1181
+ font-family: var(--mono-font), monospace;
1182
+ font-size: 10.5px;
1183
+ }
1184
+
1185
+ .sr-hit.add .sr-ln {
1186
+ color: var(--add-sign);
1187
+ }
1188
+
1189
+ .sr-hit.del .sr-ln {
1190
+ color: var(--del-sign);
1191
+ }
1192
+
1193
+ .sr-txt {
1194
+ flex: 1;
1195
+ min-width: 0;
1196
+ overflow: hidden;
1197
+ text-overflow: ellipsis;
1198
+ white-space: nowrap;
1199
+ font-family: var(--mono-font), monospace;
1200
+ font-size: 11.5px;
1201
+ }
1202
+
1203
+ #s-note {
1204
+ padding: 14px 12px;
1205
+ color: var(--fg-dim);
1206
+ font-size: 12px;
1207
+ }
1208
+
1209
+ #minimap {
1210
+ flex: none;
1211
+ width: 14px;
1212
+ cursor: pointer;
1213
+ background: var(--bg2);
1214
+ border-left: 1px solid var(--border);
1215
+ }
1216
+
1217
+ #help-overlay {
1218
+ position: fixed;
1219
+ inset: 0;
1220
+ background: rgba(0, 0, 0, .45);
1221
+ display: none;
1222
+ align-items: center;
1223
+ justify-content: center;
1224
+ z-index: 20;
1225
+ }
1226
+
1227
+ #help-overlay.show {
1228
+ display: flex;
1229
+ }
1230
+
1231
+ #help {
1232
+ background: var(--bg2);
1233
+ border: 1px solid var(--border);
1234
+ border-radius: 8px;
1235
+ padding: 18px 22px;
1236
+ max-width: 480px;
1237
+ box-shadow: 0 8px 32px rgba(0, 0, 0, .35);
1238
+ }
1239
+
1240
+ #help-title {
1241
+ font-weight: 600;
1242
+ margin-bottom: 10px;
1243
+ }
1244
+
1245
+ #help table {
1246
+ border-collapse: collapse;
1247
+ }
1248
+
1249
+ #help td {
1250
+ padding: 3px 8px;
1251
+ vertical-align: top;
1252
+ }
1253
+
1254
+ #help td:first-child {
1255
+ font-family: var(--mono-font), monospace;
1256
+ font-size: 12px;
1257
+ color: var(--accent);
1258
+ white-space: nowrap;
1259
+ text-align: right;
1260
+ }
1261
+
1262
+ #help-hint {
1263
+ margin-top: 10px;
1264
+ color: var(--fg-dim);
1265
+ font-size: 12px;
1266
+ }
1267
+
1268
+ #confirm-overlay {
1269
+ position: fixed;
1270
+ inset: 0;
1271
+ background: rgba(0, 0, 0, .45);
1272
+ display: none;
1273
+ align-items: center;
1274
+ justify-content: center;
1275
+ z-index: 21;
1276
+ }
1277
+
1278
+ #confirm-overlay.show {
1279
+ display: flex;
1280
+ }
1281
+
1282
+ #confirm {
1283
+ background: var(--bg2);
1284
+ border: 1px solid var(--border);
1285
+ border-radius: 8px;
1286
+ padding: 18px 22px;
1287
+ max-width: 420px;
1288
+ box-shadow: 0 8px 32px rgba(0, 0, 0, .35);
1289
+ }
1290
+
1291
+ #confirm-btns {
1292
+ margin-top: 14px;
1293
+ display: flex;
1294
+ gap: 10px;
1295
+ justify-content: flex-end;
1296
+ }
1297
+
1298
+ #confirm-yes {
1299
+ border-color: var(--del-sign);
1300
+ color: var(--del-sign);
1301
+ }
1302
+
1303
+ #toast {
1304
+ position: fixed;
1305
+ bottom: 24px;
1306
+ left: 50%;
1307
+ transform: translateX(-50%);
1308
+ background: var(--toast-bg);
1309
+ color: var(--toast-fg);
1310
+ padding: 8px 16px;
1311
+ border-radius: 6px;
1312
+ font-size: 12.5px;
1313
+ opacity: 0;
1314
+ transition: opacity .18s;
1315
+ pointer-events: none;
1316
+ max-width: 70vw;
1317
+ overflow: hidden;
1318
+ text-overflow: ellipsis;
1319
+ white-space: nowrap;
1320
+ z-index: 10;
1321
+ }
1322
+
1323
+ #toast.show {
1324
+ opacity: 1;
1325
+ }
1326
+
1327
+ #toast input {
1328
+ pointer-events: auto;
1329
+ font: inherit;
1330
+ width: 320px;
1331
+ }
1332
+ </style>
1333
+ </head>
1334
+
1335
+ <body>
1336
+ <nav id="sidebar">
1337
+ <div id="sb-head">
1338
+ <div id="sb-title"><span class="wm-seen">seen</span><span class="wm-diff">diff</span></div>
1339
+ <div id="sb-stats">loading…</div>
1340
+ <div id="sb-progress"><span class="rev"></span><span class="seen"></span></div>
1341
+ <div id="sb-base"></div>
1342
+ <div id="dirty-banner"></div>
1343
+ </div>
1344
+ <div id="wt-outline" hidden></div>
1345
+ <input id="filter" placeholder="filter files (g)" spellcheck="false">
1346
+ <div id="filelist"></div>
1347
+ <div id="sb-resizer" title="drag to resize"></div>
1348
+ </nav>
1349
+ <main id="main">
1350
+ <div id="topbar">
1351
+ <span id="tb-path">no file open</span>
1352
+ <span id="tb-info"></span>
1353
+ <span id="tb-sp"></span>
1354
+ <button id="btn-split" title="side-by-side / unified (s)">unified</button>
1355
+ <button id="btn-hide" title="hide fully-reviewed files (h)">hide ✓</button>
1356
+ <button id="btn-font" title="toggle font"></button>
1357
+ <button id="btn-theme" title="cycle theme (t)"></button>
1358
+ <button id="btn-keys" title="keyboard shortcuts (?)">keys?</button>
1359
+ </div>
1360
+ <div id="searchbar" hidden>
1361
+ <input id="s-input" placeholder="search all changed files" spellcheck="false">
1362
+ <span id="s-count"></span>
1363
+ <button id="s-prev" title="previous match (⇧⏎)">↑</button>
1364
+ <button id="s-next" title="next match (⏎)">↓</button>
1365
+ <button id="s-regex" title="regular expression">.*</button>
1366
+ <button id="s-panel-btn" title="show / hide the results panel">results</button>
1367
+ <button id="s-close" title="close search (Esc)">✕</button>
1368
+ </div>
1369
+ <div id="pane-wrap">
1370
+ <div id="pane">
1371
+ <div id="spacer"></div>
1372
+ </div>
1373
+ <canvas id="minimap"></canvas>
1374
+ </div>
1375
+ <div id="wt-bar" hidden>
1376
+ <div id="wt-resizer" title="drag to resize"></div>
1377
+ <div id="wt-head">
1378
+ <span id="wt-act"></span><span id="wt-count"></span>
1379
+ <span id="wt-title"></span><span id="wt-tag">detail</span>
1380
+ <span id="wt-sp"></span>
1381
+ <span id="wt-hint">← → step · [ ] act · . detail · w hide</span>
1382
+ <button id="wt-dismiss" title="dismiss this walkthrough — back to the plain diff">dismiss ✕</button>
1383
+ </div>
1384
+ <div id="wt-text"></div>
1385
+ <div id="wt-refs"></div>
1386
+ <div id="wt-pips"></div>
1387
+ </div>
1388
+ </main>
1389
+ <aside id="s-panel" hidden>
1390
+ <div id="sp-resizer" title="drag to resize"></div>
1391
+ <div id="sp-head">
1392
+ <span id="sp-title">results</span>
1393
+ <span id="sp-sub"></span>
1394
+ <button id="sp-close" title="dismiss the results panel">✕</button>
1395
+ </div>
1396
+ <div id="s-results"></div>
1397
+ </aside>
1398
+ <div id="help-overlay">
1399
+ <div id="help">
1400
+ <div id="help-title">Keyboard shortcuts</div>
1401
+ <table>
1402
+ <tr>
1403
+ <td>← / →</td>
1404
+ <td>prev / next change block in this file</td>
1405
+ </tr>
1406
+ <tr>
1407
+ <td>↑ / ↓</td>
1408
+ <td>prev / next file</td>
1409
+ </tr>
1410
+ <tr>
1411
+ <td>R</td>
1412
+ <td>mark block reviewed, advance (toggles off if reviewed)</td>
1413
+ </tr>
1414
+ <tr>
1415
+ <td>n / N</td>
1416
+ <td>next / prev unreviewed block, crosses files</td>
1417
+ </tr>
1418
+ <tr>
1419
+ <td>j / k</td>
1420
+ <td>scroll lines</td>
1421
+ </tr>
1422
+ <tr>
1423
+ <td>d / u</td>
1424
+ <td>scroll half page</td>
1425
+ </tr>
1426
+ <tr>
1427
+ <td>V</td>
1428
+ <td>mark all seen-but-unreviewed blocks in file reviewed</td>
1429
+ </tr>
1430
+ <tr>
1431
+ <td>f</td>
1432
+ <td>mark whole file reviewed, next file</td>
1433
+ </tr>
1434
+ <tr>
1435
+ <td>y</td>
1436
+ <td>copy ref + content (selection or focused block)</td>
1437
+ </tr>
1438
+ <tr>
1439
+ <td>Y</td>
1440
+ <td>copy refs for all unreviewed blocks in file</td>
1441
+ </tr>
1442
+ <tr>
1443
+ <td>s</td>
1444
+ <td>toggle side-by-side / unified</td>
1445
+ </tr>
1446
+ <tr>
1447
+ <td>Ctrl-F</td>
1448
+ <td>search all changed files — ⏎ / ⇧⏎ next / prev · Esc closes</td>
1449
+ </tr>
1450
+ <tr>
1451
+ <td>g</td>
1452
+ <td>focus file filter</td>
1453
+ </tr>
1454
+ <tr>
1455
+ <td>h</td>
1456
+ <td>hide fully-reviewed files</td>
1457
+ </tr>
1458
+ <tr>
1459
+ <td>t</td>
1460
+ <td>cycle theme</td>
1461
+ </tr>
1462
+ <tr>
1463
+ <td>X</td>
1464
+ <td>reset all seen / reviewed state (asks first)</td>
1465
+ </tr>
1466
+ <tr>
1467
+ <td>?</td>
1468
+ <td>this help &nbsp;·&nbsp; Esc closes</td>
1469
+ </tr>
1470
+ <tbody id="help-wt" hidden>
1471
+ <tr>
1472
+ <td colspan="2" style="padding-top:10px;font-weight:600">Walkthrough</td>
1473
+ </tr>
1474
+ <tr>
1475
+ <td>w</td>
1476
+ <td>enter / exit walkthrough mode</td>
1477
+ </tr>
1478
+ <tr>
1479
+ <td>← / →</td>
1480
+ <td>prev / next step (in walkthrough mode)</td>
1481
+ </tr>
1482
+ <tr>
1483
+ <td>[ / ]</td>
1484
+ <td>prev / next act</td>
1485
+ </tr>
1486
+ <tr>
1487
+ <td>1–9</td>
1488
+ <td>jump to act <i>n</i></td>
1489
+ </tr>
1490
+ <tr>
1491
+ <td>.</td>
1492
+ <td>show / hide detail (depth-1) steps</td>
1493
+ </tr>
1494
+ <tr>
1495
+ <td>⇧← / ⇧→</td>
1496
+ <td>prev / next change block (escape hatch)</td>
1497
+ </tr>
1498
+ </tbody>
1499
+ </table>
1500
+ <div id="help-hint">click a line number to copy a ref · shift-click / drag for a range</div>
1501
+ </div>
1502
+ </div>
1503
+ <div id="confirm-overlay">
1504
+ <div id="confirm">
1505
+ <div id="confirm-msg"></div>
1506
+ <div id="confirm-btns">
1507
+ <button id="confirm-yes">Reset (Enter)</button>
1508
+ <button id="confirm-no">Cancel (Esc)</button>
1509
+ </div>
1510
+ </div>
1511
+ </div>
1512
+ <div id="toast"></div>
1513
+
1514
+ <script>
1515
+ "use strict";
1516
+ const $ = id => document.getElementById(id);
1517
+ function savePref(k, v) {
1518
+ localStorage.setItem(k, v);
1519
+ fetch("/api/prefs", {
1520
+ method: "POST", headers: { "Content-Type": "application/json" },
1521
+ body: JSON.stringify({ key: k, value: String(v) }),
1522
+ }).catch(() => { });
1523
+ }
1524
+ const pane = $("pane"), spacer = $("spacer"), mm = $("minimap");
1525
+ const LH = parseFloat(getComputedStyle(document.documentElement)
1526
+ .getPropertyValue("--line-height"));
1527
+ const THEMES = ["light", "dark", "solarized-light", "solarized-dark",
1528
+ "space", "hailmary"];
1529
+ const FONTS = ["JetBrains Mono", "Ubuntu Sans Mono"];
1530
+
1531
+ let meta = null;
1532
+ let file = null;
1533
+ let blocks = [];
1534
+ let blockByRow = [];
1535
+ let curPath = null;
1536
+ let focusIdx = -1;
1537
+ let hideDone = localStorage.getItem("seendiff.hide") === "1";
1538
+ let selAnchor = null, selEnd = null;
1539
+
1540
+ let wt = null;
1541
+ let wtWatch = false;
1542
+ let wtStep = -1, wtOn = false, wtDetail = true, wtVisited = new Set();
1543
+ let wtHi = null;
1544
+ let wtAnchors = new Set();
1545
+ let wtAway = false;
1546
+ let wtActsClosed = new Set();
1547
+
1548
+ let split = localStorage.getItem("seendiff.split") === "1";
1549
+ let viewRows = [];
1550
+ let viewOf = [];
1551
+ let seenQueue = new Map();
1552
+ let dwell = new Map();
1553
+
1554
+ let allDone = null;
1555
+ function celebrate() {
1556
+ const cv = document.createElement("canvas");
1557
+ cv.id = "confetti";
1558
+ document.body.append(cv);
1559
+ const dpr = devicePixelRatio || 1;
1560
+ cv.width = innerWidth * dpr; cv.height = innerHeight * dpr;
1561
+ const ctx = cv.getContext("2d");
1562
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1563
+ const cs = getComputedStyle(document.documentElement);
1564
+ const colors = ["--add-sign", "--del-sign", "--mixed", "--accent", "--fg"]
1565
+ .map(v => cs.getPropertyValue(v).trim());
1566
+ const parts = [];
1567
+ for (let i = 0; i < 180; i++) {
1568
+ const fromLeft = i % 2 === 0;
1569
+ parts.push({
1570
+ x: fromLeft ? -10 : innerWidth + 10,
1571
+ y: innerHeight * (0.4 + Math.random() * 0.5),
1572
+ vx: (fromLeft ? 1 : -1) * (4 + Math.random() * 8),
1573
+ vy: -(7 + Math.random() * 7),
1574
+ w: 4 + Math.random() * 5, h: 6 + Math.random() * 6,
1575
+ rot: Math.random() * Math.PI, vr: (Math.random() - .5) * .3,
1576
+ color: colors[i % colors.length],
1577
+ });
1578
+ }
1579
+ const t0 = performance.now();
1580
+ (function tick(t) {
1581
+ const dt = Math.min(2, (t - (tick.last || t0)) / 16.7); tick.last = t;
1582
+ ctx.clearRect(0, 0, innerWidth, innerHeight);
1583
+ for (const p of parts) {
1584
+ p.x += p.vx * dt; p.y += p.vy * dt;
1585
+ p.vy += 0.25 * dt; p.vx *= 0.99; p.rot += p.vr * dt;
1586
+ ctx.save();
1587
+ ctx.translate(p.x, p.y); ctx.rotate(p.rot);
1588
+ ctx.fillStyle = p.color;
1589
+ ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
1590
+ ctx.restore();
1591
+ }
1592
+ if (t - t0 < 3200) requestAnimationFrame(tick);
1593
+ else cv.remove();
1594
+ })(t0);
1595
+ toast("🎉 all " + meta.stats.blocks + " blocks reviewed — ship it");
1596
+ }
1597
+
1598
+ function toast(msg, inputText) {
1599
+ const t = $("toast");
1600
+ t.textContent = msg;
1601
+ if (inputText !== undefined) {
1602
+ const inp = document.createElement("input");
1603
+ inp.value = inputText;
1604
+ t.append(" ", inp);
1605
+ inp.focus(); inp.select();
1606
+ }
1607
+ t.classList.add("show");
1608
+ clearTimeout(toast._h);
1609
+ toast._h = setTimeout(() => t.classList.remove("show"), inputText ? 4000 : 1600);
1610
+ }
1611
+ async function copyText(text, what) {
1612
+ try { await navigator.clipboard.writeText(text); toast("copied " + what); }
1613
+ catch (e) { toast("copy manually:", text); }
1614
+ }
1615
+ async function api(method, url, body) {
1616
+ const r = await fetch(url, {
1617
+ method,
1618
+ headers: body ? { "content-type": "application/json" } : undefined,
1619
+ body: body ? JSON.stringify(body) : undefined,
1620
+ });
1621
+ if (!r.ok) { toast("api error " + r.status); throw new Error(url); }
1622
+ return r.json();
1623
+ }
1624
+
1625
+ function fileGlyph(f) {
1626
+ if (f.reviewed >= f.blocks) return ["✓", "done"];
1627
+ if (f.reviewed > 0 || f.seen > 0) return ["◐", ""];
1628
+ return ["○", ""];
1629
+ }
1630
+ let collapsed = new Set(JSON.parse(localStorage.getItem("seendiff.collapsed") || "[]"));
1631
+ function saveCollapsed() {
1632
+ savePref("seendiff.collapsed", JSON.stringify([...collapsed]));
1633
+ }
1634
+ function makeBar(rev, seen, total) {
1635
+ const bar = document.createElement("span"); bar.className = "f-bar";
1636
+ const r = document.createElement("span"); r.className = "rev";
1637
+ r.style.width = (100 * rev / (total || 1)) + "%";
1638
+ const s = document.createElement("span"); s.className = "seen";
1639
+ s.style.width = (100 * seen / (total || 1)) + "%";
1640
+ bar.append(r, s);
1641
+ return bar;
1642
+ }
1643
+ let notch = { p: 5, pad: 3, n: 1 };
1644
+ function layoutNotches() {
1645
+ const dpr = devicePixelRatio || 1;
1646
+ const snap = n => Math.max(1, Math.round(n * dpr)) / dpr;
1647
+ const w = snap(7);
1648
+ notch.p = snap(10);
1649
+ notch.pad = snap(3);
1650
+ const avail = $("sb-head").clientWidth - 24 - 2 * notch.pad;
1651
+ notch.n = Math.max(1, Math.floor(avail / notch.p));
1652
+ const s = document.documentElement.style;
1653
+ s.setProperty("--notch-w", w + "px");
1654
+ s.setProperty("--notch-p", notch.p + "px");
1655
+ s.setProperty("--pbar-pad", notch.pad + "px");
1656
+ s.setProperty("--pbar-w", (notch.n * notch.p + 2 * notch.pad) + "px");
1657
+ }
1658
+ function setMainBar(rev, seen, total) {
1659
+ const bar = $("sb-progress");
1660
+ const lit = v => (!total || !v ? 0
1661
+ : Math.min(notch.n, Math.max(1, Math.round(v / total * notch.n)))
1662
+ * notch.p) + "px";
1663
+ bar.querySelector(".rev").style.width = lit(rev);
1664
+ bar.querySelector(".seen").style.width = lit(rev + seen);
1665
+ const done = total ? rev / total : 0;
1666
+ bar.style.setProperty("--glow",
1667
+ Math.min(1, Math.max(0, (done - 0.5) * 2)).toFixed(3));
1668
+ }
1669
+ function makeFileItem(f, label, indent) {
1670
+ const item = document.createElement("div");
1671
+ item.className = "f-item" + (f.path === curPath ? " cur" : "");
1672
+ item.style.paddingLeft = (8 + indent * 14) + "px";
1673
+ const [g, gcls] = fileGlyph(f);
1674
+ const glyph = document.createElement("span");
1675
+ glyph.className = "f-glyph " + gcls; glyph.textContent = g;
1676
+ glyph.title = "toggle whole file reviewed";
1677
+ glyph.onclick = e => { e.stopPropagation(); toggleFileReviewed(f); };
1678
+ const name = document.createElement("span");
1679
+ name.className = "f-name";
1680
+ const bdi = document.createElement("bdi");
1681
+ bdi.textContent = label;
1682
+ name.append(bdi);
1683
+ name.title = (f.old_path ? f.old_path + " → " : "") + f.path;
1684
+ const right = document.createElement("span"); right.className = "f-right";
1685
+ const counts = document.createElement("span"); counts.className = "f-counts";
1686
+ if (f.status === "binary" || f.status === "meta" || f.status === "submodule") {
1687
+ counts.textContent = f.status === "submodule" ? "⌥ pointer" : f.status;
1688
+ } else {
1689
+ const p = document.createElement("span"); p.className = "plus"; p.textContent = "+" + f.added;
1690
+ const m = document.createElement("span"); m.className = "minus"; m.textContent = "−" + f.removed;
1691
+ counts.append(p, " ", m);
1692
+ }
1693
+ right.append(counts, makeBar(f.reviewed_w, f.seen_w, f.weight));
1694
+ item.append(glyph, name, right);
1695
+ item.onclick = () => openFile(f.path);
1696
+ return item;
1697
+ }
1698
+ function buildTree(files) {
1699
+ const root = { dirs: new Map(), files: [] };
1700
+ for (const f of files) {
1701
+ const parts = f.path.split("/");
1702
+ let node = root;
1703
+ for (const part of parts.slice(0, -1)) {
1704
+ if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
1705
+ node = node.dirs.get(part);
1706
+ }
1707
+ node.files.push(f);
1708
+ }
1709
+ function compress(node) {
1710
+ for (const [name, child] of [...node.dirs]) {
1711
+ compress(child);
1712
+ let n = name, c = child;
1713
+ while (c.files.length === 0 && c.dirs.size === 1) {
1714
+ const [[sub, subNode]] = c.dirs;
1715
+ n = n + "/" + sub; c = subNode;
1716
+ }
1717
+ if (n !== name) { node.dirs.delete(name); node.dirs.set(n, c); }
1718
+ }
1719
+ }
1720
+ compress(root);
1721
+ return root;
1722
+ }
1723
+ function aggregate(node) {
1724
+ let a = { blocks: 0, seen: 0, reviewed: 0, weight: 0, seen_w: 0, reviewed_w: 0 };
1725
+ for (const f of node.files) {
1726
+ a.blocks += f.blocks; a.seen += f.seen; a.reviewed += f.reviewed;
1727
+ a.weight += f.weight; a.seen_w += f.seen_w; a.reviewed_w += f.reviewed_w;
1728
+ }
1729
+ for (const child of node.dirs.values()) {
1730
+ const c = aggregate(child);
1731
+ a.blocks += c.blocks; a.seen += c.seen; a.reviewed += c.reviewed;
1732
+ a.weight += c.weight; a.seen_w += c.seen_w; a.reviewed_w += c.reviewed_w;
1733
+ }
1734
+ node.agg = a;
1735
+ return a;
1736
+ }
1737
+ function renderTree(list, node, prefix, indent) {
1738
+ const dirs = [...node.dirs.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1739
+ for (const [name, child] of dirs) {
1740
+ const full = prefix + name;
1741
+ aggregate(child);
1742
+ if (hideDone && child.agg.reviewed >= child.agg.blocks) continue;
1743
+ const isClosed = collapsed.has(full);
1744
+ const item = document.createElement("div");
1745
+ item.className = "d-item";
1746
+ item.style.paddingLeft = (8 + indent * 14) + "px";
1747
+ const arrow = document.createElement("span");
1748
+ arrow.className = "d-arrow"; arrow.textContent = isClosed ? "▸" : "▾";
1749
+ const dn = document.createElement("span");
1750
+ dn.className = "d-name"; dn.textContent = name + "/"; dn.title = full;
1751
+ item.append(arrow, dn, makeBar(child.agg.reviewed_w, child.agg.seen_w, child.agg.weight));
1752
+ item.onclick = () => {
1753
+ isClosed ? collapsed.delete(full) : collapsed.add(full);
1754
+ saveCollapsed(); renderSidebar();
1755
+ };
1756
+ list.append(item);
1757
+ if (!isClosed) renderTree(list, child, full + "/", indent + 1);
1758
+ }
1759
+ for (const f of node.files.sort((a, b) => a.path.localeCompare(b.path))) {
1760
+ if (hideDone && f.reviewed >= f.blocks) continue;
1761
+ list.append(makeFileItem(f, f.path.split("/").pop(), indent));
1762
+ }
1763
+ }
1764
+ function renderSidebar() {
1765
+ const q = $("filter").value.toLowerCase();
1766
+ const list = $("filelist");
1767
+ list.textContent = "";
1768
+ if (q) {
1769
+ for (const f of meta.files) {
1770
+ if (hideDone && f.reviewed >= f.blocks) continue;
1771
+ if (!f.path.toLowerCase().includes(q)) continue;
1772
+ list.append(makeFileItem(f, (f.old_path ? f.old_path + " → " : "") + f.path, 0));
1773
+ }
1774
+ } else {
1775
+ renderTree(list, buildTree(meta.files), "", 0);
1776
+ }
1777
+ if (wt && wt.wt_paths && wt.wt_paths.length) {
1778
+ const shown = wt.wt_paths.filter(p => !q || p.toLowerCase().includes(q));
1779
+ if (shown.length) {
1780
+ const head = document.createElement("div");
1781
+ head.className = "wt-files-head"; head.textContent = "walkthrough";
1782
+ list.append(head);
1783
+ for (const p of shown) {
1784
+ const item = document.createElement("div");
1785
+ item.className = "f-item" + (p === curPath ? " cur" : "");
1786
+ const glyph = document.createElement("span");
1787
+ glyph.className = "f-glyph"; glyph.textContent = "◈";
1788
+ const name = document.createElement("span");
1789
+ name.className = "f-name";
1790
+ const bdi = document.createElement("bdi");
1791
+ bdi.textContent = p; name.append(bdi); name.title = p;
1792
+ item.append(glyph, name);
1793
+ item.onclick = () => openFile(p);
1794
+ list.append(item);
1795
+ }
1796
+ }
1797
+ }
1798
+ const s = meta.stats;
1799
+ $("sb-stats").textContent =
1800
+ `${s.reviewed}/${s.blocks} reviewed · ${s.seen + s.reviewed} seen · ${meta.files.length} files`;
1801
+ setMainBar(s.reviewed_w, s.seen_w, s.weight);
1802
+ const done = s.blocks > 0 && s.reviewed >= s.blocks;
1803
+ if (allDone === null) allDone = done;
1804
+ else if (done && !allDone) { allDone = true; celebrate(); }
1805
+ else allDone = done;
1806
+ $("sb-base").textContent =
1807
+ `${meta.branch || "detached"} vs ${meta.base_ref} @ ${meta.base_sha} (${meta.base_date.slice(0, 10)})`;
1808
+ const dirty = meta.dirty_files.length, conf = meta.conflicted_files.length;
1809
+ const banner = $("dirty-banner");
1810
+ if (dirty || conf) {
1811
+ banner.style.display = "block";
1812
+ banner.textContent = (dirty ? `${dirty} file${dirty > 1 ? "s" : ""} with uncommitted changes, not shown` : "")
1813
+ + (conf ? ` · ${conf} conflicted (skipped)` : "");
1814
+ banner.title = meta.dirty_files.concat(meta.conflicted_files).join("\n");
1815
+ } else banner.style.display = "none";
1816
+ }
1817
+ function bumpStats(f, dSeen, dRev, w) {
1818
+ f.seen = Math.max(0, Math.min(f.blocks, f.seen + dSeen));
1819
+ f.reviewed = Math.max(0, Math.min(f.blocks, f.reviewed + dRev));
1820
+ f.seen_w = Math.max(0, Math.min(f.weight, f.seen_w + dSeen * w));
1821
+ f.reviewed_w = Math.max(0, Math.min(f.weight, f.reviewed_w + dRev * w));
1822
+ meta.stats.seen = Math.max(0, meta.stats.seen + dSeen);
1823
+ meta.stats.reviewed = Math.max(0, meta.stats.reviewed + dRev);
1824
+ meta.stats.seen_w = Math.max(0, meta.stats.seen_w + dSeen * w);
1825
+ meta.stats.reviewed_w = Math.max(0, meta.stats.reviewed_w + dRev * w);
1826
+ renderSidebar();
1827
+ }
1828
+
1829
+ async function loadMeta() {
1830
+ meta = await api("GET", "/api/files");
1831
+ $("btn-hide").style.opacity = hideDone ? 1 : .55;
1832
+ renderSidebar();
1833
+ }
1834
+ async function openFile(path, focusLine) {
1835
+ file = await api("GET", "/api/file?path=" + encodeURIComponent(path));
1836
+ curPath = path;
1837
+ selAnchor = selEnd = null; dwell.clear();
1838
+ clearRows();
1839
+ $("empty-msg")?.remove();
1840
+ const rowsOf = new Map();
1841
+ file.lines.forEach((l, i) => {
1842
+ if (l.h) { if (!rowsOf.has(l.h)) rowsOf.set(l.h, [i, i]); else rowsOf.get(l.h)[1] = i; }
1843
+ });
1844
+ blocks = file.blocks.map(b => {
1845
+ const r = rowsOf.get(b.hunk_id) || [0, Math.max(0, file.lines.length - 1)];
1846
+ return Object.assign({}, b, { l0: r[0], l1: r[1] });
1847
+ }).sort((a, b) => a.l0 - b.l0);
1848
+ buildView();
1849
+ computeBlockRanges();
1850
+ focusIdx = blocks.findIndex(b => !b.reviewed);
1851
+ if (focusIdx < 0) focusIdx = blocks.length ? 0 : -1;
1852
+
1853
+ $("tb-path").textContent = (file.old_path ? file.old_path + " → " : "") + path;
1854
+ $("tb-info").textContent = `${file.total_lines} lines · ${file.lang}`
1855
+ + (file.skip_reason ? ` · plain (${file.skip_reason})` : "")
1856
+ + (file.status !== "modified" ? ` · ${file.status}` : "");
1857
+ measureMetrics();
1858
+ computeOffsets();
1859
+ wtRecompute();
1860
+ runFileSearch();
1861
+ pane.scrollTop = focusLine
1862
+ ? Math.max(0, offsets[viewOf[Math.min(focusLine - 1, file.lines.length - 1)]]
1863
+ - pane.clientHeight / 3)
1864
+ : 0;
1865
+ renderSidebar();
1866
+ renderRows(); drawMinimap();
1867
+ updateHash();
1868
+ }
1869
+
1870
+ function buildView() {
1871
+ const n = file.lines.length;
1872
+ viewRows = [];
1873
+ viewOf = new Array(n).fill(0);
1874
+ if (!split) {
1875
+ for (let i = 0; i < n; i++) { viewOf[i] = i; viewRows.push([i, null]); }
1876
+ return;
1877
+ }
1878
+ let dels = [], adds = [];
1879
+ const flush = () => {
1880
+ for (let k = 0; k < Math.max(dels.length, adds.length); k++) {
1881
+ const L = k < dels.length ? dels[k] : null;
1882
+ const R = k < adds.length ? adds[k] : null;
1883
+ if (L !== null) viewOf[L] = viewRows.length;
1884
+ if (R !== null) viewOf[R] = viewRows.length;
1885
+ viewRows.push([L, R]);
1886
+ }
1887
+ dels = []; adds = [];
1888
+ };
1889
+ for (let i = 0; i < n; i++) {
1890
+ const t = file.lines[i].type;
1891
+ if (t === "del") dels.push(i);
1892
+ else if (t === "add") adds.push(i);
1893
+ else { flush(); viewOf[i] = viewRows.length; viewRows.push([i, i]); }
1894
+ }
1895
+ flush();
1896
+ }
1897
+ function computeBlockRanges() {
1898
+ for (const b of blocks) {
1899
+ let lo = Infinity, hi = -Infinity;
1900
+ for (let i = b.l0; i <= b.l1 && i < viewOf.length; i++) {
1901
+ const v = viewOf[i];
1902
+ if (v < lo) lo = v;
1903
+ if (v > hi) hi = v;
1904
+ }
1905
+ b.r0 = lo === Infinity ? 0 : lo;
1906
+ b.r1 = hi === -Infinity ? 0 : hi;
1907
+ }
1908
+ blockByRow = new Array(viewRows.length).fill(-1);
1909
+ blocks.forEach((b, i) => { for (let r = b.r0; r <= b.r1; r++) blockByRow[r] = i; });
1910
+ }
1911
+ function viewLines(v0, v1) {
1912
+ const s = new Set();
1913
+ for (let v = Math.max(0, v0); v <= Math.min(v1, viewRows.length - 1); v++)
1914
+ for (const idx of viewRows[v]) if (idx !== null) s.add(idx);
1915
+ return [...s].sort((a, b) => a - b);
1916
+ }
1917
+ function firstLineOf(v) {
1918
+ const vr = viewRows[v];
1919
+ return vr ? (vr[0] ?? vr[1] ?? 0) : 0;
1920
+ }
1921
+
1922
+ let offsets = [0];
1923
+ let charW = 8, padPx = 114, splitPadPx = 150;
1924
+ function measureMetrics() {
1925
+ const probe = document.createElement("div");
1926
+ probe.className = "row";
1927
+ probe.style.visibility = "hidden";
1928
+ probe.innerHTML = '<span class="ln old">0</span><span class="ln new">0</span>'
1929
+ + '<span class="chk"> </span><span class="sign"> </span>'
1930
+ + '<pre class="content">' + "0".repeat(200) + '</pre>';
1931
+ spacer.append(probe);
1932
+ const content = probe.querySelector(".content");
1933
+ content.style.whiteSpace = "pre";
1934
+ const range = document.createRange();
1935
+ range.selectNodeContents(content);
1936
+ charW = range.getBoundingClientRect().width / 200 || 7.5;
1937
+ content.style.whiteSpace = "";
1938
+ padPx = probe.getBoundingClientRect().width - content.getBoundingClientRect().width;
1939
+ probe.remove();
1940
+
1941
+ const sp = document.createElement("div");
1942
+ sp.className = "row split";
1943
+ sp.style.visibility = "hidden";
1944
+ sp.innerHTML = '<span class="chk"> </span>'
1945
+ + '<div class="half left"><span class="ln old">0</span>'
1946
+ + '<span class="sign"> </span><pre class="content">0</pre></div>'
1947
+ + '<div class="half right"><span class="ln new">0</span>'
1948
+ + '<span class="sign"> </span><pre class="content">0</pre></div>';
1949
+ spacer.append(sp);
1950
+ splitPadPx = sp.getBoundingClientRect().width
1951
+ - 2 * sp.querySelector(".half.left .content").getBoundingClientRect().width;
1952
+ sp.remove();
1953
+ }
1954
+ function displayCols(text) {
1955
+ let col = 0;
1956
+ for (let i = 0; i < text.length; i++) {
1957
+ const c = text.charCodeAt(i);
1958
+ if (c === 9) col += 4 - (col % 4);
1959
+ else if (c >= 0x2e80 && c <= 0xa4cf || c >= 0xac00 && c <= 0xd7a3
1960
+ || c >= 0xf900 && c <= 0xfaff || c >= 0xff00 && c <= 0xff60)
1961
+ col += 2;
1962
+ else col += 1;
1963
+ }
1964
+ return col;
1965
+ }
1966
+ function rowText(l) { return l.t ? l.t.map(t => t[1]).join("") : (l.text ?? ""); }
1967
+ function computeOffsets() {
1968
+ if (!file) return;
1969
+ for (let pass = 0; pass < 2; pass++) {
1970
+ const paneW = pane.clientWidth;
1971
+ const avail = split
1972
+ ? Math.max(1, (paneW - splitPadPx) / 2)
1973
+ : Math.max(1, paneW - padPx);
1974
+ const cols = Math.max(1, Math.floor((avail - 1) / charW));
1975
+ const n = viewRows.length;
1976
+ offsets = new Array(n + 1);
1977
+ offsets[0] = 0;
1978
+ for (let v = 0; v < n; v++) {
1979
+ let h = 1;
1980
+ for (const idx of viewRows[v]) {
1981
+ if (idx === null) continue;
1982
+ const c = displayCols(rowText(file.lines[idx]));
1983
+ h = Math.max(h, Math.ceil(c / cols));
1984
+ }
1985
+ offsets[v + 1] = offsets[v] + h * LH;
1986
+ }
1987
+ spacer.style.height = (offsets[n] + 40) + "px";
1988
+ for (const b of blocks) b.cov = undefined;
1989
+ if (pane.clientWidth === paneW) break;
1990
+ }
1991
+ }
1992
+ function totalHeight() { return offsets[offsets.length - 1] + 40; }
1993
+ function findRow(y) {
1994
+ let lo = 0, hi = offsets.length - 2;
1995
+ while (lo < hi) {
1996
+ const mid = (lo + hi + 1) >> 1;
1997
+ if (offsets[mid] <= y) lo = mid; else hi = mid - 1;
1998
+ }
1999
+ return lo;
2000
+ }
2001
+
2002
+ let rowEls = new Map();
2003
+ function renderRows() {
2004
+ if (!file || !viewRows.length) return;
2005
+ const first = Math.max(0, findRow(pane.scrollTop) - 30);
2006
+ const last = Math.min(viewRows.length - 1,
2007
+ findRow(pane.scrollTop + pane.clientHeight) + 30);
2008
+ for (const [i, el] of rowEls) {
2009
+ if (i < first || i > last) { el.remove(); rowEls.delete(i); }
2010
+ }
2011
+ const focusB = focusIdx >= 0 ? blocks[focusIdx] : null;
2012
+ const s0 = selAnchor === null ? -1 : Math.min(selAnchor, selEnd);
2013
+ const s1 = selAnchor === null ? -1 : Math.max(selAnchor, selEnd);
2014
+ for (let i = first; i <= last; i++) {
2015
+ let el = rowEls.get(i);
2016
+ if (!el) { el = buildRow(i); rowEls.set(i, el); spacer.append(el); }
2017
+ el.style.top = offsets[i] + "px";
2018
+ el.style.height = (offsets[i + 1] - offsets[i]) + "px";
2019
+ el.classList.toggle("focused-block",
2020
+ !!focusB && i >= focusB.r0 && i <= focusB.r1);
2021
+ el.classList.toggle("selected", i >= s0 && i <= s1);
2022
+ el.classList.toggle("wt-hi", !!wtHi && i >= wtHi.r0 && i <= wtHi.r1);
2023
+ el.classList.toggle("wt-anchor", wtAnchors.has(i));
2024
+ const bi = blockByRow[i];
2025
+ const chk = el.querySelector(".chk");
2026
+ if (chk && chk.dataset.active && bi >= 0) {
2027
+ chk.classList.toggle("reviewed", !!blocks[bi].reviewed);
2028
+ chk.classList.toggle("seen", !!blocks[bi].seen && !blocks[bi].reviewed);
2029
+ chk.textContent = blocks[bi].reviewed ? "☑" : "☐";
2030
+ }
2031
+ }
2032
+ }
2033
+ function appendTok(parent, cls, text, hit) {
2034
+ let node = parent;
2035
+ if (hit) {
2036
+ node = document.createElement("mark");
2037
+ if (hit === sMatches[sIdx]) node.className = "cur";
2038
+ parent.append(node);
2039
+ }
2040
+ if (cls) {
2041
+ const sp = document.createElement("span");
2042
+ sp.className = cls; sp.textContent = text;
2043
+ node.append(sp);
2044
+ } else node.append(text);
2045
+ }
2046
+ function contentEl(l, idx) {
2047
+ const content = document.createElement("pre");
2048
+ content.className = "content hl";
2049
+ if (!l) return content;
2050
+ const toks = l.t || [["", l.text ?? ""]];
2051
+ const hits = idx == null ? null : sByLine.get(idx);
2052
+ if (!hits) {
2053
+ for (const [cls, text] of toks) appendTok(content, cls, text, null);
2054
+ return content;
2055
+ }
2056
+ let pos = 0, hi = 0;
2057
+ for (const [cls, text] of toks) {
2058
+ let off = 0;
2059
+ while (off < text.length) {
2060
+ while (hi < hits.length && hits[hi].e <= pos + off) hi++;
2061
+ const h = hits[hi];
2062
+ let end, inside = null;
2063
+ if (!h || h.s >= pos + text.length) end = text.length;
2064
+ else if (h.s > pos + off) end = h.s - pos;
2065
+ else { end = Math.min(text.length, h.e - pos); inside = h; }
2066
+ appendTok(content, cls, text.slice(off, end), inside);
2067
+ off = end;
2068
+ }
2069
+ pos += text.length;
2070
+ }
2071
+ return content;
2072
+ }
2073
+ function chkEl(v) {
2074
+ const chk = document.createElement("span");
2075
+ chk.className = "chk";
2076
+ const bi = blockByRow[v];
2077
+ if (bi >= 0 && v === blocks[bi].r0) {
2078
+ chk.dataset.active = "1";
2079
+ chk.title = "toggle reviewed (R)";
2080
+ chk.onclick = () => toggleReviewed(bi);
2081
+ } else chk.textContent = " ";
2082
+ return chk;
2083
+ }
2084
+ function buildRow(v) {
2085
+ return split ? buildSplitRow(v) : buildUnifiedRow(v);
2086
+ }
2087
+ function buildUnifiedRow(v) {
2088
+ const l = file.lines[viewRows[v][0]];
2089
+ const el = document.createElement("div");
2090
+ el.className = "row " + l.type;
2091
+ const lnOld = document.createElement("span");
2092
+ lnOld.className = "ln old"; lnOld.textContent = l.old ?? "";
2093
+ const lnNew = document.createElement("span");
2094
+ lnNew.className = "ln new"; lnNew.textContent = l.new ?? "";
2095
+ lnNew.dataset.row = lnOld.dataset.row = v;
2096
+ const sign = document.createElement("span");
2097
+ sign.className = "sign";
2098
+ sign.textContent = l.type === "add" ? "+" : l.type === "del" ? "−" : " ";
2099
+ el.append(lnOld, lnNew, chkEl(v), sign, contentEl(l, viewRows[v][0]));
2100
+ return el;
2101
+ }
2102
+ function buildSplitRow(v) {
2103
+ const el = document.createElement("div");
2104
+ el.className = "row split";
2105
+ el.append(chkEl(v), halfEl(v, "left"), halfEl(v, "right"));
2106
+ return el;
2107
+ }
2108
+ function halfEl(v, side) {
2109
+ const left = side === "left";
2110
+ const idx = viewRows[v][left ? 0 : 1];
2111
+ const l = idx === null ? null : file.lines[idx];
2112
+ const h = document.createElement("div");
2113
+ h.className = "half " + side + " "
2114
+ + (l ? (l.type === "context" ? "ctx" : l.type) : "empty");
2115
+ const ln = document.createElement("span");
2116
+ ln.className = "ln " + (left ? "old" : "new");
2117
+ ln.textContent = l ? ((left ? l.old : l.new) ?? "") : "";
2118
+ if (l) ln.dataset.row = v;
2119
+ const sign = document.createElement("span");
2120
+ sign.className = "sign";
2121
+ sign.textContent = !l || l.type === "context" ? " " : l.type === "add" ? "+" : "−";
2122
+ h.append(ln, sign, contentEl(l, idx));
2123
+ return h;
2124
+ }
2125
+ function clearRows() { for (const el of rowEls.values()) el.remove(); rowEls.clear(); }
2126
+
2127
+ function mmColor(kind) {
2128
+ const cs = getComputedStyle(document.documentElement);
2129
+ return (kind === "add" ? cs.getPropertyValue("--add-sign")
2130
+ : kind === "del" ? cs.getPropertyValue("--del-sign")
2131
+ : cs.getPropertyValue("--mixed")).trim();
2132
+ }
2133
+ function drawMinimap() {
2134
+ const dpr = devicePixelRatio || 1;
2135
+ const w = mm.clientWidth, h = mm.clientHeight;
2136
+ if (!w || !h) return;
2137
+ if (mm.width !== Math.round(w * dpr)) { mm.width = Math.round(w * dpr); mm.height = Math.round(h * dpr); }
2138
+ const ctx = mm.getContext("2d");
2139
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2140
+ ctx.clearRect(0, 0, w, h);
2141
+ if (!file || !file.lines.length) return;
2142
+ const T = totalHeight();
2143
+ for (const b of blocks) {
2144
+ const y = offsets[b.r0] / T * h;
2145
+ const bh = Math.max(3, (offsets[b.r1 + 1] - offsets[b.r0]) / T * h);
2146
+ const col = mmColor(b.kind);
2147
+ if (b.reviewed) {
2148
+ ctx.strokeStyle = col; ctx.lineWidth = 1;
2149
+ ctx.strokeRect(2.5, y + .5, w - 5, Math.max(2, bh - 1));
2150
+ } else {
2151
+ ctx.globalAlpha = b.seen ? .45 : 1;
2152
+ ctx.fillStyle = col;
2153
+ ctx.fillRect(2, y, w - 4, bh);
2154
+ ctx.globalAlpha = 1;
2155
+ }
2156
+ }
2157
+ if (sOpen && sMatches.length) {
2158
+ ctx.fillStyle = getComputedStyle(document.documentElement)
2159
+ .getPropertyValue("--mixed").trim();
2160
+ const drawn = new Set();
2161
+ sMatches.forEach((m, k) => {
2162
+ const y = Math.round(offsets[viewOf[m.i]] / T * h);
2163
+ if (k !== sIdx) { if (drawn.has(y)) return; drawn.add(y); }
2164
+ ctx.globalAlpha = k === sIdx ? 1 : .55;
2165
+ ctx.fillRect(0, y, k === sIdx ? w : 4, k === sIdx ? 2 : 1.5);
2166
+ });
2167
+ ctx.globalAlpha = 1;
2168
+ }
2169
+ if (wtHi || wtAnchors.size) {
2170
+ const accent = getComputedStyle(document.documentElement)
2171
+ .getPropertyValue("--accent").trim();
2172
+ if (wtHi) {
2173
+ const y = offsets[wtHi.r0] / T * h;
2174
+ const bh = Math.max(4, (offsets[wtHi.r1 + 1] - offsets[wtHi.r0]) / T * h);
2175
+ ctx.strokeStyle = accent; ctx.lineWidth = 2;
2176
+ ctx.beginPath();
2177
+ ctx.moveTo(4, y + 1); ctx.lineTo(1, y + 1);
2178
+ ctx.lineTo(1, y + bh - 1); ctx.lineTo(4, y + bh - 1);
2179
+ ctx.stroke();
2180
+ }
2181
+ ctx.fillStyle = accent;
2182
+ for (const v of wtAnchors) {
2183
+ ctx.globalAlpha = .6;
2184
+ ctx.fillRect(w - 5, offsets[v] / T * h, 4, 2);
2185
+ ctx.globalAlpha = 1;
2186
+ }
2187
+ }
2188
+ const y0 = pane.scrollTop / T * h;
2189
+ const y1 = (pane.scrollTop + pane.clientHeight) / T * h;
2190
+ ctx.fillStyle = getComputedStyle(document.documentElement)
2191
+ .getPropertyValue("--fg").trim();
2192
+ ctx.globalAlpha = .12;
2193
+ ctx.fillRect(0, y0, w, Math.max(4, y1 - y0));
2194
+ ctx.globalAlpha = 1;
2195
+ }
2196
+ mm.addEventListener("click", e => {
2197
+ if (!file) return;
2198
+ const h = mm.clientHeight, T = totalHeight();
2199
+ const y = e.offsetY;
2200
+ for (let i = 0; i < blocks.length; i++) {
2201
+ const b = blocks[i];
2202
+ const by = offsets[b.r0] / T * h;
2203
+ const bh = Math.max(3, (offsets[b.r1 + 1] - offsets[b.r0]) / T * h);
2204
+ if (y >= by - 2 && y <= by + bh + 2) { focusBlock(i); return; }
2205
+ }
2206
+ pane.scrollTop = y / h * T - pane.clientHeight / 2;
2207
+ });
2208
+
2209
+ function blockWeight(b) { return (b.added + b.removed) || 1; }
2210
+ function setLocal(b, seen, reviewed) {
2211
+ const f = meta.files.find(x => x.path === curPath);
2212
+ const wasSeen = b.seen && !b.reviewed, wasRev = !!b.reviewed;
2213
+ b.seen = seen; b.reviewed = reviewed;
2214
+ const isSeen = seen && !reviewed, isRev = reviewed;
2215
+ if (f) bumpStats(f, (isSeen ? 1 : 0) - (wasSeen ? 1 : 0),
2216
+ (isRev ? 1 : 0) - (wasRev ? 1 : 0), blockWeight(b));
2217
+ renderRows(); drawMinimap();
2218
+ }
2219
+ function toggleReviewed(bi) {
2220
+ const b = blocks[bi];
2221
+ if (!b) return;
2222
+ if (b.reviewed) {
2223
+ api("DELETE", `/api/reviewed?path=${encodeURIComponent(curPath)}&hunk_id=${encodeURIComponent(b.hunk_id)}`);
2224
+ setLocal(b, true, false);
2225
+ } else {
2226
+ api("POST", "/api/reviewed", { blocks: [{ path: curPath, hunk_id: b.hunk_id }] });
2227
+ setLocal(b, true, true);
2228
+ }
2229
+ }
2230
+ function toggleFileReviewed(f) {
2231
+ const done = f.reviewed >= f.blocks;
2232
+ if (done) {
2233
+ api("DELETE", "/api/reviewed?file=" + encodeURIComponent(f.path));
2234
+ if (f.path === curPath) blocks.forEach(b => { b.reviewed = false; b.seen = true; });
2235
+ meta.stats.reviewed -= f.reviewed; meta.stats.seen += f.reviewed;
2236
+ meta.stats.reviewed_w -= f.reviewed_w; meta.stats.seen_w += f.reviewed_w;
2237
+ f.seen = f.blocks; f.reviewed = 0;
2238
+ f.seen_w = f.weight; f.reviewed_w = 0;
2239
+ } else {
2240
+ api("POST", "/api/reviewed", { file: f.path });
2241
+ if (f.path === curPath) blocks.forEach(b => { b.reviewed = true; b.seen = true; });
2242
+ meta.stats.reviewed += f.blocks - f.reviewed;
2243
+ meta.stats.seen -= f.seen;
2244
+ meta.stats.reviewed_w += f.weight - f.reviewed_w;
2245
+ meta.stats.seen_w -= f.seen_w;
2246
+ f.reviewed = f.blocks; f.seen = 0;
2247
+ f.reviewed_w = f.weight; f.seen_w = 0;
2248
+ }
2249
+ renderSidebar(); renderRows(); drawMinimap();
2250
+ }
2251
+ function reviewSeenInFile() { // V
2252
+ const targets = blocks.filter(b => b.seen && !b.reviewed);
2253
+ if (!targets.length) { toast("nothing seen-but-unreviewed"); return; }
2254
+ api("POST", "/api/reviewed",
2255
+ { blocks: targets.map(b => ({ path: curPath, hunk_id: b.hunk_id })) });
2256
+ targets.forEach(b => setLocal(b, true, true));
2257
+ toast(`${targets.length} block${targets.length > 1 ? "s" : ""} reviewed`);
2258
+ }
2259
+
2260
+ function evalSeen() {
2261
+ if (!file || !meta.auto_seen || !document.hasFocus()) { dwell.clear(); return; }
2262
+ const top = pane.scrollTop, bot = pane.scrollTop + pane.clientHeight;
2263
+ const now = performance.now();
2264
+ const atBottom = pane.scrollTop + pane.clientHeight >= totalHeight() - 2;
2265
+ blocks.forEach((b, i) => {
2266
+ if (b.seen || b.reviewed) return;
2267
+ const y0 = offsets[b.r0], y1 = offsets[b.r1 + 1];
2268
+ if (y1 < top) { markSeen(b); return; }
2269
+ if (y0 > bot) { dwell.delete(i); return; }
2270
+ if (b.cov === undefined) b.cov = y0;
2271
+ if (top <= b.cov) b.cov = Math.max(b.cov, Math.min(bot, y1));
2272
+ if (b.cov >= y1 || atBottom) {
2273
+ if (!dwell.has(i)) dwell.set(i, now);
2274
+ else if (now - dwell.get(i) >= 500) markSeen(b);
2275
+ } else dwell.delete(i);
2276
+ });
2277
+ }
2278
+ function markSeen(b) {
2279
+ seenQueue.set(curPath + "|" + b.hunk_id, { path: curPath, hunk_id: b.hunk_id });
2280
+ setLocal(b, true, false);
2281
+ }
2282
+ function flushSeen(beacon) {
2283
+ if (!seenQueue.size) return;
2284
+ const payload = JSON.stringify({ blocks: [...seenQueue.values()] });
2285
+ seenQueue.clear();
2286
+ if (beacon && navigator.sendBeacon) {
2287
+ navigator.sendBeacon("/api/seen", new Blob([payload], { type: "application/json" }));
2288
+ } else {
2289
+ fetch("/api/seen", {
2290
+ method: "POST", body: payload,
2291
+ headers: { "content-type": "application/json" }
2292
+ });
2293
+ }
2294
+ }
2295
+ setInterval(() => { evalSeen(); flushSeen(false); wtPoll(); }, 2000);
2296
+ document.addEventListener("visibilitychange", () => {
2297
+ if (document.visibilityState === "hidden") flushSeen(true);
2298
+ });
2299
+
2300
+ function focusBlock(bi) {
2301
+ if (bi < 0 || bi >= blocks.length) return;
2302
+ focusIdx = bi;
2303
+ const b = blocks[bi];
2304
+ pane.scrollTop = Math.max(0, offsets[b.r0] - pane.clientHeight / 3);
2305
+ renderRows(); drawMinimap();
2306
+ }
2307
+ function nextBlock(dir) {
2308
+ if (!blocks.length) return;
2309
+ focusBlock(Math.min(blocks.length - 1, Math.max(0, focusIdx + dir)));
2310
+ }
2311
+ function fileIndex() { return meta.files.findIndex(f => f.path === curPath); }
2312
+ function visibleFiles() {
2313
+ return meta.files.filter(f => !(hideDone && f.reviewed >= f.blocks));
2314
+ }
2315
+ function nextFile(dir) {
2316
+ const vis = visibleFiles();
2317
+ if (!vis.length) return;
2318
+ const i = vis.findIndex(f => f.path === curPath);
2319
+ const j = i < 0 ? (dir > 0 ? 0 : vis.length - 1)
2320
+ : Math.min(vis.length - 1, Math.max(0, i + dir));
2321
+ if (vis[j].path !== curPath) openFile(vis[j].path);
2322
+ }
2323
+ async function nextUnreviewed(dir) {
2324
+ if (file) {
2325
+ const order = blocks.map((b, i) => i).filter(i => !blocks[i].reviewed &&
2326
+ (dir > 0 ? i > focusIdx : i < focusIdx));
2327
+ if (order.length) { focusBlock(dir > 0 ? order[0] : order[order.length - 1]); return; }
2328
+ }
2329
+ const files = meta.files, start = Math.max(0, fileIndex());
2330
+ for (let k = 1; k <= files.length; k++) {
2331
+ const j = ((start + dir * k) % files.length + files.length) % files.length;
2332
+ if (files[j].reviewed < files[j].blocks) {
2333
+ await openFile(files[j].path);
2334
+ const cand = blocks.map((b, i) => i).filter(i => !blocks[i].reviewed);
2335
+ if (cand.length) focusBlock(dir > 0 ? cand[0] : cand[cand.length - 1]);
2336
+ return;
2337
+ }
2338
+ }
2339
+ toast("everything reviewed 🎉");
2340
+ }
2341
+
2342
+ function refRangeFromRows(r0, r1) {
2343
+ const idxs = viewLines(r0, r1);
2344
+ const news = idxs.map(i => file.lines[i].new).filter(Boolean);
2345
+ if (news.length) return { a: news[0], b: news[news.length - 1], deleted: false };
2346
+ let lastNewBefore = 0;
2347
+ for (let i = 0; i < (idxs.length ? idxs[0] : 0); i++) {
2348
+ const l = file.lines[i];
2349
+ if (l.new) lastNewBefore = l.new;
2350
+ }
2351
+ return { a: Math.max(1, lastNewBefore), b: Math.max(1, lastNewBefore), deleted: true };
2352
+ }
2353
+ function refString(r) { return curPath + ":" + (r.a === r.b ? r.a : r.a + "-" + r.b); }
2354
+ function copyGutterSelection() {
2355
+ const r0 = Math.min(selAnchor, selEnd), r1 = Math.max(selAnchor, selEnd);
2356
+ const r = refRangeFromRows(r0, r1);
2357
+ copyText(refString(r), refString(r) + (r.deleted ? " (deleted lines)" : ""));
2358
+ }
2359
+ function copyRefWithContent() {
2360
+ let r0, r1;
2361
+ if (selAnchor !== null) { r0 = Math.min(selAnchor, selEnd); r1 = Math.max(selAnchor, selEnd); }
2362
+ else if (focusIdx >= 0) { r0 = blocks[focusIdx].r0; r1 = blocks[focusIdx].r1; }
2363
+ else { toast("nothing selected"); return; }
2364
+ const r = refRangeFromRows(r0, r1);
2365
+ const idxs = viewLines(r0, r1);
2366
+ const body = idxs.map(i => rowText(file.lines[i])).join("\n");
2367
+ const text = refString(r) + (r.deleted ? " (deleted lines)" : "")
2368
+ + "\n```" + (file.lang || "") + "\n" + body + "\n```\n";
2369
+ copyText(text, "ref + " + idxs.length + " lines");
2370
+ }
2371
+ function copyUnreviewedRefs() {
2372
+ const un = blocks.filter(b => !b.reviewed);
2373
+ if (!un.length) { toast("no unreviewed blocks"); return; }
2374
+ const refs = un.map(b => refString(refRangeFromRows(b.r0, b.r1)));
2375
+ copyText(refs.join("\n"), un.length + " refs");
2376
+ }
2377
+ let dragging = false;
2378
+ pane.addEventListener("mousedown", e => {
2379
+ const ln = e.target.closest(".ln");
2380
+ if (!ln) return;
2381
+ e.preventDefault();
2382
+ const row = +ln.dataset.row;
2383
+ if (wtOn && !e.shiftKey && wtAnchors.has(row)) {
2384
+ const ar = wt.steps[wtStep].also.find(a =>
2385
+ a.path === curPath && wtAnchors.has(row) && viewOf[wtLineRange(a)[0]] === row);
2386
+ if (ar) { wtJumpRef(ar); return; }
2387
+ }
2388
+ if (e.shiftKey && selAnchor !== null) selEnd = row;
2389
+ else { selAnchor = row; selEnd = row; }
2390
+ dragging = true;
2391
+ renderRows();
2392
+ });
2393
+ pane.addEventListener("mousemove", e => {
2394
+ if (!dragging) return;
2395
+ const ln = e.target.closest(".ln");
2396
+ if (ln) { selEnd = +ln.dataset.row; renderRows(); }
2397
+ });
2398
+ addEventListener("mouseup", () => {
2399
+ if (!dragging) return;
2400
+ dragging = false;
2401
+ copyGutterSelection();
2402
+ });
2403
+ {
2404
+ const sb = $("sidebar"), rz = $("sb-resizer");
2405
+ const saved = parseInt(localStorage.getItem("seendiff.sbw") || "0", 10);
2406
+ if (saved) sb.style.width = saved + "px";
2407
+ let resizing = false;
2408
+ rz.addEventListener("mousedown", e => {
2409
+ e.preventDefault();
2410
+ resizing = true;
2411
+ rz.classList.add("active");
2412
+ document.body.style.cursor = "col-resize";
2413
+ });
2414
+ addEventListener("mousemove", e => {
2415
+ if (!resizing) return;
2416
+ sb.style.width = e.clientX + "px";
2417
+ layoutNotches();
2418
+ });
2419
+ addEventListener("mouseup", () => {
2420
+ if (!resizing) return;
2421
+ resizing = false;
2422
+ rz.classList.remove("active");
2423
+ document.body.style.cursor = "";
2424
+ savePref("seendiff.sbw", sb.getBoundingClientRect().width | 0);
2425
+ if (meta) renderSidebar();
2426
+ if (file) { computeOffsets(); renderRows(); drawMinimap(); }
2427
+ });
2428
+ }
2429
+
2430
+ function setTheme(t) {
2431
+ document.documentElement.dataset.theme = t;
2432
+ savePref("seendiff.theme", t);
2433
+ $("btn-theme").textContent = t;
2434
+ drawMinimap();
2435
+ }
2436
+ function setFont(f) {
2437
+ document.documentElement.style.setProperty("--mono-font", `'${f}'`);
2438
+ savePref("seendiff.font", f);
2439
+ $("btn-font").textContent = f === "JetBrains Mono" ? "JB Mono" : "Ubuntu Mono";
2440
+ document.fonts.ready.then(() => {
2441
+ if (file) { measureMetrics(); computeOffsets(); renderRows(); drawMinimap(); }
2442
+ });
2443
+ }
2444
+ $("btn-theme").onclick = () =>
2445
+ setTheme(THEMES[(THEMES.indexOf(document.documentElement.dataset.theme) + 1) % THEMES.length]);
2446
+ $("btn-font").onclick = () => {
2447
+ const cur = localStorage.getItem("seendiff.font") || FONTS[0];
2448
+ setFont(FONTS[(FONTS.indexOf(cur) + 1) % FONTS.length]);
2449
+ };
2450
+ function setSplit(on) {
2451
+ split = on;
2452
+ savePref("seendiff.split", on ? "1" : "0");
2453
+ $("btn-split").textContent = on ? "split" : "unified";
2454
+ if (!file) return;
2455
+ const anchorView = findRow(pane.scrollTop);
2456
+ const anchorLine = firstLineOf(anchorView);
2457
+ const delta = pane.scrollTop - (offsets[anchorView] || 0);
2458
+ selAnchor = selEnd = null;
2459
+ dwell.clear();
2460
+ buildView();
2461
+ computeBlockRanges();
2462
+ wtRecompute();
2463
+ measureMetrics();
2464
+ computeOffsets();
2465
+ clearRows();
2466
+ pane.scrollTop = Math.max(0, (offsets[viewOf[anchorLine]] || 0) + delta);
2467
+ renderRows(); drawMinimap();
2468
+ }
2469
+ $("btn-split").onclick = () => setSplit(!split);
2470
+
2471
+ function askResetAll() {
2472
+ const s = meta.stats;
2473
+ $("confirm-msg").textContent =
2474
+ `Reset review state for this branch? ${s.reviewed} reviewed and `
2475
+ + `${s.seen} seen marks across ${meta.files.length} files will be cleared.`;
2476
+ $("confirm-overlay").classList.add("show");
2477
+ }
2478
+ async function doResetAll() {
2479
+ $("confirm-overlay").classList.remove("show");
2480
+ await api("DELETE", "/api/state");
2481
+ seenQueue.clear(); dwell.clear();
2482
+ meta.stats.seen = 0; meta.stats.reviewed = 0;
2483
+ meta.stats.seen_w = 0; meta.stats.reviewed_w = 0;
2484
+ meta.files.forEach(f => { f.seen = 0; f.reviewed = 0; f.seen_w = 0; f.reviewed_w = 0; });
2485
+ blocks.forEach(b => { b.seen = false; b.reviewed = false; });
2486
+ renderSidebar(); renderRows(); drawMinimap();
2487
+ toast("review state reset");
2488
+ }
2489
+ $("confirm-yes").onclick = doResetAll;
2490
+ $("confirm-no").onclick = () => $("confirm-overlay").classList.remove("show");
2491
+ $("confirm-overlay").onclick = e => {
2492
+ if (e.target === $("confirm-overlay")) $("confirm-overlay").classList.remove("show");
2493
+ };
2494
+ function toggleHelp(force) {
2495
+ $("help-overlay").classList.toggle("show", force);
2496
+ }
2497
+ $("btn-keys").onclick = () => toggleHelp();
2498
+ $("help-overlay").onclick = e => { if (e.target === $("help-overlay")) toggleHelp(false); };
2499
+ $("btn-hide").onclick = () => {
2500
+ hideDone = !hideDone;
2501
+ savePref("seendiff.hide", hideDone ? "1" : "0");
2502
+ $("btn-hide").style.opacity = hideDone ? 1 : .55;
2503
+ renderSidebar();
2504
+ };
2505
+
2506
+ async function loadWalkthrough() {
2507
+ try {
2508
+ const r = await fetch("/api/walkthrough");
2509
+ wtWatch = r.status !== 404;
2510
+ const d = r.ok ? await r.json() : null;
2511
+ wt = d && !d.empty ? d : null;
2512
+ } catch (e) { wt = null; }
2513
+ $("help-wt").hidden = !wt;
2514
+ }
2515
+ function wtAdopt(nw) {
2516
+ wt = nw;
2517
+ wtVisited = new Set();
2518
+ wtStep = Math.max(0, wtRestoreProgress());
2519
+ $("help-wt").hidden = false;
2520
+ const em = $("empty-msg");
2521
+ if (em) em.remove();
2522
+ renderSidebar();
2523
+ setWalkthrough(true);
2524
+ toast(wt.title);
2525
+ }
2526
+ function wtClear(msg) {
2527
+ wt = null; wtOn = false; wtStep = -1; wtAway = false;
2528
+ wtVisited = new Set(); wtHi = null; wtAnchors = new Set();
2529
+ $("wt-bar").hidden = true;
2530
+ $("help-wt").hidden = true;
2531
+ renderWtOutline();
2532
+ if (file) { computeOffsets(); renderRows(); drawMinimap(); }
2533
+ renderSidebar(); updateHash();
2534
+ if (msg) toast(msg);
2535
+ }
2536
+ function wtStepIdx(id) { return wt ? wt.steps.findIndex(s => s.id === id) : -1; }
2537
+ function wtSeq() {
2538
+ return wt.steps.map((s, i) => i).filter(i => wtDetail || wt.steps[i].depth === 0);
2539
+ }
2540
+ function wtBase(p) { return p.split("/").pop(); }
2541
+ function wtRefLabel(r) {
2542
+ return wtBase(r.path) + ":" + (r.lines[0] === r.lines[1]
2543
+ ? r.lines[0] : r.lines[0] + "-" + r.lines[1]);
2544
+ }
2545
+ function wtLineRange(ref) {
2546
+ if (ref.hunk_id) {
2547
+ const b = blocks.find(x => x.hunk_id === ref.hunk_id);
2548
+ if (b) return [b.l0, b.l1];
2549
+ }
2550
+ let i0 = file.lines.findIndex(l => l.new === ref.lines[0]);
2551
+ let i1 = file.lines.findIndex(l => l.new === ref.lines[1]);
2552
+ if (i0 < 0) i0 = i1 < 0 ? 0 : i1;
2553
+ if (i1 < 0) i1 = i0;
2554
+ return [Math.min(i0, i1), Math.max(i0, i1)];
2555
+ }
2556
+ function wtRecompute() {
2557
+ wtHi = null; wtAnchors = new Set();
2558
+ if (!wtOn || !wt || wtStep < 0 || !file) return;
2559
+ const step = wt.steps[wtStep];
2560
+ if (step.ref.path === curPath) {
2561
+ const [i0, i1] = wtLineRange(step.ref);
2562
+ wtHi = {
2563
+ r0: Math.min(viewOf[i0], viewOf[i1]),
2564
+ r1: Math.max(viewOf[i0], viewOf[i1])
2565
+ };
2566
+ }
2567
+ for (const ar of step.also) {
2568
+ if (ar.path !== curPath) continue;
2569
+ wtAnchors.add(viewOf[wtLineRange(ar)[0]]);
2570
+ }
2571
+ }
2572
+ function wtScrollTo(r0, r1) {
2573
+ const top = offsets[r0] ?? 0;
2574
+ const bot = offsets[Math.min(r1 + 1, offsets.length - 1)] ?? top;
2575
+ pane.scrollTop = bot - top <= pane.clientHeight
2576
+ ? Math.max(0, (top + bot) / 2 - pane.clientHeight / 2)
2577
+ : Math.max(0, top - pane.clientHeight / 4);
2578
+ }
2579
+ async function wtJumpRef(ref) {
2580
+ if (ref.path !== curPath) await openFile(ref.path);
2581
+ const [i0, i1] = wtLineRange(ref);
2582
+ wtScrollTo(viewOf[i0], viewOf[i1]);
2583
+ wtAway = true;
2584
+ renderRows(); drawMinimap();
2585
+ }
2586
+ async function gotoStep(i) {
2587
+ if (!wt || i < 0 || i >= wt.steps.length) return;
2588
+ wtStep = i; wtAway = false;
2589
+ const step = wt.steps[i];
2590
+ if (step.ref.path !== curPath) await openFile(step.ref.path);
2591
+ wtRecompute();
2592
+ if (wtHi) {
2593
+ wtScrollTo(wtHi.r0, wtHi.r1);
2594
+ focusIdx = blockByRow[wtHi.r0] ?? -1;
2595
+ }
2596
+ wtVisited.add(step.id);
2597
+ renderRows(); drawMinimap(); renderWtBar(); renderWtOutline();
2598
+ wtSaveProgress(); updateHash();
2599
+ }
2600
+ function wtMove(dir) {
2601
+ if (!wt) return;
2602
+ if (dir > 0 && wtAway) { gotoStep(wtStep); return; }
2603
+ const seq = wtSeq();
2604
+ const next = dir > 0 ? seq.find(i => i > wtStep)
2605
+ : seq.slice().reverse().find(i => i < wtStep);
2606
+ if (next !== undefined) gotoStep(next);
2607
+ else toast(dir > 0 ? "end of walkthrough" : "start of walkthrough");
2608
+ }
2609
+ function wtActIdx() {
2610
+ const s = wt.steps[wtStep];
2611
+ return s ? wt.acts.findIndex(a => a.id === s.act) : -1;
2612
+ }
2613
+ function wtGotoAct(ai) {
2614
+ if (!wt || ai < 0 || ai >= wt.acts.length) return;
2615
+ const i = wt.steps.findIndex(s => s.act === wt.acts[ai].id);
2616
+ if (i >= 0) gotoStep(i);
2617
+ }
2618
+ function renderWtBar() {
2619
+ if (!wt || wtStep < 0) return;
2620
+ const step = wt.steps[wtStep];
2621
+ const act = wt.acts.find(a => a.id === step.act);
2622
+ $("wt-act").textContent = act ? act.title : step.act;
2623
+ $("wt-act").title = wt.title + (wt.intro ? " — " + wt.intro : "");
2624
+ $("wt-count").textContent = (wtStep + 1) + "/" + wt.steps.length;
2625
+ $("wt-title").textContent = step.title;
2626
+ $("wt-bar").classList.toggle("d1", step.depth === 1);
2627
+ const txt = $("wt-text");
2628
+ txt.classList.toggle("err", !!wt.error);
2629
+ txt.textContent = wt.error ? "walkthrough reload failed:\n" + wt.error : step.text;
2630
+ txt.scrollTop = 0;
2631
+
2632
+ const refs = $("wt-refs");
2633
+ refs.textContent = "";
2634
+ const chip = (label, cls, title, fn) => {
2635
+ const c = document.createElement("span");
2636
+ c.className = "wt-chip " + cls; c.textContent = label; c.title = title;
2637
+ c.onclick = fn; refs.append(c);
2638
+ };
2639
+ chip(wtRefLabel(step.ref), "primary", step.ref.path, () => gotoStep(wtStep));
2640
+ for (const ar of step.also)
2641
+ chip("+ " + wtRefLabel(ar), "", ar.path + (ar.note ? " — " + ar.note : ""),
2642
+ () => wtJumpRef(ar));
2643
+ if (step.forward) {
2644
+ const tgt = wt.steps[wtStepIdx(step.forward)];
2645
+ const f = document.createElement("span");
2646
+ f.className = "wt-fwd";
2647
+ f.textContent = "→ later: " + (tgt ? tgt.title : step.forward);
2648
+ f.onclick = () => gotoStep(wtStepIdx(step.forward));
2649
+ refs.append(f);
2650
+ }
2651
+
2652
+ const pips = $("wt-pips");
2653
+ pips.textContent = "";
2654
+ let lastAct = null;
2655
+ wt.steps.forEach((s, i) => {
2656
+ if (lastAct !== null && s.act !== lastAct) {
2657
+ const g = document.createElement("span"); g.className = "wt-gap";
2658
+ pips.append(g);
2659
+ }
2660
+ lastAct = s.act;
2661
+ const p = document.createElement("span");
2662
+ p.className = "wt-pip" + (s.depth ? " d1" : "")
2663
+ + (i === wtStep ? " cur" : "") + (wtVisited.has(s.id) ? " visited" : "");
2664
+ p.title = s.title;
2665
+ p.onclick = () => gotoStep(i);
2666
+ pips.append(p);
2667
+ });
2668
+ }
2669
+ function renderWtOutline() {
2670
+ const o = $("wt-outline");
2671
+ if (!wt) { o.hidden = true; return; }
2672
+ o.hidden = false;
2673
+ o.textContent = "";
2674
+ const head = document.createElement("div");
2675
+ head.id = "wt-o-head";
2676
+ const arrow = document.createElement("span");
2677
+ arrow.className = "d-arrow";
2678
+ arrow.textContent = renderWtOutline.closed ? "▸ " : "▾ ";
2679
+ head.append(arrow, wt.title);
2680
+ head.onclick = () => { renderWtOutline.closed = !renderWtOutline.closed; renderWtOutline(); };
2681
+ o.append(head);
2682
+ if (renderWtOutline.closed) return;
2683
+ for (const a of wt.acts) {
2684
+ const ah = document.createElement("div");
2685
+ ah.className = "wt-o-act";
2686
+ ah.textContent = (wtActsClosed.has(a.id) ? "▸ " : "▾ ") + a.title;
2687
+ ah.onclick = () => {
2688
+ wtActsClosed.has(a.id) ? wtActsClosed.delete(a.id) : wtActsClosed.add(a.id);
2689
+ renderWtOutline();
2690
+ };
2691
+ o.append(ah);
2692
+ if (wtActsClosed.has(a.id)) continue;
2693
+ wt.steps.forEach((s, i) => {
2694
+ if (s.act !== a.id) return;
2695
+ const el = document.createElement("div");
2696
+ el.className = "wt-o-step" + (s.depth ? " d1" : "") + (i === wtStep ? " cur" : "");
2697
+ el.textContent = s.title;
2698
+ el.title = s.title;
2699
+ el.onclick = () => { if (!wtOn) setWalkthrough(true); gotoStep(i); };
2700
+ o.append(el);
2701
+ });
2702
+ }
2703
+ }
2704
+ function wtSaveProgress() {
2705
+ if (!wt || wtStep < 0) return;
2706
+ savePref("seendiff.wt." + wt.digest, JSON.stringify(
2707
+ { stepId: wt.steps[wtStep].id, visited: [...wtVisited] }));
2708
+ }
2709
+ function wtRestoreProgress() {
2710
+ try {
2711
+ const p = JSON.parse(localStorage.getItem("seendiff.wt." + wt.digest) || "null");
2712
+ if (p) { wtVisited = new Set(p.visited || []); return wtStepIdx(p.stepId); }
2713
+ } catch (e) { }
2714
+ return -1;
2715
+ }
2716
+ function setWalkthrough(on) {
2717
+ if (!wt) return;
2718
+ wtOn = on;
2719
+ $("wt-bar").hidden = !on;
2720
+ if (on) {
2721
+ if (file) computeOffsets();
2722
+ gotoStep(wtStep >= 0 ? wtStep : Math.max(0, wtRestoreProgress()));
2723
+ } else {
2724
+ wtHi = null; wtAnchors = new Set(); wtAway = false;
2725
+ if (file) { computeOffsets(); renderRows(); drawMinimap(); }
2726
+ updateHash();
2727
+ }
2728
+ renderWtOutline();
2729
+ }
2730
+ $("wt-dismiss").onclick = async () => {
2731
+ try {
2732
+ const r = await fetch("/api/walkthrough/dismiss", { method: "POST" });
2733
+ if (!r.ok) { toast("dismiss failed"); return; }
2734
+ } catch (e) { toast("dismiss failed"); return; }
2735
+ wtClear("walkthrough dismissed");
2736
+ };
2737
+ let wtPollBusy = false;
2738
+ async function wtPoll() {
2739
+ if (!wtWatch || wtPollBusy) return;
2740
+ wtPollBusy = true;
2741
+ try {
2742
+ const r = await fetch("/api/walkthrough");
2743
+ if (!r.ok) return;
2744
+ const nw = await r.json();
2745
+ if (nw.empty) { if (wt) wtClear("walkthrough dismissed"); return; }
2746
+ if (!wt) { wtAdopt(nw); return; }
2747
+ if (nw.digest !== wt.digest) {
2748
+ const curId = wtStep >= 0 ? wt.steps[wtStep].id : null;
2749
+ wt = nw;
2750
+ wtVisited = new Set();
2751
+ const keep = curId ? wtStepIdx(curId) : -1;
2752
+ wtStep = Math.max(0, keep);
2753
+ toast("walkthrough reloaded");
2754
+ renderSidebar();
2755
+ if (wtOn) gotoStep(wtStep); else renderWtOutline();
2756
+ } else if ((nw.error || null) !== (wt.error || null)) {
2757
+ wt.error = nw.error || null;
2758
+ if (wtOn) renderWtBar();
2759
+ }
2760
+ } catch (e) { } finally { wtPollBusy = false; }
2761
+ }
2762
+ function updateHash() {
2763
+ if (!curPath || !file) return;
2764
+ let h = "#f=" + encodeURIComponent(curPath)
2765
+ + "&l=" + (firstLineOf(findRow(pane.scrollTop)) + 1);
2766
+ if (wtOn && wt && wtStep >= 0)
2767
+ h += "&w=" + encodeURIComponent(wt.steps[wtStep].id);
2768
+ history.replaceState(null, "", h);
2769
+ }
2770
+ {
2771
+ const rz = $("wt-resizer");
2772
+ const saved = parseInt(localStorage.getItem("seendiff.wth") || "160", 10);
2773
+ document.documentElement.style.setProperty("--wt-height", saved + "px");
2774
+ let resizing = false, startY = 0, startH = 0;
2775
+ rz.addEventListener("mousedown", e => {
2776
+ e.preventDefault();
2777
+ resizing = true; rz.classList.add("active");
2778
+ startY = e.clientY; startH = $("wt-bar").getBoundingClientRect().height;
2779
+ document.body.style.cursor = "row-resize";
2780
+ });
2781
+ addEventListener("mousemove", e => {
2782
+ if (!resizing) return;
2783
+ const h = Math.max(80, Math.min(innerHeight * .6, startH + startY - e.clientY));
2784
+ document.documentElement.style.setProperty("--wt-height", h + "px");
2785
+ renderRows(); drawMinimap();
2786
+ });
2787
+ addEventListener("mouseup", () => {
2788
+ if (!resizing) return;
2789
+ resizing = false; rz.classList.remove("active");
2790
+ document.body.style.cursor = "";
2791
+ savePref("seendiff.wth",
2792
+ $("wt-bar").getBoundingClientRect().height | 0);
2793
+ if (file) { computeOffsets(); renderRows(); drawMinimap(); }
2794
+ });
2795
+ }
2796
+
2797
+ const SEARCH_MAX = 5000;
2798
+ const PANEL_MAX = 300;
2799
+ let sOpen = false;
2800
+ let sRegex = localStorage.getItem("seendiff.sregex") === "1";
2801
+ let sQuery = "";
2802
+ let sMatches = [];
2803
+ let sByLine = new Map();
2804
+ let sIdx = -1;
2805
+ let sGlobal = null;
2806
+ let sCur = null;
2807
+ let sPanel = true;
2808
+ let sTimer = null;
2809
+
2810
+ function searchRe(q) {
2811
+ if (!q) return null;
2812
+ const flags = "g" + (/[A-Z]/.test(q) ? "" : "i");
2813
+ const body = sRegex ? q : q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2814
+ try { return new RegExp(body, flags); } catch (e) { return null; }
2815
+ }
2816
+ function runFileSearch() {
2817
+ sMatches = []; sByLine = new Map(); sIdx = -1;
2818
+ const re = searchRe(sQuery);
2819
+ $("s-input").classList.toggle("bad", !!sQuery && !re);
2820
+ if (!re || !file) return;
2821
+ scan:
2822
+ for (let i = 0; i < file.lines.length; i++) {
2823
+ const text = rowText(file.lines[i]);
2824
+ re.lastIndex = 0;
2825
+ let m;
2826
+ while ((m = re.exec(text))) {
2827
+ if (m[0] === "") { re.lastIndex++; continue; }
2828
+ const hit = { i, s: m.index, e: m.index + m[0].length };
2829
+ sMatches.push(hit);
2830
+ if (!sByLine.has(i)) sByLine.set(i, []);
2831
+ sByLine.get(i).push(hit);
2832
+ if (sMatches.length >= SEARCH_MAX) break scan;
2833
+ }
2834
+ }
2835
+ }
2836
+ function nearestMatch(fromRow) {
2837
+ for (let k = 0; k < sMatches.length; k++)
2838
+ if (viewOf[sMatches[k].i] >= fromRow) return k;
2839
+ return sMatches.length ? 0 : -1;
2840
+ }
2841
+ function gotoMatch(k) {
2842
+ if (!sMatches.length) { sIdx = -1; redrawSearch(); return; }
2843
+ sIdx = ((k % sMatches.length) + sMatches.length) % sMatches.length;
2844
+ const v = viewOf[sMatches[sIdx].i];
2845
+ const top = offsets[v], bot = offsets[v + 1];
2846
+ if (top < pane.scrollTop || bot > pane.scrollTop + pane.clientHeight)
2847
+ pane.scrollTop = Math.max(0, (top + bot) / 2 - pane.clientHeight / 2);
2848
+ redrawSearch();
2849
+ }
2850
+ function redrawSearch() {
2851
+ clearRows(); renderRows(); drawMinimap(); renderSearchCount(); renderResults();
2852
+ }
2853
+ function renderSearchCount() {
2854
+ const c = $("s-count");
2855
+ if (!sQuery) { c.textContent = ""; return; }
2856
+ if (!sGlobal) { c.textContent = "…"; return; }
2857
+ if (!sGlobal.total) { c.textContent = "no matches"; return; }
2858
+ const tot = sGlobal.total + (sGlobal.truncated ? "+" : "");
2859
+ const k = resultIdx();
2860
+ c.textContent = k >= 0 ? (k + 1) + "/" + tot
2861
+ : tot + " in " + sGlobal.files.length + " files";
2862
+ }
2863
+
2864
+ function resultRows() {
2865
+ const out = [];
2866
+ if (sGlobal) for (const f of sGlobal.files)
2867
+ for (const m of f.matches) out.push([f.path, m]);
2868
+ return out;
2869
+ }
2870
+ function resultIdx() {
2871
+ return resultRows().findIndex(([p, m]) => p + "|" + m.line === sCur);
2872
+ }
2873
+ function snippet(el, text, s, e) {
2874
+ el.append(text.slice(0, s).replace(/^\s+/, ""));
2875
+ const mk = document.createElement("mark");
2876
+ mk.textContent = text.slice(s, e);
2877
+ el.append(mk, text.slice(e));
2878
+ }
2879
+ function renderResults() {
2880
+ const p = $("s-panel");
2881
+ p.hidden = !(sOpen && sPanel);
2882
+ $("s-panel-btn").classList.toggle("on", sPanel);
2883
+ if (p.hidden) return;
2884
+ $("sp-sub").textContent = sGlobal ? sGlobal.files.length + " files" : "";
2885
+ const box = $("s-results");
2886
+ box.textContent = "";
2887
+ const note = msg => {
2888
+ const d = document.createElement("div");
2889
+ d.id = "s-note"; d.textContent = msg; box.append(d);
2890
+ };
2891
+ if (!sQuery) { note("type to search"); return; }
2892
+ if (!sGlobal) { note("searching…"); return; }
2893
+ const all = resultRows();
2894
+ if (!all.length) {
2895
+ note("no matches in the change");
2896
+ return;
2897
+ }
2898
+ const rows = all.slice(0, PANEL_MAX);
2899
+ let lastPath = null;
2900
+ for (const [path, m] of rows) {
2901
+ if (path !== lastPath) {
2902
+ lastPath = path;
2903
+ const head = document.createElement("div");
2904
+ head.className = "sr-file"; head.title = path;
2905
+ const bdi = document.createElement("bdi");
2906
+ bdi.textContent = path;
2907
+ head.append(bdi);
2908
+ box.append(head);
2909
+ }
2910
+ const key = path + "|" + m.line;
2911
+ const el = document.createElement("div");
2912
+ el.className = "sr-hit " + m.type + (key === sCur ? " cur" : "");
2913
+ const ln = document.createElement("span");
2914
+ ln.className = "sr-ln"; ln.textContent = m.new ?? m.old ?? "";
2915
+ const tx = document.createElement("span");
2916
+ tx.className = "sr-txt";
2917
+ snippet(tx, m.text, m.start, m.end);
2918
+ el.append(ln, tx);
2919
+ el.onclick = () => openResult(path, m);
2920
+ box.append(el);
2921
+ }
2922
+ if (rows.length < all.length)
2923
+ note("showing " + rows.length + " of " + all.length + " lines");
2924
+ else if (sGlobal.truncated)
2925
+ note("stopped at " + sGlobal.total + " matches — narrow the query");
2926
+ box.querySelector(".sr-hit.cur")?.scrollIntoView({ block: "nearest" });
2927
+ }
2928
+ async function openResult(path, m) {
2929
+ sCur = path + "|" + m.line;
2930
+ if (path !== curPath) await openFile(path);
2931
+ const k = nearestMatch(viewOf[m.line] ?? 0);
2932
+ gotoMatch(k >= 0 ? k : 0);
2933
+ }
2934
+ function stepResult(dir) {
2935
+ const rows = resultRows();
2936
+ if (!rows.length) return;
2937
+ let k = rows.findIndex(([p, m]) => p + "|" + m.line === sCur);
2938
+ k = k < 0 ? (dir > 0 ? 0 : rows.length - 1)
2939
+ : ((k + dir) % rows.length + rows.length) % rows.length;
2940
+ openResult(rows[k][0], rows[k][1]);
2941
+ }
2942
+
2943
+ function scheduleGlobal() {
2944
+ clearTimeout(sTimer);
2945
+ sTimer = setTimeout(runGlobalSearch, 200);
2946
+ }
2947
+ async function runGlobalSearch() {
2948
+ clearTimeout(sTimer);
2949
+ if (!sQuery) { renderResults(); return; }
2950
+ const q = sQuery;
2951
+ try {
2952
+ const r = await fetch("/api/search?q=" + encodeURIComponent(q)
2953
+ + "&regex=" + (sRegex ? "true" : "false")
2954
+ + "&case=" + (/[A-Z]/.test(q) ? "true" : "false"));
2955
+ if (q !== sQuery) return;
2956
+ $("s-input").classList.toggle("bad", !r.ok);
2957
+ sGlobal = r.ok ? await r.json() : { total: 0, truncated: false, files: [] };
2958
+ } catch (e) {
2959
+ sGlobal = { total: 0, truncated: false, files: [] };
2960
+ }
2961
+ if (q !== sQuery) return;
2962
+ renderResults(); renderSearchCount();
2963
+ }
2964
+
2965
+ function paneResized() {
2966
+ if (!file) return;
2967
+ computeOffsets(); renderRows(); drawMinimap();
2968
+ }
2969
+ function openSearch() {
2970
+ sOpen = true;
2971
+ $("searchbar").hidden = false;
2972
+ renderResults();
2973
+ paneResized();
2974
+ const inp = $("s-input");
2975
+ inp.value = sQuery;
2976
+ inp.focus(); inp.select();
2977
+ renderSearchCount();
2978
+ }
2979
+ function closeSearch() {
2980
+ if (!sOpen) return;
2981
+ sOpen = false;
2982
+ $("searchbar").hidden = true;
2983
+ $("s-input").blur();
2984
+ renderResults();
2985
+ paneResized();
2986
+ }
2987
+ function queryChanged() {
2988
+ sQuery = $("s-input").value;
2989
+ sGlobal = null; sCur = null;
2990
+ sPanel = true;
2991
+ runFileSearch();
2992
+ if (sMatches.length) gotoMatch(nearestMatch(findRow(pane.scrollTop)));
2993
+ else redrawSearch();
2994
+ scheduleGlobal();
2995
+ }
2996
+ $("s-input").addEventListener("input", queryChanged);
2997
+ $("s-next").onclick = () => stepResult(1);
2998
+ $("s-prev").onclick = () => stepResult(-1);
2999
+ $("s-regex").onclick = () => {
3000
+ sRegex = !sRegex;
3001
+ savePref("seendiff.sregex", sRegex ? "1" : "0");
3002
+ $("s-regex").classList.toggle("on", sRegex);
3003
+ sGlobal = null;
3004
+ runFileSearch();
3005
+ redrawSearch();
3006
+ scheduleGlobal();
3007
+ };
3008
+ $("s-panel-btn").onclick = () => {
3009
+ sPanel = !sPanel;
3010
+ $("s-panel-btn").classList.toggle("on", sPanel);
3011
+ renderResults(); paneResized();
3012
+ };
3013
+ $("sp-close").onclick = () => $("s-panel-btn").onclick();
3014
+ $("s-close").onclick = closeSearch;
3015
+ {
3016
+ const p = $("s-panel"), rz = $("sp-resizer");
3017
+ const saved = parseInt(localStorage.getItem("seendiff.spw") || "0", 10);
3018
+ if (saved) p.style.width = saved + "px";
3019
+ let resizing = false;
3020
+ rz.addEventListener("mousedown", e => {
3021
+ e.preventDefault();
3022
+ resizing = true; rz.classList.add("active");
3023
+ document.body.style.cursor = "col-resize";
3024
+ });
3025
+ addEventListener("mousemove", e => {
3026
+ if (resizing) p.style.width = (innerWidth - e.clientX) + "px";
3027
+ });
3028
+ addEventListener("mouseup", () => {
3029
+ if (!resizing) return;
3030
+ resizing = false; rz.classList.remove("active");
3031
+ document.body.style.cursor = "";
3032
+ savePref("seendiff.spw", p.getBoundingClientRect().width | 0);
3033
+ paneResized();
3034
+ });
3035
+ }
3036
+
3037
+ addEventListener("keydown", e => {
3038
+ if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
3039
+ e.preventDefault(); openSearch(); return;
3040
+ }
3041
+ if (e.target === $("s-input")) {
3042
+ if (e.key === "Escape") { e.preventDefault(); closeSearch(); }
3043
+ else if (e.key === "Enter") {
3044
+ e.preventDefault();
3045
+ const dir = e.shiftKey ? -1 : 1;
3046
+ stepResult(dir);
3047
+ }
3048
+ return;
3049
+ }
3050
+ if (e.target === $("filter")) {
3051
+ if (e.key === "Escape") $("filter").blur();
3052
+ if (e.key === "Enter") {
3053
+ const vis = visibleFiles().filter(f =>
3054
+ f.path.toLowerCase().includes($("filter").value.toLowerCase()));
3055
+ if (vis.length) { openFile(vis[0].path); $("filter").blur(); }
3056
+ }
3057
+ return;
3058
+ }
3059
+ if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
3060
+ const k = e.key;
3061
+ const stop = () => e.preventDefault();
3062
+ const confirming = $("confirm-overlay").classList.contains("show");
3063
+ if (k === "Escape") {
3064
+ toggleHelp(false); $("confirm-overlay").classList.remove("show");
3065
+ closeSearch(); return;
3066
+ }
3067
+ if (confirming) {
3068
+ if (k === "Enter") { stop(); doResetAll(); }
3069
+ return;
3070
+ }
3071
+ if (k === "?") { stop(); toggleHelp(); return; }
3072
+ if ($("help-overlay").classList.contains("show")) return;
3073
+ if (k === "X") { stop(); askResetAll(); return; }
3074
+ if (k === "ArrowLeft") { stop(); wtOn && !e.shiftKey ? wtMove(-1) : nextBlock(-1); }
3075
+ else if (k === "ArrowRight") { stop(); wtOn && !e.shiftKey ? wtMove(1) : nextBlock(1); }
3076
+ else if (k === "ArrowUp") { stop(); nextFile(-1); }
3077
+ else if (k === "ArrowDown") { stop(); nextFile(1); }
3078
+ else if (k === "w") { stop(); if (wt) setWalkthrough(!wtOn); }
3079
+ else if (wtOn && k === "[") { stop(); wtGotoAct(wtActIdx() - 1); }
3080
+ else if (wtOn && k === "]") { stop(); wtGotoAct(wtActIdx() + 1); }
3081
+ else if (wtOn && k === ".") {
3082
+ stop(); wtDetail = !wtDetail;
3083
+ toast(wtDetail ? "detail steps on" : "detail steps off");
3084
+ }
3085
+ else if (wtOn && k >= "1" && k <= "9") { stop(); wtGotoAct(+k - 1); }
3086
+ else if (k === "R") {
3087
+ stop(); if (focusIdx >= 0) {
3088
+ const wasRev = blocks[focusIdx].reviewed;
3089
+ toggleReviewed(focusIdx);
3090
+ if (!wasRev && !wtOn) nextUnreviewed(1);
3091
+ } else if (wtOn) toast("no change block here");
3092
+ }
3093
+ else if (k === "n") { stop(); nextUnreviewed(1); }
3094
+ else if (k === "N") { stop(); nextUnreviewed(-1); }
3095
+ else if (k === "j") pane.scrollTop += LH * 3;
3096
+ else if (k === "k") pane.scrollTop -= LH * 3;
3097
+ else if (k === "d") pane.scrollTop += pane.clientHeight / 2;
3098
+ else if (k === "u") pane.scrollTop -= pane.clientHeight / 2;
3099
+ else if (k === "V") { stop(); reviewSeenInFile(); }
3100
+ else if (k === "f") {
3101
+ stop();
3102
+ const f = meta.files.find(x => x.path === curPath);
3103
+ if (f && f.reviewed < f.blocks) toggleFileReviewed(f);
3104
+ nextFile(1);
3105
+ }
3106
+ else if (k === "y") { stop(); copyRefWithContent(); }
3107
+ else if (k === "Y") { stop(); copyUnreviewedRefs(); }
3108
+ else if (k === "s") { stop(); setSplit(!split); }
3109
+ else if (k === "g") { stop(); $("filter").focus(); $("filter").select(); }
3110
+ else if (k === "h") { stop(); $("btn-hide").onclick(); }
3111
+ else if (k === "t") { stop(); $("btn-theme").onclick(); }
3112
+ });
3113
+
3114
+ let rafPending = false;
3115
+ pane.addEventListener("scroll", () => {
3116
+ if (rafPending) return;
3117
+ rafPending = true;
3118
+ requestAnimationFrame(() => {
3119
+ rafPending = false;
3120
+ renderRows(); drawMinimap(); evalSeen();
3121
+ updateHash();
3122
+ });
3123
+ });
3124
+ addEventListener("resize", () => {
3125
+ if (file) { computeOffsets(); }
3126
+ layoutNotches();
3127
+ if (meta) renderSidebar();
3128
+ renderRows(); drawMinimap();
3129
+ });
3130
+ document.fonts.ready.then(() => {
3131
+ if (file) { measureMetrics(); computeOffsets(); renderRows(); drawMinimap(); }
3132
+ });
3133
+ $("filter").addEventListener("input", renderSidebar);
3134
+
3135
+
3136
+
3137
+ (async function init() {
3138
+ setTheme(document.documentElement.dataset.theme);
3139
+ setFont(localStorage.getItem("seendiff.font") || FONTS[0]);
3140
+ setSplit(split);
3141
+ $("s-regex").classList.toggle("on", sRegex);
3142
+ layoutNotches();
3143
+ await loadMeta();
3144
+ await loadWalkthrough();
3145
+ if (!meta.files.length && !wt) {
3146
+ $("tb-path").textContent = "nothing to review";
3147
+ const d = document.createElement("div");
3148
+ d.id = "empty-msg";
3149
+ d.textContent = `No committed changes between ${meta.base_ref} and HEAD. `
3150
+ + `Commit your work, or pass a different base ref.`;
3151
+ pane.append(d);
3152
+ return;
3153
+ }
3154
+ if (wt) { renderSidebar(); renderWtOutline(); }
3155
+ const h = new URLSearchParams(location.hash.slice(1));
3156
+ const start = h.get("f");
3157
+ const known = p => meta.files.some(f => f.path === p)
3158
+ || (wt && wt.wt_paths.includes(p));
3159
+ if (start && known(start)) {
3160
+ await openFile(start, +(h.get("l") || 0) || undefined);
3161
+ } else if (meta.files.length) {
3162
+ await openFile(meta.files[0].path);
3163
+ }
3164
+ if (wt) {
3165
+ const wparam = h.get("w");
3166
+ const i = wparam ? wtStepIdx(wparam) : -1;
3167
+ if (i >= 0) { wtStep = i; wtVisited = new Set(); wtRestoreProgress(); }
3168
+ setWalkthrough(true);
3169
+ toast(wt.title);
3170
+ }
3171
+ })();
3172
+ </script>
3173
+ </body>
3174
+
3175
+ </html>