@cluesmith/codev 1.2.0 → 1.2.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,1336 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Annotate: {{FILE}}</title>
6
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet">
7
+ <!-- Markdown preview dependencies (loaded only for .md files) -->
8
+ <script>
9
+ if ({{IS_MARKDOWN}}) {
10
+ document.write('<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"><\/script>');
11
+ document.write('<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"><\/script>');
12
+ }
13
+ </script>
14
+ <style>
15
+ * { box-sizing: border-box; margin: 0; padding: 0; }
16
+ body {
17
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #1a1a1a;
19
+ color: #fff;
20
+ min-height: 100vh;
21
+ }
22
+ .header {
23
+ padding: 15px 20px;
24
+ background: #2a2a2a;
25
+ border-bottom: 1px solid #333;
26
+ position: sticky;
27
+ top: 0;
28
+ z-index: 100;
29
+ }
30
+ .header h1.path { font-size: 14px; font-weight: 500; font-family: monospace; color: #fff; }
31
+ .header .subtitle { color: #888; font-size: 12px; margin-top: 4px; }
32
+ .header .actions { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
33
+ .header .btn {
34
+ padding: 6px 12px; border-radius: 4px; border: none; cursor: pointer;
35
+ font-size: 12px;
36
+ }
37
+ .btn-primary { background: #3b82f6; color: #fff; }
38
+ .btn-secondary { background: #444; color: #fff; }
39
+ .btn-success { background: #22c55e; color: #fff; }
40
+ .btn:hover { opacity: 0.9; }
41
+
42
+ /* Unsaved indicator */
43
+ #unsavedIndicator {
44
+ color: #fbbf24;
45
+ font-size: 12px;
46
+ display: none;
47
+ }
48
+ #unsavedIndicator::before {
49
+ content: '●';
50
+ margin-right: 4px;
51
+ }
52
+
53
+ /* Grid layout: each row has line number + code, guaranteeing alignment */
54
+ .content {
55
+ display: grid;
56
+ grid-template-columns: auto 1fr;
57
+ padding: 15px 0;
58
+ }
59
+ .line-num {
60
+ background: #252525;
61
+ color: #666;
62
+ text-align: right;
63
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
64
+ font-size: 13px;
65
+ user-select: none;
66
+ border-right: 1px solid #333;
67
+ padding: 0 12px 0 8px;
68
+ cursor: pointer;
69
+ line-height: 1.5;
70
+ min-width: 50px;
71
+ position: sticky;
72
+ left: 0;
73
+ }
74
+ .line-num:hover { background: #333; color: #fff; }
75
+ .line-num.has-review {
76
+ background: rgba(250, 204, 21, 0.2);
77
+ color: #facc15;
78
+ }
79
+ .code-line {
80
+ background: #1a1a1a;
81
+ padding: 0 15px;
82
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
83
+ font-size: 13px;
84
+ line-height: 1.5;
85
+ white-space: pre-wrap;
86
+ word-wrap: break-word;
87
+ }
88
+ /* Hybrid markdown: syntax visible but muted, content styled */
89
+ /* All text uses same font size for consistent line heights in annotation view */
90
+ .md-syntax { color: #555; } /* Muted syntax markers */
91
+ .md-h1, .md-h2, .md-h3, .md-h4, .md-h5, .md-h6 { font-weight: bold; color: #c678dd; }
92
+ .md-bold { font-weight: bold; color: #e5c07b; }
93
+ .md-italic { font-style: italic; color: #98c379; }
94
+ .md-code { color: #e06c75; background: #2c313a; padding: 1px 4px; border-radius: 3px; }
95
+ .md-link-text { color: #61afef; text-decoration: underline; }
96
+ .md-link-url { color: #555; font-size: 0.9em; }
97
+ .md-bullet { color: #56b6c2; }
98
+ .md-blockquote { color: #abb2bf; border-left: 3px solid #555; padding-left: 10px; }
99
+ .md-fence { color: #555; }
100
+ .md-codeblock { color: #98c379; background: #2c313a; display: block; }
101
+
102
+ /* Markdown Preview Container */
103
+ #preview-container {
104
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
105
+ line-height: 1.6;
106
+ color: #fff;
107
+ max-width: 900px;
108
+ margin: 0 auto;
109
+ }
110
+
111
+ #preview-container h1,
112
+ #preview-container h2 {
113
+ border-bottom: 1px solid #333;
114
+ padding-bottom: 0.3em;
115
+ margin-top: 1.5em;
116
+ margin-bottom: 0.5em;
117
+ }
118
+
119
+ #preview-container h1 { font-size: 2em; }
120
+ #preview-container h2 { font-size: 1.5em; }
121
+ #preview-container h3 { font-size: 1.25em; margin-top: 1em; }
122
+
123
+ #preview-container code {
124
+ background: #2c313a;
125
+ padding: 0.2em 0.4em;
126
+ border-radius: 3px;
127
+ font-size: 0.9em;
128
+ }
129
+
130
+ #preview-container pre {
131
+ background: #252525;
132
+ padding: 16px;
133
+ overflow: auto;
134
+ border-radius: 6px;
135
+ margin: 1em 0;
136
+ }
137
+
138
+ #preview-container pre code {
139
+ background: none;
140
+ padding: 0;
141
+ }
142
+
143
+ #preview-container table {
144
+ border-collapse: collapse;
145
+ width: 100%;
146
+ margin: 1em 0;
147
+ }
148
+
149
+ #preview-container th,
150
+ #preview-container td {
151
+ border: 1px solid #333;
152
+ padding: 8px 12px;
153
+ text-align: left;
154
+ }
155
+
156
+ #preview-container th {
157
+ background: #2a2a2a;
158
+ }
159
+
160
+ #preview-container tr:nth-child(even) {
161
+ background: #252525;
162
+ }
163
+
164
+ #preview-container a {
165
+ color: #3b82f6;
166
+ text-decoration: underline;
167
+ }
168
+
169
+ #preview-container ul,
170
+ #preview-container ol {
171
+ padding-left: 2em;
172
+ margin: 1em 0;
173
+ }
174
+
175
+ #preview-container blockquote {
176
+ border-left: 4px solid #333;
177
+ padding-left: 1em;
178
+ margin: 1em 0;
179
+ color: #888;
180
+ }
181
+
182
+ /* Editor textarea */
183
+ #editor {
184
+ display: none;
185
+ width: 100%;
186
+ height: calc(100vh - 80px);
187
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
188
+ font-size: 13px;
189
+ line-height: 1.5;
190
+ padding: 15px;
191
+ border: none;
192
+ resize: none;
193
+ background: #1a1a1a;
194
+ color: #d4d4d4;
195
+ outline: none;
196
+ tab-size: 2;
197
+ }
198
+ #editor:focus {
199
+ outline: none;
200
+ }
201
+
202
+ /* Notification toast */
203
+ .notification {
204
+ position: fixed;
205
+ bottom: 20px;
206
+ right: 20px;
207
+ padding: 12px 20px;
208
+ background: #22c55e;
209
+ color: white;
210
+ border-radius: 6px;
211
+ font-size: 14px;
212
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
213
+ z-index: 300;
214
+ animation: slideIn 0.3s ease;
215
+ }
216
+ .notification.error {
217
+ background: #ef4444;
218
+ }
219
+ @keyframes slideIn {
220
+ from { transform: translateX(100%); opacity: 0; }
221
+ to { transform: translateX(0); opacity: 1; }
222
+ }
223
+
224
+ .review-line {
225
+ background: rgba(250, 204, 21, 0.1);
226
+ display: block;
227
+ border-left: 3px solid #facc15;
228
+ padding-left: 5px;
229
+ margin-left: -8px;
230
+ }
231
+
232
+ /* Comment Dialog - positioned popup */
233
+ .overlay {
234
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
235
+ background: rgba(0,0,0,0.3);
236
+ display: none;
237
+ z-index: 200;
238
+ }
239
+ .overlay.active { display: block; }
240
+
241
+ .dialog {
242
+ position: absolute;
243
+ background: #2a2a2a;
244
+ padding: 20px;
245
+ border-radius: 8px;
246
+ width: 700px;
247
+ max-width: 90vw;
248
+ box-shadow: 0 10px 40px rgba(0,0,0,0.5);
249
+ border: 1px solid #444;
250
+ }
251
+ .dialog h3 { font-size: 16px; margin-bottom: 5px; }
252
+ .dialog .line-info { color: #888; font-size: 13px; margin-bottom: 15px; }
253
+ .dialog .line-preview {
254
+ background: #1a1a1a; padding: 10px; border-radius: 4px;
255
+ font-family: monospace; font-size: 12px; margin-bottom: 15px;
256
+ overflow-x: auto; white-space: pre;
257
+ }
258
+ .dialog textarea {
259
+ width: 100%; height: 100px;
260
+ background: #1a1a1a; border: 1px solid #444;
261
+ color: #fff; padding: 10px; border-radius: 4px;
262
+ font-family: inherit; font-size: 14px;
263
+ resize: vertical;
264
+ }
265
+ .dialog textarea:focus { outline: none; border-color: #3b82f6; }
266
+ .dialog .actions { margin-top: 15px; text-align: right; }
267
+ .dialog .btn { margin-left: 8px; }
268
+ .btn-danger { background: #ef4444; color: #fff; }
269
+
270
+ /* Annotations list */
271
+ .annotations-panel {
272
+ position: fixed; right: 0; top: 0; bottom: 0; width: 300px;
273
+ background: #252525; border-left: 1px solid #333;
274
+ transform: translateX(100%);
275
+ transition: transform 0.2s;
276
+ z-index: 150;
277
+ overflow-y: auto;
278
+ }
279
+ .annotations-panel.open { transform: translateX(0); }
280
+ .annotations-panel h3 { padding: 15px; border-bottom: 1px solid #333; font-size: 14px; }
281
+ .annotation-item {
282
+ padding: 12px 15px; border-bottom: 1px solid #333; cursor: pointer;
283
+ }
284
+ .annotation-item:hover { background: #2a2a2a; }
285
+ .annotation-item .line { color: #3b82f6; font-size: 12px; }
286
+ .annotation-item .text { font-size: 13px; margin-top: 4px; }
287
+
288
+ /* Image Viewer */
289
+ #image-viewer {
290
+ display: none;
291
+ flex-direction: column;
292
+ height: calc(100vh - 80px);
293
+ padding: 15px;
294
+ }
295
+ .image-controls {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 8px;
299
+ padding: 10px 0;
300
+ flex-shrink: 0;
301
+ }
302
+ .image-controls .btn {
303
+ padding: 6px 12px;
304
+ border-radius: 4px;
305
+ border: none;
306
+ cursor: pointer;
307
+ font-size: 12px;
308
+ background: #444;
309
+ color: #fff;
310
+ }
311
+ .image-controls .btn:hover { opacity: 0.9; }
312
+ .image-controls .btn.active {
313
+ background: #3b82f6;
314
+ }
315
+ #image-info {
316
+ color: #888;
317
+ font-size: 12px;
318
+ margin-left: auto;
319
+ }
320
+ .image-container {
321
+ flex: 1;
322
+ overflow: auto;
323
+ display: flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ background: #222;
327
+ border-radius: 4px;
328
+ }
329
+ .image-container.zoom-fit img {
330
+ max-width: 100%;
331
+ max-height: 100%;
332
+ width: auto;
333
+ height: auto;
334
+ object-fit: contain;
335
+ }
336
+ .image-container.zoom-100 img,
337
+ .image-container.zoom-custom img {
338
+ max-width: none;
339
+ max-height: none;
340
+ }
341
+ #image-display {
342
+ transition: transform 0.1s ease;
343
+ }
344
+ #image-error {
345
+ color: #ef4444;
346
+ text-align: center;
347
+ padding: 40px;
348
+ }
349
+ </style>
350
+ </head>
351
+ <body data-builder="{{BUILDER_ID}}" data-file="{{FILE_PATH}}" data-lang="{{LANG}}" data-is-image="{{IS_IMAGE}}" data-file-size="{{FILE_SIZE}}">
352
+ <div class="header">
353
+ <h1 class="path">{{FILE_PATH}}</h1>
354
+ <div class="subtitle" id="subtitle">Click on a line number to leave an annotation.</div>
355
+ <div id="image-header-info" style="display: none; color: #888; font-size: 12px; margin-top: 4px;"></div>
356
+ <div class="actions">
357
+ <button id="reloadBtn" class="btn btn-secondary" onclick="reloadFile()" title="Reload from disk" aria-label="Reload file from disk">↻</button>
358
+ <button id="togglePreviewBtn" class="btn btn-secondary" style="display: none;" title="Toggle Preview (Cmd+Shift+P)">
359
+ <span id="toggle-icon">👁</span> <span id="toggle-text">Preview</span>
360
+ </button>
361
+ <button id="editBtn" class="btn btn-primary" onclick="toggleEditMode()">Switch to Editing</button>
362
+ <button id="saveBtn" class="btn btn-success" onclick="saveEdit()" style="display: none;">Save</button>
363
+ <button id="cancelBtn" class="btn btn-secondary" onclick="cancelEdit()" style="display: none;">Cancel</button>
364
+ <span id="unsavedIndicator">Unsaved changes</span>
365
+ </div>
366
+ </div>
367
+
368
+ <div class="content" id="viewMode">
369
+ <!-- Grid: alternating line-num and code-line elements -->
370
+ </div>
371
+
372
+ <!-- Markdown preview mode -->
373
+ <div id="preview-container" style="display: none; padding: 20px; overflow: auto; height: calc(100vh - 80px);"></div>
374
+
375
+ <!-- Image viewer mode -->
376
+ <div id="image-viewer">
377
+ <div class="image-controls">
378
+ <button id="zoomFitBtn" class="btn active" onclick="zoomFit()">Fit</button>
379
+ <button id="zoom100Btn" class="btn" onclick="zoom100()">100%</button>
380
+ <button id="zoomInBtn" class="btn" onclick="zoomIn()">+</button>
381
+ <button id="zoomOutBtn" class="btn" onclick="zoomOut()">−</button>
382
+ <span id="zoom-level"></span>
383
+ <span id="image-info"></span>
384
+ </div>
385
+ <div class="image-container zoom-fit" id="image-container">
386
+ <img id="image-display" alt="Image preview" />
387
+ <div id="image-error" style="display: none;"></div>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Editor mode -->
392
+ <textarea id="editor" spellcheck="false"></textarea>
393
+
394
+ <!-- Comment Dialog -->
395
+ <div class="overlay" id="overlay">
396
+ <div class="dialog">
397
+ <h3 id="dialogTitle">Add Review Comment</h3>
398
+ <div class="line-info">Line <span id="dialogLine"></span></div>
399
+ <div class="line-preview" id="dialogPreview"></div>
400
+ <textarea id="commentText" placeholder="Enter your review comment..."></textarea>
401
+ <div class="hint" style="color: #666; font-size: 12px; margin-top: 8px;">Tip: Press Enter 3 times to submit</div>
402
+ <div class="actions">
403
+ <button class="btn btn-secondary" onclick="closeDialog()">Cancel</button>
404
+ <button class="btn btn-danger" onclick="resolveComment()" id="resolveBtn" style="display:none">Resolve</button>
405
+ <button class="btn btn-primary" onclick="saveComment()">Add Comment</button>
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- Annotations Panel -->
411
+ <div class="annotations-panel" id="annotationsPanel">
412
+ <h3>Review Comments</h3>
413
+ <div id="annotationsList"></div>
414
+ </div>
415
+
416
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
417
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
418
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
419
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
420
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
421
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
422
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
423
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
424
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
425
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script>
426
+
427
+ <script>
428
+ // State
429
+ let fileLines = [];
430
+ let currentLine = null;
431
+ let isEditing = false; // For comment dialog editing
432
+ let consecutiveEnters = 0; // Track triple-enter to submit
433
+
434
+ // Edit mode state
435
+ let editMode = false;
436
+ let originalContent = '';
437
+ let currentContent = '';
438
+ let hasUnsavedChanges = false;
439
+
440
+ // Markdown preview state
441
+ const isMarkdownFile = {{IS_MARKDOWN}};
442
+ let isPreviewMode = false;
443
+
444
+ // Image viewer state
445
+ const isImageFile = {{IS_IMAGE}};
446
+ let currentZoomMode = 'fit'; // 'fit', '100', 'custom'
447
+ let currentZoomLevel = 1.0;
448
+ let imageNaturalWidth = 0;
449
+ let imageNaturalHeight = 0;
450
+
451
+ // Comment patterns by file extension
452
+ const COMMENT_PATTERNS = {
453
+ js: { prefix: '// REVIEW', regex: /^(\s*)\/\/\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
454
+ ts: { prefix: '// REVIEW', regex: /^(\s*)\/\/\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
455
+ jsx: { prefix: '// REVIEW', regex: /^(\s*)\/\/\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
456
+ tsx: { prefix: '// REVIEW', regex: /^(\s*)\/\/\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
457
+ py: { prefix: '# REVIEW', regex: /^(\s*)#\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
458
+ sh: { prefix: '# REVIEW', regex: /^(\s*)#\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
459
+ bash: { prefix: '# REVIEW', regex: /^(\s*)#\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
460
+ yaml: { prefix: '# REVIEW', regex: /^(\s*)#\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
461
+ yml: { prefix: '# REVIEW', regex: /^(\s*)#\s*REVIEW(\(@\w+\))?:\s*(.*)$/ },
462
+ md: { prefix: '<!-- REVIEW', suffix: ' -->', regex: /^(\s*)<!--\s*REVIEW(\(@\w+\))?:\s*(.*?)\s*-->$/ },
463
+ html: { prefix: '<!-- REVIEW', suffix: ' -->', regex: /^(\s*)<!--\s*REVIEW(\(@\w+\))?:\s*(.*?)\s*-->$/ },
464
+ css: { prefix: '/* REVIEW', suffix: ' */', regex: /^(\s*)\/\*\s*REVIEW(\(@\w+\))?:\s*(.*?)\s*\*\/$/ },
465
+ };
466
+
467
+ // Get file extension and language
468
+ const filePath = document.body.dataset.file;
469
+ const ext = filePath.split('.').pop().toLowerCase();
470
+ const lang = document.body.dataset.lang || ext;
471
+ const commentStyle = COMMENT_PATTERNS[ext] || COMMENT_PATTERNS.js;
472
+
473
+ // Initialize
474
+ function init(content) {
475
+ currentContent = content;
476
+ originalContent = content;
477
+ fileLines = content.split('\n');
478
+ renderFile();
479
+ updateAnnotationsList();
480
+ initPreviewToggle();
481
+ }
482
+
483
+ // Initialize preview toggle for markdown files
484
+ function initPreviewToggle() {
485
+ const togglePreviewBtn = document.getElementById('togglePreviewBtn');
486
+ if (isMarkdownFile && togglePreviewBtn) {
487
+ togglePreviewBtn.style.display = 'inline-block';
488
+ togglePreviewBtn.addEventListener('click', togglePreviewMode);
489
+ }
490
+ }
491
+
492
+ // Initialize image viewer
493
+ function initImage(fileSize) {
494
+ // Hide code view elements
495
+ document.getElementById('viewMode').style.display = 'none';
496
+ document.getElementById('editBtn').style.display = 'none';
497
+ document.getElementById('togglePreviewBtn').style.display = 'none';
498
+
499
+ // Update subtitle
500
+ document.querySelector('.subtitle').textContent = 'Image viewer - use zoom controls to adjust view.';
501
+
502
+ // Show image viewer
503
+ const imageViewer = document.getElementById('image-viewer');
504
+ imageViewer.style.display = 'flex';
505
+
506
+ // Load image
507
+ const img = document.getElementById('image-display');
508
+ const imageError = document.getElementById('image-error');
509
+
510
+ img.onload = function() {
511
+ imageNaturalWidth = img.naturalWidth;
512
+ imageNaturalHeight = img.naturalHeight;
513
+ updateImageInfo(fileSize);
514
+ imageError.style.display = 'none';
515
+ img.style.display = 'block';
516
+ };
517
+
518
+ img.onerror = function() {
519
+ imageError.textContent = 'Failed to load image. The file may be corrupted.';
520
+ imageError.style.display = 'block';
521
+ img.style.display = 'none';
522
+ };
523
+
524
+ // Add cache-busting query param to allow reload
525
+ img.src = '/api/image?t=' + Date.now();
526
+ }
527
+
528
+ // Update image info display (in both header and controls)
529
+ function updateImageInfo(fileSize) {
530
+ const sizeStr = formatFileSize(fileSize);
531
+ const infoText = `${imageNaturalWidth} × ${imageNaturalHeight} px · ${sizeStr}`;
532
+
533
+ // Update controls area
534
+ const info = document.getElementById('image-info');
535
+ info.textContent = infoText;
536
+
537
+ // Update header area
538
+ const headerInfo = document.getElementById('image-header-info');
539
+ headerInfo.textContent = infoText;
540
+ headerInfo.style.display = 'block';
541
+ }
542
+
543
+ // Format file size in human-readable form
544
+ function formatFileSize(bytes) {
545
+ if (bytes < 1024) return bytes + ' B';
546
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
547
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
548
+ }
549
+
550
+ // Update zoom level display
551
+ function updateZoomLevelDisplay() {
552
+ const zoomLevelEl = document.getElementById('zoom-level');
553
+ if (currentZoomMode === 'fit') {
554
+ zoomLevelEl.textContent = '';
555
+ } else {
556
+ zoomLevelEl.textContent = Math.round(currentZoomLevel * 100) + '%';
557
+ }
558
+ }
559
+
560
+ // Update active button state
561
+ function updateZoomButtons() {
562
+ document.getElementById('zoomFitBtn').classList.toggle('active', currentZoomMode === 'fit');
563
+ document.getElementById('zoom100Btn').classList.toggle('active', currentZoomMode === '100');
564
+ }
565
+
566
+ // Zoom fit - image fits within container
567
+ function zoomFit() {
568
+ const container = document.getElementById('image-container');
569
+ const img = document.getElementById('image-display');
570
+
571
+ container.className = 'image-container zoom-fit';
572
+ img.style.transform = '';
573
+ img.style.width = '';
574
+ img.style.height = '';
575
+
576
+ currentZoomMode = 'fit';
577
+ currentZoomLevel = 1.0;
578
+ updateZoomButtons();
579
+ updateZoomLevelDisplay();
580
+ }
581
+
582
+ // Zoom 100% - actual pixels
583
+ function zoom100() {
584
+ const container = document.getElementById('image-container');
585
+ const img = document.getElementById('image-display');
586
+
587
+ container.className = 'image-container zoom-100';
588
+ img.style.transform = '';
589
+ img.style.width = imageNaturalWidth + 'px';
590
+ img.style.height = imageNaturalHeight + 'px';
591
+
592
+ currentZoomMode = '100';
593
+ currentZoomLevel = 1.0;
594
+ updateZoomButtons();
595
+ updateZoomLevelDisplay();
596
+ }
597
+
598
+ // Zoom in by 25%
599
+ function zoomIn() {
600
+ if (currentZoomMode === 'fit') {
601
+ // Switch to custom zoom starting at 100%
602
+ currentZoomLevel = 1.0;
603
+ }
604
+ currentZoomLevel = Math.min(currentZoomLevel * 1.25, 10.0); // Max 1000%
605
+ applyCustomZoom();
606
+ }
607
+
608
+ // Zoom out by 20%
609
+ function zoomOut() {
610
+ if (currentZoomMode === 'fit') {
611
+ // Switch to custom zoom starting at 100%
612
+ currentZoomLevel = 1.0;
613
+ }
614
+ currentZoomLevel = Math.max(currentZoomLevel * 0.8, 0.1); // Min 10%
615
+ applyCustomZoom();
616
+ }
617
+
618
+ // Apply custom zoom level
619
+ function applyCustomZoom() {
620
+ const container = document.getElementById('image-container');
621
+ const img = document.getElementById('image-display');
622
+
623
+ container.className = 'image-container zoom-custom';
624
+ img.style.transform = '';
625
+ img.style.width = (imageNaturalWidth * currentZoomLevel) + 'px';
626
+ img.style.height = (imageNaturalHeight * currentZoomLevel) + 'px';
627
+
628
+ currentZoomMode = 'custom';
629
+ updateZoomButtons();
630
+ updateZoomLevelDisplay();
631
+ }
632
+
633
+ // Toggle between annotated view and preview mode
634
+ function togglePreviewMode() {
635
+ // Don't allow preview toggle while in textarea edit mode
636
+ if (editMode) {
637
+ return;
638
+ }
639
+
640
+ const viewMode = document.getElementById('viewMode');
641
+ const previewContainer = document.getElementById('preview-container');
642
+ const editBtn = document.getElementById('editBtn');
643
+ const toggleIcon = document.getElementById('toggle-icon');
644
+ const toggleText = document.getElementById('toggle-text');
645
+
646
+ // Capture scroll position as percentage before switching
647
+ const scrollPercent = document.documentElement.scrollHeight > 0
648
+ ? window.scrollY / document.documentElement.scrollHeight
649
+ : 0;
650
+
651
+ isPreviewMode = !isPreviewMode;
652
+
653
+ if (isPreviewMode) {
654
+ // Switch to Preview mode: hide viewMode, show preview
655
+ renderPreview();
656
+ viewMode.style.display = 'none';
657
+ previewContainer.style.display = 'block';
658
+ toggleIcon.textContent = '📝';
659
+ toggleText.textContent = 'Annotate';
660
+ } else {
661
+ // Switch back to annotated view
662
+ viewMode.style.display = 'grid';
663
+ previewContainer.style.display = 'none';
664
+ toggleIcon.textContent = '👁';
665
+ toggleText.textContent = 'Preview';
666
+ }
667
+
668
+ // Restore approximate scroll position after content is rendered
669
+ requestAnimationFrame(() => {
670
+ window.scrollTo(0, scrollPercent * document.documentElement.scrollHeight);
671
+ });
672
+ }
673
+
674
+ // Configure marked.js with secure link renderer (runs once when marked is loaded)
675
+ function configureMarked() {
676
+ if (typeof marked !== 'undefined' && !marked._configured) {
677
+ marked.use({
678
+ renderer: {
679
+ link(href, title, text) {
680
+ const titleAttr = title ? ` title="${title}"` : '';
681
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
682
+ }
683
+ }
684
+ });
685
+ marked._configured = true;
686
+ }
687
+ }
688
+
689
+ // Render markdown preview with DOMPurify sanitization
690
+ function renderPreview() {
691
+ const previewContainer = document.getElementById('preview-container');
692
+
693
+ // Check if marked and DOMPurify are available
694
+ if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
695
+ previewContainer.innerHTML = '<p style="color: #ff6b6b;">Error: Markdown libraries not loaded. Please refresh the page.</p>';
696
+ return;
697
+ }
698
+
699
+ // Configure marked.js on first use
700
+ configureMarked();
701
+
702
+ // Parse markdown and sanitize HTML
703
+ const rawHtml = marked.parse(currentContent);
704
+ const cleanHtml = DOMPurify.sanitize(rawHtml);
705
+ previewContainer.innerHTML = cleanHtml;
706
+
707
+ // Highlight code blocks with Prism.js
708
+ previewContainer.querySelectorAll('pre code').forEach((block) => {
709
+ // Add language class if detected from code fence
710
+ const langMatch = block.className.match(/language-(\w+)/);
711
+ if (langMatch) {
712
+ block.parentElement.classList.add(`language-${langMatch[1]}`);
713
+ }
714
+ Prism.highlightElement(block);
715
+ });
716
+ }
717
+
718
+ function renderFile() {
719
+ const viewMode = document.getElementById('viewMode');
720
+
721
+ // Render code with syntax highlighting - highlight LINE BY LINE
722
+ const isMarkdown = lang === 'markdown' || lang === 'md';
723
+ const prismLang = Prism.languages[lang] || Prism.languages.plaintext;
724
+
725
+ // Reset markdown state before rendering
726
+ if (isMarkdown) resetMarkdownState();
727
+
728
+ // For markdown: two-pass table rendering
729
+ let tableMap = new Map();
730
+ if (isMarkdown) {
731
+ const codeBlockRanges = findCodeBlockRanges(fileLines);
732
+ const tables = identifyTables(fileLines, codeBlockRanges);
733
+ tableMap = buildTableMap(tables);
734
+ }
735
+
736
+ // Generate grid rows: each row has [line-num, code-line]
737
+ const gridHtml = fileLines.map((line, i) => {
738
+ let lineToRender = line;
739
+
740
+ // For markdown tables: pad cells for alignment
741
+ if (isMarkdown) {
742
+ const tableInfo = tableMap.get(i);
743
+ if (tableInfo) {
744
+ const isSep = isSeparatorRow(line);
745
+ lineToRender = renderTableRow(line, tableInfo.columns, isSep);
746
+ }
747
+ }
748
+
749
+ // Use custom hybrid renderer for markdown
750
+ const highlighted = isMarkdown ? renderMarkdownLine(lineToRender) : Prism.highlight(lineToRender, prismLang, lang);
751
+ const isReview = commentStyle.regex.test(line);
752
+ const codeContent = isReview ? `<span class="review-line">${highlighted}</span>` : highlighted;
753
+
754
+ const hasReviewClass = isReview ? 'has-review' : '';
755
+ return `<span class="line-num ${hasReviewClass}" onclick="openDialog(${i}, event)">${i + 1}</span><div class="code-line" data-line="${i}">${codeContent || '&nbsp;'}</div>`;
756
+ }).join('');
757
+
758
+ viewMode.innerHTML = gridHtml;
759
+ }
760
+
761
+ function openDialog(lineIndex, event) {
762
+ currentLine = lineIndex;
763
+ const line = fileLines[lineIndex];
764
+ const match = line.match(commentStyle.regex);
765
+
766
+ document.getElementById('dialogLine').textContent = lineIndex + 1;
767
+
768
+ // Show prior 3 lines + current line
769
+ const startLine = Math.max(0, lineIndex - 3);
770
+ const previewLines = [];
771
+ for (let i = startLine; i <= lineIndex; i++) {
772
+ const lineNum = (i + 1).toString().padStart(4, ' ');
773
+ const marker = i === lineIndex ? '→' : ' ';
774
+ previewLines.push(`${lineNum}${marker} ${fileLines[i]}`);
775
+ }
776
+ document.getElementById('dialogPreview').textContent = previewLines.join('\n');
777
+
778
+ if (match) {
779
+ // Editing existing comment
780
+ isEditing = true;
781
+ document.getElementById('dialogTitle').textContent = 'Edit Review Comment';
782
+ document.getElementById('commentText').value = match[3] || '';
783
+ document.getElementById('resolveBtn').style.display = 'inline-block';
784
+ } else {
785
+ // Adding new comment
786
+ isEditing = false;
787
+ document.getElementById('dialogTitle').textContent = 'Add Review Comment';
788
+ document.getElementById('commentText').value = '';
789
+ document.getElementById('resolveBtn').style.display = 'none';
790
+ }
791
+
792
+ // Position dialog next to click
793
+ const dialog = document.querySelector('.dialog');
794
+ const rect = event.target.getBoundingClientRect();
795
+ const dialogWidth = 700;
796
+ const dialogHeight = 350;
797
+
798
+ // Position to the right of the line number, with some padding
799
+ let left = rect.right + 10;
800
+ let top = rect.top;
801
+
802
+ // Keep dialog in viewport
803
+ if (left + dialogWidth > window.innerWidth) {
804
+ left = window.innerWidth - dialogWidth - 20;
805
+ }
806
+ if (top + dialogHeight > window.innerHeight) {
807
+ top = window.innerHeight - dialogHeight - 20;
808
+ }
809
+ if (top < 60) top = 60; // Below header
810
+
811
+ dialog.style.left = left + 'px';
812
+ dialog.style.top = top + 'px';
813
+
814
+ document.getElementById('overlay').classList.add('active');
815
+ document.getElementById('commentText').focus();
816
+ }
817
+
818
+ function closeDialog() {
819
+ document.getElementById('overlay').classList.remove('active');
820
+ currentLine = null;
821
+ isEditing = false;
822
+ }
823
+
824
+ // Open dialog from annotation panel (center it since no click position)
825
+ function openDialogFromPanel(lineIndex) {
826
+ // Create a fake event positioned at the line number
827
+ // Exclude continuation spans (..) so indexing matches fileLines
828
+ const lineNums = document.querySelectorAll('.line-num:not(.continuation)');
829
+ if (lineNums[lineIndex]) {
830
+ const fakeEvent = { target: lineNums[lineIndex] };
831
+ openDialog(lineIndex, fakeEvent);
832
+ // Scroll to the line
833
+ lineNums[lineIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
834
+ }
835
+ }
836
+
837
+ function saveComment() {
838
+ const text = document.getElementById('commentText').value.trim();
839
+ if (!text) return;
840
+
841
+ const line = fileLines[currentLine];
842
+ const indent = line.match(/^(\s*)/)[1];
843
+
844
+ let commentLine = indent + commentStyle.prefix + '(@architect): ' + text;
845
+ if (commentStyle.suffix) {
846
+ commentLine += commentStyle.suffix;
847
+ }
848
+
849
+ if (isEditing) {
850
+ // Replace existing comment
851
+ fileLines[currentLine] = commentLine;
852
+ } else {
853
+ // Insert new comment BELOW the current line (so the annotated line keeps its number)
854
+ fileLines.splice(currentLine + 1, 0, commentLine);
855
+ }
856
+
857
+ renderFile();
858
+ updateAnnotationsList();
859
+ closeDialog();
860
+ saveFile(); // Auto-save
861
+ }
862
+
863
+ function resolveComment() {
864
+ if (currentLine !== null && isEditing) {
865
+ fileLines.splice(currentLine, 1);
866
+ renderFile();
867
+ updateAnnotationsList();
868
+ closeDialog();
869
+ saveFile(); // Auto-save
870
+ }
871
+ }
872
+
873
+ function updateAnnotationsList() {
874
+ const list = document.getElementById('annotationsList');
875
+ const annotations = [];
876
+
877
+ fileLines.forEach((line, i) => {
878
+ const match = line.match(commentStyle.regex);
879
+ if (match) {
880
+ annotations.push({
881
+ line: i + 1,
882
+ author: match[2] || '',
883
+ text: match[3] || ''
884
+ });
885
+ }
886
+ });
887
+
888
+ if (annotations.length === 0) {
889
+ list.innerHTML = '<div style="padding: 15px; color: #666;">No review comments</div>';
890
+ } else {
891
+ list.innerHTML = annotations.map(a => `
892
+ <div class="annotation-item" onclick="openDialogFromPanel(${a.line - 1})">
893
+ <div class="line">Line ${a.line} ${a.author}</div>
894
+ <div class="text">${escapeHtml(a.text)}</div>
895
+ </div>
896
+ `).join('');
897
+ }
898
+ }
899
+
900
+ function toggleAnnotations() {
901
+ document.getElementById('annotationsPanel').classList.toggle('open');
902
+ }
903
+
904
+ function escapeHtml(text) {
905
+ const div = document.createElement('div');
906
+ div.textContent = text;
907
+ return div.innerHTML;
908
+ }
909
+
910
+ // Hybrid markdown: syntax visible but muted, content styled
911
+ // State for multi-line constructs
912
+ let inCodeBlock = false;
913
+
914
+ function renderMarkdownLine(line) {
915
+ // Handle code block fences
916
+ const fenceMatch = line.match(/^(\s*)(```+)(\w*)$/);
917
+ if (fenceMatch) {
918
+ inCodeBlock = !inCodeBlock;
919
+ return `<span class="md-fence">${escapeHtml(line)}</span>`;
920
+ }
921
+
922
+ // Inside code block - render as code, no markdown processing
923
+ if (inCodeBlock) {
924
+ return `<span class="md-codeblock">${escapeHtml(line)}</span>`;
925
+ }
926
+
927
+ let html = escapeHtml(line);
928
+
929
+ // Headers: # visible but muted, content styled
930
+ const headerMatch = html.match(/^(#{1,6})\s+(.*)$/);
931
+ if (headerMatch) {
932
+ const level = headerMatch[1].length;
933
+ const hashes = headerMatch[1];
934
+ const content = headerMatch[2];
935
+ return `<span class="md-syntax">${hashes}</span> <span class="md-h${level}">${content}</span>`;
936
+ }
937
+
938
+ // List items: - or * visible but styled
939
+ html = html.replace(/^(\s*)([-*])\s+/, '$1<span class="md-bullet">$2</span> ');
940
+ html = html.replace(/^(\s*)(\d+\.)\s+/, '$1<span class="md-bullet">$2</span> ');
941
+
942
+ // Bold: ** visible but muted, content bold
943
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<span class="md-syntax">**</span><span class="md-bold">$1</span><span class="md-syntax">**</span>');
944
+
945
+ // Italic: * visible but muted, content italic (but not inside **)
946
+ html = html.replace(/(?<!\*)(?<!\<span class="md-syntax"\>)\*([^*]+)\*(?!\*)/g, '<span class="md-syntax">*</span><span class="md-italic">$1</span><span class="md-syntax">*</span>');
947
+
948
+ // Inline code: ` visible but muted, content styled
949
+ html = html.replace(/`([^`]+)`/g, '<span class="md-syntax">`</span><span class="md-code">$1</span><span class="md-syntax">`</span>');
950
+
951
+ // Links: [text](url) - brackets muted, text styled, url muted
952
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="md-syntax">[</span><span class="md-link-text">$1</span><span class="md-syntax">](</span><span class="md-link-url">$2</span><span class="md-syntax">)</span>');
953
+
954
+ // Blockquote: > visible but muted
955
+ html = html.replace(/^(\s*)(&gt;)\s*(.*)$/, '$1<span class="md-syntax">$2</span> <span class="md-blockquote">$3</span>');
956
+
957
+ return html;
958
+ }
959
+
960
+ // Reset code block state when re-rendering
961
+ function resetMarkdownState() {
962
+ inCodeBlock = false;
963
+ }
964
+
965
+ // ==================== Table Alignment Functions ====================
966
+
967
+ // Find code block ranges to exclude from table processing
968
+ function findCodeBlockRanges(lines) {
969
+ const ranges = [];
970
+ let start = null;
971
+
972
+ lines.forEach((line, i) => {
973
+ if (/^(\s*)(```+|~~~+)/.test(line)) {
974
+ if (start === null) {
975
+ start = i;
976
+ } else {
977
+ ranges.push({ start, end: i });
978
+ start = null;
979
+ }
980
+ }
981
+ });
982
+
983
+ // Handle unclosed code block (extends to end of file)
984
+ if (start !== null) {
985
+ ranges.push({ start, end: lines.length - 1 });
986
+ }
987
+
988
+ return ranges;
989
+ }
990
+
991
+ // Check if line is inside a code block
992
+ function isInsideCodeBlock(lineNum, ranges) {
993
+ return ranges.some(r => lineNum >= r.start && lineNum <= r.end);
994
+ }
995
+
996
+ // Check if line is a table separator row (e.g., |---|---|)
997
+ function isSeparatorRow(line) {
998
+ // Must contain at least one dash and be mostly dashes, pipes, colons, and spaces
999
+ return /^\|?[\s:|-]+\|?$/.test(line) && line.includes('-');
1000
+ }
1001
+
1002
+ // Parse cells from a table row, handling leading/trailing pipes
1003
+ function parseTableCells(line) {
1004
+ // Split by | (not escaped \|)
1005
+ const parts = line.split(/(?<!\\)\|/);
1006
+
1007
+ // Remove empty first/last elements from leading/trailing pipes
1008
+ if (parts.length > 0 && parts[0].trim() === '') parts.shift();
1009
+ if (parts.length > 0 && parts[parts.length - 1].trim() === '') parts.pop();
1010
+
1011
+ return parts;
1012
+ }
1013
+
1014
+ // Pad a separator cell preserving alignment markers (:---:, :---, ---:)
1015
+ function padSeparatorCell(cell, width) {
1016
+ const trimmed = cell.trim();
1017
+ const leftColon = trimmed.startsWith(':');
1018
+ const rightColon = trimmed.endsWith(':');
1019
+
1020
+ // Calculate dash count: width minus colons
1021
+ const dashCount = width - (leftColon ? 1 : 0) - (rightColon ? 1 : 0);
1022
+
1023
+ return (leftColon ? ':' : '') + '-'.repeat(Math.max(1, dashCount)) + (rightColon ? ':' : '');
1024
+ }
1025
+
1026
+ // Pad a regular cell to target width
1027
+ function padCell(cell, width) {
1028
+ const trimmed = cell.trim();
1029
+ return trimmed.padEnd(width);
1030
+ }
1031
+
1032
+ // Identify tables in the document and compute column widths
1033
+ function identifyTables(lines, codeBlockRanges) {
1034
+ const tables = [];
1035
+
1036
+ for (let i = 0; i < lines.length - 1; i++) {
1037
+ // Skip lines inside code blocks
1038
+ if (isInsideCodeBlock(i, codeBlockRanges)) continue;
1039
+
1040
+ const line = lines[i];
1041
+ const nextLine = lines[i + 1];
1042
+
1043
+ // Look for header + separator pattern
1044
+ if (line.includes('|') && isSeparatorRow(nextLine) && !isInsideCodeBlock(i + 1, codeBlockRanges)) {
1045
+ const table = { startLine: i, endLine: i + 1, columns: [] };
1046
+
1047
+ // Extend table to include all following rows with |
1048
+ let j = i + 2;
1049
+ while (j < lines.length && lines[j].includes('|') && !isInsideCodeBlock(j, codeBlockRanges)) {
1050
+ table.endLine = j;
1051
+ j++;
1052
+ }
1053
+
1054
+ // Compute column widths across all rows
1055
+ for (let row = table.startLine; row <= table.endLine; row++) {
1056
+ const cells = parseTableCells(lines[row]);
1057
+ cells.forEach((cell, col) => {
1058
+ const cellWidth = cell.trim().length;
1059
+ table.columns[col] = Math.max(table.columns[col] || 0, cellWidth);
1060
+ });
1061
+ }
1062
+
1063
+ tables.push(table);
1064
+ i = table.endLine; // Skip past this table
1065
+ }
1066
+ }
1067
+
1068
+ return tables;
1069
+ }
1070
+
1071
+ // Build a map: lineNumber -> tableInfo (or undefined)
1072
+ function buildTableMap(tables) {
1073
+ const map = new Map();
1074
+ for (const table of tables) {
1075
+ for (let i = table.startLine; i <= table.endLine; i++) {
1076
+ map.set(i, table);
1077
+ }
1078
+ }
1079
+ return map;
1080
+ }
1081
+
1082
+ // Render a table row with padded cells
1083
+ function renderTableRow(line, columnWidths, isSeparator) {
1084
+ // Preserve original indentation for nested tables (in lists, blockquotes)
1085
+ const indent = line.match(/^(\s*)/)[1];
1086
+ const trimmedLine = line.trimStart();
1087
+
1088
+ const cells = parseTableCells(line);
1089
+ const hasLeadingPipe = trimmedLine.startsWith('|');
1090
+ const hasTrailingPipe = line.trimEnd().endsWith('|');
1091
+
1092
+ const paddedCells = cells.map((cell, i) => {
1093
+ const targetWidth = columnWidths[i] || cell.trim().length;
1094
+ if (isSeparator) {
1095
+ return padSeparatorCell(cell, targetWidth);
1096
+ } else {
1097
+ return padCell(cell, targetWidth);
1098
+ }
1099
+ });
1100
+
1101
+ // Reconstruct line with pipes, preserving indentation
1102
+ let result = indent;
1103
+ if (hasLeadingPipe) result += '| ';
1104
+ result += paddedCells.join(' | ');
1105
+ if (hasTrailingPipe) result += ' |';
1106
+
1107
+ return result;
1108
+ }
1109
+
1110
+ async function saveFile() {
1111
+ try {
1112
+ const response = await fetch('/save', {
1113
+ method: 'POST',
1114
+ headers: { 'Content-Type': 'application/json' },
1115
+ body: JSON.stringify({
1116
+ file: filePath,
1117
+ content: fileLines.join('\n')
1118
+ })
1119
+ });
1120
+
1121
+ if (!response.ok) {
1122
+ console.error('Failed to save:', await response.text());
1123
+ }
1124
+ } catch (err) {
1125
+ console.error('Failed to save:', err.message);
1126
+ }
1127
+ }
1128
+
1129
+ // Reload file from disk (after external edits)
1130
+ function reloadFile() {
1131
+ // Warn if there are unsaved changes
1132
+ if (hasUnsavedChanges) {
1133
+ if (!confirm('You have unsaved changes. Reload anyway?')) {
1134
+ return;
1135
+ }
1136
+ }
1137
+
1138
+ // Reload the page - server re-reads file on each request
1139
+ window.location.reload();
1140
+ }
1141
+
1142
+ // ==================== Edit Mode Functions ====================
1143
+
1144
+ async function toggleEditMode() {
1145
+ const viewMode = document.getElementById('viewMode');
1146
+ const editor = document.getElementById('editor');
1147
+ const editBtn = document.getElementById('editBtn');
1148
+ const saveBtn = document.getElementById('saveBtn');
1149
+ const cancelBtn = document.getElementById('cancelBtn');
1150
+ const previewContainer = document.getElementById('preview-container');
1151
+
1152
+ const subtitle = document.querySelector('.subtitle');
1153
+
1154
+ // If in preview mode, exit preview first
1155
+ if (isPreviewMode) {
1156
+ togglePreviewMode();
1157
+ }
1158
+
1159
+ if (!editMode) {
1160
+ // Enter edit mode
1161
+ // Capture scroll position from view mode
1162
+ const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
1163
+
1164
+ currentContent = fileLines.join('\n');
1165
+ // Note: originalContent is set only in init() and after successful saves
1166
+ // to ensure Cancel always reverts to the true disk state
1167
+ editor.value = currentContent;
1168
+ viewMode.style.display = 'none';
1169
+ editor.style.display = 'block';
1170
+ editBtn.textContent = 'Switch to Annotate';
1171
+ subtitle.textContent = 'Edit the file directly by clicking and typing.';
1172
+ saveBtn.style.display = 'inline-block';
1173
+ cancelBtn.style.display = 'inline-block';
1174
+ editMode = true;
1175
+
1176
+ // Apply scroll position to editor
1177
+ editor.scrollTop = scrollTop;
1178
+ editor.focus();
1179
+ } else {
1180
+ // Exit to view mode - auto-save changes per spec
1181
+ // Capture scroll position from editor
1182
+ const scrollTop = editor.scrollTop;
1183
+
1184
+ // Auto-save if there are changes (spec requires: "Edit text, click View → changes saved")
1185
+ if (hasUnsavedChanges) {
1186
+ await saveEdit();
1187
+ }
1188
+
1189
+ currentContent = editor.value;
1190
+ fileLines = currentContent.split('\n');
1191
+ editor.style.display = 'none';
1192
+ viewMode.style.display = 'grid';
1193
+ editBtn.textContent = 'Switch to Editing';
1194
+ subtitle.textContent = 'Click on a line number to leave an annotation.';
1195
+ saveBtn.style.display = 'none';
1196
+ cancelBtn.style.display = 'none';
1197
+ editMode = false;
1198
+
1199
+ renderFile();
1200
+ updateAnnotationsList();
1201
+
1202
+ // Apply scroll position to document
1203
+ requestAnimationFrame(() => {
1204
+ document.documentElement.scrollTop = scrollTop;
1205
+ document.body.scrollTop = scrollTop;
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ async function saveEdit() {
1211
+ const editor = document.getElementById('editor');
1212
+ const content = editor.value;
1213
+
1214
+ try {
1215
+ const response = await fetch('/save', {
1216
+ method: 'POST',
1217
+ headers: { 'Content-Type': 'application/json' },
1218
+ body: JSON.stringify({ file: filePath, content })
1219
+ });
1220
+
1221
+ if (response.ok) {
1222
+ currentContent = content;
1223
+ originalContent = content;
1224
+ fileLines = content.split('\n');
1225
+ hasUnsavedChanges = false;
1226
+ updateUnsavedIndicator();
1227
+ showNotification('File saved!');
1228
+ } else {
1229
+ const errorText = await response.text();
1230
+ throw new Error(errorText || 'Save failed');
1231
+ }
1232
+ } catch (err) {
1233
+ showNotification('Error saving file: ' + err.message, 'error');
1234
+ }
1235
+ }
1236
+
1237
+ function cancelEdit() {
1238
+ const editor = document.getElementById('editor');
1239
+ editor.value = originalContent;
1240
+ currentContent = originalContent;
1241
+ fileLines = originalContent.split('\n');
1242
+ hasUnsavedChanges = false;
1243
+ updateUnsavedIndicator();
1244
+ toggleEditMode(); // Back to view mode
1245
+ }
1246
+
1247
+ function updateUnsavedIndicator() {
1248
+ const indicator = document.getElementById('unsavedIndicator');
1249
+ indicator.style.display = hasUnsavedChanges ? 'inline' : 'none';
1250
+ }
1251
+
1252
+ function showNotification(message, type = 'success') {
1253
+ // Remove existing notification
1254
+ const existing = document.querySelector('.notification');
1255
+ if (existing) existing.remove();
1256
+
1257
+ const notification = document.createElement('div');
1258
+ notification.className = 'notification' + (type === 'error' ? ' error' : '');
1259
+ notification.textContent = message;
1260
+ document.body.appendChild(notification);
1261
+
1262
+ setTimeout(() => notification.remove(), 3000);
1263
+ }
1264
+
1265
+ // Track unsaved changes in editor
1266
+ document.getElementById('editor').addEventListener('input', () => {
1267
+ hasUnsavedChanges = document.getElementById('editor').value !== originalContent;
1268
+ updateUnsavedIndicator();
1269
+ });
1270
+
1271
+ // Tab key handling for indentation
1272
+ document.getElementById('editor').addEventListener('keydown', (e) => {
1273
+ if (e.key === 'Tab') {
1274
+ e.preventDefault();
1275
+ const editor = e.target;
1276
+ const start = editor.selectionStart;
1277
+ const end = editor.selectionEnd;
1278
+
1279
+ // Insert 2 spaces (or could use \t)
1280
+ editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end);
1281
+ editor.selectionStart = editor.selectionEnd = start + 2;
1282
+
1283
+ // Trigger input event to update unsaved indicator
1284
+ editor.dispatchEvent(new Event('input'));
1285
+ }
1286
+ });
1287
+
1288
+ // Handle keyboard shortcuts
1289
+ document.addEventListener('keydown', (e) => {
1290
+ if (e.key === 'Escape') {
1291
+ closeDialog();
1292
+ }
1293
+
1294
+ // Cmd/Ctrl+S to save
1295
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
1296
+ e.preventDefault();
1297
+ if (editMode) {
1298
+ saveEdit();
1299
+ }
1300
+ }
1301
+
1302
+ // Cmd/Ctrl+Shift+P to toggle preview (markdown files only)
1303
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'p' || e.key === 'P')) {
1304
+ if (isMarkdownFile) {
1305
+ e.preventDefault();
1306
+ togglePreviewMode();
1307
+ }
1308
+ }
1309
+ });
1310
+
1311
+ // Warn before leaving with unsaved changes
1312
+ window.addEventListener('beforeunload', (e) => {
1313
+ if (hasUnsavedChanges) {
1314
+ e.preventDefault();
1315
+ e.returnValue = '';
1316
+ }
1317
+ });
1318
+
1319
+ // Triple-enter to submit annotation
1320
+ document.getElementById('commentText').addEventListener('keydown', (e) => {
1321
+ if (e.key === 'Enter' && !e.shiftKey) {
1322
+ consecutiveEnters++;
1323
+ if (consecutiveEnters >= 3) {
1324
+ e.preventDefault();
1325
+ consecutiveEnters = 0;
1326
+ saveComment();
1327
+ }
1328
+ } else {
1329
+ consecutiveEnters = 0;
1330
+ }
1331
+ });
1332
+
1333
+ // FILE_CONTENT will be injected by the server
1334
+ </script>
1335
+ </body>
1336
+ </html>