git-reviewer 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,1439 @@
1
+ // Use current origin for Electron compatibility (dynamic port)
2
+ // In web mode, this will be http://localhost:4500
3
+ // In Electron, this will be the random port Electron assigned
4
+ const API_BASE = `${window.location.origin}/api`;
5
+
6
+ let currentRepoId = null;
7
+ let currentFiles = [];
8
+ let currentFile = null;
9
+ let currentDiffLines = [];
10
+ let comments = [];
11
+
12
+ // Auto-save comments to backend
13
+ async function saveCommentsToBackend() {
14
+ if (!currentRepoId) return;
15
+
16
+ try {
17
+ await fetch(`${API_BASE}/save-comments`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({
21
+ repoId: currentRepoId,
22
+ comments: comments // Send empty array if no comments
23
+ })
24
+ });
25
+ } catch (error) {
26
+ console.error('Failed to save comments:', error);
27
+ }
28
+ }
29
+
30
+ // Load comments from backend
31
+ async function loadCommentsFromBackend() {
32
+ if (!currentRepoId) return [];
33
+
34
+ try {
35
+ const response = await fetch(`${API_BASE}/load-comments/${currentRepoId}`);
36
+ const data = await response.json();
37
+ return data.comments || [];
38
+ } catch (error) {
39
+ console.error('Failed to load comments:', error);
40
+ return [];
41
+ }
42
+ }
43
+
44
+ // Fuzzy match saved comments to current diff
45
+ function matchCommentsToDiff(savedComments, filePath, diffLines) {
46
+ const fileComments = savedComments.filter(c => c.file === filePath);
47
+ const matched = [];
48
+
49
+ fileComments.forEach(savedComment => {
50
+ let matchResult = null;
51
+
52
+ // Try exact match first (line number + content)
53
+ const exactMatch = diffLines.findIndex(dl =>
54
+ (dl.newLine === savedComment.line || dl.oldLine === savedComment.line) &&
55
+ dl.content === savedComment.lineContent
56
+ );
57
+
58
+ if (exactMatch !== -1) {
59
+ matchResult = {
60
+ ...savedComment,
61
+ diffLineIndex: exactMatch,
62
+ matchType: 'exact'
63
+ };
64
+ } else {
65
+ // Try fuzzy match (search ±5 lines for similar content)
66
+ let bestMatch = -1;
67
+ let bestSimilarity = 0;
68
+
69
+ for (let i = 0; i < diffLines.length; i++) {
70
+ const dl = diffLines[i];
71
+ const lineNum = dl.newLine || dl.oldLine;
72
+
73
+ // Check if within ±5 lines
74
+ if (Math.abs(lineNum - savedComment.line) <= 5) {
75
+ const similarity = calculateSimilarity(dl.content, savedComment.lineContent);
76
+ if (similarity > bestSimilarity && similarity > 0.6) {
77
+ bestSimilarity = similarity;
78
+ bestMatch = i;
79
+ }
80
+ }
81
+ }
82
+
83
+ if (bestMatch !== -1) {
84
+ matchResult = {
85
+ ...savedComment,
86
+ diffLineIndex: bestMatch,
87
+ matchType: 'fuzzy',
88
+ newLineContent: diffLines[bestMatch].content
89
+ };
90
+ } else {
91
+ // No match found - mark as unmatched
92
+ matchResult = {
93
+ ...savedComment,
94
+ diffLineIndex: null,
95
+ matchType: 'unmatched'
96
+ };
97
+ }
98
+ }
99
+
100
+ if (matchResult) {
101
+ matched.push(matchResult);
102
+ }
103
+ });
104
+
105
+ return matched;
106
+ }
107
+
108
+ // Simple similarity calculation (Levenshtein-based approximation)
109
+ function calculateSimilarity(str1, str2) {
110
+ if (str1 === str2) return 1;
111
+ if (!str1 || !str2) return 0;
112
+
113
+ const longer = str1.length > str2.length ? str1 : str2;
114
+ const shorter = str1.length > str2.length ? str2 : str1;
115
+
116
+ if (longer.length === 0) return 1;
117
+
118
+ // Simple matching: count common characters
119
+ const longerLower = longer.toLowerCase().trim();
120
+ const shorterLower = shorter.toLowerCase().trim();
121
+
122
+ if (longerLower.includes(shorterLower) || shorterLower.includes(longerLower)) {
123
+ return 0.8;
124
+ }
125
+
126
+ // Count matching words
127
+ const words1 = str1.toLowerCase().split(/\s+/);
128
+ const words2 = str2.toLowerCase().split(/\s+/);
129
+ const commonWords = words1.filter(w => words2.includes(w));
130
+
131
+ return commonWords.length / Math.max(words1.length, words2.length);
132
+ }
133
+
134
+ // Load local repository
135
+ async function loadRepo() {
136
+ const repoPath = document.getElementById('repoPath').value.trim();
137
+
138
+ if (!repoPath) {
139
+ showStatus('Please enter a repository path', 'error');
140
+ return;
141
+ }
142
+
143
+ showStatus('Loading repository...', 'loading');
144
+ document.getElementById('loadBtn').disabled = true;
145
+
146
+ try {
147
+ const response = await fetch(`${API_BASE}/load-repo`, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ repoPath })
151
+ });
152
+
153
+ const data = await response.json();
154
+
155
+ if (!response.ok) {
156
+ throw new Error(data.error || 'Failed to load repository');
157
+ }
158
+
159
+ currentRepoId = data.repoId;
160
+ currentFiles = data.files;
161
+
162
+ if (data.files.length === 0) {
163
+ showStatus('No uncommitted changes found in the repository', 'error');
164
+ document.getElementById('loadBtn').disabled = false;
165
+ return;
166
+ }
167
+
168
+ showStatus(data.message, 'success');
169
+ displayFiles(data.files);
170
+
171
+ // Load saved comments
172
+ const savedComments = await loadCommentsFromBackend();
173
+ if (savedComments.length > 0) {
174
+ // Store saved comments in a global variable for matching later
175
+ window.savedComments = savedComments;
176
+
177
+ // Add all saved comments to the comments array immediately
178
+ comments = savedComments.map(c => ({
179
+ ...c,
180
+ diffLineIndex: null, // Will be updated when file is loaded
181
+ matchType: c.matchType || 'exact'
182
+ }));
183
+
184
+ // Show comments sidebar immediately
185
+ updateCommentsSidebar();
186
+
187
+ showStatus(`${data.message} - Loaded ${savedComments.length} saved comment(s)`, 'success');
188
+ }
189
+
190
+ } catch (error) {
191
+ showStatus(`Error: ${error.message}`, 'error');
192
+ } finally {
193
+ document.getElementById('loadBtn').disabled = false;
194
+ }
195
+ }
196
+
197
+ // Display list of changed files
198
+ function displayFiles(files) {
199
+ const sidebar = document.getElementById('sidebar');
200
+ const filesList = document.getElementById('filesList');
201
+ const fileCount = document.getElementById('fileCount');
202
+ const submitBtn = document.getElementById('submitReviewBtn');
203
+ const resizeHandle = document.getElementById('resizeHandle');
204
+
205
+ filesList.innerHTML = files.map((fileObj, index) => {
206
+ const file = fileObj.path;
207
+ const status = fileObj.status;
208
+ const parts = file.split('/');
209
+ const filename = parts[parts.length - 1];
210
+ const path = parts.slice(0, -1).join('/');
211
+
212
+ // Because of RTL, we need to reverse the order in HTML
213
+ const displayText = path ? `${path}/<span class="filename">${filename}</span>` : `<span class="filename">${filename}</span>`;
214
+ const statusClass = status === 'A' ? 'file-status-added' : 'file-status-modified';
215
+ const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
216
+
217
+ return `<div class="file-item" onclick="loadFile('${file}', ${index})" title="${escapeHtml(file)}">
218
+ ${statusBadge}<span class="file-path-text">${displayText}</span>
219
+ </div>`;
220
+ }).join('');
221
+
222
+ fileCount.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`;
223
+ sidebar.classList.remove('hidden');
224
+ resizeHandle.classList.remove('hidden');
225
+ submitBtn.classList.remove('hidden');
226
+
227
+ // Update comments sidebar
228
+ updateCommentsSidebar();
229
+ }
230
+
231
+ // Load file content
232
+ async function loadFile(filePath, index) {
233
+ if (!currentRepoId) return Promise.resolve();
234
+
235
+ currentFile = filePath;
236
+ currentFileIndex = index; // Track for keyboard navigation
237
+
238
+ // Reset full context mode when switching files
239
+ isFullContextMode = false;
240
+ storedFullFileLines = null;
241
+
242
+ // Update active file highlight and scroll into view
243
+ document.querySelectorAll('.file-item').forEach((el, i) => {
244
+ el.classList.toggle('active', i === index);
245
+ if (i === index) {
246
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
247
+ }
248
+ });
249
+
250
+ try {
251
+ const response = await fetch(`${API_BASE}/file/${currentRepoId}/${filePath}`);
252
+ const data = await response.json();
253
+
254
+ if (!response.ok) {
255
+ throw new Error(data.error || 'Failed to load file');
256
+ }
257
+
258
+ // Match saved comments to current diff if available
259
+ if (window.savedComments && window.savedComments.length > 0) {
260
+ const matchedComments = matchCommentsToDiff(window.savedComments, filePath, data.diffLines);
261
+
262
+ // Update existing comments in the array with matched diff indices
263
+ matchedComments.forEach(mc => {
264
+ const existing = comments.find(c =>
265
+ c.file === mc.file &&
266
+ c.line === mc.line &&
267
+ c.text === mc.text
268
+ );
269
+
270
+ if (existing) {
271
+ // Update existing comment with new diff line index and match type
272
+ existing.diffLineIndex = mc.diffLineIndex;
273
+ existing.matchType = mc.matchType;
274
+ existing.newLineContent = mc.newLineContent;
275
+ // Preserve followUps from saved data
276
+ if (mc.followUps) {
277
+ existing.followUps = mc.followUps;
278
+ }
279
+ } else {
280
+ // Add new comment if it doesn't exist
281
+ comments.push({
282
+ file: mc.file,
283
+ diffLineIndex: mc.diffLineIndex,
284
+ line: mc.line,
285
+ lineContent: mc.lineContent,
286
+ selectedText: mc.selectedText,
287
+ text: mc.text,
288
+ matchType: mc.matchType,
289
+ newLineContent: mc.newLineContent,
290
+ followUps: mc.followUps || []
291
+ });
292
+ }
293
+ });
294
+
295
+ // Update comments sidebar after matching comments
296
+ updateCommentsSidebar();
297
+ }
298
+
299
+ displayCode(data.filePath, data.diffLines);
300
+ updateFullContextButton();
301
+ return Promise.resolve();
302
+
303
+ } catch (error) {
304
+ showStatus(`Error loading file: ${error.message}`, 'error');
305
+ return Promise.reject(error);
306
+ }
307
+ }
308
+
309
+ // Display code with diff highlighting
310
+ function displayCode(filePath, diffLines) {
311
+ const codeSection = document.getElementById('codeSection');
312
+ const currentFileEl = document.getElementById('currentFile');
313
+ const codeViewer = document.getElementById('codeViewer');
314
+
315
+ currentFileEl.textContent = filePath;
316
+ currentDiffLines = diffLines; // Store for reference when adding comments
317
+
318
+ const fileComments = comments.filter(c => c.file === filePath);
319
+
320
+ let html = '';
321
+ diffLines.forEach((diffLine, index) => {
322
+ // Use diffLineIndex to uniquely identify each line in the diff
323
+ const diffLineIndex = index;
324
+ const hasComment = fileComments.find(c => c.diffLineIndex === diffLineIndex);
325
+
326
+ const lineClass = `line diff-${diffLine.type} ${hasComment ? 'commented' : ''}`;
327
+
328
+ // Use the appropriate line number for clicking (newLine for add/unchanged, oldLine for delete)
329
+ const clickableLineNum = diffLine.newLine || diffLine.oldLine;
330
+
331
+ // Make both old and new line numbers clickable
332
+ const oldLineClick = diffLine.type === 'delete' ? `onclick="toggleCommentInput(${diffLineIndex})"` : '';
333
+ const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${diffLineIndex})"` : '';
334
+
335
+ html += `
336
+ <div class="${lineClass}" data-diff-index="${diffLineIndex}">
337
+ ${hasComment ? '<span class="comment-indicator"></span>' : ''}
338
+ <div class="line-numbers">
339
+ <span class="old-line-number" ${oldLineClick}>${diffLine.oldLine || ''}</span>
340
+ <span class="new-line-number" ${newLineClick}>${diffLine.newLine || ''}</span>
341
+ </div>
342
+ <div class="line-content">${escapeHtml(diffLine.content) || ' '}</div>
343
+ </div>
344
+ `;
345
+
346
+ if (hasComment) {
347
+ const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
348
+ const matchTypeClass = hasComment.matchType === 'fuzzy' ? 'comment-box-fuzzy' : '';
349
+ const fuzzyWarning = hasComment.matchType === 'fuzzy' ? `
350
+ <div class="comment-fuzzy-warning">
351
+ ⚠️ Code changed - comment anchored to similar line
352
+ ${hasComment.newLineContent !== hasComment.lineContent ? `<div class="comment-original-code">Original: <code>${escapeHtml(hasComment.lineContent)}</code></div>` : ''}
353
+ </div>
354
+ ` : '';
355
+
356
+ // Generate follow-ups HTML
357
+ let followUpsHtml = '';
358
+ if (hasComment.followUps && hasComment.followUps.length > 0) {
359
+ followUpsHtml = '<div class="comment-followups">';
360
+ hasComment.followUps.forEach((followUp, idx) => {
361
+ const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
362
+ followUpsHtml += `
363
+ <div class="comment-followup-item">
364
+ <span class="followup-text">${escapeHtml(followUp.text)}</span>
365
+ ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
366
+ </div>
367
+ `;
368
+ });
369
+ followUpsHtml += '</div>';
370
+ }
371
+
372
+ html += `
373
+ <div class="comment-box ${matchTypeClass}">
374
+ ${fuzzyWarning}
375
+ <span class="comment-text">${escapeHtml(hasComment.text)}</span>
376
+ ${followUpsHtml}
377
+ <div class="comment-actions">
378
+ <button class="comment-reply" onclick="addFollowUp(${diffLineIndex})">Reply</button>
379
+ <button class="comment-edit" onclick="editComment(${diffLineIndex}, ${selectedTextAttr})">Edit</button>
380
+ <button class="comment-delete" onclick="deleteComment(${diffLineIndex})">Delete</button>
381
+ </div>
382
+ </div>
383
+ `;
384
+ }
385
+ });
386
+
387
+ // Show unmatched comments at the end
388
+ const unmatchedComments = fileComments.filter(c => c.matchType === 'unmatched');
389
+ if (unmatchedComments.length > 0) {
390
+ html += '<div class="unmatched-comments-section">';
391
+ unmatchedComments.forEach(comment => {
392
+ const commentId = `unmatched-${Math.random().toString(36).substr(2, 9)}`;
393
+
394
+ // Generate follow-ups for unmatched comments
395
+ let unmatchedFollowUpsHtml = '';
396
+ if (comment.followUps && comment.followUps.length > 0) {
397
+ unmatchedFollowUpsHtml = '<div class="comment-followups">';
398
+ comment.followUps.forEach((followUp, idx) => {
399
+ const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
400
+ unmatchedFollowUpsHtml += `
401
+ <div class="comment-followup-item">
402
+ <span class="followup-text">${escapeHtml(followUp.text)}</span>
403
+ ${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
404
+ </div>
405
+ `;
406
+ });
407
+ unmatchedFollowUpsHtml += '</div>';
408
+ }
409
+
410
+ html += `
411
+ <div class="unmatched-comment-item collapsed" id="${commentId}">
412
+ <div class="unmatched-comment-header" onclick="toggleUnmatchedComment('${commentId}')">
413
+ <span class="unmatched-comment-icon">⚠️</span>
414
+ <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
415
+ <span class="unmatched-comment-toggle">▶</span>
416
+ </div>
417
+ <div class="unmatched-comment-body">
418
+ <div class="unmatched-comment-original">
419
+ <strong>Original line:</strong>
420
+ <code>${escapeHtml(comment.lineContent)}</code>
421
+ </div>
422
+ <div class="comment-text">${escapeHtml(comment.text)}</div>
423
+ ${comment.selectedText ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>` : ''}
424
+ ${unmatchedFollowUpsHtml}
425
+ <div class="comment-actions">
426
+ <button class="comment-reply" onclick="addFollowUpToUnmatched('${comment.file.replace(/'/g, "\\'")}', ${comment.line}, '${comment.text.replace(/'/g, "\\'")}')">Reply</button>
427
+ <button class="comment-delete" onclick="deleteUnmatchedComment('${comment.file.replace(/'/g, "\\'")}', ${comment.line}, '${comment.text.replace(/'/g, "\\'")}')">Delete</button>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ `;
432
+ });
433
+ html += '</div>';
434
+ }
435
+
436
+ codeViewer.innerHTML = html;
437
+ codeSection.classList.remove('hidden');
438
+
439
+ // Add text selection handler
440
+ codeViewer.removeEventListener('mousedown', trackShiftKeyDown);
441
+ codeViewer.removeEventListener('mouseup', handleTextSelection);
442
+ codeViewer.addEventListener('mousedown', trackShiftKeyDown);
443
+ codeViewer.addEventListener('mouseup', handleTextSelection);
444
+ }
445
+
446
+ // Toggle unmatched comment expansion
447
+ function toggleUnmatchedComment(commentId) {
448
+ const element = document.getElementById(commentId);
449
+ if (!element) return;
450
+
451
+ element.classList.toggle('collapsed');
452
+ const toggle = element.querySelector('.unmatched-comment-toggle');
453
+ if (toggle) {
454
+ toggle.textContent = element.classList.contains('collapsed') ? '▶' : '▼';
455
+ }
456
+ }
457
+
458
+ // Delete unmatched comment
459
+ function deleteUnmatchedComment(file, line, text) {
460
+ comments = comments.filter(c => !(c.file === file && c.line === line && c.text === text));
461
+
462
+ // Auto-save to backend
463
+ saveCommentsToBackend();
464
+
465
+ // Update comments sidebar
466
+ updateCommentsSidebar();
467
+
468
+ // Reload the current file to remove the comment
469
+ if (currentFile === file) {
470
+ const fileIndex = currentFiles.findIndex(f => f.path === file);
471
+ loadFile(file, fileIndex);
472
+ }
473
+ }
474
+
475
+ // Add follow-up to a regular comment
476
+ function addFollowUp(diffLineIndex) {
477
+ // Remove any existing follow-up input
478
+ const existingInput = document.querySelector('.followup-input-box');
479
+ if (existingInput) {
480
+ existingInput.remove();
481
+ }
482
+
483
+ // Find the comment box
484
+ const lineEl = document.querySelector(`.line[data-diff-index="${diffLineIndex}"]`);
485
+ if (!lineEl) return;
486
+
487
+ const commentBox = lineEl.nextElementSibling;
488
+ if (!commentBox || !commentBox.classList.contains('comment-box')) return;
489
+
490
+ // Create follow-up input
491
+ const inputBox = document.createElement('div');
492
+ inputBox.className = 'followup-input-box';
493
+ inputBox.innerHTML = `
494
+ <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="followupInput"></textarea>
495
+ <div class="actions">
496
+ <button onclick="saveFollowUp(${diffLineIndex})">Add Follow-up</button>
497
+ <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
498
+ </div>
499
+ `;
500
+
501
+ // Insert after the comment actions
502
+ const actionsDiv = commentBox.querySelector('.comment-actions');
503
+ if (actionsDiv) {
504
+ actionsDiv.after(inputBox);
505
+ }
506
+
507
+ const textarea = document.getElementById('followupInput');
508
+ textarea.focus();
509
+
510
+ // Auto-resize textarea
511
+ const autoResize = () => {
512
+ textarea.style.height = 'auto';
513
+ textarea.style.height = textarea.scrollHeight + 'px';
514
+ };
515
+
516
+ textarea.addEventListener('input', autoResize);
517
+ autoResize();
518
+
519
+ // Add keyboard shortcuts
520
+ textarea.addEventListener('keydown', (e) => {
521
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
522
+ e.preventDefault();
523
+ saveFollowUp(diffLineIndex);
524
+ }
525
+ if (e.key === 'Escape') {
526
+ e.preventDefault();
527
+ inputBox.remove();
528
+ }
529
+ });
530
+ }
531
+
532
+ // Save follow-up to a regular comment
533
+ function saveFollowUp(diffLineIndex) {
534
+ const input = document.getElementById('followupInput');
535
+ const text = input?.value.trim();
536
+
537
+ if (!text) {
538
+ showStatus('Please enter a follow-up comment', 'error');
539
+ return;
540
+ }
541
+
542
+ const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
543
+ if (!comment) return;
544
+
545
+ // Initialize followUps if it doesn't exist
546
+ if (!comment.followUps) {
547
+ comment.followUps = [];
548
+ }
549
+
550
+ comment.followUps.push({
551
+ text: text,
552
+ timestamp: new Date().toISOString()
553
+ });
554
+
555
+ // Auto-save to backend
556
+ saveCommentsToBackend();
557
+
558
+ // Update comments sidebar
559
+ updateCommentsSidebar();
560
+
561
+ // Reload the current file to show the follow-up
562
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
563
+ loadFile(currentFile, fileIndex);
564
+ }
565
+
566
+ // Add follow-up to an unmatched comment
567
+ function addFollowUpToUnmatched(file, line, text) {
568
+ // Remove any existing follow-up input
569
+ const existingInput = document.querySelector('.followup-input-box');
570
+ if (existingInput) {
571
+ existingInput.remove();
572
+ }
573
+
574
+ // Find the unmatched comment element
575
+ const unmatchedItems = document.querySelectorAll('.unmatched-comment-item');
576
+ let targetItem = null;
577
+
578
+ unmatchedItems.forEach(item => {
579
+ const itemText = item.querySelector('.comment-text')?.textContent;
580
+ const itemLine = item.querySelector('.unmatched-comment-title')?.textContent;
581
+ if (itemText === text && itemLine?.includes(`Line ${line}`)) {
582
+ targetItem = item;
583
+ }
584
+ });
585
+
586
+ if (!targetItem) return;
587
+
588
+ const commentBody = targetItem.querySelector('.unmatched-comment-body');
589
+ if (!commentBody) return;
590
+
591
+ // Create follow-up input
592
+ const inputBox = document.createElement('div');
593
+ inputBox.className = 'followup-input-box';
594
+
595
+ // Generate unique ID for this specific input
596
+ const inputId = `followupInput-${Math.random().toString(36).substr(2, 9)}`;
597
+
598
+ inputBox.innerHTML = `
599
+ <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="${inputId}"></textarea>
600
+ <div class="actions">
601
+ <button onclick="saveFollowUpToUnmatched('${file.replace(/'/g, "\\'")}', ${line}, '${text.replace(/'/g, "\\'")}', '${inputId}')">Add Follow-up</button>
602
+ <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
603
+ </div>
604
+ `;
605
+
606
+ // Insert before the comment actions
607
+ const actionsDiv = commentBody.querySelector('.comment-actions');
608
+ if (actionsDiv) {
609
+ actionsDiv.before(inputBox);
610
+ }
611
+
612
+ const textarea = document.getElementById(inputId);
613
+ textarea.focus();
614
+
615
+ // Auto-resize textarea
616
+ const autoResize = () => {
617
+ textarea.style.height = 'auto';
618
+ textarea.style.height = textarea.scrollHeight + 'px';
619
+ };
620
+
621
+ textarea.addEventListener('input', autoResize);
622
+ autoResize();
623
+
624
+ // Add keyboard shortcuts
625
+ textarea.addEventListener('keydown', (e) => {
626
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
627
+ e.preventDefault();
628
+ saveFollowUpToUnmatched(file, line, text, inputId);
629
+ }
630
+ if (e.key === 'Escape') {
631
+ e.preventDefault();
632
+ inputBox.remove();
633
+ }
634
+ });
635
+ }
636
+
637
+ // Save follow-up to an unmatched comment
638
+ function saveFollowUpToUnmatched(file, line, text, inputId) {
639
+ const input = document.getElementById(inputId);
640
+ const followUpText = input?.value.trim();
641
+
642
+ if (!followUpText) {
643
+ showStatus('Please enter a follow-up comment', 'error');
644
+ return;
645
+ }
646
+
647
+ const comment = comments.find(c => c.file === file && c.line === line && c.text === text);
648
+ if (!comment) return;
649
+
650
+ // Initialize followUps if it doesn't exist
651
+ if (!comment.followUps) {
652
+ comment.followUps = [];
653
+ }
654
+
655
+ comment.followUps.push({
656
+ text: followUpText,
657
+ timestamp: new Date().toISOString()
658
+ });
659
+
660
+ // Auto-save to backend
661
+ saveCommentsToBackend();
662
+
663
+ // Update comments sidebar
664
+ updateCommentsSidebar();
665
+
666
+ // Reload the current file to show the follow-up
667
+ if (currentFile === file) {
668
+ const fileIndex = currentFiles.findIndex(f => f.path === file);
669
+ loadFile(file, fileIndex);
670
+ }
671
+ }
672
+
673
+ // Track command key state during selection
674
+ let commandKeyPressed = false;
675
+
676
+ // Track command key on mousedown
677
+ function trackShiftKeyDown(e) {
678
+ commandKeyPressed = e.metaKey;
679
+ }
680
+
681
+ // Handle text selection in code viewer
682
+ function handleTextSelection(e) {
683
+ const selection = window.getSelection();
684
+ const rawSelectedText = selection.toString().trim();
685
+
686
+ if (!rawSelectedText) {
687
+ commandKeyPressed = false;
688
+ return;
689
+ }
690
+
691
+ // Only trigger comment input if Command key was held during selection
692
+ if (!commandKeyPressed) {
693
+ commandKeyPressed = false;
694
+ return;
695
+ }
696
+
697
+ // Reset command key tracking
698
+ commandKeyPressed = false;
699
+
700
+ // Extract only the code content, excluding line numbers
701
+ const range = selection.getRangeAt(0);
702
+ const container = document.createElement('div');
703
+ container.appendChild(range.cloneContents());
704
+
705
+ // Remove all line numbers from the cloned selection
706
+ const lineNumbers = container.querySelectorAll('.line-numbers, .old-line-number, .new-line-number');
707
+ lineNumbers.forEach(el => el.remove());
708
+
709
+ // Extract text from each .line-content separately to avoid extra whitespace
710
+ const lineContents = container.querySelectorAll('.line-content');
711
+ let selectedText = '';
712
+
713
+ if (lineContents.length > 0) {
714
+ // Get text from each line-content and join with single newlines
715
+ const lines = Array.from(lineContents).map(el => el.textContent);
716
+ selectedText = lines.join('\n').trim();
717
+ } else {
718
+ // Fallback to full textContent if no line-content elements found
719
+ selectedText = container.textContent.trim();
720
+ }
721
+
722
+ if (!selectedText) {
723
+ selection.removeAllRanges();
724
+ return;
725
+ }
726
+
727
+ // Find the line element where selection ends
728
+ let targetElement = selection.focusNode;
729
+
730
+ // Traverse up to find the line element
731
+ while (targetElement && !targetElement.classList?.contains('line')) {
732
+ targetElement = targetElement.parentElement;
733
+ }
734
+
735
+ if (!targetElement) {
736
+ selection.removeAllRanges();
737
+ return;
738
+ }
739
+
740
+ const diffLineIndex = parseInt(targetElement.dataset.diffIndex);
741
+ if (diffLineIndex === undefined || isNaN(diffLineIndex)) {
742
+ selection.removeAllRanges();
743
+ return;
744
+ }
745
+
746
+ // Clear selection
747
+ selection.removeAllRanges();
748
+
749
+ // Show comment input with selected text
750
+ toggleCommentInputWithSelection(diffLineIndex, selectedText);
751
+ }
752
+
753
+ // Toggle comment input
754
+ function toggleCommentInput(diffLineIndex) {
755
+ toggleCommentInputWithSelection(diffLineIndex, null);
756
+ }
757
+
758
+ // Toggle comment input with optional selected text
759
+ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
760
+ const existing = document.querySelector('.comment-input-box');
761
+ if (existing) {
762
+ existing.remove();
763
+ }
764
+
765
+ // Check if comment already exists
766
+ if (comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex)) {
767
+ return;
768
+ }
769
+
770
+ const lineEl = document.querySelector(`.line[data-diff-index="${diffLineIndex}"]`);
771
+ const inputBox = document.createElement('div');
772
+ inputBox.className = 'comment-input-box';
773
+
774
+ // Show selected text if available
775
+ const selectedTextHtml = selectedText
776
+ ? `<div class="selected-text-preview">
777
+ <strong>Selected code:</strong>
778
+ <pre>${escapeHtml(selectedText)}</pre>
779
+ </div>`
780
+ : '';
781
+
782
+ inputBox.innerHTML = `
783
+ ${selectedTextHtml}
784
+ <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput"></textarea>
785
+ <div class="actions">
786
+ <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
787
+ <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
788
+ </div>
789
+ `;
790
+
791
+ lineEl.after(inputBox);
792
+ const textarea = document.getElementById('commentInput');
793
+ textarea.focus();
794
+
795
+ // Auto-resize textarea
796
+ const autoResize = () => {
797
+ textarea.style.height = 'auto';
798
+ textarea.style.height = textarea.scrollHeight + 'px';
799
+ };
800
+
801
+ textarea.addEventListener('input', autoResize);
802
+ autoResize(); // Initial resize
803
+
804
+ // Add keyboard shortcuts
805
+ textarea.addEventListener('keydown', (e) => {
806
+ // Cmd/Ctrl+Enter to save
807
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
808
+ e.preventDefault();
809
+ saveComment(diffLineIndex, selectedText);
810
+ }
811
+ // Escape to close
812
+ if (e.key === 'Escape') {
813
+ e.preventDefault();
814
+ inputBox.remove();
815
+ }
816
+ });
817
+ }
818
+
819
+ // Save comment
820
+ function saveComment(diffLineIndex, selectedText = null) {
821
+ const input = document.getElementById('commentInput');
822
+ const text = input.value.trim();
823
+
824
+ if (!text) {
825
+ showStatus('Please enter a comment', 'error');
826
+ return;
827
+ }
828
+
829
+ // Find the diff line
830
+ const diffLine = currentDiffLines[diffLineIndex];
831
+ if (!diffLine) return;
832
+
833
+ const lineContent = diffLine.content || '';
834
+ const lineNum = diffLine.newLine || diffLine.oldLine;
835
+
836
+ comments.push({
837
+ file: currentFile,
838
+ diffLineIndex: diffLineIndex,
839
+ line: lineNum,
840
+ lineContent: lineContent,
841
+ selectedText: selectedText,
842
+ text: text,
843
+ matchType: 'exact', // New comments are exact matches
844
+ followUps: [] // Initialize empty follow-ups array
845
+ });
846
+
847
+ // Auto-save to backend
848
+ saveCommentsToBackend();
849
+
850
+ // Update comments sidebar
851
+ updateCommentsSidebar();
852
+
853
+ // Reload the current file to show the new comment
854
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
855
+ loadFile(currentFile, fileIndex);
856
+ }
857
+
858
+ // Edit comment
859
+ function editComment(diffLineIndex, selectedText = null) {
860
+ const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
861
+ if (!comment) return;
862
+
863
+ // Remove the comment from the list temporarily
864
+ comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
865
+
866
+ // Reload file and show edit input
867
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
868
+ loadFile(currentFile, fileIndex).then(() => {
869
+ // Show comment input with existing text
870
+ const lineEl = document.querySelector(`.line[data-diff-index="${diffLineIndex}"]`);
871
+ if (!lineEl) return;
872
+
873
+ const inputBox = document.createElement('div');
874
+ inputBox.className = 'comment-input-box';
875
+
876
+ const selectedTextHtml = selectedText
877
+ ? `<div class="selected-text-preview">
878
+ <strong>Selected code:</strong>
879
+ <pre>${escapeHtml(selectedText)}</pre>
880
+ </div>`
881
+ : '';
882
+
883
+ inputBox.innerHTML = `
884
+ ${selectedTextHtml}
885
+ <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
886
+ <div class="actions">
887
+ <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
888
+ <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
889
+ </div>
890
+ `;
891
+
892
+ lineEl.after(inputBox);
893
+ const textarea = document.getElementById('commentInput');
894
+ textarea.focus();
895
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
896
+
897
+ // Auto-resize textarea
898
+ const autoResize = () => {
899
+ textarea.style.height = 'auto';
900
+ textarea.style.height = textarea.scrollHeight + 'px';
901
+ };
902
+
903
+ textarea.addEventListener('input', autoResize);
904
+ autoResize(); // Initial resize
905
+
906
+ // Add keyboard shortcuts
907
+ textarea.addEventListener('keydown', (e) => {
908
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
909
+ e.preventDefault();
910
+ saveComment(diffLineIndex, selectedText);
911
+ }
912
+ if (e.key === 'Escape') {
913
+ e.preventDefault();
914
+ inputBox.remove();
915
+ // Restore the comment if canceled
916
+ comments.push(comment);
917
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
918
+ loadFile(currentFile, fileIndex);
919
+ }
920
+ });
921
+ });
922
+ }
923
+
924
+ // Delete comment
925
+ function deleteComment(diffLineIndex) {
926
+ comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
927
+
928
+ // Auto-save to backend
929
+ saveCommentsToBackend();
930
+
931
+ // Update comments sidebar
932
+ updateCommentsSidebar();
933
+
934
+ // Reload the current file to remove the comment
935
+ const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
936
+ loadFile(currentFile, fileIndex);
937
+ }
938
+
939
+ // Submit review
940
+ async function submitReview() {
941
+ if (!currentRepoId) {
942
+ showStatus('Please load a repository first', 'error');
943
+ return;
944
+ }
945
+
946
+ // Ensure comments are saved before generating review
947
+ await saveCommentsToBackend();
948
+
949
+ try {
950
+ const response = await fetch(`${API_BASE}/submit-review`, {
951
+ method: 'POST',
952
+ headers: { 'Content-Type': 'application/json' },
953
+ body: JSON.stringify({
954
+ repoId: currentRepoId
955
+ // Backend reads from JSON file (single source of truth)
956
+ })
957
+ });
958
+
959
+ const data = await response.json();
960
+
961
+ if (!response.ok) {
962
+ throw new Error(data.error || 'Failed to submit review');
963
+ }
964
+
965
+ showReviewModal(data.reviewContent, data.filename);
966
+ showStatus(`Review submitted successfully! ${data.totalComments} comments`, 'success');
967
+
968
+ } catch (error) {
969
+ showStatus(`Error: ${error.message}`, 'error');
970
+ }
971
+ }
972
+
973
+ // Show review modal with download and copy options
974
+ function showReviewModal(reviewContent, filename) {
975
+ // Create modal overlay
976
+ const modal = document.createElement('div');
977
+ modal.className = 'review-modal';
978
+ modal.innerHTML = `
979
+ <div class="review-modal-content">
980
+ <div class="review-modal-header">
981
+ <h2>Review Submitted</h2>
982
+ <button class="close-modal" onclick="this.closest('.review-modal').remove()">×</button>
983
+ </div>
984
+ <div class="review-modal-body">
985
+ <pre class="review-text">${escapeHtml(reviewContent)}</pre>
986
+ </div>
987
+ <div class="review-modal-footer">
988
+ <button onclick="downloadReview('${filename}', this.closest('.review-modal').querySelector('.review-text').textContent)">
989
+ Download ${filename}
990
+ </button>
991
+ <button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
992
+ Copy to Clipboard
993
+ </button>
994
+ </div>
995
+ </div>
996
+ `;
997
+
998
+ document.body.appendChild(modal);
999
+ }
1000
+
1001
+ // Download review as file
1002
+ function downloadReview(filename, content) {
1003
+ const blob = new Blob([content], { type: 'text/plain' });
1004
+ const url = URL.createObjectURL(blob);
1005
+ const a = document.createElement('a');
1006
+ a.href = url;
1007
+ a.download = filename;
1008
+ a.click();
1009
+ URL.revokeObjectURL(url);
1010
+ showStatus('Review downloaded successfully', 'success');
1011
+ }
1012
+
1013
+ // Copy review to clipboard
1014
+ async function copyReviewToClipboard(content) {
1015
+ try {
1016
+ await navigator.clipboard.writeText(content);
1017
+ showStatus('Review copied to clipboard!', 'success');
1018
+ } catch (error) {
1019
+ showStatus('Failed to copy to clipboard', 'error');
1020
+ }
1021
+ }
1022
+
1023
+ // Cleanup session
1024
+ async function cleanupRepo() {
1025
+ if (!currentRepoId) return;
1026
+
1027
+ try {
1028
+ await fetch(`${API_BASE}/cleanup/${currentRepoId}`, {
1029
+ method: 'DELETE'
1030
+ });
1031
+
1032
+ showStatus('Session cleared', 'success');
1033
+ resetApp();
1034
+
1035
+ } catch (error) {
1036
+ console.error('Cleanup error:', error);
1037
+ }
1038
+ }
1039
+
1040
+ // Reset application
1041
+ function resetApp() {
1042
+ currentRepoId = null;
1043
+ currentFiles = [];
1044
+ currentFile = null;
1045
+ currentFileIndex = -1;
1046
+ comments = [];
1047
+
1048
+ document.getElementById('repoPath').value = '';
1049
+ document.getElementById('sidebar').classList.add('hidden');
1050
+ document.getElementById('resizeHandle').classList.add('hidden');
1051
+ document.getElementById('codeSection').classList.add('hidden');
1052
+ document.getElementById('commentsSidebar').classList.add('hidden');
1053
+ document.getElementById('submitReviewBtn').classList.add('hidden');
1054
+ document.getElementById('status').textContent = '';
1055
+ document.getElementById('status').className = 'status';
1056
+ }
1057
+
1058
+ // Show status message
1059
+ function showStatus(message, type) {
1060
+ const statusEl = document.getElementById('status');
1061
+ statusEl.textContent = 'Status: ' + message;
1062
+ statusEl.className = `status ${type}`;
1063
+ }
1064
+
1065
+ // Track if we're in full context mode
1066
+ let isFullContextMode = false;
1067
+ let storedFullFileLines = null;
1068
+
1069
+ // Toggle between full context and diff-only view
1070
+ async function showFullContext() {
1071
+ if (!currentRepoId || !currentFile) return;
1072
+
1073
+ if (isFullContextMode) {
1074
+ // Switch back to diff view
1075
+ displayCode(currentFile, currentDiffLines);
1076
+ isFullContextMode = false;
1077
+ updateFullContextButton();
1078
+ } else {
1079
+ // Switch to full context
1080
+ try {
1081
+ const response = await fetch(`${API_BASE}/file-full/${currentRepoId}/${currentFile}`);
1082
+ const data = await response.json();
1083
+
1084
+ if (!response.ok) {
1085
+ throw new Error(data.error || 'Failed to load full file content');
1086
+ }
1087
+
1088
+ storedFullFileLines = data.lines;
1089
+ displayFullContext(data.filePath, data.lines);
1090
+ isFullContextMode = true;
1091
+ updateFullContextButton();
1092
+
1093
+ } catch (error) {
1094
+ showStatus(`Error loading full context: ${error.message}`, 'error');
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ // Update the full context button text
1100
+ function updateFullContextButton() {
1101
+ const button = document.querySelector('.full-context-btn');
1102
+ if (button) {
1103
+ const icon = button.querySelector('svg');
1104
+ if (isFullContextMode) {
1105
+ button.innerHTML = icon.outerHTML + ' Show Diff Only';
1106
+ } else {
1107
+ button.innerHTML = icon.outerHTML + ' Full Context';
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ // Display full file content with deleted lines
1113
+ function displayFullContext(filePath, lines) {
1114
+ const codeViewer = document.getElementById('codeViewer');
1115
+ const fileComments = comments.filter(c => c.file === filePath);
1116
+
1117
+ // Create a merged view: currentDiffLines + missing lines from full content
1118
+ const mergedLines = [];
1119
+ const coveredNewLines = new Set();
1120
+
1121
+ // First, add all diff lines (includes deleted, added, and changed lines)
1122
+ let lastNewLine = 0;
1123
+ currentDiffLines.forEach(diffLine => {
1124
+ // Check if we need to fill in any missing lines before this diffLine
1125
+ if (diffLine.newLine && diffLine.newLine > lastNewLine + 1) {
1126
+ // Fill in the gap with unchanged lines
1127
+ for (let i = lastNewLine + 1; i < diffLine.newLine; i++) {
1128
+ if (lines[i - 1] !== undefined) {
1129
+ mergedLines.push({
1130
+ oldLine: i,
1131
+ newLine: i,
1132
+ type: 'unchanged',
1133
+ content: lines[i - 1]
1134
+ });
1135
+ coveredNewLines.add(i);
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ mergedLines.push(diffLine);
1141
+ if (diffLine.newLine) {
1142
+ coveredNewLines.add(diffLine.newLine);
1143
+ lastNewLine = diffLine.newLine;
1144
+ }
1145
+ });
1146
+
1147
+ // Add any remaining lines after the last diff
1148
+ for (let i = lastNewLine + 1; i <= lines.length; i++) {
1149
+ if (!coveredNewLines.has(i) && lines[i - 1] !== undefined) {
1150
+ mergedLines.push({
1151
+ oldLine: i,
1152
+ newLine: i,
1153
+ type: 'unchanged',
1154
+ content: lines[i - 1]
1155
+ });
1156
+ }
1157
+ }
1158
+
1159
+ // Find matching diffLineIndex for comments in full context
1160
+ // Need to map mergedLines back to original diffLines indices
1161
+ const mergedToDiffIndexMap = new Map();
1162
+ mergedLines.forEach((mergedLine, mergedIndex) => {
1163
+ // Find this line in original diffLines
1164
+ const diffIndex = currentDiffLines.findIndex(dl =>
1165
+ (dl.newLine === mergedLine.newLine && dl.oldLine === mergedLine.oldLine && dl.content === mergedLine.content)
1166
+ );
1167
+ if (diffIndex !== -1) {
1168
+ mergedToDiffIndexMap.set(mergedIndex, diffIndex);
1169
+ }
1170
+ });
1171
+
1172
+ // Now render the merged lines
1173
+ let html = '';
1174
+ mergedLines.forEach((diffLine, mergedIndex) => {
1175
+ const diffLineIndex = mergedToDiffIndexMap.get(mergedIndex);
1176
+ const hasComment = diffLineIndex !== undefined ? fileComments.find(c => c.diffLineIndex === diffLineIndex) : null;
1177
+
1178
+ const lineClass = `line diff-${diffLine.type} ${hasComment ? 'commented' : ''}`;
1179
+ const dataDiffIndexAttr = diffLineIndex !== undefined ? `data-diff-index="${diffLineIndex}"` : '';
1180
+
1181
+ html += `
1182
+ <div class="${lineClass}" ${dataDiffIndexAttr}>
1183
+ ${hasComment ? '<span class="comment-indicator"></span>' : ''}
1184
+ <div class="line-numbers">
1185
+ <span class="old-line-number">${diffLine.oldLine || ''}</span>
1186
+ <span class="new-line-number" ${diffLineIndex !== undefined ? `onclick="toggleCommentInput(${diffLineIndex})"` : ''}>${diffLine.newLine || ''}</span>
1187
+ </div>
1188
+ <div class="line-content">${escapeHtml(diffLine.content) || ' '}</div>
1189
+ </div>
1190
+ `;
1191
+
1192
+ if (hasComment) {
1193
+ const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
1194
+ html += `
1195
+ <div class="comment-box">
1196
+ <span class="comment-text">${escapeHtml(hasComment.text)}</span>
1197
+ <div class="comment-actions">
1198
+ <button class="comment-edit" onclick="editComment(${diffLineIndex}, ${selectedTextAttr})">Edit</button>
1199
+ <button class="comment-delete" onclick="deleteComment(${diffLineIndex})">Delete</button>
1200
+ </div>
1201
+ </div>
1202
+ `;
1203
+ }
1204
+ });
1205
+
1206
+ codeViewer.innerHTML = html;
1207
+
1208
+ // Add text selection handler for full context too
1209
+ codeViewer.removeEventListener('mousedown', trackShiftKeyDown);
1210
+ codeViewer.removeEventListener('mouseup', handleTextSelection);
1211
+ codeViewer.addEventListener('mousedown', trackShiftKeyDown);
1212
+ codeViewer.addEventListener('mouseup', handleTextSelection);
1213
+
1214
+ showStatus('Showing full file context', 'success');
1215
+ }
1216
+
1217
+ // Escape HTML
1218
+ function escapeHtml(text) {
1219
+ const div = document.createElement('div');
1220
+ div.textContent = text;
1221
+ return div.innerHTML;
1222
+ }
1223
+
1224
+ // Sidebar resize functionality
1225
+ function initResizeHandle() {
1226
+ const resizeHandle = document.getElementById('resizeHandle');
1227
+ const sidebar = document.getElementById('sidebar');
1228
+ const commentsResizeHandle = document.getElementById('commentsResizeHandle');
1229
+ const commentsSidebar = document.getElementById('commentsSidebar');
1230
+ let isResizing = false;
1231
+ let isResizingComments = false;
1232
+
1233
+ // Left sidebar resize
1234
+ resizeHandle.addEventListener('mousedown', (e) => {
1235
+ isResizing = true;
1236
+ resizeHandle.classList.add('resizing');
1237
+ document.body.style.cursor = 'col-resize';
1238
+ document.body.style.userSelect = 'none';
1239
+ });
1240
+
1241
+ // Comments sidebar resize
1242
+ commentsResizeHandle.addEventListener('mousedown', (e) => {
1243
+ isResizingComments = true;
1244
+ commentsResizeHandle.classList.add('resizing');
1245
+ document.body.style.cursor = 'col-resize';
1246
+ document.body.style.userSelect = 'none';
1247
+ });
1248
+
1249
+ document.addEventListener('mousemove', (e) => {
1250
+ if (isResizing) {
1251
+ const newWidth = e.clientX;
1252
+ const minWidth = 200;
1253
+ const maxWidth = 600;
1254
+
1255
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
1256
+ sidebar.style.width = `${newWidth}px`;
1257
+ }
1258
+ }
1259
+
1260
+ if (isResizingComments) {
1261
+ const newWidth = window.innerWidth - e.clientX;
1262
+ const minWidth = 250;
1263
+ const maxWidth = 600;
1264
+
1265
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
1266
+ commentsSidebar.style.width = `${newWidth}px`;
1267
+ }
1268
+ }
1269
+ });
1270
+
1271
+ document.addEventListener('mouseup', () => {
1272
+ if (isResizing) {
1273
+ isResizing = false;
1274
+ resizeHandle.classList.remove('resizing');
1275
+ document.body.style.cursor = '';
1276
+ document.body.style.userSelect = '';
1277
+ }
1278
+ if (isResizingComments) {
1279
+ isResizingComments = false;
1280
+ commentsResizeHandle.classList.remove('resizing');
1281
+ document.body.style.cursor = '';
1282
+ document.body.style.userSelect = '';
1283
+ }
1284
+ });
1285
+ }
1286
+
1287
+ // Track current file index for keyboard navigation
1288
+ let currentFileIndex = -1;
1289
+
1290
+ // Update comments sidebar
1291
+ function updateCommentsSidebar() {
1292
+ const sidebar = document.getElementById('commentsSidebar');
1293
+ const commentsList = document.getElementById('commentsList');
1294
+ const commentsCount = document.getElementById('commentsCount');
1295
+ const commentsResizeHandle = document.getElementById('commentsResizeHandle');
1296
+
1297
+ commentsCount.textContent = comments.length;
1298
+
1299
+ if (comments.length === 0) {
1300
+ sidebar.classList.add('hidden');
1301
+ commentsResizeHandle.classList.add('hidden');
1302
+ commentsList.innerHTML = '<div style="text-align: center; color: #586069; padding: 20px; font-size: 13px;">No comments yet</div>';
1303
+ return;
1304
+ }
1305
+
1306
+ sidebar.classList.remove('hidden');
1307
+ commentsResizeHandle.classList.remove('hidden');
1308
+
1309
+ // Group comments by file
1310
+ const commentsByFile = {};
1311
+ comments.forEach(comment => {
1312
+ if (!commentsByFile[comment.file]) {
1313
+ commentsByFile[comment.file] = [];
1314
+ }
1315
+ commentsByFile[comment.file].push(comment);
1316
+ });
1317
+
1318
+ let html = '';
1319
+ for (const [file, fileComments] of Object.entries(commentsByFile)) {
1320
+ fileComments
1321
+ .sort((a, b) => a.line - b.line)
1322
+ .forEach(comment => {
1323
+ const selectedHtml = comment.selectedText
1324
+ ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>`
1325
+ : '';
1326
+
1327
+ const fileEscaped = file.replace(/'/g, "\\'");
1328
+ html += `
1329
+ <div class="comment-item">
1330
+ <button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar('${fileEscaped}', ${comment.diffLineIndex})" title="Delete comment">×</button>
1331
+ <div class="comment-item-content" onclick="jumpToComment('${fileEscaped}', ${comment.diffLineIndex})">
1332
+ <div class="comment-item-file">${escapeHtml(file)}</div>
1333
+ <div class="comment-item-line">Line ${comment.line}</div>
1334
+ <div class="comment-item-text">${escapeHtml(comment.text)}</div>
1335
+ ${selectedHtml}
1336
+ </div>
1337
+ </div>
1338
+ `;
1339
+ });
1340
+ }
1341
+
1342
+ commentsList.innerHTML = html;
1343
+ }
1344
+
1345
+ // Delete comment from sidebar
1346
+ function deleteCommentFromSidebar(file, diffLineIndex) {
1347
+ comments = comments.filter(c => !(c.file === file && c.diffLineIndex === diffLineIndex));
1348
+
1349
+ // Auto-save to backend
1350
+ saveCommentsToBackend();
1351
+
1352
+ // Update comments sidebar
1353
+ updateCommentsSidebar();
1354
+
1355
+ // If we're currently viewing this file, reload it to remove the comment
1356
+ if (currentFile === file) {
1357
+ const fileIndex = currentFiles.findIndex(f => f.path === file);
1358
+ loadFile(file, fileIndex);
1359
+ }
1360
+ }
1361
+
1362
+ // Jump to a specific comment
1363
+ function jumpToComment(file, diffLineIndex) {
1364
+ // Find the file index
1365
+ const fileIndex = currentFiles.findIndex(f => f.path === file);
1366
+ if (fileIndex === -1) return;
1367
+
1368
+ // Load the file
1369
+ loadFile(file, fileIndex).then(() => {
1370
+ // Scroll to the comment
1371
+ const lineEl = document.querySelector(`.line[data-diff-index="${diffLineIndex}"]`);
1372
+ if (lineEl) {
1373
+ lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
1374
+ // Highlight briefly
1375
+ lineEl.style.backgroundColor = 'rgba(3, 102, 214, 0.1)';
1376
+ setTimeout(() => {
1377
+ lineEl.style.backgroundColor = '';
1378
+ }, 1000);
1379
+ }
1380
+ });
1381
+ }
1382
+
1383
+ // Navigate to next/previous file
1384
+ function navigateFiles(direction) {
1385
+ if (currentFiles.length === 0) return;
1386
+
1387
+ let newIndex = currentFileIndex;
1388
+
1389
+ // If no file is loaded yet, load the first file
1390
+ if (currentFileIndex === -1) {
1391
+ newIndex = 0;
1392
+ } else if (direction === 'up') {
1393
+ newIndex = currentFileIndex > 0 ? currentFileIndex - 1 : 0;
1394
+ } else if (direction === 'down') {
1395
+ newIndex = currentFileIndex < currentFiles.length - 1 ? currentFileIndex + 1 : currentFiles.length - 1;
1396
+ }
1397
+
1398
+ if (newIndex !== currentFileIndex) {
1399
+ currentFileIndex = newIndex;
1400
+ const file = currentFiles[newIndex].path;
1401
+ loadFile(file, newIndex);
1402
+ }
1403
+ }
1404
+
1405
+ // Allow Enter key to load repo
1406
+ document.addEventListener('DOMContentLoaded', () => {
1407
+ document.getElementById('repoPath').addEventListener('keypress', (e) => {
1408
+ if (e.key === 'Enter') {
1409
+ loadRepo();
1410
+ }
1411
+ });
1412
+
1413
+ // Initialize resize handle
1414
+ initResizeHandle();
1415
+
1416
+ // Add keyboard navigation for files
1417
+ document.addEventListener('keydown', (e) => {
1418
+ // Only handle arrow keys when not in an input/textarea
1419
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
1420
+ return;
1421
+ }
1422
+
1423
+ if (e.key === 'ArrowUp') {
1424
+ e.preventDefault();
1425
+ navigateFiles('up');
1426
+ } else if (e.key === 'ArrowDown') {
1427
+ e.preventDefault();
1428
+ navigateFiles('down');
1429
+ }
1430
+ });
1431
+
1432
+ // `reviewer /path/to/repo` opens the page with the repository in the query
1433
+ // string, so the review is on screen without anyone typing a path.
1434
+ const requestedRepo = new URLSearchParams(window.location.search).get('repo');
1435
+ if (requestedRepo) {
1436
+ document.getElementById('repoPath').value = requestedRepo;
1437
+ loadRepo();
1438
+ }
1439
+ });