@in-the-loop-labs/pair-review 1.6.2 → 2.0.1
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/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
package/public/js/pr.js
CHANGED
|
@@ -13,19 +13,6 @@ const STALE_TIMEOUT = 2000;
|
|
|
13
13
|
|
|
14
14
|
class PRManager {
|
|
15
15
|
// Forward static constants from modules for backward compatibility
|
|
16
|
-
static get CATEGORY_EMOJI_MAP() {
|
|
17
|
-
return window.SuggestionManager?.CATEGORY_EMOJI_MAP || {
|
|
18
|
-
'bug': '\u{1F41B}',
|
|
19
|
-
'performance': '\u{26A1}',
|
|
20
|
-
'design': '\u{1F4D0}',
|
|
21
|
-
'code-style': '\u{1F9F9}',
|
|
22
|
-
'improvement': '\u{1F4A1}',
|
|
23
|
-
'praise': '\u{2B50}',
|
|
24
|
-
'security': '\u{1F512}',
|
|
25
|
-
'suggestion': '\u{1F4AC}'
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
16
|
static get FOLD_UP_ICON() {
|
|
30
17
|
return window.HunkParser?.FOLD_UP_ICON || '';
|
|
31
18
|
}
|
|
@@ -131,14 +118,23 @@ class PRManager {
|
|
|
131
118
|
this.collapsedFiles = new Set();
|
|
132
119
|
// File viewed state - tracks which files are marked as viewed
|
|
133
120
|
this.viewedFiles = new Set();
|
|
121
|
+
// Context files - pinned non-diff file ranges
|
|
122
|
+
this.contextFiles = [];
|
|
134
123
|
// Canonical file order - sorted file paths for consistent ordering across components
|
|
135
124
|
this.canonicalFileOrder = new Map();
|
|
125
|
+
// Raw per-file patch text for chat context enrichment
|
|
126
|
+
this.filePatches = new Map();
|
|
136
127
|
// Analysis history manager - for switching between analysis runs
|
|
137
128
|
this.analysisHistoryManager = null;
|
|
138
129
|
// Currently selected analysis run ID (null = latest)
|
|
139
130
|
this.selectedRunId = null;
|
|
140
131
|
// Keyboard shortcuts manager
|
|
141
132
|
this.keyboardShortcuts = null;
|
|
133
|
+
// Unique client ID for self-echo suppression on SSE review events.
|
|
134
|
+
// Sent as X-Client-Id header on mutation requests; the server echoes
|
|
135
|
+
// it back in the SSE broadcast so this tab can skip its own events.
|
|
136
|
+
this._clientId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
137
|
+
this._installFetchInterceptor();
|
|
142
138
|
|
|
143
139
|
// Initialize modules
|
|
144
140
|
this.lineTracker = new window.LineTracker();
|
|
@@ -184,6 +180,48 @@ class PRManager {
|
|
|
184
180
|
}
|
|
185
181
|
}
|
|
186
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Install a global fetch interceptor that adds X-Client-Id to all
|
|
185
|
+
* mutation requests (POST/PUT/DELETE) targeting the review API.
|
|
186
|
+
* This is the SINGLE SOURCE of X-Client-Id injection — no individual
|
|
187
|
+
* fetch call site should manually set this header.
|
|
188
|
+
* This ensures that even direct fetch() calls (e.g. from page.evaluate
|
|
189
|
+
* in tests, or any code that bypasses PRManager methods) carry the
|
|
190
|
+
* client ID so the server can tag the SSE broadcast for self-echo
|
|
191
|
+
* suppression.
|
|
192
|
+
*/
|
|
193
|
+
_installFetchInterceptor() {
|
|
194
|
+
if (window._prFetchIntercepted) return;
|
|
195
|
+
window._prFetchIntercepted = true;
|
|
196
|
+
|
|
197
|
+
const originalFetch = window.fetch;
|
|
198
|
+
const prManager = this;
|
|
199
|
+
|
|
200
|
+
window.fetch = function(input, init) {
|
|
201
|
+
const url = typeof input === 'string' ? input : input?.url || '';
|
|
202
|
+
const method = (init?.method || 'GET').toUpperCase();
|
|
203
|
+
|
|
204
|
+
// Only intercept mutations to the reviews API
|
|
205
|
+
if ((method === 'POST' || method === 'PUT' || method === 'DELETE') &&
|
|
206
|
+
url.includes('/api/reviews/') && prManager._clientId) {
|
|
207
|
+
init = init || {};
|
|
208
|
+
// Merge X-Client-Id into existing headers
|
|
209
|
+
if (init.headers instanceof Headers) {
|
|
210
|
+
if (!init.headers.has('X-Client-Id')) {
|
|
211
|
+
init.headers.set('X-Client-Id', prManager._clientId);
|
|
212
|
+
}
|
|
213
|
+
} else if (typeof init.headers === 'object' && init.headers !== null) {
|
|
214
|
+
if (!init.headers['X-Client-Id']) {
|
|
215
|
+
init.headers['X-Client-Id'] = prManager._clientId;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
init.headers = { 'X-Client-Id': prManager._clientId };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return originalFetch.call(this, input, init);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
187
225
|
/**
|
|
188
226
|
* Set up event handlers
|
|
189
227
|
*/
|
|
@@ -408,8 +446,8 @@ class PRManager {
|
|
|
408
446
|
// Check if AI analysis is currently running
|
|
409
447
|
await this.checkRunningAnalysis();
|
|
410
448
|
|
|
411
|
-
// Listen for
|
|
412
|
-
this.
|
|
449
|
+
// Listen for review mutation events via multiplexed SSE
|
|
450
|
+
this._initReviewEventListeners();
|
|
413
451
|
|
|
414
452
|
} catch (error) {
|
|
415
453
|
console.error('Error loading PR:', error);
|
|
@@ -420,39 +458,112 @@ class PRManager {
|
|
|
420
458
|
}
|
|
421
459
|
|
|
422
460
|
/**
|
|
423
|
-
* Listen for
|
|
424
|
-
*
|
|
425
|
-
* it broadcasts on `review-${reviewId}`. This listener picks that up
|
|
426
|
-
* and refreshes suggestions automatically.
|
|
461
|
+
* Listen for review-scoped CustomEvents dispatched by ChatPanel's
|
|
462
|
+
* multiplexed SSE connection. Replaces the old per-review EventSource.
|
|
427
463
|
*/
|
|
428
|
-
|
|
429
|
-
if (this.
|
|
430
|
-
|
|
431
|
-
if (!reviewId) return;
|
|
464
|
+
_initReviewEventListeners() {
|
|
465
|
+
if (this._reviewEventsBound) return;
|
|
466
|
+
this._reviewEventsBound = true;
|
|
432
467
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
);
|
|
468
|
+
// Eagerly connect chat SSE so review events flow even before chat opens
|
|
469
|
+
window.chatPanel?._ensureGlobalSSE();
|
|
436
470
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
471
|
+
// Late-bind reviewId to ChatPanel if it was auto-opened by PanelGroup
|
|
472
|
+
// before prManager was ready (DOMContentLoaded race condition)
|
|
473
|
+
if (this.currentPR?.id) {
|
|
474
|
+
window.chatPanel?._lateBindReview(this.currentPR.id).catch(err => console.warn('[ChatPanel] Late-bind failed:', err));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Dirty flags for stale-tab recovery
|
|
478
|
+
this._dirtyComments = false;
|
|
479
|
+
this._dirtySuggestions = false;
|
|
480
|
+
this._dirtyAnalysis = false;
|
|
481
|
+
this._dirtyAnalysisStarted = false;
|
|
482
|
+
this._dirtyContextFiles = false;
|
|
483
|
+
|
|
484
|
+
// Simple debounce helper
|
|
485
|
+
const timers = {};
|
|
486
|
+
const debounced = (key, fn, ms = 300) => {
|
|
487
|
+
clearTimeout(timers[key]);
|
|
488
|
+
timers[key] = setTimeout(fn, ms);
|
|
450
489
|
};
|
|
451
490
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
491
|
+
const reviewId = () => this.currentPR?.id;
|
|
492
|
+
|
|
493
|
+
document.addEventListener('review:comments_changed', (e) => {
|
|
494
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
495
|
+
// Suppress self-echo: if this tab originated the mutation, skip reload
|
|
496
|
+
if (e.detail?.sourceClientId === this._clientId) return;
|
|
497
|
+
if (document.hidden) { this._dirtyComments = true; return; }
|
|
498
|
+
debounced('comments', () => this.loadUserComments());
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
document.addEventListener('review:suggestions_changed', (e) => {
|
|
502
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
503
|
+
// Suppress self-echo for suggestion mutations too
|
|
504
|
+
if (e.detail?.sourceClientId === this._clientId) return;
|
|
505
|
+
if (document.hidden) { this._dirtySuggestions = true; return; }
|
|
506
|
+
debounced('suggestions', () => this.loadAISuggestions());
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
document.addEventListener('review:analysis_started', (e) => {
|
|
510
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
511
|
+
if (document.hidden) { this._dirtyAnalysisStarted = true; return; }
|
|
512
|
+
debounced('analysisStarted', () => this.checkRunningAnalysis());
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
document.addEventListener('review:analysis_completed', (e) => {
|
|
516
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
517
|
+
if (document.hidden) { this._dirtyAnalysis = true; return; }
|
|
518
|
+
debounced('analysis', () => {
|
|
519
|
+
if (this.analysisHistoryManager) {
|
|
520
|
+
this.analysisHistoryManager.refresh({ switchToNew: true })
|
|
521
|
+
.then(() => this.loadAISuggestions());
|
|
522
|
+
} else {
|
|
523
|
+
this.loadAISuggestions();
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
document.addEventListener('review:context_files_changed', (e) => {
|
|
529
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
530
|
+
if (e.detail?.sourceClientId === this._clientId) return;
|
|
531
|
+
if (document.hidden) { this._dirtyContextFiles = true; return; }
|
|
532
|
+
debounced('contextFiles', () => this.loadContextFiles());
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
document.addEventListener('review:expand_hunk', async (e) => {
|
|
536
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
537
|
+
const { file, line_start, line_end, side } = e.detail;
|
|
538
|
+
await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
document.addEventListener('visibilitychange', () => {
|
|
542
|
+
if (document.hidden) return;
|
|
543
|
+
if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
|
|
544
|
+
if (this._dirtyAnalysisStarted) {
|
|
545
|
+
this._dirtyAnalysisStarted = false;
|
|
546
|
+
// Skip if analysis already completed while hidden — the completed handler below will refresh everything
|
|
547
|
+
if (!this._dirtyAnalysis) {
|
|
548
|
+
this.checkRunningAnalysis();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (this._dirtyAnalysis) {
|
|
552
|
+
this._dirtyAnalysis = false;
|
|
553
|
+
this._dirtySuggestions = false; // analysis refresh includes suggestion reload
|
|
554
|
+
if (this.analysisHistoryManager) {
|
|
555
|
+
this.analysisHistoryManager.refresh({ switchToNew: true })
|
|
556
|
+
.then(() => this.loadAISuggestions());
|
|
557
|
+
} else {
|
|
558
|
+
this.loadAISuggestions();
|
|
559
|
+
}
|
|
560
|
+
} else if (this._dirtySuggestions) {
|
|
561
|
+
this._dirtySuggestions = false;
|
|
562
|
+
this.loadAISuggestions();
|
|
563
|
+
}
|
|
564
|
+
if (this._dirtyContextFiles) {
|
|
565
|
+
this._dirtyContextFiles = false;
|
|
566
|
+
this.loadContextFiles();
|
|
456
567
|
}
|
|
457
568
|
});
|
|
458
569
|
}
|
|
@@ -485,6 +596,7 @@ class PRManager {
|
|
|
485
596
|
|
|
486
597
|
// Parse the unified diff to extract per-file patches
|
|
487
598
|
const filePatchMap = this.parseUnifiedDiff(fullDiff);
|
|
599
|
+
this.filePatches = filePatchMap;
|
|
488
600
|
|
|
489
601
|
// Merge patch data into file objects
|
|
490
602
|
const filesWithPatches = files.map(file => ({
|
|
@@ -779,6 +891,10 @@ class PRManager {
|
|
|
779
891
|
} else {
|
|
780
892
|
diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
|
|
781
893
|
}
|
|
894
|
+
|
|
895
|
+
// Load context files after diff is rendered
|
|
896
|
+
this.contextFiles = [];
|
|
897
|
+
this.loadContextFiles();
|
|
782
898
|
}
|
|
783
899
|
|
|
784
900
|
/**
|
|
@@ -855,6 +971,26 @@ class PRManager {
|
|
|
855
971
|
|
|
856
972
|
// Store reference for updating icon state later
|
|
857
973
|
fileCommentsZone.headerButton = fileCommentBtn;
|
|
974
|
+
|
|
975
|
+
// Add file chat button to header
|
|
976
|
+
const fileChatBtn = document.createElement('button');
|
|
977
|
+
fileChatBtn.className = 'file-header-chat-btn';
|
|
978
|
+
fileChatBtn.title = 'Chat about file';
|
|
979
|
+
fileChatBtn.dataset.file = file.file;
|
|
980
|
+
fileChatBtn.innerHTML = `
|
|
981
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
982
|
+
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
983
|
+
</svg>
|
|
984
|
+
`;
|
|
985
|
+
fileChatBtn.addEventListener('click', (e) => {
|
|
986
|
+
e.stopPropagation();
|
|
987
|
+
if (window.chatPanel) {
|
|
988
|
+
window.chatPanel.open({
|
|
989
|
+
fileContext: { file: file.file }
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
header.appendChild(fileChatBtn);
|
|
858
994
|
}
|
|
859
995
|
|
|
860
996
|
// Create diff table
|
|
@@ -1077,6 +1213,30 @@ class PRManager {
|
|
|
1077
1213
|
this.showCommentForm(row, lineNumber, file, diffPosition, null, side);
|
|
1078
1214
|
}
|
|
1079
1215
|
},
|
|
1216
|
+
onChatButtonClick: (_e, row, lineNumber, file, lineData) => {
|
|
1217
|
+
if (!window.chatPanel) return;
|
|
1218
|
+
let startLine = lineNumber;
|
|
1219
|
+
let endLine = null;
|
|
1220
|
+
|
|
1221
|
+
if (this.lineTracker.hasActiveSelection() &&
|
|
1222
|
+
this.lineTracker.rangeSelectionStart.fileName === file) {
|
|
1223
|
+
const range = this.lineTracker.getSelectionRange();
|
|
1224
|
+
startLine = range.start;
|
|
1225
|
+
endLine = range.end;
|
|
1226
|
+
this.lineTracker.clearRangeSelection();
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
window.chatPanel.open({
|
|
1230
|
+
commentContext: {
|
|
1231
|
+
type: 'line',
|
|
1232
|
+
body: null,
|
|
1233
|
+
file: file || '',
|
|
1234
|
+
line_start: startLine,
|
|
1235
|
+
line_end: endLine || startLine,
|
|
1236
|
+
source: 'user'
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
},
|
|
1080
1240
|
onMouseOver: (_e, row, lineNumber, file) => {
|
|
1081
1241
|
// Check if we have a potential drag start and convert it to an actual drag
|
|
1082
1242
|
if (this.lineTracker.potentialDragStart && !this.lineTracker.isDraggingRange) {
|
|
@@ -1091,6 +1251,7 @@ class PRManager {
|
|
|
1091
1251
|
onMouseUp: (_e, row, lineNumber, file) => {
|
|
1092
1252
|
if (this.lineTracker.potentialDragStart) {
|
|
1093
1253
|
const start = this.lineTracker.potentialDragStart;
|
|
1254
|
+
const isChat = start.isChat;
|
|
1094
1255
|
this.lineTracker.potentialDragStart = null;
|
|
1095
1256
|
|
|
1096
1257
|
if (start.lineNumber !== lineNumber || start.fileName !== file) {
|
|
@@ -1100,6 +1261,24 @@ class PRManager {
|
|
|
1100
1261
|
this.lineTracker.startDragSelection(start.row, start.lineNumber, start.fileName, start.side);
|
|
1101
1262
|
}
|
|
1102
1263
|
this.lineTracker.completeDragSelection(row, lineNumber, file);
|
|
1264
|
+
|
|
1265
|
+
// For chat drags, immediately open chat with the selected range
|
|
1266
|
+
if (isChat && this.lineTracker.hasActiveSelection()) {
|
|
1267
|
+
const range = this.lineTracker.getSelectionRange();
|
|
1268
|
+
this.lineTracker.clearRangeSelection();
|
|
1269
|
+
if (window.chatPanel) {
|
|
1270
|
+
window.chatPanel.open({
|
|
1271
|
+
commentContext: {
|
|
1272
|
+
type: 'line',
|
|
1273
|
+
body: null,
|
|
1274
|
+
file: file || '',
|
|
1275
|
+
line_start: range.start,
|
|
1276
|
+
line_end: range.end,
|
|
1277
|
+
source: 'user'
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1103
1282
|
}
|
|
1104
1283
|
} else if (this.lineTracker.isDraggingRange) {
|
|
1105
1284
|
this.lineTracker.completeDragSelection(row, lineNumber, file);
|
|
@@ -1249,11 +1428,11 @@ class PRManager {
|
|
|
1249
1428
|
* @returns {Promise<{lines: string[]}|null>} File content with lines array, or null on error
|
|
1250
1429
|
*/
|
|
1251
1430
|
async fetchFileContent(fileName) {
|
|
1252
|
-
|
|
1431
|
+
const reviewId = this.currentPR?.id;
|
|
1432
|
+
if (!reviewId) return null;
|
|
1253
1433
|
|
|
1254
|
-
const { owner, repo, number } = this.currentPR;
|
|
1255
1434
|
const response = await fetch(
|
|
1256
|
-
`/api/file-content
|
|
1435
|
+
`/api/reviews/${reviewId}/file-content/${encodeURIComponent(fileName)}`
|
|
1257
1436
|
);
|
|
1258
1437
|
const data = await response.json();
|
|
1259
1438
|
|
|
@@ -1706,6 +1885,40 @@ class PRManager {
|
|
|
1706
1885
|
return true;
|
|
1707
1886
|
}
|
|
1708
1887
|
|
|
1888
|
+
/**
|
|
1889
|
+
* Ensure that the given line ranges are visible in the diff view.
|
|
1890
|
+
* For each item, checks if the target line rows exist in the DOM; if not,
|
|
1891
|
+
* calls expandForSuggestion() to expand the gap containing those lines.
|
|
1892
|
+
* @param {Array<{file: string, line_start: number, line_end: number, side: string}>} items
|
|
1893
|
+
*/
|
|
1894
|
+
async ensureLinesVisible(items) {
|
|
1895
|
+
for (const item of items) {
|
|
1896
|
+
const { file, line_start, line_end, side } = item;
|
|
1897
|
+
const resolvedSide = (side || 'right').toUpperCase();
|
|
1898
|
+
|
|
1899
|
+
const fileElement = this.findFileElement(file);
|
|
1900
|
+
if (!fileElement) continue;
|
|
1901
|
+
|
|
1902
|
+
// Check if any line in the range is already visible
|
|
1903
|
+
let anyLineVisible = false;
|
|
1904
|
+
const lineRows = fileElement.querySelectorAll('tr');
|
|
1905
|
+
for (let checkLine = line_start; checkLine <= (line_end || line_start); checkLine++) {
|
|
1906
|
+
for (const row of lineRows) {
|
|
1907
|
+
const lineNum = this.getLineNumber(row, resolvedSide);
|
|
1908
|
+
if (lineNum === checkLine) {
|
|
1909
|
+
anyLineVisible = true;
|
|
1910
|
+
break;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
if (anyLineVisible) break;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (!anyLineVisible) {
|
|
1917
|
+
await this.expandForSuggestion(file, line_start, line_end || line_start, resolvedSide);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1709
1922
|
/**
|
|
1710
1923
|
* Line range selection methods - delegate to LineTracker
|
|
1711
1924
|
*/
|
|
@@ -1791,7 +2004,7 @@ class PRManager {
|
|
|
1791
2004
|
let currentText = bodyDiv.dataset.originalMarkdown || '';
|
|
1792
2005
|
|
|
1793
2006
|
if (!currentText) {
|
|
1794
|
-
const response = await fetch(`/api/
|
|
2007
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`);
|
|
1795
2008
|
if (response.ok) {
|
|
1796
2009
|
const data = await response.json();
|
|
1797
2010
|
currentText = data.body || bodyDiv.textContent.trim();
|
|
@@ -1894,7 +2107,7 @@ class PRManager {
|
|
|
1894
2107
|
return;
|
|
1895
2108
|
}
|
|
1896
2109
|
|
|
1897
|
-
const response = await fetch(`/api/
|
|
2110
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`, {
|
|
1898
2111
|
method: 'PUT',
|
|
1899
2112
|
headers: { 'Content-Type': 'application/json' },
|
|
1900
2113
|
body: JSON.stringify({ body: editedText })
|
|
@@ -1920,9 +2133,6 @@ class PRManager {
|
|
|
1920
2133
|
if (editFormEl) editFormEl.remove();
|
|
1921
2134
|
commentDiv.classList.remove('editing-mode');
|
|
1922
2135
|
|
|
1923
|
-
const timestamp = commentDiv.querySelector('.user-comment-timestamp');
|
|
1924
|
-
if (timestamp) timestamp.textContent = new Date().toLocaleString();
|
|
1925
|
-
|
|
1926
2136
|
// Notify AI Panel about the updated comment body
|
|
1927
2137
|
if (window.aiPanel?.updateComment) {
|
|
1928
2138
|
window.aiPanel.updateComment(commentId, { body: editedText });
|
|
@@ -1953,11 +2163,6 @@ class PRManager {
|
|
|
1953
2163
|
bodyDiv.style.display = '';
|
|
1954
2164
|
if (editForm) editForm.remove();
|
|
1955
2165
|
commentDiv.classList.remove('editing-mode');
|
|
1956
|
-
|
|
1957
|
-
const timestamp = commentDiv.querySelector('.user-comment-timestamp');
|
|
1958
|
-
if (timestamp && timestamp.textContent === 'Editing comment...') {
|
|
1959
|
-
timestamp.textContent = 'Draft';
|
|
1960
|
-
}
|
|
1961
2166
|
}
|
|
1962
2167
|
|
|
1963
2168
|
/**
|
|
@@ -1970,7 +2175,9 @@ class PRManager {
|
|
|
1970
2175
|
*/
|
|
1971
2176
|
async deleteUserComment(commentId) {
|
|
1972
2177
|
try {
|
|
1973
|
-
const response = await fetch(`/api/
|
|
2178
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`, {
|
|
2179
|
+
method: 'DELETE'
|
|
2180
|
+
});
|
|
1974
2181
|
if (!response.ok) throw new Error('Failed to delete comment');
|
|
1975
2182
|
|
|
1976
2183
|
const apiResult = await response.json();
|
|
@@ -2029,7 +2236,9 @@ class PRManager {
|
|
|
2029
2236
|
*/
|
|
2030
2237
|
async restoreUserComment(commentId) {
|
|
2031
2238
|
try {
|
|
2032
|
-
const response = await fetch(`/api/
|
|
2239
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}/restore`, {
|
|
2240
|
+
method: 'PUT'
|
|
2241
|
+
});
|
|
2033
2242
|
if (!response.ok) throw new Error('Failed to restore comment');
|
|
2034
2243
|
|
|
2035
2244
|
// Reload comments to update both the diff view and AI panel
|
|
@@ -2091,7 +2300,7 @@ class PRManager {
|
|
|
2091
2300
|
if (dialogResult !== 'confirm') return;
|
|
2092
2301
|
|
|
2093
2302
|
try {
|
|
2094
|
-
const response = await fetch(`/api/
|
|
2303
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments`, {
|
|
2095
2304
|
method: 'DELETE'
|
|
2096
2305
|
});
|
|
2097
2306
|
|
|
@@ -2156,7 +2365,7 @@ class PRManager {
|
|
|
2156
2365
|
|
|
2157
2366
|
try {
|
|
2158
2367
|
const queryParam = includeDismissed ? '?includeDismissed=true' : '';
|
|
2159
|
-
const response = await fetch(`/api/
|
|
2368
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/comments${queryParam}`);
|
|
2160
2369
|
if (!response.ok) return;
|
|
2161
2370
|
|
|
2162
2371
|
const data = await response.json();
|
|
@@ -2185,6 +2394,16 @@ class PRManager {
|
|
|
2185
2394
|
// Clear existing comment rows before re-rendering
|
|
2186
2395
|
document.querySelectorAll('.user-comment-row').forEach(row => row.remove());
|
|
2187
2396
|
|
|
2397
|
+
// Before rendering, ensure all comment target lines are visible
|
|
2398
|
+
// (expand hidden hunks so the line rows exist in the DOM)
|
|
2399
|
+
const lineItems = lineLevelComments.map(c => ({
|
|
2400
|
+
file: c.file,
|
|
2401
|
+
line_start: c.line_start,
|
|
2402
|
+
line_end: c.line_start,
|
|
2403
|
+
side: c.side || 'RIGHT'
|
|
2404
|
+
}));
|
|
2405
|
+
await this.ensureLinesVisible(lineItems);
|
|
2406
|
+
|
|
2188
2407
|
// Display line-level comments inline with diff (only active comments reach here)
|
|
2189
2408
|
lineLevelComments.forEach(comment => {
|
|
2190
2409
|
const fileElement = this.findFileElement(comment.file);
|
|
@@ -2243,7 +2462,8 @@ class PRManager {
|
|
|
2243
2462
|
// First, check if analysis has been run for this PR and get summary for the selected run
|
|
2244
2463
|
let analysisHasRun = false;
|
|
2245
2464
|
try {
|
|
2246
|
-
|
|
2465
|
+
const id = this.currentPR.id;
|
|
2466
|
+
let checkUrl = `/api/reviews/${id}/suggestions/check`;
|
|
2247
2467
|
if (filterRunId) {
|
|
2248
2468
|
checkUrl += `?runId=${filterRunId}`;
|
|
2249
2469
|
}
|
|
@@ -2270,7 +2490,7 @@ class PRManager {
|
|
|
2270
2490
|
window.aiPanel.setAnalysisState(analysisHasRun ? 'complete' : 'unknown');
|
|
2271
2491
|
}
|
|
2272
2492
|
|
|
2273
|
-
let url = `/api/
|
|
2493
|
+
let url = `/api/reviews/${this.currentPR.id}/suggestions?levels=${filterLevel}`;
|
|
2274
2494
|
if (filterRunId) {
|
|
2275
2495
|
url += `&runId=${filterRunId}`;
|
|
2276
2496
|
}
|
|
@@ -2313,10 +2533,6 @@ class PRManager {
|
|
|
2313
2533
|
return this.suggestionManager.getFileAndLineInfo(suggestionDiv);
|
|
2314
2534
|
}
|
|
2315
2535
|
|
|
2316
|
-
async collapseAISuggestion(suggestionId, suggestionRow, collapsedText, status) {
|
|
2317
|
-
return this.suggestionManager.collapseAISuggestion(suggestionId, suggestionRow, collapsedText, status);
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
2536
|
getCategoryEmoji(category) {
|
|
2321
2537
|
return this.suggestionManager.getCategoryEmoji(category);
|
|
2322
2538
|
}
|
|
@@ -2325,14 +2541,103 @@ class PRManager {
|
|
|
2325
2541
|
return this.suggestionManager.formatAdoptedComment(text, category);
|
|
2326
2542
|
}
|
|
2327
2543
|
|
|
2328
|
-
async createUserCommentFromSuggestion(suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side) {
|
|
2329
|
-
return this.suggestionManager.createUserCommentFromSuggestion(suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side);
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
2544
|
getTypeDescription(type) {
|
|
2333
2545
|
return this.suggestionManager.getTypeDescription(type);
|
|
2334
2546
|
}
|
|
2335
2547
|
|
|
2548
|
+
/**
|
|
2549
|
+
* Collapse a suggestion div in the UI after adoption.
|
|
2550
|
+
* Handles adding collapsed class, updating text to 'Suggestion adopted',
|
|
2551
|
+
* updating the restore button, and setting hiddenForAdoption flag.
|
|
2552
|
+
* @param {HTMLElement} suggestionRow - The suggestion row element
|
|
2553
|
+
* @param {number|string} suggestionId - Suggestion ID
|
|
2554
|
+
*/
|
|
2555
|
+
collapseSuggestionForAdoption(suggestionRow, suggestionId) {
|
|
2556
|
+
if (!suggestionRow) return;
|
|
2557
|
+
const targetDiv = suggestionRow.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
2558
|
+
if (!targetDiv) return;
|
|
2559
|
+
targetDiv.classList.add('collapsed');
|
|
2560
|
+
const collapsedContent = targetDiv.querySelector('.collapsed-text');
|
|
2561
|
+
if (collapsedContent) collapsedContent.textContent = 'Suggestion adopted';
|
|
2562
|
+
const restoreButton = targetDiv.querySelector('.btn-restore');
|
|
2563
|
+
if (restoreButton) {
|
|
2564
|
+
restoreButton.title = 'Show suggestion';
|
|
2565
|
+
const btnText = restoreButton.querySelector('.btn-text');
|
|
2566
|
+
if (btnText) btnText.textContent = 'Show';
|
|
2567
|
+
}
|
|
2568
|
+
targetDiv.dataset.hiddenForAdoption = 'true';
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* Shared helper for adoptAndEditSuggestion and adoptSuggestion.
|
|
2573
|
+
* Performs the /adopt fetch, collapses the suggestion, formats the comment,
|
|
2574
|
+
* and builds the newComment object. Returns { newComment, suggestionRow }
|
|
2575
|
+
* or null on failure. Throws on errors so the caller can handle them.
|
|
2576
|
+
*/
|
|
2577
|
+
async _adoptAndBuildComment(suggestionId, suggestionDiv) {
|
|
2578
|
+
const { suggestionText, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
|
|
2579
|
+
const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
|
|
2580
|
+
|
|
2581
|
+
// File-level suggestions are handled by FileCommentManager; signal the caller
|
|
2582
|
+
if (isFileLevel) {
|
|
2583
|
+
return { isFileLevel: true, suggestionText, suggestionType, suggestionTitle, fileName, suggestionRow };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Use the atomic /adopt endpoint which creates the user comment, sets parent_id
|
|
2587
|
+
// linkage, and updates suggestion status to 'adopted' in a single request
|
|
2588
|
+
const reviewId = this.currentPR?.id;
|
|
2589
|
+
const adoptResponse = await fetch(`/api/reviews/${reviewId}/suggestions/${suggestionId}/adopt`, {
|
|
2590
|
+
method: 'POST',
|
|
2591
|
+
headers: { 'Content-Type': 'application/json' }
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
if (!adoptResponse.ok) throw new Error('Failed to adopt suggestion');
|
|
2595
|
+
|
|
2596
|
+
const adoptResult = await adoptResponse.json();
|
|
2597
|
+
|
|
2598
|
+
// Collapse the suggestion in the UI
|
|
2599
|
+
this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
|
|
2600
|
+
|
|
2601
|
+
// Build comment data from the adopt response and suggestion metadata
|
|
2602
|
+
const formattedText = this.formatAdoptedComment(suggestionText, suggestionType);
|
|
2603
|
+
const newComment = {
|
|
2604
|
+
id: adoptResult.userCommentId,
|
|
2605
|
+
file: fileName,
|
|
2606
|
+
line_start: parseInt(lineNumber),
|
|
2607
|
+
body: formattedText,
|
|
2608
|
+
type: suggestionType,
|
|
2609
|
+
title: suggestionTitle,
|
|
2610
|
+
parent_id: suggestionId,
|
|
2611
|
+
diff_position: diffPosition ? parseInt(diffPosition) : null,
|
|
2612
|
+
side: side || 'RIGHT',
|
|
2613
|
+
created_at: new Date().toISOString()
|
|
2614
|
+
};
|
|
2615
|
+
|
|
2616
|
+
return { isFileLevel: false, newComment, suggestionRow };
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/**
|
|
2620
|
+
* Notify panels and navigator after a successful adoption
|
|
2621
|
+
*/
|
|
2622
|
+
_notifyAdoption(suggestionId, newComment) {
|
|
2623
|
+
if (window.aiPanel?.addComment) {
|
|
2624
|
+
window.aiPanel.addComment(newComment);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
if (this.suggestionNavigator?.suggestions) {
|
|
2628
|
+
const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
|
|
2629
|
+
s.id === suggestionId ? { ...s, status: 'adopted' } : s
|
|
2630
|
+
);
|
|
2631
|
+
this.suggestionNavigator.updateSuggestions(updatedSuggestions);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
if (window.aiPanel) {
|
|
2635
|
+
window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
this.updateCommentCount();
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2336
2641
|
/**
|
|
2337
2642
|
* Adopt an AI suggestion and open it in edit mode
|
|
2338
2643
|
*/
|
|
@@ -2341,54 +2646,27 @@ class PRManager {
|
|
|
2341
2646
|
const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
2342
2647
|
if (!suggestionDiv) throw new Error('Suggestion element not found');
|
|
2343
2648
|
|
|
2344
|
-
const
|
|
2345
|
-
const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
|
|
2649
|
+
const result = await this._adoptAndBuildComment(suggestionId, suggestionDiv);
|
|
2346
2650
|
|
|
2347
|
-
|
|
2348
|
-
if (isFileLevel) {
|
|
2651
|
+
if (result.isFileLevel) {
|
|
2349
2652
|
if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
|
|
2350
|
-
const zone = this.fileCommentManager.findZoneForFile(fileName);
|
|
2351
|
-
if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
|
|
2653
|
+
const zone = this.fileCommentManager.findZoneForFile(result.fileName);
|
|
2654
|
+
if (!zone) throw new Error(`Could not find file comments zone for ${result.fileName}`);
|
|
2352
2655
|
|
|
2353
|
-
// Build suggestion object for FileCommentManager
|
|
2354
2656
|
const suggestion = {
|
|
2355
2657
|
id: suggestionId,
|
|
2356
|
-
file: fileName,
|
|
2357
|
-
body: suggestionText,
|
|
2358
|
-
type: suggestionType,
|
|
2359
|
-
title: suggestionTitle
|
|
2658
|
+
file: result.fileName,
|
|
2659
|
+
body: result.suggestionText,
|
|
2660
|
+
type: result.suggestionType,
|
|
2661
|
+
title: result.suggestionTitle
|
|
2360
2662
|
};
|
|
2361
2663
|
|
|
2362
|
-
// Use editAndAdoptAISuggestion which opens an edit form
|
|
2363
2664
|
this.fileCommentManager.editAndAdoptAISuggestion(zone, suggestion);
|
|
2364
2665
|
return;
|
|
2365
2666
|
}
|
|
2366
2667
|
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
const newComment = await this.createUserCommentFromSuggestion(
|
|
2370
|
-
suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side
|
|
2371
|
-
);
|
|
2372
|
-
|
|
2373
|
-
this.displayUserCommentInEditMode(newComment, suggestionRow);
|
|
2374
|
-
|
|
2375
|
-
// Notify AI Panel about the new adopted comment
|
|
2376
|
-
if (window.aiPanel?.addComment) {
|
|
2377
|
-
window.aiPanel.addComment(newComment);
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
if (this.suggestionNavigator?.suggestions) {
|
|
2381
|
-
const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
|
|
2382
|
-
s.id === suggestionId ? { ...s, status: 'adopted' } : s
|
|
2383
|
-
);
|
|
2384
|
-
this.suggestionNavigator.updateSuggestions(updatedSuggestions);
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
if (window.aiPanel) {
|
|
2388
|
-
window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
this.updateCommentCount();
|
|
2668
|
+
this.displayUserCommentInEditMode(result.newComment, result.suggestionRow);
|
|
2669
|
+
this._notifyAdoption(suggestionId, result.newComment);
|
|
2392
2670
|
} catch (error) {
|
|
2393
2671
|
console.error('Error adopting and editing suggestion:', error);
|
|
2394
2672
|
alert(`Failed to adopt suggestion: ${error.message}`);
|
|
@@ -2403,53 +2681,27 @@ class PRManager {
|
|
|
2403
2681
|
const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
2404
2682
|
if (!suggestionDiv) throw new Error('Suggestion element not found');
|
|
2405
2683
|
|
|
2406
|
-
const
|
|
2407
|
-
const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
|
|
2684
|
+
const result = await this._adoptAndBuildComment(suggestionId, suggestionDiv);
|
|
2408
2685
|
|
|
2409
|
-
|
|
2410
|
-
if (isFileLevel) {
|
|
2686
|
+
if (result.isFileLevel) {
|
|
2411
2687
|
if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
|
|
2412
|
-
const zone = this.fileCommentManager.findZoneForFile(fileName);
|
|
2413
|
-
if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
|
|
2688
|
+
const zone = this.fileCommentManager.findZoneForFile(result.fileName);
|
|
2689
|
+
if (!zone) throw new Error(`Could not find file comments zone for ${result.fileName}`);
|
|
2414
2690
|
|
|
2415
|
-
// Build suggestion object for FileCommentManager
|
|
2416
2691
|
const suggestion = {
|
|
2417
2692
|
id: suggestionId,
|
|
2418
|
-
file: fileName,
|
|
2419
|
-
body: suggestionText,
|
|
2420
|
-
type: suggestionType,
|
|
2421
|
-
title: suggestionTitle
|
|
2693
|
+
file: result.fileName,
|
|
2694
|
+
body: result.suggestionText,
|
|
2695
|
+
type: result.suggestionType,
|
|
2696
|
+
title: result.suggestionTitle
|
|
2422
2697
|
};
|
|
2423
2698
|
|
|
2424
2699
|
await this.fileCommentManager.adoptAISuggestion(zone, suggestion);
|
|
2425
2700
|
return;
|
|
2426
2701
|
}
|
|
2427
2702
|
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
const newComment = await this.createUserCommentFromSuggestion(
|
|
2431
|
-
suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side
|
|
2432
|
-
);
|
|
2433
|
-
|
|
2434
|
-
this.displayUserComment(newComment, suggestionRow);
|
|
2435
|
-
|
|
2436
|
-
// Notify AI Panel about the new adopted comment
|
|
2437
|
-
if (window.aiPanel?.addComment) {
|
|
2438
|
-
window.aiPanel.addComment(newComment);
|
|
2439
|
-
}
|
|
2440
|
-
|
|
2441
|
-
if (this.suggestionNavigator?.suggestions) {
|
|
2442
|
-
const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
|
|
2443
|
-
s.id === suggestionId ? { ...s, status: 'adopted' } : s
|
|
2444
|
-
);
|
|
2445
|
-
this.suggestionNavigator.updateSuggestions(updatedSuggestions);
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
if (window.aiPanel) {
|
|
2449
|
-
window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
this.updateCommentCount();
|
|
2703
|
+
this.displayUserComment(result.newComment, result.suggestionRow);
|
|
2704
|
+
this._notifyAdoption(suggestionId, result.newComment);
|
|
2453
2705
|
} catch (error) {
|
|
2454
2706
|
console.error('Error adopting suggestion:', error);
|
|
2455
2707
|
alert(`Failed to adopt suggestion: ${error.message}`);
|
|
@@ -2480,7 +2732,7 @@ class PRManager {
|
|
|
2480
2732
|
return;
|
|
2481
2733
|
}
|
|
2482
2734
|
|
|
2483
|
-
const response = await fetch(`/api/
|
|
2735
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/suggestions/${suggestionId}/status`, {
|
|
2484
2736
|
method: 'POST',
|
|
2485
2737
|
headers: { 'Content-Type': 'application/json' },
|
|
2486
2738
|
body: JSON.stringify({ status: 'dismissed' })
|
|
@@ -2536,7 +2788,7 @@ class PRManager {
|
|
|
2536
2788
|
return;
|
|
2537
2789
|
}
|
|
2538
2790
|
|
|
2539
|
-
const response = await fetch(`/api/
|
|
2791
|
+
const response = await fetch(`/api/reviews/${this.currentPR.id}/suggestions/${suggestionId}/status`, {
|
|
2540
2792
|
method: 'POST',
|
|
2541
2793
|
headers: { 'Content-Type': 'application/json' },
|
|
2542
2794
|
body: JSON.stringify({ status: 'active' })
|
|
@@ -2815,15 +3067,10 @@ class PRManager {
|
|
|
2815
3067
|
return;
|
|
2816
3068
|
}
|
|
2817
3069
|
|
|
2818
|
-
//
|
|
3070
|
+
// Use unified review comments API (works for both PR and local mode)
|
|
3071
|
+
const reviewId = pr.id;
|
|
2819
3072
|
let response;
|
|
2820
|
-
|
|
2821
|
-
// Local mode - use local API endpoint
|
|
2822
|
-
response = await fetch(`/api/local/${window.localManager.reviewId}/user-comments`);
|
|
2823
|
-
} else {
|
|
2824
|
-
// PR mode - use PR API endpoint
|
|
2825
|
-
response = await fetch(`/api/pr/${pr.owner}/${pr.repo}/${pr.number}/user-comments`);
|
|
2826
|
-
}
|
|
3073
|
+
response = await fetch(`/api/reviews/${reviewId}/comments`);
|
|
2827
3074
|
|
|
2828
3075
|
if (!response.ok) {
|
|
2829
3076
|
throw new Error('Failed to load comments');
|
|
@@ -2924,6 +3171,11 @@ class PRManager {
|
|
|
2924
3171
|
generated: file.generated || false,
|
|
2925
3172
|
renamed: file.renamed || false,
|
|
2926
3173
|
renamedFrom: file.renamedFrom || null,
|
|
3174
|
+
contextFile: file.contextFile || false,
|
|
3175
|
+
contextId: file.contextId || null,
|
|
3176
|
+
label: file.label || null,
|
|
3177
|
+
lineStart: file.lineStart || null,
|
|
3178
|
+
lineEnd: file.lineEnd || null,
|
|
2927
3179
|
});
|
|
2928
3180
|
});
|
|
2929
3181
|
|
|
@@ -2938,6 +3190,9 @@ class PRManager {
|
|
|
2938
3190
|
const fileListContainer = document.getElementById('file-list');
|
|
2939
3191
|
if (!fileListContainer) return;
|
|
2940
3192
|
|
|
3193
|
+
// Store diff-only files for merging with context files later
|
|
3194
|
+
this.diffFiles = files.filter(f => !f.contextFile);
|
|
3195
|
+
|
|
2941
3196
|
// Update sidebar file count badge
|
|
2942
3197
|
const fileCountEl = document.getElementById('sidebar-file-count');
|
|
2943
3198
|
if (fileCountEl) {
|
|
@@ -3008,6 +3263,7 @@ class PRManager {
|
|
|
3008
3263
|
item.dataset.status = file.status;
|
|
3009
3264
|
|
|
3010
3265
|
if (file.generated) item.classList.add('generated');
|
|
3266
|
+
if (file.contextFile) item.classList.add('context-file-item');
|
|
3011
3267
|
if (file.renamed && file.renamedFrom) {
|
|
3012
3268
|
item.title = `Renamed from: ${file.renamedFrom}`;
|
|
3013
3269
|
const renameIcon = document.createElement('span');
|
|
@@ -3023,7 +3279,13 @@ class PRManager {
|
|
|
3023
3279
|
const changes = document.createElement('span');
|
|
3024
3280
|
changes.className = 'file-changes';
|
|
3025
3281
|
|
|
3026
|
-
if (file.
|
|
3282
|
+
if (file.contextFile) {
|
|
3283
|
+
const badge = document.createElement('span');
|
|
3284
|
+
badge.className = 'context-badge';
|
|
3285
|
+
badge.textContent = 'CONTEXT';
|
|
3286
|
+
if (file.label) badge.title = file.label;
|
|
3287
|
+
changes.appendChild(badge);
|
|
3288
|
+
} else if (file.binary) {
|
|
3027
3289
|
changes.textContent = 'BIN';
|
|
3028
3290
|
} else {
|
|
3029
3291
|
if (file.additions > 0) {
|
|
@@ -3045,7 +3307,11 @@ class PRManager {
|
|
|
3045
3307
|
|
|
3046
3308
|
item.addEventListener('click', (e) => {
|
|
3047
3309
|
e.preventDefault();
|
|
3048
|
-
|
|
3310
|
+
if (file.contextFile) {
|
|
3311
|
+
this.scrollToContextFile(file.fullPath, file.lineStart, file.contextId);
|
|
3312
|
+
} else {
|
|
3313
|
+
this.scrollToFile(file.fullPath);
|
|
3314
|
+
}
|
|
3049
3315
|
this.setActiveFileItem(file.fullPath);
|
|
3050
3316
|
});
|
|
3051
3317
|
|
|
@@ -3377,8 +3643,9 @@ class PRManager {
|
|
|
3377
3643
|
if (!this.currentPR) return;
|
|
3378
3644
|
|
|
3379
3645
|
try {
|
|
3380
|
-
const
|
|
3381
|
-
|
|
3646
|
+
const reviewId = this.currentPR.id;
|
|
3647
|
+
if (!reviewId) return;
|
|
3648
|
+
const response = await fetch(`/api/reviews/${reviewId}/analyses/status`);
|
|
3382
3649
|
|
|
3383
3650
|
if (!response.ok) {
|
|
3384
3651
|
console.warn('Could not check analysis status:', response.statusText);
|
|
@@ -3664,7 +3931,7 @@ class PRManager {
|
|
|
3664
3931
|
// Determine endpoint and body based on whether this is a council analysis
|
|
3665
3932
|
let analyzeUrl, analyzeBody;
|
|
3666
3933
|
if (config.isCouncil) {
|
|
3667
|
-
analyzeUrl = `/api/
|
|
3934
|
+
analyzeUrl = `/api/pr/${owner}/${repo}/${number}/analyses/council`;
|
|
3668
3935
|
analyzeBody = {
|
|
3669
3936
|
councilId: config.councilId || undefined,
|
|
3670
3937
|
councilConfig: config.councilConfig || undefined,
|
|
@@ -3672,7 +3939,7 @@ class PRManager {
|
|
|
3672
3939
|
customInstructions: config.customInstructions || null
|
|
3673
3940
|
};
|
|
3674
3941
|
} else {
|
|
3675
|
-
analyzeUrl = `/api/
|
|
3942
|
+
analyzeUrl = `/api/pr/${owner}/${repo}/${number}/analyses`;
|
|
3676
3943
|
analyzeBody = {
|
|
3677
3944
|
provider: config.provider || 'claude',
|
|
3678
3945
|
model: config.model || 'opus',
|
|
@@ -3839,6 +4106,589 @@ class PRManager {
|
|
|
3839
4106
|
}
|
|
3840
4107
|
}
|
|
3841
4108
|
}
|
|
4109
|
+
|
|
4110
|
+
// ─── Context Files ──────────────────────────────────────────────
|
|
4111
|
+
|
|
4112
|
+
/**
|
|
4113
|
+
* Load context files for the current review and render them in the diff panel.
|
|
4114
|
+
* Called after renderDiff() and on SSE context_files_changed events.
|
|
4115
|
+
*/
|
|
4116
|
+
async loadContextFiles() {
|
|
4117
|
+
const reviewId = this.currentPR?.id;
|
|
4118
|
+
if (!reviewId) return;
|
|
4119
|
+
|
|
4120
|
+
try {
|
|
4121
|
+
const response = await fetch(`/api/reviews/${reviewId}/context-files`);
|
|
4122
|
+
if (!response.ok) return;
|
|
4123
|
+
|
|
4124
|
+
const data = await response.json();
|
|
4125
|
+
const newFiles = data.contextFiles || [];
|
|
4126
|
+
|
|
4127
|
+
const oldIds = new Set((this.contextFiles || []).map(f => f.id));
|
|
4128
|
+
const newIds = new Set(newFiles.map(f => f.id));
|
|
4129
|
+
|
|
4130
|
+
// Remove only deleted context files (handles both standalone and merged wrappers)
|
|
4131
|
+
for (const old of this.contextFiles || []) {
|
|
4132
|
+
if (!newIds.has(old.id)) {
|
|
4133
|
+
const el = document.querySelector(`[data-context-id="${old.id}"]`);
|
|
4134
|
+
if (!el) continue;
|
|
4135
|
+
if (el.classList.contains('context-file')) {
|
|
4136
|
+
// Standalone wrapper (legacy) — remove entirely
|
|
4137
|
+
el.remove();
|
|
4138
|
+
} else {
|
|
4139
|
+
// Chunk tbody within a merged wrapper
|
|
4140
|
+
const wrapper = el.closest('.context-file');
|
|
4141
|
+
// Also remove adjacent separator tbody if present
|
|
4142
|
+
const prevSib = el.previousElementSibling;
|
|
4143
|
+
const nextSib = el.nextElementSibling;
|
|
4144
|
+
if (prevSib && prevSib.classList.contains('context-chunk-separator')) {
|
|
4145
|
+
prevSib.remove();
|
|
4146
|
+
} else if (nextSib && nextSib.classList.contains('context-chunk-separator')) {
|
|
4147
|
+
nextSib.remove();
|
|
4148
|
+
}
|
|
4149
|
+
el.remove();
|
|
4150
|
+
// If no more chunks remain, remove the wrapper too
|
|
4151
|
+
if (wrapper && !wrapper.querySelector('.context-chunk')) {
|
|
4152
|
+
wrapper.remove();
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
// Add only new context files
|
|
4159
|
+
let newFilesRendered = false;
|
|
4160
|
+
for (const cf of newFiles) {
|
|
4161
|
+
if (!oldIds.has(cf.id)) {
|
|
4162
|
+
await this.renderContextFile(cf);
|
|
4163
|
+
newFilesRendered = true;
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
this.contextFiles = newFiles;
|
|
4168
|
+
|
|
4169
|
+
// Rebuild sidebar with context files interleaved in natural path order
|
|
4170
|
+
this.rebuildFileListWithContext();
|
|
4171
|
+
|
|
4172
|
+
// Re-anchor comments after new context files are rendered so that
|
|
4173
|
+
// comments targeting lines in these files find their DOM targets.
|
|
4174
|
+
// loadUserComments() is idempotent (clears existing comment rows first).
|
|
4175
|
+
if (newFilesRendered) {
|
|
4176
|
+
const includeDismissed = window.aiPanel?.showDismissedComments || false;
|
|
4177
|
+
await this.loadUserComments(includeDismissed);
|
|
4178
|
+
}
|
|
4179
|
+
} catch (error) {
|
|
4180
|
+
console.error('Error loading context files:', error);
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
/**
|
|
4185
|
+
* Rebuild the sidebar file list with context files interleaved in natural path order.
|
|
4186
|
+
* Merges stored diff files with current context files and re-renders the sidebar.
|
|
4187
|
+
* Delegates to the shared FileListMerger module for the merge/sort logic.
|
|
4188
|
+
*/
|
|
4189
|
+
rebuildFileListWithContext() {
|
|
4190
|
+
const { mergeFileListWithContext } = window.FileListMerger || {};
|
|
4191
|
+
if (!mergeFileListWithContext) {
|
|
4192
|
+
console.warn('FileListMerger not loaded - cannot rebuild file list with context');
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
const merged = mergeFileListWithContext(this.diffFiles, this.contextFiles);
|
|
4196
|
+
this.updateFileList(merged);
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
/**
|
|
4200
|
+
* Build a context chunk tbody with line rows for a context file range.
|
|
4201
|
+
* @param {Object} data - { lines: string[] } from fetchFileContent
|
|
4202
|
+
* @param {Object} contextFile - { id, file, line_start, line_end }
|
|
4203
|
+
* @returns {HTMLElement} tbody element with class context-chunk
|
|
4204
|
+
* @private
|
|
4205
|
+
*/
|
|
4206
|
+
_buildContextChunkTbody(data, contextFile) {
|
|
4207
|
+
const tbody = document.createElement('tbody');
|
|
4208
|
+
tbody.className = 'd2h-diff-tbody context-chunk';
|
|
4209
|
+
tbody.dataset.contextId = contextFile.id;
|
|
4210
|
+
tbody.dataset.lineStart = contextFile.line_start;
|
|
4211
|
+
|
|
4212
|
+
// Chunk header row with range label and per-chunk dismiss button
|
|
4213
|
+
const headerRow = document.createElement('tr');
|
|
4214
|
+
headerRow.className = 'context-chunk-header';
|
|
4215
|
+
const lineNumTd = document.createElement('td');
|
|
4216
|
+
lineNumTd.className = 'd2h-code-linenumber';
|
|
4217
|
+
headerRow.appendChild(lineNumTd);
|
|
4218
|
+
const contentTd = document.createElement('td');
|
|
4219
|
+
contentTd.className = 'd2h-code-side-line';
|
|
4220
|
+
contentTd.colSpan = 3;
|
|
4221
|
+
const rangeLabel = document.createElement('span');
|
|
4222
|
+
rangeLabel.className = 'context-range-label';
|
|
4223
|
+
const lineEnd = Math.min(contextFile.line_end, data.lines.length);
|
|
4224
|
+
rangeLabel.textContent = `Lines ${contextFile.line_start}\u2013${lineEnd}`;
|
|
4225
|
+
contentTd.appendChild(rangeLabel);
|
|
4226
|
+
const chunkDismiss = document.createElement('button');
|
|
4227
|
+
chunkDismiss.className = 'context-chunk-dismiss';
|
|
4228
|
+
chunkDismiss.title = 'Remove this range';
|
|
4229
|
+
chunkDismiss.innerHTML = '\u00d7';
|
|
4230
|
+
chunkDismiss.addEventListener('click', (e) => {
|
|
4231
|
+
e.stopPropagation();
|
|
4232
|
+
this.removeContextFile(contextFile.id);
|
|
4233
|
+
});
|
|
4234
|
+
contentTd.appendChild(chunkDismiss);
|
|
4235
|
+
headerRow.appendChild(contentTd);
|
|
4236
|
+
tbody.appendChild(headerRow);
|
|
4237
|
+
|
|
4238
|
+
const lineStart = contextFile.line_start;
|
|
4239
|
+
const clampedEnd = Math.min(contextFile.line_end, data.lines.length);
|
|
4240
|
+
|
|
4241
|
+
// Add expand-up gap row if there are lines above the context range
|
|
4242
|
+
if (lineStart > 1) {
|
|
4243
|
+
const gapAboveSize = lineStart - 1;
|
|
4244
|
+
const gapAbove = window.HunkParser.createGapRowElement(
|
|
4245
|
+
contextFile.file,
|
|
4246
|
+
1, // startLine (old coords)
|
|
4247
|
+
lineStart - 1, // endLine (old coords)
|
|
4248
|
+
gapAboveSize,
|
|
4249
|
+
'above',
|
|
4250
|
+
this.expandGapContext.bind(this),
|
|
4251
|
+
1 // startLineNew (same as old for context files — no diff offset)
|
|
4252
|
+
);
|
|
4253
|
+
tbody.appendChild(gapAbove);
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
for (let i = lineStart; i <= clampedEnd; i++) {
|
|
4257
|
+
const lineData = {
|
|
4258
|
+
type: 'context',
|
|
4259
|
+
oldNumber: i,
|
|
4260
|
+
newNumber: i,
|
|
4261
|
+
content: ' ' + (data.lines[i - 1] || '')
|
|
4262
|
+
};
|
|
4263
|
+
this.renderDiffLine(tbody, lineData, contextFile.file, null);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
// Add expand-down gap row if there are lines below the context range
|
|
4267
|
+
const totalLines = data.lines.length;
|
|
4268
|
+
if (clampedEnd < totalLines) {
|
|
4269
|
+
const gapBelowSize = totalLines - clampedEnd;
|
|
4270
|
+
const gapBelow = window.HunkParser.createGapRowElement(
|
|
4271
|
+
contextFile.file,
|
|
4272
|
+
clampedEnd + 1, // startLine (old coords)
|
|
4273
|
+
totalLines, // endLine (old coords)
|
|
4274
|
+
gapBelowSize,
|
|
4275
|
+
'below',
|
|
4276
|
+
this.expandGapContext.bind(this),
|
|
4277
|
+
clampedEnd + 1 // startLineNew (same as old)
|
|
4278
|
+
);
|
|
4279
|
+
tbody.appendChild(gapBelow);
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
return tbody;
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
/**
|
|
4286
|
+
* Insert a chunk tbody into an existing table in sorted position by line_start.
|
|
4287
|
+
* Adds a visual separator tbody between non-contiguous ranges.
|
|
4288
|
+
* @param {HTMLElement} table - the d2h-diff-table
|
|
4289
|
+
* @param {HTMLElement} newTbody - the context-chunk tbody to insert
|
|
4290
|
+
* @private
|
|
4291
|
+
*/
|
|
4292
|
+
_insertChunkSorted(table, newTbody) {
|
|
4293
|
+
const newStart = parseInt(newTbody.dataset.lineStart, 10);
|
|
4294
|
+
const existingChunks = [...table.querySelectorAll('tbody.context-chunk')];
|
|
4295
|
+
|
|
4296
|
+
// Find insertion point
|
|
4297
|
+
let insertBeforeChunk = null;
|
|
4298
|
+
for (const chunk of existingChunks) {
|
|
4299
|
+
const chunkStart = parseInt(chunk.dataset.lineStart, 10);
|
|
4300
|
+
if (chunkStart > newStart) {
|
|
4301
|
+
insertBeforeChunk = chunk;
|
|
4302
|
+
break;
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
// Determine the element to insert before (including any separator before it)
|
|
4307
|
+
if (insertBeforeChunk) {
|
|
4308
|
+
const prevSibling = insertBeforeChunk.previousElementSibling;
|
|
4309
|
+
const hasSepBefore = prevSibling && prevSibling.classList.contains('context-chunk-separator');
|
|
4310
|
+
if (hasSepBefore) {
|
|
4311
|
+
table.insertBefore(newTbody, prevSibling);
|
|
4312
|
+
const sep = this._createChunkSeparator();
|
|
4313
|
+
table.insertBefore(sep, newTbody);
|
|
4314
|
+
} else {
|
|
4315
|
+
table.insertBefore(newTbody, insertBeforeChunk);
|
|
4316
|
+
const sep = this._createChunkSeparator();
|
|
4317
|
+
table.insertBefore(sep, insertBeforeChunk);
|
|
4318
|
+
}
|
|
4319
|
+
} else {
|
|
4320
|
+
// Append after the last chunk — add separator before if there are existing chunks
|
|
4321
|
+
if (existingChunks.length > 0) {
|
|
4322
|
+
const sep = this._createChunkSeparator();
|
|
4323
|
+
table.appendChild(sep);
|
|
4324
|
+
}
|
|
4325
|
+
table.appendChild(newTbody);
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
/**
|
|
4330
|
+
* Create a visual separator tbody between context chunks.
|
|
4331
|
+
* @returns {HTMLElement} tbody with a single separator row
|
|
4332
|
+
* @private
|
|
4333
|
+
*/
|
|
4334
|
+
_createChunkSeparator() {
|
|
4335
|
+
const sep = document.createElement('tbody');
|
|
4336
|
+
sep.className = 'context-chunk-separator';
|
|
4337
|
+
const row = document.createElement('tr');
|
|
4338
|
+
row.className = 'context-chunk-separator-row';
|
|
4339
|
+
const td = document.createElement('td');
|
|
4340
|
+
td.colSpan = 4;
|
|
4341
|
+
td.className = 'd2h-code-side-line context-chunk-separator-cell';
|
|
4342
|
+
row.appendChild(td);
|
|
4343
|
+
sep.appendChild(row);
|
|
4344
|
+
return sep;
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
/**
|
|
4348
|
+
* Render a single context file range in the diff panel.
|
|
4349
|
+
* Merges ranges for the same file into a single wrapper with multiple chunk tbodies.
|
|
4350
|
+
* @param {Object} contextFile - { id, review_id, file, line_start, line_end, label }
|
|
4351
|
+
*/
|
|
4352
|
+
async renderContextFile(contextFile) {
|
|
4353
|
+
const diffContainer = document.getElementById('diff-container');
|
|
4354
|
+
if (!diffContainer) return;
|
|
4355
|
+
|
|
4356
|
+
// Fetch file content
|
|
4357
|
+
const data = await this.fetchFileContent(contextFile.file);
|
|
4358
|
+
if (!data || !data.lines) return;
|
|
4359
|
+
|
|
4360
|
+
// Check if a wrapper already exists for this file
|
|
4361
|
+
const existing = diffContainer.querySelector(
|
|
4362
|
+
`.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(contextFile.file)}"]`
|
|
4363
|
+
);
|
|
4364
|
+
|
|
4365
|
+
if (existing) {
|
|
4366
|
+
// Merge into existing wrapper — add a new chunk tbody
|
|
4367
|
+
const table = existing.querySelector('.d2h-diff-table');
|
|
4368
|
+
if (!table) return;
|
|
4369
|
+
const newTbody = this._buildContextChunkTbody(data, contextFile);
|
|
4370
|
+
this._insertChunkSorted(table, newTbody);
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// No existing wrapper — create a new one
|
|
4375
|
+
const wrapper = document.createElement('div');
|
|
4376
|
+
wrapper.className = 'd2h-file-wrapper context-file';
|
|
4377
|
+
wrapper.dataset.fileName = contextFile.file;
|
|
4378
|
+
|
|
4379
|
+
// Build file header — matches regular diff headers (chevron, viewed, comment btn, chat btn)
|
|
4380
|
+
const header = document.createElement('div');
|
|
4381
|
+
header.className = 'd2h-file-header context-file-header';
|
|
4382
|
+
|
|
4383
|
+
// Chevron toggle for expand/collapse
|
|
4384
|
+
const chevronBtn = document.createElement('button');
|
|
4385
|
+
chevronBtn.className = 'file-collapse-toggle';
|
|
4386
|
+
chevronBtn.title = 'Collapse file';
|
|
4387
|
+
chevronBtn.innerHTML = window.DiffRenderer.CHEVRON_DOWN_ICON;
|
|
4388
|
+
chevronBtn.addEventListener('click', (e) => {
|
|
4389
|
+
e.stopPropagation();
|
|
4390
|
+
this.toggleFileCollapse(contextFile.file);
|
|
4391
|
+
});
|
|
4392
|
+
header.appendChild(chevronBtn);
|
|
4393
|
+
|
|
4394
|
+
const fileName = document.createElement('span');
|
|
4395
|
+
fileName.className = 'd2h-file-name';
|
|
4396
|
+
fileName.textContent = contextFile.file;
|
|
4397
|
+
header.appendChild(fileName);
|
|
4398
|
+
|
|
4399
|
+
const contextLabel = document.createElement('span');
|
|
4400
|
+
contextLabel.className = 'context-badge';
|
|
4401
|
+
contextLabel.textContent = 'CONTEXT';
|
|
4402
|
+
if (contextFile.label) contextLabel.title = contextFile.label;
|
|
4403
|
+
header.appendChild(contextLabel);
|
|
4404
|
+
|
|
4405
|
+
// Viewed checkbox (right-aligned group start)
|
|
4406
|
+
const viewedLabel = document.createElement('label');
|
|
4407
|
+
viewedLabel.className = 'file-viewed-label';
|
|
4408
|
+
viewedLabel.title = 'Mark file as viewed';
|
|
4409
|
+
const viewedCheckbox = document.createElement('input');
|
|
4410
|
+
viewedCheckbox.type = 'checkbox';
|
|
4411
|
+
viewedCheckbox.className = 'file-viewed-checkbox';
|
|
4412
|
+
viewedCheckbox.checked = this.viewedFiles.has(contextFile.file);
|
|
4413
|
+
viewedCheckbox.addEventListener('change', (e) => {
|
|
4414
|
+
e.stopPropagation();
|
|
4415
|
+
this.toggleFileViewed(contextFile.file, viewedCheckbox.checked);
|
|
4416
|
+
});
|
|
4417
|
+
viewedLabel.appendChild(viewedCheckbox);
|
|
4418
|
+
viewedLabel.appendChild(document.createTextNode('Viewed'));
|
|
4419
|
+
header.appendChild(viewedLabel);
|
|
4420
|
+
|
|
4421
|
+
// File comment button
|
|
4422
|
+
if (this.fileCommentManager) {
|
|
4423
|
+
const fileCommentsZone = this.fileCommentManager.createFileCommentsZone(contextFile.file);
|
|
4424
|
+
wrapper._fileCommentsZone = fileCommentsZone;
|
|
4425
|
+
|
|
4426
|
+
const fileCommentBtn = document.createElement('button');
|
|
4427
|
+
fileCommentBtn.className = 'file-header-comment-btn';
|
|
4428
|
+
fileCommentBtn.title = 'Add file comment';
|
|
4429
|
+
fileCommentBtn.dataset.file = contextFile.file;
|
|
4430
|
+
fileCommentBtn.innerHTML = `
|
|
4431
|
+
<svg class="comment-icon-outline" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
4432
|
+
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.5 0v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75a.25.25 0 0 0-.25.25Z"/>
|
|
4433
|
+
</svg>
|
|
4434
|
+
<svg class="comment-icon-filled" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display:none">
|
|
4435
|
+
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5Z"/>
|
|
4436
|
+
</svg>
|
|
4437
|
+
`;
|
|
4438
|
+
fileCommentBtn.addEventListener('click', (e) => {
|
|
4439
|
+
e.stopPropagation();
|
|
4440
|
+
this.fileCommentManager.showCommentForm(fileCommentsZone, contextFile.file);
|
|
4441
|
+
});
|
|
4442
|
+
header.appendChild(fileCommentBtn);
|
|
4443
|
+
fileCommentsZone.headerButton = fileCommentBtn;
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
// Chat/discussion button
|
|
4447
|
+
const fileChatBtn = document.createElement('button');
|
|
4448
|
+
fileChatBtn.className = 'file-header-chat-btn';
|
|
4449
|
+
fileChatBtn.title = 'Chat about file';
|
|
4450
|
+
fileChatBtn.dataset.file = contextFile.file;
|
|
4451
|
+
fileChatBtn.innerHTML = `
|
|
4452
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
4453
|
+
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
4454
|
+
</svg>
|
|
4455
|
+
`;
|
|
4456
|
+
fileChatBtn.addEventListener('click', (e) => {
|
|
4457
|
+
e.stopPropagation();
|
|
4458
|
+
if (window.chatPanel) {
|
|
4459
|
+
window.chatPanel.open({ fileContext: { file: contextFile.file } });
|
|
4460
|
+
}
|
|
4461
|
+
});
|
|
4462
|
+
header.appendChild(fileChatBtn);
|
|
4463
|
+
|
|
4464
|
+
// Dismiss button — removes ALL context ranges for this file
|
|
4465
|
+
const dismissBtn = document.createElement('button');
|
|
4466
|
+
dismissBtn.className = 'context-file-dismiss';
|
|
4467
|
+
dismissBtn.title = 'Remove context file';
|
|
4468
|
+
dismissBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>`;
|
|
4469
|
+
dismissBtn.addEventListener('click', (e) => {
|
|
4470
|
+
e.stopPropagation();
|
|
4471
|
+
// Remove all context ranges for this file
|
|
4472
|
+
const fileWrapper = e.target.closest('.context-file');
|
|
4473
|
+
if (!fileWrapper) return;
|
|
4474
|
+
const chunkIds = [...fileWrapper.querySelectorAll('tbody.context-chunk[data-context-id]')]
|
|
4475
|
+
.map(tb => tb.dataset.contextId);
|
|
4476
|
+
if (chunkIds.length === 0) return;
|
|
4477
|
+
// Remove all ranges — fire sequentially to avoid race conditions
|
|
4478
|
+
const removeAll = async () => {
|
|
4479
|
+
for (const cid of chunkIds) {
|
|
4480
|
+
await this.removeContextFile(cid);
|
|
4481
|
+
}
|
|
4482
|
+
};
|
|
4483
|
+
removeAll();
|
|
4484
|
+
});
|
|
4485
|
+
header.appendChild(dismissBtn);
|
|
4486
|
+
|
|
4487
|
+
// Click anywhere on header to toggle collapse (except interactive controls)
|
|
4488
|
+
header.addEventListener('click', (e) => {
|
|
4489
|
+
if (e.target.closest('.file-viewed-label') || e.target.closest('.file-collapse-toggle') ||
|
|
4490
|
+
e.target.closest('.file-header-comment-btn') || e.target.closest('.file-header-chat-btn') ||
|
|
4491
|
+
e.target.closest('.context-file-dismiss')) {
|
|
4492
|
+
return;
|
|
4493
|
+
}
|
|
4494
|
+
this.toggleFileCollapse(contextFile.file);
|
|
4495
|
+
});
|
|
4496
|
+
|
|
4497
|
+
wrapper.appendChild(header);
|
|
4498
|
+
|
|
4499
|
+
// Insert file comments zone between header and diff content
|
|
4500
|
+
if (wrapper._fileCommentsZone) {
|
|
4501
|
+
wrapper.appendChild(wrapper._fileCommentsZone);
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
// Build code table with the first chunk
|
|
4505
|
+
const table = document.createElement('table');
|
|
4506
|
+
table.className = 'd2h-diff-table';
|
|
4507
|
+
const tbody = this._buildContextChunkTbody(data, contextFile);
|
|
4508
|
+
table.appendChild(tbody);
|
|
4509
|
+
wrapper.appendChild(table);
|
|
4510
|
+
|
|
4511
|
+
// Insert in sorted path order among existing file wrappers
|
|
4512
|
+
const allWrappers = [...diffContainer.querySelectorAll('.d2h-file-wrapper')];
|
|
4513
|
+
const insertBefore = allWrappers.find(w => w.dataset.fileName > contextFile.file);
|
|
4514
|
+
if (insertBefore) {
|
|
4515
|
+
diffContainer.insertBefore(wrapper, insertBefore);
|
|
4516
|
+
} else {
|
|
4517
|
+
diffContainer.appendChild(wrapper);
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
/**
|
|
4522
|
+
* Remove a context file by ID.
|
|
4523
|
+
* @param {number} contextFileId
|
|
4524
|
+
*/
|
|
4525
|
+
async removeContextFile(contextFileId) {
|
|
4526
|
+
const reviewId = this.currentPR?.id;
|
|
4527
|
+
if (!reviewId) return;
|
|
4528
|
+
|
|
4529
|
+
try {
|
|
4530
|
+
const resp = await fetch(`/api/reviews/${reviewId}/context-files/${contextFileId}`, {
|
|
4531
|
+
method: 'DELETE',
|
|
4532
|
+
headers: { 'Content-Type': 'application/json' }
|
|
4533
|
+
});
|
|
4534
|
+
if (!resp.ok) {
|
|
4535
|
+
console.error('Failed to remove context file:', resp.status);
|
|
4536
|
+
return;
|
|
4537
|
+
}
|
|
4538
|
+
// Refresh immediately — SSE self-echo is suppressed by the client ID filter
|
|
4539
|
+
await this.loadContextFiles();
|
|
4540
|
+
} catch (error) {
|
|
4541
|
+
console.error('Error removing context file:', error);
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
/**
|
|
4546
|
+
* Scroll to a context file (or diff file) in the diff panel.
|
|
4547
|
+
* @param {string} file - File path
|
|
4548
|
+
* @param {number} [lineStart] - Optional line number to highlight
|
|
4549
|
+
*/
|
|
4550
|
+
scrollToContextFile(file, lineStart, contextId) {
|
|
4551
|
+
// Use contextId to find a specific chunk tbody within a merged wrapper,
|
|
4552
|
+
// or fall back to a standalone wrapper or the file-level wrapper.
|
|
4553
|
+
let target;
|
|
4554
|
+
if (contextId) {
|
|
4555
|
+
// First try finding a specific chunk tbody (merged wrapper case)
|
|
4556
|
+
const chunk = document.querySelector(`.context-chunk[data-context-id="${CSS.escape(contextId)}"]`);
|
|
4557
|
+
if (chunk) {
|
|
4558
|
+
target = chunk;
|
|
4559
|
+
} else {
|
|
4560
|
+
// Fallback: legacy standalone wrapper with data-context-id on the wrapper itself
|
|
4561
|
+
target = document.querySelector(`.d2h-file-wrapper.context-file[data-context-id="${CSS.escape(contextId)}"]`);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
if (!target) {
|
|
4565
|
+
target = document.querySelector(`.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(file)}"]`);
|
|
4566
|
+
}
|
|
4567
|
+
if (!target) return;
|
|
4568
|
+
|
|
4569
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4570
|
+
|
|
4571
|
+
if (lineStart) {
|
|
4572
|
+
// Search for the line row within the wrapper (not just the target chunk)
|
|
4573
|
+
const wrapper = target.closest('.d2h-file-wrapper') || target;
|
|
4574
|
+
// Brief delay to let scroll settle, then highlight the target line
|
|
4575
|
+
setTimeout(() => {
|
|
4576
|
+
const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
4577
|
+
if (row) {
|
|
4578
|
+
row.classList.remove('chat-line-highlight');
|
|
4579
|
+
void row.offsetWidth;
|
|
4580
|
+
row.classList.add('chat-line-highlight');
|
|
4581
|
+
row.addEventListener('animationend', () => {
|
|
4582
|
+
row.classList.remove('chat-line-highlight');
|
|
4583
|
+
}, { once: true });
|
|
4584
|
+
}
|
|
4585
|
+
}, 400);
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4589
|
+
async ensureContextFile(file, lineStart = null, lineEnd = null) {
|
|
4590
|
+
// 1. Guard: no review loaded
|
|
4591
|
+
if (!this.currentPR?.id) return null;
|
|
4592
|
+
|
|
4593
|
+
// 2. Check diff files
|
|
4594
|
+
if (this.diffFiles?.some(f => f.file === file)) {
|
|
4595
|
+
return { type: 'diff' };
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
// 3. Compute line range values up front (used by both existing-check and POST)
|
|
4599
|
+
let lineStartVal, lineEndVal;
|
|
4600
|
+
if (lineStart == null && lineEnd == null) {
|
|
4601
|
+
lineStartVal = 1;
|
|
4602
|
+
lineEndVal = 100;
|
|
4603
|
+
} else if (lineEnd == null) {
|
|
4604
|
+
lineStartVal = lineStart;
|
|
4605
|
+
lineEndVal = lineStart + 49;
|
|
4606
|
+
} else {
|
|
4607
|
+
lineStartVal = lineStart;
|
|
4608
|
+
lineEndVal = Math.min(lineEnd, lineStart + 499);
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
// 4. Check existing context files — expand range if needed
|
|
4612
|
+
const existingEntries = this.contextFiles?.filter(cf => cf.file === file) || [];
|
|
4613
|
+
if (existingEntries.length > 0 && lineStart != null) {
|
|
4614
|
+
const covering = existingEntries.find(cf =>
|
|
4615
|
+
cf.line_start <= lineStartVal && cf.line_end >= lineEndVal
|
|
4616
|
+
);
|
|
4617
|
+
if (covering) {
|
|
4618
|
+
return { type: 'context', contextFile: covering };
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
const overlapping = existingEntries.find(cf =>
|
|
4622
|
+
cf.line_start <= lineEndVal && cf.line_end >= lineStartVal
|
|
4623
|
+
);
|
|
4624
|
+
if (overlapping) {
|
|
4625
|
+
const newStart = Math.min(overlapping.line_start, lineStartVal);
|
|
4626
|
+
let newEnd = Math.max(overlapping.line_end, lineEndVal);
|
|
4627
|
+
if (newEnd - newStart + 1 > 500) {
|
|
4628
|
+
newEnd = newStart + 499;
|
|
4629
|
+
}
|
|
4630
|
+
const reviewId = this.currentPR.id;
|
|
4631
|
+
try {
|
|
4632
|
+
const resp = await fetch(`/api/reviews/${reviewId}/context-files/${overlapping.id}`, {
|
|
4633
|
+
method: 'PATCH',
|
|
4634
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4635
|
+
body: JSON.stringify({ line_start: newStart, line_end: newEnd })
|
|
4636
|
+
});
|
|
4637
|
+
if (resp.ok) {
|
|
4638
|
+
// Evict stale entries for this file so loadContextFiles sees
|
|
4639
|
+
// them as new IDs and triggers a fresh render.
|
|
4640
|
+
const staleFile = overlapping.file;
|
|
4641
|
+
this.contextFiles = (this.contextFiles || []).filter(cf => cf.file !== staleFile);
|
|
4642
|
+
// Remove the file wrapper from the DOM so chunks are re-created
|
|
4643
|
+
const staleWrapper = document.querySelector(
|
|
4644
|
+
`.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(staleFile)}"]`
|
|
4645
|
+
);
|
|
4646
|
+
if (staleWrapper) staleWrapper.remove();
|
|
4647
|
+
|
|
4648
|
+
await this.loadContextFiles();
|
|
4649
|
+
const updated = this.contextFiles?.find(cf => cf.id === overlapping.id);
|
|
4650
|
+
return { type: 'context', contextFile: updated || overlapping, expanded: true };
|
|
4651
|
+
}
|
|
4652
|
+
} catch (err) {
|
|
4653
|
+
console.error('Error expanding context file range:', err);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
} else if (existingEntries.length > 0) {
|
|
4657
|
+
return { type: 'context', contextFile: existingEntries[0] };
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
// 5. POST to add context file
|
|
4661
|
+
const reviewId = this.currentPR.id;
|
|
4662
|
+
try {
|
|
4663
|
+
const resp = await fetch(`/api/reviews/${reviewId}/context-files`, {
|
|
4664
|
+
method: 'POST',
|
|
4665
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4666
|
+
body: JSON.stringify({ file, line_start: lineStartVal, line_end: lineEndVal })
|
|
4667
|
+
});
|
|
4668
|
+
|
|
4669
|
+
if (resp.status === 201) {
|
|
4670
|
+
// 6. Reload context files to render
|
|
4671
|
+
await this.loadContextFiles();
|
|
4672
|
+
const added = this.contextFiles?.find(cf => cf.file === file);
|
|
4673
|
+
return { type: 'context', contextFile: added || null };
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4676
|
+
if (resp.status === 400) {
|
|
4677
|
+
const data = await resp.json().catch(() => ({}));
|
|
4678
|
+
if (data.error?.includes('already part of the diff')) {
|
|
4679
|
+
return { type: 'diff' };
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
// 7. Other errors
|
|
4684
|
+
console.error('Failed to add context file:', resp.status);
|
|
4685
|
+
return null;
|
|
4686
|
+
} catch (err) {
|
|
4687
|
+
console.error('Error adding context file:', err);
|
|
4688
|
+
return null;
|
|
4689
|
+
}
|
|
4690
|
+
}
|
|
4691
|
+
|
|
3842
4692
|
}
|
|
3843
4693
|
|
|
3844
4694
|
// Initialize PR manager when DOM is loaded (browser environment only)
|