@in-the-loop-labs/pair-review 3.4.1 → 3.5.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/README.md +24 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +2 -1
- package/src/database.js +566 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/github/client.js +77 -1
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/server.js +9 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* ExternalCommentManager - Read-only renderer for external review comments.
|
|
4
|
+
*
|
|
5
|
+
* Consumes `GET /api/reviews/:reviewId/external-comments?source=<source>` and
|
|
6
|
+
* renders thread-grouped comments as `.external-comment-row` rows inserted
|
|
7
|
+
* after the appropriate diff line.
|
|
8
|
+
*
|
|
9
|
+
* Read-only: no draft / submit / edit / dismiss flows. Only chat-about
|
|
10
|
+
* actions, which delegate to the global chat panel.
|
|
11
|
+
*
|
|
12
|
+
* Ordering rule (shared diff-row surface — see plans/fetch-external-review-comments.md
|
|
13
|
+
* "Hazards"): three independent renderers append rows after the same diff
|
|
14
|
+
* line: `.ai-suggestion-row`, `.user-comment-row`, `.external-comment-row`.
|
|
15
|
+
* External rows sit BELOW AI suggestion + user comment rows for the same
|
|
16
|
+
* diff line. This module ONLY touches `.external-comment-row` elements when
|
|
17
|
+
* clearing — it never strips rows owned by other renderers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
class ExternalCommentManager {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} opts
|
|
23
|
+
* @param {string|number} opts.reviewId - Review id used to build the API URL
|
|
24
|
+
* @param {string[]} [opts.sources=['github']] - External sources to load
|
|
25
|
+
* @param {Object} [opts.chatPanel=window.chatPanel] - Chat panel reference
|
|
26
|
+
*/
|
|
27
|
+
constructor({ reviewId, sources = ['github'], chatPanel } = {}) {
|
|
28
|
+
this.reviewId = reviewId;
|
|
29
|
+
this.sources = sources;
|
|
30
|
+
// Bind lazily so tests / late-loaded chat panel both work
|
|
31
|
+
this.chatPanel = chatPanel || (typeof window !== 'undefined' ? window.chatPanel : null);
|
|
32
|
+
// source -> threads[]
|
|
33
|
+
this.threadsBySource = new Map();
|
|
34
|
+
// Track whether we've already warned about a missing anchor for a given thread
|
|
35
|
+
this._anchorWarnings = new Set();
|
|
36
|
+
// Per-manager in-flight promise. Coalesces concurrent loadAndRender calls
|
|
37
|
+
// (page-load + manual refresh + post-AI re-render) into one round-trip.
|
|
38
|
+
this._inflight = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------------
|
|
42
|
+
// Data fetch
|
|
43
|
+
// ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch threads for a single source and cache them in `threadsBySource`.
|
|
47
|
+
* @param {string} source - Source identifier (e.g. 'github')
|
|
48
|
+
* @returns {Promise<Array>} The fetched threads
|
|
49
|
+
*/
|
|
50
|
+
async fetch(source) {
|
|
51
|
+
if (!this.reviewId) {
|
|
52
|
+
throw new Error('ExternalCommentManager: reviewId is required to fetch');
|
|
53
|
+
}
|
|
54
|
+
const url = `/api/reviews/${encodeURIComponent(this.reviewId)}/external-comments?source=${encodeURIComponent(source)}`;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetch(url);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
this._toast(`Failed to load ${source} review comments`, 'error');
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
this._toast(`Failed to load ${source} review comments`, 'error');
|
|
64
|
+
throw new Error(`Failed to fetch external comments: ${res.status}`);
|
|
65
|
+
}
|
|
66
|
+
const data = await res.json();
|
|
67
|
+
const threads = Array.isArray(data) ? data : (data.threads || []);
|
|
68
|
+
this.threadsBySource.set(source, threads);
|
|
69
|
+
return threads;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch + render for all configured sources. Failure in one source does
|
|
74
|
+
* not abort the rest.
|
|
75
|
+
*
|
|
76
|
+
* Canonical refresh entry point for GET-only callers (analysis rebuilds,
|
|
77
|
+
* whitespace toggles, post-AI rerender). For full sync+load — POST to
|
|
78
|
+
* /external-comments/sync followed by GET + render — use `syncAndRender`
|
|
79
|
+
* instead. Both methods share `this._inflight`, so a GET-only caller
|
|
80
|
+
* that races an in-flight `syncAndRender` joins the full sync+load
|
|
81
|
+
* promise rather than racing the POST with a stale GET. This is NOT
|
|
82
|
+
* declared `async` on purpose; an async wrapper would create a fresh
|
|
83
|
+
* Promise on each call and break the shared-Promise contract.
|
|
84
|
+
*/
|
|
85
|
+
loadAndRender() {
|
|
86
|
+
if (this._inflight) return this._inflight;
|
|
87
|
+
const inflight = (async () => {
|
|
88
|
+
const result = await this._fetchAllAndRender();
|
|
89
|
+
return result;
|
|
90
|
+
})().finally(() => {
|
|
91
|
+
this._inflight = null;
|
|
92
|
+
});
|
|
93
|
+
this._inflight = inflight;
|
|
94
|
+
return this._inflight;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sync upstream → load → render. The orchestration sequence for the
|
|
99
|
+
* "refresh external comments" button and PR-page load.
|
|
100
|
+
*
|
|
101
|
+
* Shares `this._inflight` with `loadAndRender`: while a sync+load is in
|
|
102
|
+
* flight, any GET-only caller (analysis rebuild, whitespace toggle) that
|
|
103
|
+
* calls `loadAndRender` joins this promise instead of racing the POST
|
|
104
|
+
* with a stale GET. The POST happens BEFORE the GET so the GET sees the
|
|
105
|
+
* latest mirror.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} options
|
|
108
|
+
* @param {() => Promise<{count: number, lostAnchors: number, deleted: number, syncedAt: string}>} options.syncFn -
|
|
109
|
+
* Async function that performs the POST /external-comments/sync. Injected so the manager
|
|
110
|
+
* doesn't have to know about pr.js — keeps it testable and source-agnostic.
|
|
111
|
+
* @returns {Promise<{errors: Array, syncResult: Object|null, syncError: Error|null}>}
|
|
112
|
+
*/
|
|
113
|
+
syncAndRender({ syncFn } = {}) {
|
|
114
|
+
if (this._inflight) return this._inflight;
|
|
115
|
+
const inflight = (async () => {
|
|
116
|
+
let syncResult = null;
|
|
117
|
+
let syncError = null;
|
|
118
|
+
if (typeof syncFn === 'function') {
|
|
119
|
+
try {
|
|
120
|
+
syncResult = await syncFn();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Sync failure shouldn't block render — we may have cached rows
|
|
123
|
+
// from a previous run. The caller is responsible for surfacing
|
|
124
|
+
// the failure (toast, etc.); we just keep going.
|
|
125
|
+
syncError = err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const renderResult = await this._fetchAllAndRender();
|
|
129
|
+
return { ...renderResult, syncResult, syncError };
|
|
130
|
+
})().finally(() => {
|
|
131
|
+
this._inflight = null;
|
|
132
|
+
});
|
|
133
|
+
this._inflight = inflight;
|
|
134
|
+
return this._inflight;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Internal helper shared by loadAndRender and syncAndRender. Pulls
|
|
139
|
+
* threads for each configured source and re-renders. Failures in one
|
|
140
|
+
* source don't abort the rest.
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
async _fetchAllAndRender() {
|
|
144
|
+
const errors = [];
|
|
145
|
+
for (const source of this.sources) {
|
|
146
|
+
try {
|
|
147
|
+
await this.fetch(source);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
errors.push({ source, err });
|
|
150
|
+
// Toast already shown in fetch()
|
|
151
|
+
if (typeof console !== 'undefined') {
|
|
152
|
+
console.warn(`[ExternalCommentManager] Failed to fetch ${source}:`, err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await this.render();
|
|
157
|
+
// Hand off the flattened thread list to the Review panel so its
|
|
158
|
+
// External segment stays in sync with the inline rows.
|
|
159
|
+
this._notifyPanel();
|
|
160
|
+
return { errors };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Flatten threadsBySource into a single array. The Review panel does
|
|
165
|
+
* not care about source grouping in its list — sort + display keys are
|
|
166
|
+
* file + line — so a flat union is the right shape to hand off.
|
|
167
|
+
*
|
|
168
|
+
* @returns {Array<Object>} The flattened thread roots.
|
|
169
|
+
*/
|
|
170
|
+
getAllThreads() {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const threads of this.threadsBySource.values()) {
|
|
173
|
+
if (Array.isArray(threads)) {
|
|
174
|
+
for (const thread of threads) out.push(thread);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Push the current flattened thread list onto the Review panel's
|
|
182
|
+
* External segment. No-op when the panel isn't ready (e.g. before
|
|
183
|
+
* DOMContentLoaded or in tests that don't initialize it).
|
|
184
|
+
* @private
|
|
185
|
+
*/
|
|
186
|
+
_notifyPanel() {
|
|
187
|
+
if (typeof window === 'undefined') return;
|
|
188
|
+
const panel = window.aiPanel;
|
|
189
|
+
if (!panel || typeof panel.setExternalThreads !== 'function') return;
|
|
190
|
+
try {
|
|
191
|
+
panel.setExternalThreads(this.getAllThreads());
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (typeof console !== 'undefined') {
|
|
194
|
+
console.warn('[ExternalCommentManager] setExternalThreads threw', err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ------------------------------------------------------------------
|
|
200
|
+
// Render
|
|
201
|
+
// ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render all cached threads into the diff. Idempotent: clears any
|
|
205
|
+
* previously rendered external-comment rows first.
|
|
206
|
+
*
|
|
207
|
+
* Async because `_renderThread` may await `prManager.ensureLinesVisible`
|
|
208
|
+
* when an outdated comment's anchor row is collapsed.
|
|
209
|
+
*/
|
|
210
|
+
async render() {
|
|
211
|
+
this.clear();
|
|
212
|
+
for (const [, threads] of this.threadsBySource) {
|
|
213
|
+
if (!Array.isArray(threads)) continue;
|
|
214
|
+
for (const thread of threads) {
|
|
215
|
+
try {
|
|
216
|
+
await this._renderThread(thread);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (typeof console !== 'undefined') {
|
|
219
|
+
console.warn('[ExternalCommentManager] Failed to render thread', thread?.id, err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Rebuild minimize-mode indicators so external rows are reflected in
|
|
226
|
+
// the per-line count badges. Mirrors what comment-manager and
|
|
227
|
+
// suggestion-manager do on render. No-op when the minimizer isn't
|
|
228
|
+
// active (refreshIndicators short-circuits on `!this._active`).
|
|
229
|
+
try {
|
|
230
|
+
if (typeof window !== 'undefined' && window.prManager && window.prManager.commentMinimizer) {
|
|
231
|
+
window.prManager.commentMinimizer.refreshIndicators();
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (typeof console !== 'undefined') {
|
|
235
|
+
console.warn('[ExternalCommentManager] minimizer.refreshIndicators threw', err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Remove all `.external-comment-row` rows we own from the DOM.
|
|
242
|
+
* Leaves `.user-comment-row` and `.ai-suggestion-row` rows untouched.
|
|
243
|
+
*/
|
|
244
|
+
clear() {
|
|
245
|
+
if (typeof document === 'undefined') return;
|
|
246
|
+
const rows = document.querySelectorAll('.external-comment-row');
|
|
247
|
+
rows.forEach((row) => row.remove());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ------------------------------------------------------------------
|
|
251
|
+
// Internals
|
|
252
|
+
// ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve the diff row that an external comment / thread root anchors to.
|
|
256
|
+
* Uses the project-wide DiffRenderer file lookup when available, and the
|
|
257
|
+
* LineTracker side-aware coordinate lookup when present — both already
|
|
258
|
+
* power the suggestion-manager and comment-manager renderers, so behavior
|
|
259
|
+
* stays consistent across the three.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} file
|
|
262
|
+
* @param {number} line
|
|
263
|
+
* @param {string} side - 'LEFT' or 'RIGHT'
|
|
264
|
+
* @returns {Element|null}
|
|
265
|
+
*/
|
|
266
|
+
_findDiffLineRow(file, line, side) {
|
|
267
|
+
if (typeof document === 'undefined') return null;
|
|
268
|
+
if (!file || !Number.isFinite(line)) return null;
|
|
269
|
+
|
|
270
|
+
let fileElement = null;
|
|
271
|
+
if (typeof window !== 'undefined' && window.DiffRenderer && typeof window.DiffRenderer.findFileElement === 'function') {
|
|
272
|
+
fileElement = window.DiffRenderer.findFileElement(file);
|
|
273
|
+
}
|
|
274
|
+
if (!fileElement) {
|
|
275
|
+
try {
|
|
276
|
+
const escaped = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
|
|
277
|
+
? globalThis.CSS.escape(file)
|
|
278
|
+
: file;
|
|
279
|
+
fileElement = document.querySelector(`[data-file-name="${escaped}"]`);
|
|
280
|
+
} catch {
|
|
281
|
+
fileElement = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!fileElement) return null;
|
|
285
|
+
|
|
286
|
+
const wantedSide = side || 'RIGHT';
|
|
287
|
+
const rows = fileElement.querySelectorAll('tr');
|
|
288
|
+
const lineTracker = (typeof window !== 'undefined' && window.LineTracker)
|
|
289
|
+
? new window.LineTracker()
|
|
290
|
+
: null;
|
|
291
|
+
|
|
292
|
+
for (const row of rows) {
|
|
293
|
+
let lineNum = null;
|
|
294
|
+
if (lineTracker && typeof lineTracker.getLineNumber === 'function') {
|
|
295
|
+
lineNum = lineTracker.getLineNumber(row, wantedSide);
|
|
296
|
+
} else {
|
|
297
|
+
// Minimal fallback: dataset.lineNumber + dataset.side check
|
|
298
|
+
const ds = row.dataset || {};
|
|
299
|
+
if (wantedSide === 'LEFT' && ds.oldLineNumber) {
|
|
300
|
+
lineNum = parseInt(ds.oldLineNumber, 10);
|
|
301
|
+
} else if (wantedSide === 'RIGHT' && ds.newLineNumber) {
|
|
302
|
+
lineNum = parseInt(ds.newLineNumber, 10);
|
|
303
|
+
} else if (ds.lineNumber && (!ds.side || ds.side === wantedSide)) {
|
|
304
|
+
lineNum = parseInt(ds.lineNumber, 10);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (lineNum === line) return row;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Compute the (file, line, side) anchor for a comment, accounting for
|
|
314
|
+
* outdated rows that fall back to the original anchor.
|
|
315
|
+
* Returns null if the comment cannot be anchored.
|
|
316
|
+
*/
|
|
317
|
+
_resolveAnchor(comment) {
|
|
318
|
+
if (!comment || !comment.file) return null;
|
|
319
|
+
const side = comment.side || 'RIGHT';
|
|
320
|
+
const outdated = comment.is_outdated === 1 || comment.is_outdated === true;
|
|
321
|
+
|
|
322
|
+
// Treat `is_outdated` as a hint about which anchor to PREFER, not a
|
|
323
|
+
// strict switch. The GitHub adapter couples is_outdated with line_end
|
|
324
|
+
// being null, but future adapters (GitLab, Linear) won't necessarily
|
|
325
|
+
// observe that invariant. Falling back to the other anchor when our
|
|
326
|
+
// preferred one is missing keeps cells like "outdated row that still
|
|
327
|
+
// has a live line_end" or "non-outdated row missing line_end"
|
|
328
|
+
// renderable instead of silently dropped.
|
|
329
|
+
const live = Number.isFinite(comment.line_end) ? comment.line_end : null;
|
|
330
|
+
const orig = Number.isFinite(comment.original_line_end) ? comment.original_line_end : null;
|
|
331
|
+
const line = outdated ? (orig ?? live) : (live ?? orig);
|
|
332
|
+
if (line == null) return null;
|
|
333
|
+
return { file: comment.file, line, side };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Render a single thread (root + replies) into the diff.
|
|
338
|
+
*
|
|
339
|
+
* When the anchor line isn't initially in the DOM (collapsed context for
|
|
340
|
+
* outdated comments is the common case) we ask the PR manager to expand
|
|
341
|
+
* any hidden lines that cover the anchor and re-look-up before giving up.
|
|
342
|
+
* If still missing we drop to a file-level fallback so the discussion
|
|
343
|
+
* stays visible rather than disappearing silently.
|
|
344
|
+
*
|
|
345
|
+
* Async because `PRManager.ensureLinesVisible` returns a Promise — we
|
|
346
|
+
* MUST await it before re-looking-up, otherwise the freshly-materialized
|
|
347
|
+
* row is not yet in the DOM when we ask for it.
|
|
348
|
+
* @private
|
|
349
|
+
*/
|
|
350
|
+
async _renderThread(thread) {
|
|
351
|
+
if (!thread) return;
|
|
352
|
+
const anchor = this._resolveAnchor(thread);
|
|
353
|
+
if (!anchor) {
|
|
354
|
+
const key = `${thread.source}:${thread.id}`;
|
|
355
|
+
if (!this._anchorWarnings.has(key)) {
|
|
356
|
+
this._anchorWarnings.add(key);
|
|
357
|
+
if (typeof console !== 'undefined') {
|
|
358
|
+
console.warn('[ExternalCommentManager] Skipping thread with no anchor', thread.id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let targetRow = this._findDiffLineRow(anchor.file, anchor.line, anchor.side);
|
|
365
|
+
|
|
366
|
+
// Outdated discussions often land on lines that the diff renderer
|
|
367
|
+
// collapsed behind an "expand context" gap. Ask the PR manager (when
|
|
368
|
+
// present) to expand the gap, then await before retrying the lookup.
|
|
369
|
+
// PRManager.ensureLinesVisible takes an array of items and returns a
|
|
370
|
+
// Promise — must await or the row isn't in the DOM yet.
|
|
371
|
+
if (!targetRow && this._canEnsureLinesVisible()) {
|
|
372
|
+
try {
|
|
373
|
+
await window.prManager.ensureLinesVisible([
|
|
374
|
+
{
|
|
375
|
+
file: anchor.file,
|
|
376
|
+
line_start: anchor.line,
|
|
377
|
+
line_end: anchor.line,
|
|
378
|
+
side: anchor.side
|
|
379
|
+
}
|
|
380
|
+
]);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (typeof console !== 'undefined') {
|
|
383
|
+
console.warn('[ExternalCommentManager] ensureLinesVisible threw, falling back', err);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
targetRow = this._findDiffLineRow(anchor.file, anchor.line, anchor.side);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!targetRow) {
|
|
390
|
+
// File-level fallback: outdated discussions that we can't anchor
|
|
391
|
+
// precisely still need to be discoverable. Render them at the top of
|
|
392
|
+
// the file wrapper so the reviewer can find them via the file panel.
|
|
393
|
+
const fallbackTarget = this._resolveFileFallbackTarget(anchor.file);
|
|
394
|
+
if (fallbackTarget) {
|
|
395
|
+
const externalRow = this._buildThreadRow(thread, fallbackTarget);
|
|
396
|
+
if (externalRow) {
|
|
397
|
+
externalRow.classList.add('external-comment-row--file-fallback');
|
|
398
|
+
this._insertAtOrderedPosition(fallbackTarget, externalRow);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (typeof console !== 'undefined') {
|
|
404
|
+
console.warn(`[ExternalCommentManager] Could not find diff row for ${anchor.file}:${anchor.line} (${anchor.side})`);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const externalRow = this._buildThreadRow(thread, targetRow);
|
|
410
|
+
if (!externalRow) return;
|
|
411
|
+
this._insertAtOrderedPosition(targetRow, externalRow);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @returns {boolean} true when `window.prManager.ensureLinesVisible` is callable.
|
|
416
|
+
* @private
|
|
417
|
+
*/
|
|
418
|
+
_canEnsureLinesVisible() {
|
|
419
|
+
return (
|
|
420
|
+
typeof window !== 'undefined' &&
|
|
421
|
+
window.prManager &&
|
|
422
|
+
typeof window.prManager.ensureLinesVisible === 'function'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Find a fallback insertion target for a file when no diff row matches
|
|
428
|
+
* the comment's anchor (e.g. outdated comment whose original line is no
|
|
429
|
+
* longer in the diff at all). Returns the first <tr> in the file
|
|
430
|
+
* wrapper's table so the thread renders at file-level. Returns null when
|
|
431
|
+
* the file wrapper itself isn't in the DOM.
|
|
432
|
+
* @private
|
|
433
|
+
*/
|
|
434
|
+
_resolveFileFallbackTarget(file) {
|
|
435
|
+
if (typeof document === 'undefined' || !file) return null;
|
|
436
|
+
let fileElement = null;
|
|
437
|
+
if (typeof window !== 'undefined' && window.DiffRenderer && typeof window.DiffRenderer.findFileElement === 'function') {
|
|
438
|
+
fileElement = window.DiffRenderer.findFileElement(file);
|
|
439
|
+
}
|
|
440
|
+
if (!fileElement) {
|
|
441
|
+
try {
|
|
442
|
+
const escaped = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
|
|
443
|
+
? globalThis.CSS.escape(file)
|
|
444
|
+
: file;
|
|
445
|
+
fileElement = document.querySelector(`[data-file-name="${escaped}"]`);
|
|
446
|
+
} catch {
|
|
447
|
+
fileElement = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!fileElement) return null;
|
|
451
|
+
return fileElement.querySelector('tr');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build the `<tr class="external-comment-row">` wrapper containing the
|
|
456
|
+
* root comment, replies, and per-thread actions.
|
|
457
|
+
* @private
|
|
458
|
+
*/
|
|
459
|
+
_buildThreadRow(thread, targetRow) {
|
|
460
|
+
if (typeof document === 'undefined') return null;
|
|
461
|
+
const tr = document.createElement('tr');
|
|
462
|
+
tr.className = 'external-comment-row';
|
|
463
|
+
tr.dataset.threadId = thread.id != null ? String(thread.id) : '';
|
|
464
|
+
tr.dataset.source = thread.source || '';
|
|
465
|
+
|
|
466
|
+
const td = document.createElement('td');
|
|
467
|
+
// Match user-comment-row colspan of 4 used elsewhere in the diff table
|
|
468
|
+
td.colSpan = this._resolveColSpan(targetRow);
|
|
469
|
+
td.className = 'external-comment-cell';
|
|
470
|
+
|
|
471
|
+
const threadEl = document.createElement('div');
|
|
472
|
+
threadEl.className = 'external-comment-thread';
|
|
473
|
+
|
|
474
|
+
// Root comment
|
|
475
|
+
const rootEl = this._buildCommentElement(thread, { isReply: false });
|
|
476
|
+
threadEl.appendChild(rootEl);
|
|
477
|
+
|
|
478
|
+
// Replies
|
|
479
|
+
const replies = Array.isArray(thread.replies) ? thread.replies : [];
|
|
480
|
+
for (const reply of replies) {
|
|
481
|
+
const replyEl = this._buildCommentElement(reply, { isReply: true });
|
|
482
|
+
threadEl.appendChild(replyEl);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
td.appendChild(threadEl);
|
|
486
|
+
tr.appendChild(td);
|
|
487
|
+
return tr;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Best-effort colSpan resolution. The diff renderer uses a 4-column
|
|
492
|
+
* layout (matches `.user-comment-cell` colSpan=4). Fall back to that
|
|
493
|
+
* if we can't introspect the target row's column count.
|
|
494
|
+
* @private
|
|
495
|
+
*/
|
|
496
|
+
_resolveColSpan(targetRow) {
|
|
497
|
+
try {
|
|
498
|
+
const cells = targetRow?.cells || targetRow?.children;
|
|
499
|
+
if (cells && cells.length) return cells.length;
|
|
500
|
+
} catch {
|
|
501
|
+
// ignore
|
|
502
|
+
}
|
|
503
|
+
return 4;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Build a single `.external-comment` element (root or reply).
|
|
508
|
+
* @private
|
|
509
|
+
*/
|
|
510
|
+
_buildCommentElement(comment, { isReply }) {
|
|
511
|
+
const escapeHtml = this._escapeHtml;
|
|
512
|
+
const source = comment.source || 'github';
|
|
513
|
+
|
|
514
|
+
const el = document.createElement('div');
|
|
515
|
+
const classes = ['external-comment', `source-${source}`];
|
|
516
|
+
if (isReply) classes.push('is-reply');
|
|
517
|
+
if (comment.is_outdated === 1 || comment.is_outdated === true) classes.push('is-outdated');
|
|
518
|
+
el.className = classes.join(' ');
|
|
519
|
+
el.dataset.commentId = comment.id != null ? String(comment.id) : '';
|
|
520
|
+
el.dataset.source = source;
|
|
521
|
+
if (comment.external_id != null) el.dataset.externalId = String(comment.external_id);
|
|
522
|
+
|
|
523
|
+
// ---- Header ----
|
|
524
|
+
const header = document.createElement('div');
|
|
525
|
+
header.className = 'external-comment-header';
|
|
526
|
+
|
|
527
|
+
const headerLeft = document.createElement('div');
|
|
528
|
+
headerLeft.className = 'external-comment-header-left';
|
|
529
|
+
|
|
530
|
+
// Author (link only when the URL is safe — falls back to plain text
|
|
531
|
+
// for `javascript:`, `data:`, or other non-navigational schemes).
|
|
532
|
+
if (comment.author_url && this._isSafeUrl(comment.author_url)) {
|
|
533
|
+
const a = document.createElement('a');
|
|
534
|
+
a.className = 'external-comment-author';
|
|
535
|
+
a.href = comment.author_url;
|
|
536
|
+
a.target = '_blank';
|
|
537
|
+
a.rel = 'noopener noreferrer';
|
|
538
|
+
a.textContent = comment.author || '';
|
|
539
|
+
headerLeft.appendChild(a);
|
|
540
|
+
} else {
|
|
541
|
+
const span = document.createElement('span');
|
|
542
|
+
span.className = 'external-comment-author';
|
|
543
|
+
span.textContent = comment.author || '';
|
|
544
|
+
headerLeft.appendChild(span);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Outdated badge
|
|
548
|
+
if (comment.is_outdated === 1 || comment.is_outdated === true) {
|
|
549
|
+
const badge = document.createElement('span');
|
|
550
|
+
badge.className = 'external-comment-outdated-badge';
|
|
551
|
+
badge.title = 'This comment was made against an earlier version of the file';
|
|
552
|
+
badge.textContent = 'outdated';
|
|
553
|
+
headerLeft.appendChild(badge);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Timestamp
|
|
557
|
+
const tsText = this._formatTimestamp(comment.external_created_at);
|
|
558
|
+
if (tsText) {
|
|
559
|
+
const ts = document.createElement('span');
|
|
560
|
+
ts.className = 'external-comment-timestamp';
|
|
561
|
+
ts.textContent = tsText;
|
|
562
|
+
if (comment.external_created_at) ts.title = comment.external_created_at;
|
|
563
|
+
headerLeft.appendChild(ts);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
header.appendChild(headerLeft);
|
|
567
|
+
|
|
568
|
+
// Right side of header: chat-about + permalink, mirroring the
|
|
569
|
+
// header-right layout used by user comments and AI suggestions.
|
|
570
|
+
const headerRight = document.createElement('div');
|
|
571
|
+
headerRight.className = 'external-comment-header-right';
|
|
572
|
+
|
|
573
|
+
const chatBtn = this._buildChatCommentButton(comment, { isReply });
|
|
574
|
+
headerRight.appendChild(chatBtn);
|
|
575
|
+
|
|
576
|
+
if (comment.external_url && this._isSafeUrl(comment.external_url)) {
|
|
577
|
+
const link = document.createElement('a');
|
|
578
|
+
link.className = 'external-comment-permalink';
|
|
579
|
+
link.href = comment.external_url;
|
|
580
|
+
link.target = '_blank';
|
|
581
|
+
link.rel = 'noopener noreferrer';
|
|
582
|
+
link.title = 'Open in source system';
|
|
583
|
+
link.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M7.75 2.5a.75.75 0 0 0 0 1.5h2.69L5.22 9.22a.75.75 0 1 0 1.06 1.06L11.5 5.06v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75ZM3.75 3A1.75 1.75 0 0 0 2 4.75v7.5C2 13.216 2.784 14 3.75 14h7.5A1.75 1.75 0 0 0 13 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5Z"/></svg>';
|
|
584
|
+
headerRight.appendChild(link);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
header.appendChild(headerRight);
|
|
588
|
+
el.appendChild(header);
|
|
589
|
+
|
|
590
|
+
// ---- Body ----
|
|
591
|
+
const body = document.createElement('div');
|
|
592
|
+
body.className = 'external-comment-body';
|
|
593
|
+
const bodyText = comment.body || '';
|
|
594
|
+
if (typeof window !== 'undefined' && typeof window.renderMarkdown === 'function') {
|
|
595
|
+
body.innerHTML = window.renderMarkdown(bodyText);
|
|
596
|
+
} else {
|
|
597
|
+
body.textContent = bodyText;
|
|
598
|
+
}
|
|
599
|
+
el.appendChild(body);
|
|
600
|
+
|
|
601
|
+
return el;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Build the per-comment chat button.
|
|
606
|
+
* - On the thread root (`isReply: false`), chats about the whole thread.
|
|
607
|
+
* - On a reply, chats about just that reply.
|
|
608
|
+
* @private
|
|
609
|
+
*/
|
|
610
|
+
_buildChatCommentButton(comment, { isReply } = {}) {
|
|
611
|
+
const btn = document.createElement('button');
|
|
612
|
+
btn.type = 'button';
|
|
613
|
+
btn.className = 'btn-chat-comment external-comment-chat-btn';
|
|
614
|
+
btn.title = isReply ? 'Chat about this comment' : 'Chat about thread';
|
|
615
|
+
btn.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><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"/></svg>';
|
|
616
|
+
|
|
617
|
+
btn.addEventListener('click', (e) => {
|
|
618
|
+
e.stopPropagation();
|
|
619
|
+
if (isReply) {
|
|
620
|
+
this._openCommentChat(comment);
|
|
621
|
+
} else {
|
|
622
|
+
this._openThreadChat(comment);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
return btn;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Dispatch chat-about-comment to the chat panel using the canonical
|
|
630
|
+
* `commentContext` shape (see plans/fetch-external-review-comments.md
|
|
631
|
+
* § 10 and Phase 4 task spec).
|
|
632
|
+
* @private
|
|
633
|
+
*/
|
|
634
|
+
_openCommentChat(comment) {
|
|
635
|
+
const panel = this._resolveChatPanel();
|
|
636
|
+
if (!panel || typeof panel.open !== 'function') {
|
|
637
|
+
// The panel is gone — surface that to the reviewer instead of
|
|
638
|
+
// silently dropping the click. Buttons should also be CSS-hidden via
|
|
639
|
+
// [data-chat='disabled'], this is the backstop for racing state.
|
|
640
|
+
this._toast('Chat is unavailable', 'warn');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const outdated = comment.is_outdated === 1 || comment.is_outdated === true;
|
|
644
|
+
panel.open({
|
|
645
|
+
commentContext: {
|
|
646
|
+
commentId: comment.id,
|
|
647
|
+
body: comment.body,
|
|
648
|
+
file: comment.file,
|
|
649
|
+
side: comment.side || 'RIGHT',
|
|
650
|
+
line_start: outdated ? comment.original_line_start : comment.line_start,
|
|
651
|
+
line_end: outdated ? comment.original_line_end : comment.line_end,
|
|
652
|
+
source: 'external',
|
|
653
|
+
externalSource: comment.source,
|
|
654
|
+
author: comment.author,
|
|
655
|
+
externalUrl: comment.external_url,
|
|
656
|
+
isOutdated: !!outdated,
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Dispatch chat-about-thread to the chat panel using the canonical
|
|
663
|
+
* `threadContext` shape.
|
|
664
|
+
* @private
|
|
665
|
+
*/
|
|
666
|
+
_openThreadChat(root) {
|
|
667
|
+
const panel = this._resolveChatPanel();
|
|
668
|
+
if (!panel || typeof panel.open !== 'function') {
|
|
669
|
+
this._toast('Chat is unavailable', 'warn');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const outdated = root.is_outdated === 1 || root.is_outdated === true;
|
|
673
|
+
const replies = Array.isArray(root.replies) ? root.replies : [];
|
|
674
|
+
panel.open({
|
|
675
|
+
threadContext: {
|
|
676
|
+
rootId: root.id,
|
|
677
|
+
source: 'external',
|
|
678
|
+
externalSource: root.source,
|
|
679
|
+
file: root.file,
|
|
680
|
+
side: root.side || 'RIGHT',
|
|
681
|
+
line_start: outdated ? root.original_line_start : root.line_start,
|
|
682
|
+
line_end: outdated ? root.original_line_end : root.line_end,
|
|
683
|
+
comments: [
|
|
684
|
+
{
|
|
685
|
+
author: root.author,
|
|
686
|
+
body: root.body,
|
|
687
|
+
isOutdated: !!outdated,
|
|
688
|
+
externalUrl: root.external_url,
|
|
689
|
+
externalCreatedAt: root.external_created_at,
|
|
690
|
+
},
|
|
691
|
+
...replies.map((r) => ({
|
|
692
|
+
author: r.author,
|
|
693
|
+
body: r.body,
|
|
694
|
+
isOutdated: !!(r.is_outdated === 1 || r.is_outdated === true),
|
|
695
|
+
externalUrl: r.external_url,
|
|
696
|
+
externalCreatedAt: r.external_created_at,
|
|
697
|
+
})),
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Resolve the chat panel reference late so callers that attach
|
|
705
|
+
* `window.chatPanel` after this manager is constructed still work.
|
|
706
|
+
* @private
|
|
707
|
+
*/
|
|
708
|
+
_resolveChatPanel() {
|
|
709
|
+
if (this.chatPanel) return this.chatPanel;
|
|
710
|
+
if (typeof window !== 'undefined' && window.chatPanel) {
|
|
711
|
+
this.chatPanel = window.chatPanel;
|
|
712
|
+
return this.chatPanel;
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Insert the external comment row at the correct position after the diff
|
|
719
|
+
* line, preserving the ordering rule:
|
|
720
|
+
* AI suggestions → user comments → external comments
|
|
721
|
+
*
|
|
722
|
+
* Walk forward from `targetRow.nextSibling` while we see rows that should
|
|
723
|
+
* come BEFORE us (AI suggestion rows, user comment rows, or already-
|
|
724
|
+
* existing external comment rows for the same diff line). Insert before
|
|
725
|
+
* the first non-comment row we encounter, or at the end of the table.
|
|
726
|
+
* @private
|
|
727
|
+
*/
|
|
728
|
+
_insertAtOrderedPosition(targetRow, externalRow) {
|
|
729
|
+
const parent = targetRow.parentNode;
|
|
730
|
+
if (!parent) return;
|
|
731
|
+
let insertBefore = targetRow.nextSibling;
|
|
732
|
+
while (insertBefore && this._isOwnedCommentRow(insertBefore)) {
|
|
733
|
+
insertBefore = insertBefore.nextSibling;
|
|
734
|
+
}
|
|
735
|
+
if (insertBefore) {
|
|
736
|
+
parent.insertBefore(externalRow, insertBefore);
|
|
737
|
+
} else {
|
|
738
|
+
parent.appendChild(externalRow);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Returns true if the given node is a comment-like row that belongs
|
|
744
|
+
* to one of the three diff renderers and should remain ABOVE this
|
|
745
|
+
* new external-comment row.
|
|
746
|
+
* @private
|
|
747
|
+
*/
|
|
748
|
+
_isOwnedCommentRow(node) {
|
|
749
|
+
if (!node || node.nodeType !== 1 /* ELEMENT_NODE */) return false;
|
|
750
|
+
const cls = node.classList;
|
|
751
|
+
if (!cls) return false;
|
|
752
|
+
return (
|
|
753
|
+
cls.contains('ai-suggestion-row') ||
|
|
754
|
+
cls.contains('user-comment-row') ||
|
|
755
|
+
cls.contains('external-comment-row')
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ------------------------------------------------------------------
|
|
760
|
+
// Small helpers
|
|
761
|
+
// ------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Show a toast via the project-wide toast helper. Falls back to console
|
|
765
|
+
* when the helper isn't available (e.g. unit tests).
|
|
766
|
+
* @private
|
|
767
|
+
*/
|
|
768
|
+
_toast(message, level = 'error') {
|
|
769
|
+
if (typeof window === 'undefined') return;
|
|
770
|
+
const t = window.toast;
|
|
771
|
+
if (t) {
|
|
772
|
+
if (level === 'error' && typeof t.showError === 'function') return t.showError(message);
|
|
773
|
+
if (level === 'warn' && typeof t.showWarning === 'function') return t.showWarning(message);
|
|
774
|
+
if (level === 'info' && typeof t.showInfo === 'function') return t.showInfo(message);
|
|
775
|
+
if (level === 'success' && typeof t.showSuccess === 'function') return t.showSuccess(message);
|
|
776
|
+
}
|
|
777
|
+
if (typeof window.showToast === 'function') {
|
|
778
|
+
return window.showToast(message, level);
|
|
779
|
+
}
|
|
780
|
+
// Last-resort log only — never throw from a toast helper.
|
|
781
|
+
if (typeof console !== 'undefined') {
|
|
782
|
+
console.warn(`[ExternalCommentManager] toast(${level}): ${message}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Format an ISO timestamp into a short relative description.
|
|
788
|
+
* Falls back to the raw string on parse failure.
|
|
789
|
+
* @private
|
|
790
|
+
*/
|
|
791
|
+
_formatTimestamp(iso) {
|
|
792
|
+
if (!iso) return '';
|
|
793
|
+
const date = new Date(iso);
|
|
794
|
+
if (isNaN(date.getTime())) return iso;
|
|
795
|
+
const now = Date.now();
|
|
796
|
+
const diffMs = now - date.getTime();
|
|
797
|
+
const seconds = Math.round(diffMs / 1000);
|
|
798
|
+
if (seconds < 60) return 'just now';
|
|
799
|
+
const minutes = Math.round(seconds / 60);
|
|
800
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
801
|
+
const hours = Math.round(minutes / 60);
|
|
802
|
+
if (hours < 24) return `${hours}h ago`;
|
|
803
|
+
const days = Math.round(hours / 24);
|
|
804
|
+
if (days < 30) return `${days}d ago`;
|
|
805
|
+
const months = Math.round(days / 30);
|
|
806
|
+
if (months < 12) return `${months}mo ago`;
|
|
807
|
+
const years = Math.round(months / 12);
|
|
808
|
+
return `${years}y ago`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Allow only http/https/mailto URLs. Used to gate `<a href>` attributes
|
|
813
|
+
* built from server-supplied data so a malicious upstream cannot smuggle
|
|
814
|
+
* `javascript:` or `data:` URLs into our DOM.
|
|
815
|
+
* @private
|
|
816
|
+
*/
|
|
817
|
+
_isSafeUrl(url) {
|
|
818
|
+
if (typeof url !== 'string' || !url) return false;
|
|
819
|
+
const trimmed = url.trim();
|
|
820
|
+
if (!trimmed) return false;
|
|
821
|
+
// Relative URLs are safe — they resolve under our origin.
|
|
822
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?')) return true;
|
|
823
|
+
try {
|
|
824
|
+
const u = new URL(trimmed, (typeof window !== 'undefined' && window.location) ? window.location.href : 'http://localhost/');
|
|
825
|
+
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:';
|
|
826
|
+
} catch {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Minimal HTML escape used as a fallback when window helpers aren't loaded.
|
|
833
|
+
* @private
|
|
834
|
+
*/
|
|
835
|
+
_escapeHtml(s) {
|
|
836
|
+
if (s == null) return '';
|
|
837
|
+
return String(s)
|
|
838
|
+
.replace(/&/g, '&')
|
|
839
|
+
.replace(/</g, '<')
|
|
840
|
+
.replace(/>/g, '>')
|
|
841
|
+
.replace(/"/g, '"')
|
|
842
|
+
.replace(/'/g, ''');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Browser singleton — instantiated on DOMContentLoaded so the PR page
|
|
847
|
+
// can immediately `await window.externalCommentManager.loadAndRender()`
|
|
848
|
+
// once the review id is known. Callers should set `reviewId` and
|
|
849
|
+
// `sources` before invoking `loadAndRender` (see PR page wiring).
|
|
850
|
+
if (typeof window !== 'undefined') {
|
|
851
|
+
window.ExternalCommentManager = ExternalCommentManager;
|
|
852
|
+
|
|
853
|
+
if (typeof document !== 'undefined' && !window.externalCommentManager) {
|
|
854
|
+
const initSingleton = () => {
|
|
855
|
+
if (!window.externalCommentManager) {
|
|
856
|
+
window.externalCommentManager = new ExternalCommentManager({});
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
if (document.readyState === 'loading') {
|
|
860
|
+
document.addEventListener('DOMContentLoaded', initSingleton);
|
|
861
|
+
} else {
|
|
862
|
+
initSingleton();
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Export for Node.js testing
|
|
868
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
869
|
+
module.exports = { ExternalCommentManager };
|
|
870
|
+
}
|