@in-the-loop-labs/pair-review 1.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/LICENSE +674 -0
- package/README.md +371 -0
- package/bin/git-diff-lines +146 -0
- package/bin/pair-review.js +49 -0
- package/package.json +71 -0
- package/public/css/ai-summary-modal.css +183 -0
- package/public/css/pr.css +8698 -0
- package/public/css/repo-settings.css +891 -0
- package/public/css/styles.css +479 -0
- package/public/favicon.png +0 -0
- package/public/index.html +1104 -0
- package/public/js/components/AIPanel.js +1639 -0
- package/public/js/components/AISummaryModal.js +278 -0
- package/public/js/components/AnalysisConfigModal.js +684 -0
- package/public/js/components/ConfirmDialog.js +227 -0
- package/public/js/components/PreviewModal.js +344 -0
- package/public/js/components/ProgressModal.js +678 -0
- package/public/js/components/ReviewModal.js +531 -0
- package/public/js/components/SplitButton.js +382 -0
- package/public/js/components/StatusIndicator.js +265 -0
- package/public/js/components/SuggestionNavigator.js +489 -0
- package/public/js/components/Toast.js +166 -0
- package/public/js/local.js +1580 -0
- package/public/js/modules/analysis-history.js +940 -0
- package/public/js/modules/comment-manager.js +643 -0
- package/public/js/modules/diff-renderer.js +585 -0
- package/public/js/modules/file-comment-manager.js +1242 -0
- package/public/js/modules/gap-coordinates.js +190 -0
- package/public/js/modules/hunk-parser.js +358 -0
- package/public/js/modules/line-tracker.js +386 -0
- package/public/js/modules/panel-resizer.js +228 -0
- package/public/js/modules/storage-cleanup.js +36 -0
- package/public/js/modules/suggestion-manager.js +692 -0
- package/public/js/pr.js +3503 -0
- package/public/js/repo-settings.js +691 -0
- package/public/js/utils/file-order.js +87 -0
- package/public/js/utils/markdown.js +97 -0
- package/public/js/utils/suggestion-ui.js +55 -0
- package/public/js/utils/tier-icons.js +25 -0
- package/public/local.html +460 -0
- package/public/pr.html +329 -0
- package/public/repo-settings.html +243 -0
- package/src/ai/analyzer.js +2592 -0
- package/src/ai/claude-cli.js +153 -0
- package/src/ai/claude-provider.js +261 -0
- package/src/ai/codex-provider.js +361 -0
- package/src/ai/copilot-provider.js +345 -0
- package/src/ai/gemini-provider.js +375 -0
- package/src/ai/index.js +47 -0
- package/src/ai/prompts/baseline/_meta.json +14 -0
- package/src/ai/prompts/baseline/level1/balanced.js +239 -0
- package/src/ai/prompts/baseline/level1/fast.js +194 -0
- package/src/ai/prompts/baseline/level1/thorough.js +319 -0
- package/src/ai/prompts/baseline/level2/balanced.js +248 -0
- package/src/ai/prompts/baseline/level2/fast.js +201 -0
- package/src/ai/prompts/baseline/level2/thorough.js +367 -0
- package/src/ai/prompts/baseline/level3/balanced.js +280 -0
- package/src/ai/prompts/baseline/level3/fast.js +220 -0
- package/src/ai/prompts/baseline/level3/thorough.js +459 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
- package/src/ai/prompts/config.js +52 -0
- package/src/ai/prompts/index.js +267 -0
- package/src/ai/prompts/shared/diff-instructions.js +50 -0
- package/src/ai/prompts/shared/output-schema.js +179 -0
- package/src/ai/prompts/shared/valid-files.js +37 -0
- package/src/ai/provider.js +260 -0
- package/src/config.js +139 -0
- package/src/database.js +2284 -0
- package/src/git/gitattributes.js +207 -0
- package/src/git/worktree.js +688 -0
- package/src/github/client.js +893 -0
- package/src/github/parser.js +247 -0
- package/src/local-review.js +691 -0
- package/src/main.js +987 -0
- package/src/routes/analysis.js +897 -0
- package/src/routes/comments.js +534 -0
- package/src/routes/config.js +250 -0
- package/src/routes/local.js +1728 -0
- package/src/routes/pr.js +1164 -0
- package/src/routes/shared.js +218 -0
- package/src/routes/worktrees.js +500 -0
- package/src/server.js +295 -0
- package/src/utils/diff-annotator.js +414 -0
- package/src/utils/instructions.js +33 -0
- package/src/utils/json-extractor.js +107 -0
- package/src/utils/line-validation.js +183 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/paths.js +161 -0
- package/src/utils/stats-calculator.js +86 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* FileCommentManager - File-level comment UI handling
|
|
4
|
+
* Handles file-level comments zone rendering, forms, and interactions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class FileCommentManager {
|
|
8
|
+
// Category to emoji mapping for formatting adopted comments (matches SuggestionManager)
|
|
9
|
+
static CATEGORY_EMOJI_MAP = {
|
|
10
|
+
'bug': '\u{1F41B}', // bug
|
|
11
|
+
'improvement': '\u{1F4A1}', // lightbulb
|
|
12
|
+
'suggestion': '\u{1F4AD}', // thought balloon
|
|
13
|
+
'design': '\u{1F3D7}', // building construction
|
|
14
|
+
'performance': '\u{1F680}', // rocket
|
|
15
|
+
'security': '\u{1F512}', // lock
|
|
16
|
+
'refactor': '\u{1F527}', // wrench
|
|
17
|
+
'documentation': '\u{1F4DD}', // memo
|
|
18
|
+
'test': '\u{2705}', // white heavy check mark
|
|
19
|
+
'style': '\u{1F3A8}', // artist palette
|
|
20
|
+
'chore': '\u{1F9F9}', // broom
|
|
21
|
+
'feat': '\u{2728}', // sparkles
|
|
22
|
+
'fix': '\u{1F527}' // wrench
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
constructor(prManagerRef) {
|
|
26
|
+
// Reference to parent PRManager for API calls and state access
|
|
27
|
+
this.prManager = prManagerRef;
|
|
28
|
+
// Track file-level comments by file path
|
|
29
|
+
this.fileComments = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get emoji for suggestion category
|
|
34
|
+
* @param {string} category - Category name
|
|
35
|
+
* @returns {string} Emoji character
|
|
36
|
+
*/
|
|
37
|
+
getCategoryEmoji(category) {
|
|
38
|
+
return FileCommentManager.CATEGORY_EMOJI_MAP[category] || '\u{1F4AC}';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format adopted comment text with emoji and category prefix
|
|
43
|
+
* @param {string} text - Comment text
|
|
44
|
+
* @param {string} category - Category name
|
|
45
|
+
* @returns {string} Formatted text
|
|
46
|
+
*/
|
|
47
|
+
formatAdoptedComment(text, category) {
|
|
48
|
+
if (!category) {
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
const emoji = this.getCategoryEmoji(category);
|
|
52
|
+
// Properly capitalize hyphenated categories (e.g., "code-style" -> "Code Style")
|
|
53
|
+
const capitalizedCategory = category
|
|
54
|
+
.split('-')
|
|
55
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
56
|
+
.join(' ');
|
|
57
|
+
return `${emoji} **${capitalizedCategory}**: ${text}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the appropriate API endpoint and request body for file-level comments
|
|
62
|
+
* @private
|
|
63
|
+
* @param {string} operation - Operation type: 'create', 'update', 'delete'
|
|
64
|
+
* @param {Object} options - Options object with commentId, file, body, etc.
|
|
65
|
+
* @returns {Object} Object with endpoint and requestBody
|
|
66
|
+
*/
|
|
67
|
+
_getFileCommentEndpoint(operation, options = {}) {
|
|
68
|
+
const reviewId = this.prManager?.currentPR?.id;
|
|
69
|
+
const reviewType = this.prManager?.currentPR?.reviewType;
|
|
70
|
+
const headSha = this.prManager?.currentPR?.head_sha;
|
|
71
|
+
const isLocal = reviewType === 'local';
|
|
72
|
+
|
|
73
|
+
let endpoint;
|
|
74
|
+
let requestBody = null;
|
|
75
|
+
|
|
76
|
+
switch (operation) {
|
|
77
|
+
case 'create':
|
|
78
|
+
endpoint = isLocal
|
|
79
|
+
? `/api/local/${reviewId}/file-comment`
|
|
80
|
+
: '/api/file-comment';
|
|
81
|
+
|
|
82
|
+
requestBody = isLocal
|
|
83
|
+
? {
|
|
84
|
+
file: options.file,
|
|
85
|
+
body: options.body,
|
|
86
|
+
parent_id: options.parent_id,
|
|
87
|
+
type: options.type,
|
|
88
|
+
title: options.title
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
review_id: reviewId,
|
|
92
|
+
file: options.file,
|
|
93
|
+
body: options.body,
|
|
94
|
+
commit_sha: headSha,
|
|
95
|
+
parent_id: options.parent_id,
|
|
96
|
+
type: options.type,
|
|
97
|
+
title: options.title
|
|
98
|
+
};
|
|
99
|
+
break;
|
|
100
|
+
|
|
101
|
+
case 'update':
|
|
102
|
+
endpoint = isLocal
|
|
103
|
+
? `/api/local/${reviewId}/file-comment/${options.commentId}`
|
|
104
|
+
: `/api/user-comment/${options.commentId}`;
|
|
105
|
+
|
|
106
|
+
requestBody = { body: options.body };
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case 'delete':
|
|
110
|
+
endpoint = isLocal
|
|
111
|
+
? `/api/local/${reviewId}/file-comment/${options.commentId}`
|
|
112
|
+
: `/api/user-comment/${options.commentId}`;
|
|
113
|
+
|
|
114
|
+
// No body needed for DELETE
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
default:
|
|
118
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { endpoint, requestBody };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create the file comments zone element for a file
|
|
126
|
+
* File comments are always visible (no collapsible behavior).
|
|
127
|
+
* The comment icon button in the file header directly adds a new comment.
|
|
128
|
+
* @param {string} fileName - The file path
|
|
129
|
+
* @returns {HTMLElement} The file comments zone element
|
|
130
|
+
*/
|
|
131
|
+
createFileCommentsZone(fileName) {
|
|
132
|
+
const zone = document.createElement('div');
|
|
133
|
+
zone.className = 'file-comments-zone';
|
|
134
|
+
zone.dataset.fileName = fileName;
|
|
135
|
+
|
|
136
|
+
// Comments container (no header with toggle/add buttons - always visible)
|
|
137
|
+
const container = document.createElement('div');
|
|
138
|
+
container.className = 'file-comments-container';
|
|
139
|
+
|
|
140
|
+
zone.appendChild(container);
|
|
141
|
+
|
|
142
|
+
return zone;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Show the comment form for a file
|
|
148
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
149
|
+
* @param {string} fileName - The file path
|
|
150
|
+
*/
|
|
151
|
+
showCommentForm(zone, fileName) {
|
|
152
|
+
const container = zone.querySelector('.file-comments-container');
|
|
153
|
+
|
|
154
|
+
// Close any existing form in this zone
|
|
155
|
+
const existingForm = container.querySelector('.file-comment-form');
|
|
156
|
+
if (existingForm) {
|
|
157
|
+
existingForm.remove();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Create form
|
|
161
|
+
const form = document.createElement('div');
|
|
162
|
+
form.className = 'file-comment-form';
|
|
163
|
+
form.innerHTML = `
|
|
164
|
+
<div class="file-comment-form-header">
|
|
165
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
166
|
+
<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"/>
|
|
167
|
+
</svg>
|
|
168
|
+
<label>Add file-level comment</label>
|
|
169
|
+
</div>
|
|
170
|
+
<textarea
|
|
171
|
+
class="file-comment-textarea"
|
|
172
|
+
placeholder="Write a comment about this file... (Ctrl+Enter to save)"
|
|
173
|
+
data-file="${window.escapeHtmlAttribute(fileName)}"
|
|
174
|
+
></textarea>
|
|
175
|
+
<div class="file-comment-form-footer">
|
|
176
|
+
<button class="file-comment-form-btn submit submit-btn" disabled>Save</button>
|
|
177
|
+
<button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
|
|
178
|
+
</div>
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
container.appendChild(form);
|
|
182
|
+
|
|
183
|
+
// Get elements
|
|
184
|
+
const textarea = form.querySelector('.file-comment-textarea');
|
|
185
|
+
const submitBtn = form.querySelector('.submit-btn');
|
|
186
|
+
const cancelBtn = form.querySelector('.cancel-btn');
|
|
187
|
+
|
|
188
|
+
// Focus textarea
|
|
189
|
+
textarea.focus();
|
|
190
|
+
|
|
191
|
+
// Focus/blur for styling
|
|
192
|
+
textarea.addEventListener('focus', () => form.classList.add('focused'));
|
|
193
|
+
textarea.addEventListener('blur', () => form.classList.remove('focused'));
|
|
194
|
+
|
|
195
|
+
// Enable/disable submit based on content
|
|
196
|
+
textarea.addEventListener('input', () => {
|
|
197
|
+
submitBtn.disabled = !textarea.value.trim();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Keyboard shortcuts
|
|
201
|
+
textarea.addEventListener('keydown', (e) => {
|
|
202
|
+
if (e.key === 'Escape') {
|
|
203
|
+
this.hideCommentForm(zone);
|
|
204
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
|
|
205
|
+
this.saveFileComment(zone, fileName, textarea.value.trim());
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Cancel button
|
|
210
|
+
cancelBtn.addEventListener('click', () => this.hideCommentForm(zone));
|
|
211
|
+
|
|
212
|
+
// Submit button
|
|
213
|
+
submitBtn.addEventListener('click', () => {
|
|
214
|
+
if (textarea.value.trim()) {
|
|
215
|
+
this.saveFileComment(zone, fileName, textarea.value.trim());
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Hide the comment form
|
|
223
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
224
|
+
*/
|
|
225
|
+
hideCommentForm(zone) {
|
|
226
|
+
const container = zone.querySelector('.file-comments-container');
|
|
227
|
+
const form = container.querySelector('.file-comment-form');
|
|
228
|
+
|
|
229
|
+
if (form) {
|
|
230
|
+
form.remove();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Save a file-level comment
|
|
236
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
237
|
+
* @param {string} fileName - The file path
|
|
238
|
+
* @param {string} body - The comment body
|
|
239
|
+
*/
|
|
240
|
+
async saveFileComment(zone, fileName, body) {
|
|
241
|
+
try {
|
|
242
|
+
const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
|
|
243
|
+
file: fileName,
|
|
244
|
+
body: body
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const response = await fetch(endpoint, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json'
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify(requestBody)
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
throw new Error('Failed to save file-level comment');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const result = await response.json();
|
|
260
|
+
|
|
261
|
+
// Build comment object
|
|
262
|
+
const commentData = {
|
|
263
|
+
id: result.commentId,
|
|
264
|
+
file: fileName,
|
|
265
|
+
body: body,
|
|
266
|
+
source: 'user',
|
|
267
|
+
is_file_level: 1,
|
|
268
|
+
created_at: new Date().toISOString()
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Display the new comment
|
|
272
|
+
this.displayUserComment(zone, commentData);
|
|
273
|
+
|
|
274
|
+
// Hide the form
|
|
275
|
+
this.hideCommentForm(zone);
|
|
276
|
+
|
|
277
|
+
// Update count badge
|
|
278
|
+
this.updateCommentCount(zone);
|
|
279
|
+
|
|
280
|
+
// Notify AI Panel if available
|
|
281
|
+
if (window.aiPanel?.addComment) {
|
|
282
|
+
window.aiPanel.addComment(commentData);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Update parent comment count
|
|
286
|
+
if (this.prManager?.updateCommentCount) {
|
|
287
|
+
this.prManager.updateCommentCount();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error('Error saving file-level comment:', error);
|
|
292
|
+
if (window.toast) {
|
|
293
|
+
window.toast.showError('Failed to save file-level comment');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Display a user file-level comment
|
|
300
|
+
* Note: Dismissed comments are never rendered in the diff view per design decision.
|
|
301
|
+
* They only appear in the AI/Review Panel. This method only receives active comments.
|
|
302
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
303
|
+
* @param {Object} comment - The comment data
|
|
304
|
+
*/
|
|
305
|
+
displayUserComment(zone, comment) {
|
|
306
|
+
const container = zone.querySelector('.file-comments-container');
|
|
307
|
+
|
|
308
|
+
const card = document.createElement('div');
|
|
309
|
+
// Match line-level: add adopted-comment and comment-ai-origin classes when AI-originated
|
|
310
|
+
const isAIOrigin = !!comment.parent_id;
|
|
311
|
+
card.className = `file-comment-card user-comment ${isAIOrigin ? 'adopted-comment comment-ai-origin' : 'comment-user-origin'}`;
|
|
312
|
+
card.dataset.commentId = comment.id;
|
|
313
|
+
|
|
314
|
+
const renderedBody = window.renderMarkdown
|
|
315
|
+
? window.renderMarkdown(comment.body)
|
|
316
|
+
: this.escapeHtml(comment.body);
|
|
317
|
+
|
|
318
|
+
// Choose icon based on comment origin (AI-adopted vs user-originated) - matches line-level
|
|
319
|
+
const commentIcon = isAIOrigin
|
|
320
|
+
? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
|
|
321
|
+
<path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
|
|
322
|
+
</svg>`
|
|
323
|
+
: `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
|
|
324
|
+
<path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
|
|
325
|
+
</svg>`;
|
|
326
|
+
|
|
327
|
+
// Praise badge for "Nice Work" comments - matches line-level
|
|
328
|
+
const praiseBadge = comment.type === 'praise'
|
|
329
|
+
? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
|
|
330
|
+
: '';
|
|
331
|
+
|
|
332
|
+
// Title for AI-adopted comments - matches line-level
|
|
333
|
+
const titleHtml = comment.title
|
|
334
|
+
? `<span class="adopted-title">${this.escapeHtml(comment.title)}</span>`
|
|
335
|
+
: '';
|
|
336
|
+
|
|
337
|
+
// Use same structure as line-level user comments
|
|
338
|
+
card.innerHTML = `
|
|
339
|
+
<div class="user-comment-header">
|
|
340
|
+
<span class="comment-origin-icon">
|
|
341
|
+
${commentIcon}
|
|
342
|
+
</span>
|
|
343
|
+
<span class="file-comment-badge" title="Comment applies to the entire file">File comment</span>
|
|
344
|
+
${praiseBadge}
|
|
345
|
+
${titleHtml}
|
|
346
|
+
<span class="user-comment-timestamp">${this.formatTimestamp(comment.created_at)}</span>
|
|
347
|
+
<div class="user-comment-actions">
|
|
348
|
+
<button class="btn-edit-comment" title="Edit comment">
|
|
349
|
+
<svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
|
|
350
|
+
<path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
|
|
351
|
+
</svg>
|
|
352
|
+
</button>
|
|
353
|
+
<button class="btn-delete-comment" title="Dismiss comment">
|
|
354
|
+
<svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
|
|
355
|
+
<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>
|
|
356
|
+
</svg>
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="user-comment-body" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${renderedBody}</div>
|
|
361
|
+
`;
|
|
362
|
+
|
|
363
|
+
// Wire up edit/delete buttons
|
|
364
|
+
const editBtn = card.querySelector('.btn-edit-comment');
|
|
365
|
+
const deleteBtn = card.querySelector('.btn-delete-comment');
|
|
366
|
+
|
|
367
|
+
editBtn.addEventListener('click', () => this.editFileComment(zone, comment));
|
|
368
|
+
deleteBtn.addEventListener('click', () => this.deleteFileComment(zone, comment.id));
|
|
369
|
+
|
|
370
|
+
// Insert before form if present, otherwise append
|
|
371
|
+
const form = container.querySelector('.file-comment-form');
|
|
372
|
+
if (form) {
|
|
373
|
+
container.insertBefore(card, form);
|
|
374
|
+
} else {
|
|
375
|
+
container.appendChild(card);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Display an AI file-level suggestion
|
|
381
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
382
|
+
* @param {Object} suggestion - The suggestion data
|
|
383
|
+
*/
|
|
384
|
+
displayAISuggestion(zone, suggestion) {
|
|
385
|
+
const container = zone.querySelector('.file-comments-container');
|
|
386
|
+
|
|
387
|
+
// Use the same structure as line-level AI suggestions for consistency
|
|
388
|
+
const card = document.createElement('div');
|
|
389
|
+
// Include ai-type-${type} class for proper category styling (especially praise badge)
|
|
390
|
+
card.className = `file-comment-card ai-suggestion ai-type-${suggestion.type || 'suggestion'}`;
|
|
391
|
+
card.dataset.suggestionId = suggestion.id;
|
|
392
|
+
// Store original markdown body for adopt functionality via extractSuggestionData
|
|
393
|
+
// Use JSON.stringify to preserve newlines and special characters (matches line-level suggestions)
|
|
394
|
+
card.dataset.originalBody = JSON.stringify(suggestion.body || '');
|
|
395
|
+
|
|
396
|
+
// Store target info on the card for reliable retrieval in getFileAndLineInfo
|
|
397
|
+
// File-level suggestions don't have line numbers, just the file name
|
|
398
|
+
card.dataset.fileName = suggestion.file || '';
|
|
399
|
+
card.dataset.lineNumber = '';
|
|
400
|
+
card.dataset.side = '';
|
|
401
|
+
card.dataset.diffPosition = '';
|
|
402
|
+
card.dataset.isFileLevel = 'true';
|
|
403
|
+
|
|
404
|
+
// Check if this suggestion was adopted by looking at status or user comments with matching parent_id
|
|
405
|
+
// This mirrors the behavior in suggestion-manager.js for line-level suggestions
|
|
406
|
+
const userComments = this.prManager?.userComments || [];
|
|
407
|
+
const suggestionIdNum = parseInt(suggestion.id);
|
|
408
|
+
const wasAdopted = userComments.some(comment =>
|
|
409
|
+
comment.parent_id && (comment.parent_id === suggestion.id || comment.parent_id === suggestionIdNum)
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Determine if suggestion should be collapsed based on status or adoption
|
|
413
|
+
const isAdopted = wasAdopted || suggestion.status === 'adopted';
|
|
414
|
+
const isDismissed = suggestion.status === 'dismissed';
|
|
415
|
+
|
|
416
|
+
// Apply collapsed class if the suggestion is dismissed or was adopted
|
|
417
|
+
if (isAdopted || isDismissed) {
|
|
418
|
+
card.classList.add('collapsed');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Get category label for display (same as line-level)
|
|
422
|
+
const categoryLabel = suggestion.type || suggestion.category || '';
|
|
423
|
+
|
|
424
|
+
const renderedBody = window.renderMarkdown
|
|
425
|
+
? window.renderMarkdown(suggestion.body)
|
|
426
|
+
: this.escapeHtml(suggestion.body);
|
|
427
|
+
|
|
428
|
+
// Use exact same HTML structure as line-level suggestions (suggestion-manager.js)
|
|
429
|
+
card.innerHTML = `
|
|
430
|
+
<div class="ai-suggestion-header">
|
|
431
|
+
<div class="ai-suggestion-header-left">
|
|
432
|
+
${suggestion.type === 'praise'
|
|
433
|
+
? `<span class="praise-badge" title="Nice Work"><svg viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
|
|
434
|
+
: `<span class="ai-suggestion-badge" data-type="${suggestion.type}" title="AI Suggestion"><svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>AI Suggestion</span>`}
|
|
435
|
+
<span class="file-comment-badge" title="Comment applies to the entire file">File comment</span>
|
|
436
|
+
${categoryLabel ? `<span class="ai-suggestion-category">${this.escapeHtml(categoryLabel)}</span>` : ''}
|
|
437
|
+
<span class="ai-title">${this.escapeHtml(suggestion.title || '')}</span>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="ai-suggestion-collapsed-content">
|
|
441
|
+
${suggestion.type === 'praise'
|
|
442
|
+
? `<span class="praise-badge" title="Nice Work"><svg viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
|
|
443
|
+
: `<span class="ai-suggestion-badge collapsed" data-type="${suggestion.type}" title="AI Suggestion"><svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>AI Suggestion</span>`}
|
|
444
|
+
<span class="collapsed-text">${isAdopted ? 'Suggestion adopted' : 'Hidden AI suggestion'}</span>
|
|
445
|
+
<span class="collapsed-title">${this.escapeHtml(suggestion.title || '')}</span>
|
|
446
|
+
<button class="btn-restore" title="Show suggestion">
|
|
447
|
+
<svg class="octicon octicon-eye" viewBox="0 0 16 16" width="16" height="16">
|
|
448
|
+
<path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"></path>
|
|
449
|
+
</svg>
|
|
450
|
+
<span class="btn-text">Show</span>
|
|
451
|
+
</button>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="ai-suggestion-body">
|
|
454
|
+
${renderedBody}
|
|
455
|
+
</div>
|
|
456
|
+
<div class="ai-suggestion-actions">
|
|
457
|
+
<button class="ai-action ai-action-adopt">
|
|
458
|
+
<svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>
|
|
459
|
+
Adopt
|
|
460
|
+
</button>
|
|
461
|
+
<button class="ai-action ai-action-edit">
|
|
462
|
+
<svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path></svg>
|
|
463
|
+
Edit
|
|
464
|
+
</button>
|
|
465
|
+
<button class="ai-action ai-action-dismiss">
|
|
466
|
+
<svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path></svg>
|
|
467
|
+
Dismiss
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
`;
|
|
471
|
+
|
|
472
|
+
// Wire up action buttons (using same class names as line-level)
|
|
473
|
+
const adoptBtn = card.querySelector('.ai-action-adopt');
|
|
474
|
+
const dismissBtn = card.querySelector('.ai-action-dismiss');
|
|
475
|
+
const editBtn = card.querySelector('.ai-action-edit');
|
|
476
|
+
const restoreBtn = card.querySelector('.btn-restore');
|
|
477
|
+
|
|
478
|
+
adoptBtn.addEventListener('click', () => this.adoptAISuggestion(zone, suggestion));
|
|
479
|
+
dismissBtn.addEventListener('click', () => this.dismissAISuggestion(zone, suggestion.id));
|
|
480
|
+
editBtn.addEventListener('click', () => this.editAndAdoptAISuggestion(zone, suggestion));
|
|
481
|
+
restoreBtn.addEventListener('click', async () => await this.restoreAISuggestion(zone, suggestion.id));
|
|
482
|
+
|
|
483
|
+
// Insert at the beginning (AI suggestions shown first)
|
|
484
|
+
const firstUserComment = container.querySelector('.file-comment-card:not(.ai-suggestion)');
|
|
485
|
+
if (firstUserComment) {
|
|
486
|
+
container.insertBefore(card, firstUserComment);
|
|
487
|
+
} else {
|
|
488
|
+
const form = container.querySelector('.file-comment-form');
|
|
489
|
+
if (form) {
|
|
490
|
+
container.insertBefore(card, form);
|
|
491
|
+
} else {
|
|
492
|
+
container.appendChild(card);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Adopt an AI suggestion as a user comment
|
|
499
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
500
|
+
* @param {Object} suggestion - The suggestion data
|
|
501
|
+
*/
|
|
502
|
+
async adoptAISuggestion(zone, suggestion) {
|
|
503
|
+
try {
|
|
504
|
+
// Format the comment body with category prefix (matches line-level behavior)
|
|
505
|
+
const formattedBody = this.formatAdoptedComment(suggestion.body, suggestion.type);
|
|
506
|
+
|
|
507
|
+
// Create a file-level user comment from the suggestion, including parent_id/type/title for adopted suggestions
|
|
508
|
+
const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
|
|
509
|
+
file: suggestion.file,
|
|
510
|
+
body: formattedBody,
|
|
511
|
+
parent_id: suggestion.id,
|
|
512
|
+
type: suggestion.type,
|
|
513
|
+
title: suggestion.title
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const createResponse = await fetch(endpoint, {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { 'Content-Type': 'application/json' },
|
|
519
|
+
body: JSON.stringify(requestBody)
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
if (!createResponse.ok) throw new Error('Failed to create user comment');
|
|
523
|
+
|
|
524
|
+
const createResult = await createResponse.json();
|
|
525
|
+
|
|
526
|
+
// Update the AI suggestion status to adopted (mode-aware endpoint)
|
|
527
|
+
const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
|
|
528
|
+
const statusResponse = await fetch(statusEndpoint, {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'Content-Type': 'application/json' },
|
|
531
|
+
body: JSON.stringify({ status: 'adopted' })
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
|
|
535
|
+
|
|
536
|
+
// Collapse the AI suggestion card instead of removing it
|
|
537
|
+
const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
|
|
538
|
+
if (suggestionCard) {
|
|
539
|
+
suggestionCard.classList.add('collapsed');
|
|
540
|
+
// Update collapsed text to show "Suggestion adopted"
|
|
541
|
+
const collapsedText = suggestionCard.querySelector('.collapsed-text');
|
|
542
|
+
if (collapsedText) {
|
|
543
|
+
collapsedText.textContent = 'Suggestion adopted';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Display as user comment with formatted body
|
|
548
|
+
const commentData = {
|
|
549
|
+
id: createResult.commentId,
|
|
550
|
+
file: suggestion.file,
|
|
551
|
+
body: formattedBody,
|
|
552
|
+
source: 'user',
|
|
553
|
+
parent_id: suggestion.id,
|
|
554
|
+
type: suggestion.type,
|
|
555
|
+
title: suggestion.title,
|
|
556
|
+
is_file_level: 1,
|
|
557
|
+
created_at: new Date().toISOString()
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
this.displayUserComment(zone, commentData);
|
|
561
|
+
this.updateCommentCount(zone);
|
|
562
|
+
|
|
563
|
+
// Update parent comment count for Preview button
|
|
564
|
+
if (this.prManager?.updateCommentCount) {
|
|
565
|
+
this.prManager.updateCommentCount();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Add comment to AI Panel's comment list for navigation and display
|
|
569
|
+
if (window.aiPanel?.addComment) {
|
|
570
|
+
window.aiPanel.addComment(commentData);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Update finding status in AI Panel (mark suggestion as adopted)
|
|
574
|
+
if (window.aiPanel?.updateFindingStatus) {
|
|
575
|
+
window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.error('Error adopting suggestion:', error);
|
|
580
|
+
if (window.toast) {
|
|
581
|
+
window.toast.showError('Failed to adopt suggestion');
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Dismiss an AI suggestion
|
|
588
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
589
|
+
* @param {number} suggestionId - The suggestion ID
|
|
590
|
+
*/
|
|
591
|
+
async dismissAISuggestion(zone, suggestionId) {
|
|
592
|
+
try {
|
|
593
|
+
// Update the AI suggestion status to dismissed (mode-aware endpoint)
|
|
594
|
+
const endpoint = this._getSuggestionStatusEndpoint(suggestionId);
|
|
595
|
+
const response = await fetch(endpoint, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: { 'Content-Type': 'application/json' },
|
|
598
|
+
body: JSON.stringify({ status: 'dismissed' })
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
if (!response.ok) throw new Error('Failed to dismiss suggestion');
|
|
602
|
+
|
|
603
|
+
// Collapse the card instead of removing it
|
|
604
|
+
const card = zone.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
605
|
+
if (card) {
|
|
606
|
+
card.classList.add('collapsed');
|
|
607
|
+
// Update collapsed text to show "Hidden AI suggestion"
|
|
608
|
+
const collapsedText = card.querySelector('.collapsed-text');
|
|
609
|
+
if (collapsedText) {
|
|
610
|
+
collapsedText.textContent = 'Hidden AI suggestion';
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
this.updateCommentCount(zone);
|
|
615
|
+
|
|
616
|
+
// Update finding status in AI Panel (mark suggestion as dismissed)
|
|
617
|
+
if (window.aiPanel?.updateFindingStatus) {
|
|
618
|
+
window.aiPanel.updateFindingStatus(suggestionId, 'dismissed');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
} catch (error) {
|
|
622
|
+
console.error('Error dismissing suggestion:', error);
|
|
623
|
+
if (window.toast) {
|
|
624
|
+
window.toast.showError('Failed to dismiss suggestion');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Restore (show) a collapsed AI suggestion
|
|
631
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
632
|
+
* @param {number} suggestionId - The suggestion ID
|
|
633
|
+
*/
|
|
634
|
+
async restoreAISuggestion(zone, suggestionId) {
|
|
635
|
+
try {
|
|
636
|
+
// Use shared helper for mode-aware endpoint
|
|
637
|
+
const endpoint = this._getSuggestionStatusEndpoint(suggestionId);
|
|
638
|
+
|
|
639
|
+
// Call API to update suggestion status to active
|
|
640
|
+
const response = await fetch(endpoint, {
|
|
641
|
+
method: 'POST',
|
|
642
|
+
headers: { 'Content-Type': 'application/json' },
|
|
643
|
+
body: JSON.stringify({ status: 'active' })
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (!response.ok) throw new Error('Failed to restore suggestion');
|
|
647
|
+
|
|
648
|
+
// Update the UI - remove collapsed state
|
|
649
|
+
const card = zone.querySelector(`[data-suggestion-id="${suggestionId}"]`);
|
|
650
|
+
if (card) {
|
|
651
|
+
card.classList.remove('collapsed');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Update finding status in AI Panel (mark suggestion as active)
|
|
655
|
+
if (window.aiPanel?.updateFindingStatus) {
|
|
656
|
+
window.aiPanel.updateFindingStatus(suggestionId, 'active');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Update comment count (for consistency with dismissAISuggestion)
|
|
660
|
+
this.updateCommentCount(zone);
|
|
661
|
+
|
|
662
|
+
} catch (error) {
|
|
663
|
+
console.error('Error restoring suggestion:', error);
|
|
664
|
+
if (window.toast) {
|
|
665
|
+
window.toast.showError('Failed to restore suggestion');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Edit and adopt an AI suggestion
|
|
672
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
673
|
+
* @param {Object} suggestion - The suggestion data
|
|
674
|
+
*/
|
|
675
|
+
editAndAdoptAISuggestion(zone, suggestion) {
|
|
676
|
+
const container = zone.querySelector('.file-comments-container');
|
|
677
|
+
|
|
678
|
+
// Close any existing form
|
|
679
|
+
const existingForm = container.querySelector('.file-comment-form');
|
|
680
|
+
if (existingForm) {
|
|
681
|
+
existingForm.remove();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Create form with pre-filled content
|
|
685
|
+
const form = document.createElement('div');
|
|
686
|
+
form.className = 'file-comment-form focused';
|
|
687
|
+
form.dataset.suggestionId = suggestion.id;
|
|
688
|
+
form.innerHTML = `
|
|
689
|
+
<div class="file-comment-form-header">
|
|
690
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
691
|
+
<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"/>
|
|
692
|
+
</svg>
|
|
693
|
+
<label>Edit AI suggestion</label>
|
|
694
|
+
</div>
|
|
695
|
+
<textarea
|
|
696
|
+
class="file-comment-textarea"
|
|
697
|
+
placeholder="Edit the suggestion..."
|
|
698
|
+
data-file="${window.escapeHtmlAttribute(suggestion.file)}"
|
|
699
|
+
>${this.escapeHtml(suggestion.body)}</textarea>
|
|
700
|
+
<div class="file-comment-form-footer">
|
|
701
|
+
<button class="file-comment-form-btn submit submit-btn">Adopt</button>
|
|
702
|
+
<button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
|
|
703
|
+
</div>
|
|
704
|
+
`;
|
|
705
|
+
|
|
706
|
+
// Insert form after the suggestion card
|
|
707
|
+
const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
|
|
708
|
+
if (suggestionCard) {
|
|
709
|
+
suggestionCard.after(form);
|
|
710
|
+
} else {
|
|
711
|
+
container.appendChild(form);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const textarea = form.querySelector('.file-comment-textarea');
|
|
715
|
+
const submitBtn = form.querySelector('.submit-btn');
|
|
716
|
+
const cancelBtn = form.querySelector('.cancel-btn');
|
|
717
|
+
|
|
718
|
+
textarea.focus();
|
|
719
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
720
|
+
|
|
721
|
+
textarea.addEventListener('focus', () => form.classList.add('focused'));
|
|
722
|
+
textarea.addEventListener('blur', () => form.classList.remove('focused'));
|
|
723
|
+
|
|
724
|
+
textarea.addEventListener('input', () => {
|
|
725
|
+
submitBtn.disabled = !textarea.value.trim();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
textarea.addEventListener('keydown', (e) => {
|
|
729
|
+
if (e.key === 'Escape') {
|
|
730
|
+
form.remove();
|
|
731
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
|
|
732
|
+
this.adoptWithEdit(zone, suggestion, textarea.value.trim());
|
|
733
|
+
form.remove();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
cancelBtn.addEventListener('click', () => form.remove());
|
|
738
|
+
|
|
739
|
+
submitBtn.addEventListener('click', () => {
|
|
740
|
+
if (textarea.value.trim()) {
|
|
741
|
+
this.adoptWithEdit(zone, suggestion, textarea.value.trim());
|
|
742
|
+
form.remove();
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Adopt an AI suggestion with edited body
|
|
749
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
750
|
+
* @param {Object} suggestion - The original suggestion
|
|
751
|
+
* @param {string} editedBody - The edited comment body
|
|
752
|
+
*/
|
|
753
|
+
async adoptWithEdit(zone, suggestion, editedBody) {
|
|
754
|
+
try {
|
|
755
|
+
// Format the edited body with category prefix (matches line-level behavior)
|
|
756
|
+
const formattedBody = this.formatAdoptedComment(editedBody, suggestion.type);
|
|
757
|
+
|
|
758
|
+
// Create a file-level user comment with the edited body, including parent_id/type/title for adopted suggestions
|
|
759
|
+
const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
|
|
760
|
+
file: suggestion.file,
|
|
761
|
+
body: formattedBody,
|
|
762
|
+
parent_id: suggestion.id,
|
|
763
|
+
type: suggestion.type,
|
|
764
|
+
title: suggestion.title
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const createResponse = await fetch(endpoint, {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
headers: { 'Content-Type': 'application/json' },
|
|
770
|
+
body: JSON.stringify(requestBody)
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (!createResponse.ok) throw new Error('Failed to create user comment');
|
|
774
|
+
|
|
775
|
+
const createResult = await createResponse.json();
|
|
776
|
+
|
|
777
|
+
// Update the AI suggestion status to adopted (mode-aware endpoint)
|
|
778
|
+
const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
|
|
779
|
+
const statusResponse = await fetch(statusEndpoint, {
|
|
780
|
+
method: 'POST',
|
|
781
|
+
headers: { 'Content-Type': 'application/json' },
|
|
782
|
+
body: JSON.stringify({ status: 'adopted' })
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
|
|
786
|
+
|
|
787
|
+
// Collapse the AI suggestion card instead of removing it
|
|
788
|
+
const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
|
|
789
|
+
if (suggestionCard) {
|
|
790
|
+
suggestionCard.classList.add('collapsed');
|
|
791
|
+
// Update collapsed text to show "Suggestion adopted"
|
|
792
|
+
const collapsedText = suggestionCard.querySelector('.collapsed-text');
|
|
793
|
+
if (collapsedText) {
|
|
794
|
+
collapsedText.textContent = 'Suggestion adopted';
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Display as user comment with formatted body
|
|
799
|
+
const commentData = {
|
|
800
|
+
id: createResult.commentId,
|
|
801
|
+
file: suggestion.file,
|
|
802
|
+
body: formattedBody,
|
|
803
|
+
source: 'user',
|
|
804
|
+
parent_id: suggestion.id,
|
|
805
|
+
type: suggestion.type,
|
|
806
|
+
title: suggestion.title,
|
|
807
|
+
is_file_level: 1,
|
|
808
|
+
created_at: new Date().toISOString()
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
this.displayUserComment(zone, commentData);
|
|
812
|
+
this.updateCommentCount(zone);
|
|
813
|
+
|
|
814
|
+
// Update parent comment count for Preview button
|
|
815
|
+
if (this.prManager?.updateCommentCount) {
|
|
816
|
+
this.prManager.updateCommentCount();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Add comment to AI Panel's comment list for navigation and display
|
|
820
|
+
if (window.aiPanel?.addComment) {
|
|
821
|
+
window.aiPanel.addComment(commentData);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Update finding status in AI Panel (mark suggestion as adopted)
|
|
825
|
+
if (window.aiPanel?.updateFindingStatus) {
|
|
826
|
+
window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
} catch (error) {
|
|
830
|
+
console.error('Error adopting suggestion with edit:', error);
|
|
831
|
+
if (window.toast) {
|
|
832
|
+
window.toast.showError('Failed to adopt suggestion');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Edit a user file-level comment
|
|
839
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
840
|
+
* @param {Object} comment - The comment data
|
|
841
|
+
*/
|
|
842
|
+
editFileComment(zone, comment) {
|
|
843
|
+
const card = zone.querySelector(`[data-comment-id="${comment.id}"]`);
|
|
844
|
+
if (!card) return;
|
|
845
|
+
|
|
846
|
+
const bodyEl = card.querySelector('.user-comment-body');
|
|
847
|
+
const originalMarkdown = bodyEl.dataset.originalMarkdown || comment.body;
|
|
848
|
+
|
|
849
|
+
// Replace body with edit form (matching line-level comment edit form styling)
|
|
850
|
+
bodyEl.innerHTML = `
|
|
851
|
+
<textarea class="file-comment-textarea" style="min-height: 80px;">${this.escapeHtml(originalMarkdown)}</textarea>
|
|
852
|
+
<div class="comment-edit-actions">
|
|
853
|
+
<button class="btn btn-sm btn-primary save-edit-btn">Save</button>
|
|
854
|
+
<button class="btn btn-sm btn-secondary cancel-edit-btn">Cancel</button>
|
|
855
|
+
</div>
|
|
856
|
+
`;
|
|
857
|
+
|
|
858
|
+
const textarea = bodyEl.querySelector('.file-comment-textarea');
|
|
859
|
+
const saveBtn = bodyEl.querySelector('.save-edit-btn');
|
|
860
|
+
const cancelBtn = bodyEl.querySelector('.cancel-edit-btn');
|
|
861
|
+
|
|
862
|
+
textarea.focus();
|
|
863
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
864
|
+
|
|
865
|
+
const restoreView = () => {
|
|
866
|
+
const renderedBody = window.renderMarkdown
|
|
867
|
+
? window.renderMarkdown(originalMarkdown)
|
|
868
|
+
: this.escapeHtml(originalMarkdown);
|
|
869
|
+
bodyEl.innerHTML = renderedBody;
|
|
870
|
+
bodyEl.dataset.originalMarkdown = originalMarkdown;
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
cancelBtn.addEventListener('click', restoreView);
|
|
874
|
+
|
|
875
|
+
textarea.addEventListener('keydown', (e) => {
|
|
876
|
+
if (e.key === 'Escape') {
|
|
877
|
+
restoreView();
|
|
878
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
|
|
879
|
+
this.saveEditedComment(zone, comment.id, textarea.value.trim(), bodyEl);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
saveBtn.addEventListener('click', () => {
|
|
884
|
+
if (textarea.value.trim()) {
|
|
885
|
+
this.saveEditedComment(zone, comment.id, textarea.value.trim(), bodyEl);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Save an edited comment
|
|
892
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
893
|
+
* @param {number} commentId - The comment ID
|
|
894
|
+
* @param {string} newBody - The new comment body
|
|
895
|
+
* @param {HTMLElement} bodyEl - The body element to update
|
|
896
|
+
*/
|
|
897
|
+
async saveEditedComment(zone, commentId, newBody, bodyEl) {
|
|
898
|
+
try {
|
|
899
|
+
const { endpoint, requestBody } = this._getFileCommentEndpoint('update', {
|
|
900
|
+
commentId: commentId,
|
|
901
|
+
body: newBody
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const response = await fetch(endpoint, {
|
|
905
|
+
method: 'PUT',
|
|
906
|
+
headers: { 'Content-Type': 'application/json' },
|
|
907
|
+
body: JSON.stringify(requestBody)
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
if (!response.ok) throw new Error('Failed to update comment');
|
|
911
|
+
|
|
912
|
+
// Update the display
|
|
913
|
+
const renderedBody = window.renderMarkdown
|
|
914
|
+
? window.renderMarkdown(newBody)
|
|
915
|
+
: this.escapeHtml(newBody);
|
|
916
|
+
bodyEl.innerHTML = renderedBody;
|
|
917
|
+
bodyEl.dataset.originalMarkdown = newBody;
|
|
918
|
+
|
|
919
|
+
} catch (error) {
|
|
920
|
+
console.error('Error updating comment:', error);
|
|
921
|
+
if (window.toast) {
|
|
922
|
+
window.toast.showError('Failed to update comment');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Delete a user file-level comment
|
|
929
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
930
|
+
* @param {number} commentId - The comment ID
|
|
931
|
+
*/
|
|
932
|
+
async deleteFileComment(zone, commentId) {
|
|
933
|
+
try {
|
|
934
|
+
const { endpoint } = this._getFileCommentEndpoint('delete', {
|
|
935
|
+
commentId: commentId
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
const response = await fetch(endpoint, {
|
|
939
|
+
method: 'DELETE'
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
if (!response.ok) throw new Error('Failed to delete comment');
|
|
943
|
+
|
|
944
|
+
const apiResult = await response.json();
|
|
945
|
+
|
|
946
|
+
// Remove the card
|
|
947
|
+
const card = zone.querySelector(`[data-comment-id="${commentId}"]`);
|
|
948
|
+
if (card) {
|
|
949
|
+
card.remove();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
this.updateCommentCount(zone);
|
|
953
|
+
|
|
954
|
+
// Update parent comment count
|
|
955
|
+
if (this.prManager?.updateCommentCount) {
|
|
956
|
+
this.prManager.updateCommentCount();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Notify AI Panel about the deleted comment
|
|
960
|
+
if (window.aiPanel?.removeComment) {
|
|
961
|
+
window.aiPanel.removeComment(commentId);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// If a parent suggestion existed, the suggestion card is still collapsed/dismissed in the diff view.
|
|
965
|
+
// Update AIPanel to show the suggestion as 'dismissed' (matching its visual state).
|
|
966
|
+
// User can click "Show" to restore it to active state if they want to re-adopt.
|
|
967
|
+
if (apiResult.dismissedSuggestionId && window.aiPanel?.updateFindingStatus) {
|
|
968
|
+
window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
} catch (error) {
|
|
972
|
+
console.error('Error deleting comment:', error);
|
|
973
|
+
if (window.toast) {
|
|
974
|
+
window.toast.showError('Failed to delete comment');
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Get the appropriate API endpoint for updating AI suggestion status
|
|
981
|
+
* Handles both local and PR modes.
|
|
982
|
+
* @private
|
|
983
|
+
* @param {number|string} suggestionId - The suggestion ID
|
|
984
|
+
* @returns {string} The API endpoint URL
|
|
985
|
+
*/
|
|
986
|
+
_getSuggestionStatusEndpoint(suggestionId) {
|
|
987
|
+
const reviewId = this.prManager?.currentPR?.id;
|
|
988
|
+
const reviewType = this.prManager?.currentPR?.reviewType;
|
|
989
|
+
const isLocal = reviewType === 'local';
|
|
990
|
+
|
|
991
|
+
return isLocal
|
|
992
|
+
? `/api/local/${reviewId}/ai-suggestion/${suggestionId}/status`
|
|
993
|
+
: `/api/ai-suggestion/${suggestionId}/status`;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Update the zone state based on comment count
|
|
998
|
+
* @param {HTMLElement} zone - The file comments zone
|
|
999
|
+
*/
|
|
1000
|
+
updateCommentCount(zone) {
|
|
1001
|
+
const container = zone.querySelector('.file-comments-container');
|
|
1002
|
+
|
|
1003
|
+
const userComments = container.querySelectorAll('.file-comment-card:not(.ai-suggestion)').length;
|
|
1004
|
+
const aiSuggestions = container.querySelectorAll('.file-comment-card.ai-suggestion').length;
|
|
1005
|
+
const total = userComments + aiSuggestions;
|
|
1006
|
+
|
|
1007
|
+
// Update header button icon state (outline vs filled)
|
|
1008
|
+
this.updateHeaderButtonState(zone, total);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Update the header button icon state based on comment count.
|
|
1013
|
+
*
|
|
1014
|
+
* Note: The `zone.headerButton` property is injected externally by pr.js
|
|
1015
|
+
* during file header rendering. This coupling allows the file-comment-manager
|
|
1016
|
+
* to update the header's comment icon state (outline vs filled) without
|
|
1017
|
+
* needing direct access to the header DOM structure.
|
|
1018
|
+
*
|
|
1019
|
+
* @param {HTMLElement} zone - The file comments zone element
|
|
1020
|
+
* @param {number} count - Total comment count (user + AI suggestions)
|
|
1021
|
+
*/
|
|
1022
|
+
updateHeaderButtonState(zone, count) {
|
|
1023
|
+
const headerBtn = zone.headerButton;
|
|
1024
|
+
if (!headerBtn) return;
|
|
1025
|
+
|
|
1026
|
+
const outlineIcon = headerBtn.querySelector('.comment-icon-outline');
|
|
1027
|
+
const filledIcon = headerBtn.querySelector('.comment-icon-filled');
|
|
1028
|
+
|
|
1029
|
+
if (count > 0) {
|
|
1030
|
+
// Has comments - show filled icon
|
|
1031
|
+
if (outlineIcon) outlineIcon.style.display = 'none';
|
|
1032
|
+
if (filledIcon) filledIcon.style.display = '';
|
|
1033
|
+
headerBtn.classList.add('has-comments');
|
|
1034
|
+
headerBtn.title = `${count} file comment${count > 1 ? 's' : ''} - click to add more`;
|
|
1035
|
+
} else {
|
|
1036
|
+
// No comments - show outline icon
|
|
1037
|
+
if (outlineIcon) outlineIcon.style.display = '';
|
|
1038
|
+
if (filledIcon) filledIcon.style.display = 'none';
|
|
1039
|
+
headerBtn.classList.remove('has-comments');
|
|
1040
|
+
headerBtn.title = 'Add file comment';
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Load and display file-level comments for all files
|
|
1046
|
+
* @param {Array} comments - Array of file-level comments
|
|
1047
|
+
* @param {Array} suggestions - Array of file-level AI suggestions
|
|
1048
|
+
*/
|
|
1049
|
+
loadFileComments(comments, suggestions) {
|
|
1050
|
+
// Group by file
|
|
1051
|
+
const commentsByFile = new Map();
|
|
1052
|
+
const suggestionsByFile = new Map();
|
|
1053
|
+
|
|
1054
|
+
if (comments) {
|
|
1055
|
+
for (const comment of comments) {
|
|
1056
|
+
if (comment.is_file_level === 1) {
|
|
1057
|
+
if (!commentsByFile.has(comment.file)) {
|
|
1058
|
+
commentsByFile.set(comment.file, []);
|
|
1059
|
+
}
|
|
1060
|
+
commentsByFile.get(comment.file).push(comment);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (suggestions) {
|
|
1066
|
+
for (const suggestion of suggestions) {
|
|
1067
|
+
if (suggestion.is_file_level === 1) {
|
|
1068
|
+
if (!suggestionsByFile.has(suggestion.file)) {
|
|
1069
|
+
suggestionsByFile.set(suggestion.file, []);
|
|
1070
|
+
}
|
|
1071
|
+
suggestionsByFile.get(suggestion.file).push(suggestion);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Find all file comment zones and populate them
|
|
1077
|
+
const zones = document.querySelectorAll('.file-comments-zone');
|
|
1078
|
+
for (const zone of zones) {
|
|
1079
|
+
const fileName = zone.dataset.fileName;
|
|
1080
|
+
const container = zone.querySelector('.file-comments-container');
|
|
1081
|
+
|
|
1082
|
+
// Selectively clear existing cards based on what we're about to reload
|
|
1083
|
+
// This prevents user comments from being cleared when only reloading AI suggestions
|
|
1084
|
+
if (container) {
|
|
1085
|
+
// Only clear AI suggestions if we have suggestions to display
|
|
1086
|
+
// (prevents stale suggestions from persisting when reloading or changing levels)
|
|
1087
|
+
if (suggestions && suggestions.length > 0) {
|
|
1088
|
+
const existingAISuggestions = container.querySelectorAll('.file-comment-card.ai-suggestion');
|
|
1089
|
+
for (const card of existingAISuggestions) {
|
|
1090
|
+
card.remove();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Only clear user comments if we have comments to display
|
|
1095
|
+
if (comments && comments.length > 0) {
|
|
1096
|
+
const existingUserComments = container.querySelectorAll('.file-comment-card.user-comment');
|
|
1097
|
+
for (const card of existingUserComments) {
|
|
1098
|
+
card.remove();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const fileComments = commentsByFile.get(fileName) || [];
|
|
1104
|
+
const fileSuggestions = suggestionsByFile.get(fileName) || [];
|
|
1105
|
+
|
|
1106
|
+
// Display AI suggestions first
|
|
1107
|
+
for (const suggestion of fileSuggestions) {
|
|
1108
|
+
this.displayAISuggestion(zone, suggestion);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Then user comments
|
|
1112
|
+
for (const comment of fileComments) {
|
|
1113
|
+
this.displayUserComment(zone, comment);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Update count
|
|
1117
|
+
this.updateCommentCount(zone);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Find the file comments zone for a given file
|
|
1123
|
+
* @param {string} fileName - The file path
|
|
1124
|
+
* @returns {HTMLElement|null} The zone element or null
|
|
1125
|
+
*/
|
|
1126
|
+
findZoneForFile(fileName) {
|
|
1127
|
+
return document.querySelector(`.file-comments-zone[data-file-name="${fileName}"]`);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Escape HTML characters
|
|
1132
|
+
* @param {string} text - Text to escape
|
|
1133
|
+
* @returns {string} Escaped text
|
|
1134
|
+
*/
|
|
1135
|
+
escapeHtml(text) {
|
|
1136
|
+
if (!text) return '';
|
|
1137
|
+
const div = document.createElement('div');
|
|
1138
|
+
div.textContent = text;
|
|
1139
|
+
return div.innerHTML;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Format timestamp for display
|
|
1144
|
+
* @param {string} timestamp - ISO timestamp
|
|
1145
|
+
* @returns {string} Formatted timestamp
|
|
1146
|
+
*/
|
|
1147
|
+
formatTimestamp(timestamp) {
|
|
1148
|
+
if (!timestamp) return 'Just now';
|
|
1149
|
+
const date = new Date(timestamp);
|
|
1150
|
+
const now = new Date();
|
|
1151
|
+
const diffMs = now - date;
|
|
1152
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
1153
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
1154
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
1155
|
+
|
|
1156
|
+
if (diffMins < 1) return 'Just now';
|
|
1157
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
|
1158
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
|
1159
|
+
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
|
1160
|
+
return date.toLocaleDateString();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Check if a suggestion block already exists in the textarea
|
|
1165
|
+
* @param {string} text - The textarea content
|
|
1166
|
+
* @returns {boolean} True if a suggestion block exists
|
|
1167
|
+
*/
|
|
1168
|
+
hasSuggestionBlock(text) {
|
|
1169
|
+
// Match both ``` and ```` suggestion blocks, allowing leading whitespace
|
|
1170
|
+
return /^\s*(`{3,})suggestion\s*$/m.test(text);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Update the suggestion button state based on textarea content
|
|
1175
|
+
* Disables the button if a suggestion block already exists
|
|
1176
|
+
* @param {HTMLTextAreaElement} textarea - The textarea to check
|
|
1177
|
+
* @param {HTMLButtonElement} button - The suggestion button
|
|
1178
|
+
*/
|
|
1179
|
+
updateSuggestionButtonState(textarea, button) {
|
|
1180
|
+
if (!button) return;
|
|
1181
|
+
const hasSuggestion = this.hasSuggestionBlock(textarea.value);
|
|
1182
|
+
button.disabled = hasSuggestion;
|
|
1183
|
+
button.title = hasSuggestion ? 'Only one suggestion per comment' : 'Insert a suggestion';
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Insert a suggestion block into the textarea at cursor position
|
|
1188
|
+
* For file-level comments, inserts an empty suggestion block
|
|
1189
|
+
* @param {HTMLTextAreaElement} textarea - The textarea to insert into
|
|
1190
|
+
* @param {HTMLButtonElement} [button] - Optional suggestion button to disable after insert
|
|
1191
|
+
*/
|
|
1192
|
+
insertSuggestionBlock(textarea, button) {
|
|
1193
|
+
// Check if suggestion already exists
|
|
1194
|
+
if (this.hasSuggestionBlock(textarea.value)) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// For file-level comments, insert empty suggestion block
|
|
1199
|
+
const backticks = '```';
|
|
1200
|
+
const suggestionBlock = `${backticks}suggestion\n\n${backticks}`;
|
|
1201
|
+
|
|
1202
|
+
// Get current cursor position
|
|
1203
|
+
const start = textarea.selectionStart;
|
|
1204
|
+
const end = textarea.selectionEnd;
|
|
1205
|
+
const text = textarea.value;
|
|
1206
|
+
|
|
1207
|
+
// Insert at cursor position (or replace selection)
|
|
1208
|
+
const before = text.substring(0, start);
|
|
1209
|
+
const after = text.substring(end);
|
|
1210
|
+
|
|
1211
|
+
// Add newlines if needed for clean formatting
|
|
1212
|
+
const needsNewlineBefore = before.length > 0 && !before.endsWith('\n');
|
|
1213
|
+
const needsNewlineAfter = after.length > 0 && !after.startsWith('\n');
|
|
1214
|
+
|
|
1215
|
+
const prefix = needsNewlineBefore ? '\n' : '';
|
|
1216
|
+
const suffix = needsNewlineAfter ? '\n' : '';
|
|
1217
|
+
|
|
1218
|
+
textarea.value = before + prefix + suggestionBlock + suffix + after;
|
|
1219
|
+
|
|
1220
|
+
// Position cursor inside the suggestion block
|
|
1221
|
+
const newCursorPos = start + prefix.length + backticks.length + 'suggestion\n'.length;
|
|
1222
|
+
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
1223
|
+
textarea.focus();
|
|
1224
|
+
|
|
1225
|
+
// Trigger input event for auto-resize and state updates
|
|
1226
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1227
|
+
|
|
1228
|
+
// Disable the suggestion button
|
|
1229
|
+
if (button) {
|
|
1230
|
+
button.disabled = true;
|
|
1231
|
+
button.title = 'Only one suggestion per comment';
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Make FileCommentManager available globally
|
|
1237
|
+
window.FileCommentManager = FileCommentManager;
|
|
1238
|
+
|
|
1239
|
+
// Export for CommonJS testing environments
|
|
1240
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1241
|
+
module.exports = { FileCommentManager };
|
|
1242
|
+
}
|