@in-the-loop-labs/pair-review 3.5.2 → 3.6.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/package.json +15 -20
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/pr.css +603 -6
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/public/js/pr.js
CHANGED
|
@@ -138,6 +138,95 @@ class PRManager {
|
|
|
138
138
|
this.diffOptionsDropdown = null;
|
|
139
139
|
// Comment minimizer — manages minimize mode indicators
|
|
140
140
|
this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
|
|
141
|
+
// Hunk summary renderer (Phase 5) — inline natural-language summaries
|
|
142
|
+
this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
|
|
143
|
+
// Per-render anchor map: contentHash -> first code-line <tr> of the hunk
|
|
144
|
+
this._summaryAnchorsByHash = new Map();
|
|
145
|
+
// Per-render file map: filePath -> Set<contentHash>
|
|
146
|
+
this._summaryHashesByFile = new Map();
|
|
147
|
+
// Summaries that arrived (via WS or fetch) before their hunk had been hashed
|
|
148
|
+
this._pendingSummariesByHash = new Map();
|
|
149
|
+
// Per-file summary visibility (persisted in localStorage per-review)
|
|
150
|
+
this.summariesHiddenFiles = new Set();
|
|
151
|
+
// Render-generation token — incremented at the top of renderDiff() so any
|
|
152
|
+
// fire-and-forget _kickOffHunkSummaries() from a prior render can detect
|
|
153
|
+
// it's stale and bail (refresh / whitespace toggle / scope change race).
|
|
154
|
+
this._renderGen = 0;
|
|
155
|
+
// Cached /api/config response (lazy-loaded)
|
|
156
|
+
this._appConfigPromise = null;
|
|
157
|
+
// Review-level summary visibility (persisted in localStorage per-review)
|
|
158
|
+
this._summariesHidden = false;
|
|
159
|
+
// Whether the background summary job is currently running for this review.
|
|
160
|
+
// Cleared by the `review:background_job_finished` event for jobType=summaries.
|
|
161
|
+
this._summariesGenerating = false;
|
|
162
|
+
// Whether any hunk summaries exist for this review (loaded from the server
|
|
163
|
+
// or mounted live during generation). Gates the toolbar button's `.active`
|
|
164
|
+
// (blue) state: before any summary exists the button stays colorless so the
|
|
165
|
+
// user can tell nothing has been generated yet. Set true by
|
|
166
|
+
// `_applyHunkSummaries` and the initial existing-summaries fetch.
|
|
167
|
+
this._summariesGenerated = false;
|
|
168
|
+
// Tri-state: true when /api/config reports summaries.enabled, false when
|
|
169
|
+
// disabled, null until /api/config resolves. Per-file toggle buttons are
|
|
170
|
+
// gated on this so users on disabled deployments don't see them flicker
|
|
171
|
+
// in.
|
|
172
|
+
this._summariesEnabled = null;
|
|
173
|
+
// Tri-state mirror of `summaries.auto_generate` in /api/config. When
|
|
174
|
+
// false, the click handler hits the manual-start endpoint instead of
|
|
175
|
+
// expecting a server-initiated kickoff. Null until /api/config resolves.
|
|
176
|
+
this._summariesAutoGenerate = null;
|
|
177
|
+
// ---- Tour state (Phase 8) -----------------------------------------
|
|
178
|
+
// Lazy-instantiated TourBar / TourRenderer; populated on first open.
|
|
179
|
+
this._tourBar = null;
|
|
180
|
+
this._tourRenderer = null;
|
|
181
|
+
// Tri-state mirror of `tours.enabled` in /api/config. Tours are
|
|
182
|
+
// independent of summaries on both the server and the client (see
|
|
183
|
+
// the explanatory comment in setupEventHandlers()).
|
|
184
|
+
this._toursEnabled = null;
|
|
185
|
+
// Tri-state mirror of `tours.auto_generate` in /api/config. When false,
|
|
186
|
+
// the click handler hits the manual-start endpoint instead of expecting
|
|
187
|
+
// a server-initiated kickoff. Null until /api/config resolves.
|
|
188
|
+
this._toursAutoGenerate = null;
|
|
189
|
+
// Cached stops from the most recent /api/reviews/:id/tour fetch, or null
|
|
190
|
+
// when nothing has been loaded for this review yet.
|
|
191
|
+
this._tourStops = null;
|
|
192
|
+
// 0-based index of the current stop while a tour is open; -1 when no
|
|
193
|
+
// tour is mounted.
|
|
194
|
+
this._tourActiveIndex = -1;
|
|
195
|
+
// Whether the background tour-generation job is currently running; drives
|
|
196
|
+
// the pulse on the toolbar button.
|
|
197
|
+
this._tourGenerating = false;
|
|
198
|
+
// When a `review:tour_ready` event fires while a tour is already mounted,
|
|
199
|
+
// we stash the new stops here rather than yank the current tour out from
|
|
200
|
+
// under the user. The pending stops are applied on the next exit or
|
|
201
|
+
// restart. Cleared once consumed.
|
|
202
|
+
this._tourStopsPendingRestart = null;
|
|
203
|
+
// Bound keydown handler; tracked so it can be removed on tour exit.
|
|
204
|
+
this._tourKeydownHandler = null;
|
|
205
|
+
// Re-entry guard for `_promptCancelJob`. The cancel-confirm dialog
|
|
206
|
+
// opens off the same pulsing toolbar button that triggered it, and
|
|
207
|
+
// the button stays clickable while the dialog is up — `ConfirmDialog`
|
|
208
|
+
// is a singleton, so a second click would overwrite the first
|
|
209
|
+
// invocation's callbacks and leave its Promise dangling. Held true
|
|
210
|
+
// for the lifetime of the dialog; cleared in a `finally`.
|
|
211
|
+
this._cancelPromptOpen = false;
|
|
212
|
+
// Re-entrance latch for the async `_advanceTour` probe loop. Holds the
|
|
213
|
+
// `_tourGen` value the in-flight call belongs to (or -1 when no call
|
|
214
|
+
// is in flight). Generation-scoped — not a plain boolean — so a
|
|
215
|
+
// teardown that bumps `_tourGen` auto-invalidates the holder and the
|
|
216
|
+
// next reopen passes the latch check without any teardown path having
|
|
217
|
+
// to remember to reset the flag explicitly.
|
|
218
|
+
this._advanceInFlightGen = -1;
|
|
219
|
+
// Tour-open generation. Bumped on every open and exit so an in-flight
|
|
220
|
+
// async `_advanceTour` can detect that the tour it started navigating
|
|
221
|
+
// has since been torn down (Escape, exit button, toolbar toggle) and
|
|
222
|
+
// bail instead of mutating a dead tour's state.
|
|
223
|
+
this._tourGen = 0;
|
|
224
|
+
// Drain promise stashed by `_exitTour`. Resolves once every async
|
|
225
|
+
// teardown step from the prior tour (fire-and-forget context-file
|
|
226
|
+
// DELETEs + their loadContextFiles reloads) has settled. The next
|
|
227
|
+
// open awaits this before reading wrappers so a stale DELETE can't
|
|
228
|
+
// rip the newly-mounted tour's wrapper out from under it.
|
|
229
|
+
this._tourCleanupPending = null;
|
|
141
230
|
// Cached staleness check promise — shared between on-load and triggerAIAnalysis
|
|
142
231
|
this._stalenessPromise = null;
|
|
143
232
|
// Unique client ID for self-echo suppression on WebSocket review events.
|
|
@@ -320,6 +409,67 @@ class PRManager {
|
|
|
320
409
|
}
|
|
321
410
|
}
|
|
322
411
|
|
|
412
|
+
// Hunk summary toolbar toggle (Phase 5).
|
|
413
|
+
// Hidden by default in HTML; revealed asynchronously when /api/config
|
|
414
|
+
// reports summaries.enabled. The same config check also controls the
|
|
415
|
+
// per-file `.file-header-summary-toggle` buttons (which are created
|
|
416
|
+
// hidden by createFileHeader and revealed/removed once config resolves).
|
|
417
|
+
const summaryToggle = document.getElementById('summary-toggle-btn');
|
|
418
|
+
if (summaryToggle) {
|
|
419
|
+
summaryToggle.addEventListener('click', () => this._handleSummaryToggleClick());
|
|
420
|
+
}
|
|
421
|
+
this._getAppConfig().then((cfg) => {
|
|
422
|
+
const summariesCfg = (cfg && cfg.summaries) || {};
|
|
423
|
+
this._summariesEnabled = summariesCfg.enabled === true;
|
|
424
|
+
this._summariesAutoGenerate = summariesCfg.auto_generate !== false;
|
|
425
|
+
if (this._summariesEnabled) {
|
|
426
|
+
if (summaryToggle) {
|
|
427
|
+
summaryToggle.style.display = '';
|
|
428
|
+
this._syncSummaryToolbarButton();
|
|
429
|
+
}
|
|
430
|
+
document
|
|
431
|
+
.querySelectorAll('.file-header-summary-toggle.summary-toggle-pending')
|
|
432
|
+
.forEach((btn) => {
|
|
433
|
+
btn.classList.remove('summary-toggle-pending');
|
|
434
|
+
btn.style.display = '';
|
|
435
|
+
});
|
|
436
|
+
} else {
|
|
437
|
+
document
|
|
438
|
+
.querySelectorAll('.file-header-summary-toggle')
|
|
439
|
+
.forEach((btn) => btn.remove());
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Tour toolbar toggle (Phase 8). Hidden by default in HTML; revealed
|
|
444
|
+
// asynchronously once /api/config confirms `tours.enabled` is on.
|
|
445
|
+
// Tours are independent of `summaries.enabled` — both server- and
|
|
446
|
+
// client-side gates check only `tours.enabled`.
|
|
447
|
+
const tourToggle = document.getElementById('tour-toggle-btn');
|
|
448
|
+
if (tourToggle) {
|
|
449
|
+
tourToggle.addEventListener('click', () => this._handleTourToggleClick());
|
|
450
|
+
}
|
|
451
|
+
this._getAppConfig().then((cfg) => {
|
|
452
|
+
const toursCfg = (cfg && cfg.tours) || {};
|
|
453
|
+
this._toursEnabled = toursCfg.enabled === true;
|
|
454
|
+
this._toursAutoGenerate = toursCfg.auto_generate !== false;
|
|
455
|
+
if (this._toursEnabled && tourToggle) {
|
|
456
|
+
tourToggle.style.display = '';
|
|
457
|
+
this._syncTourToolbarButton();
|
|
458
|
+
}
|
|
459
|
+
// NOTE: do NOT probe /api/reviews/:id/tour here when no diff has
|
|
460
|
+
// rendered yet — `currentPR.id` is not populated until
|
|
461
|
+
// init()/LocalManager loads the review. The probe is normally
|
|
462
|
+
// deferred to renderDiff() (which fires after currentPR is set).
|
|
463
|
+
//
|
|
464
|
+
// RACE: if renderDiff() has ALREADY run by the time /api/config
|
|
465
|
+
// resolves, its `_toursEnabled === true` check failed (was still
|
|
466
|
+
// null) and the probe was skipped. Catch that case here so the
|
|
467
|
+
// tour toolbar still surfaces a generated tour.
|
|
468
|
+
if (this._toursEnabled && this._renderGen > 0) {
|
|
469
|
+
this._loadAndStashTour({ cancelOnRender: false }).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
323
473
|
// PR description popover
|
|
324
474
|
this.setupPRDescriptionPopover();
|
|
325
475
|
|
|
@@ -903,6 +1053,961 @@ class PRManager {
|
|
|
903
1053
|
return this._rerenderAllOverlays();
|
|
904
1054
|
}
|
|
905
1055
|
|
|
1056
|
+
/**
|
|
1057
|
+
* Resolve the cached `/api/config` payload, fetching it on first use.
|
|
1058
|
+
* @returns {Promise<Object>}
|
|
1059
|
+
*/
|
|
1060
|
+
_getAppConfig() {
|
|
1061
|
+
if (!this._appConfigPromise) {
|
|
1062
|
+
this._appConfigPromise = fetch('/api/config')
|
|
1063
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
1064
|
+
.catch(() => ({}));
|
|
1065
|
+
}
|
|
1066
|
+
return this._appConfigPromise;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Attach `data-hunk-start` to each anchor row using the server-supplied
|
|
1071
|
+
* `content_hash`, then kick off the initial summary fetch (gated by
|
|
1072
|
+
* `summaries.enabled` in `/api/config`). Drains any summaries that
|
|
1073
|
+
* arrived before mounting finished.
|
|
1074
|
+
*
|
|
1075
|
+
* Order matters:
|
|
1076
|
+
* 1. Config gate first — bail before paying any setup cost when the
|
|
1077
|
+
* feature is off.
|
|
1078
|
+
* 2. Restore localStorage visibility state.
|
|
1079
|
+
* 3. Walk records and wire each anchor row to its server-supplied
|
|
1080
|
+
* content hash. The server computes hashes from the canonical
|
|
1081
|
+
* (non-whitespace-filtered) diff so they stay aligned with persisted
|
|
1082
|
+
* summary keys; records lacking a `contentHash` are logged-and-skipped.
|
|
1083
|
+
* 4. Drain pending summaries against the freshly-built anchor map.
|
|
1084
|
+
* 5. Fetch existing summaries from the server.
|
|
1085
|
+
*
|
|
1086
|
+
* Race-safety: `_renderGen` is captured at entry and rechecked after
|
|
1087
|
+
* every `await`. If `renderDiff()` ran again mid-flight, we stop touching
|
|
1088
|
+
* the (now stale) maps and DOM.
|
|
1089
|
+
* @returns {Promise<void>}
|
|
1090
|
+
*/
|
|
1091
|
+
async _kickOffHunkSummaries() {
|
|
1092
|
+
const gen = this._renderGen;
|
|
1093
|
+
const records = this._pendingHunkRecords || [];
|
|
1094
|
+
this._pendingHunkRecords = null; // single-use; renderDiff resets
|
|
1095
|
+
|
|
1096
|
+
// 1. Config gate — bail before doing any work when the feature is off.
|
|
1097
|
+
const cfg = await this._getAppConfig();
|
|
1098
|
+
if (gen !== this._renderGen) return;
|
|
1099
|
+
if (!(cfg.summaries && cfg.summaries.enabled)) return;
|
|
1100
|
+
|
|
1101
|
+
// 2. Restore localStorage visibility state.
|
|
1102
|
+
if (this.currentPR?.id) {
|
|
1103
|
+
const hidden = window.localStorage.getItem(`pair-review:summaries-hidden:${this.currentPR.id}`) === '1';
|
|
1104
|
+
this._summariesHidden = hidden;
|
|
1105
|
+
document.body.classList.toggle('summaries-hidden', hidden);
|
|
1106
|
+
this._syncSummaryToolbarButton();
|
|
1107
|
+
this._restoreSummariesHiddenFiles();
|
|
1108
|
+
// Apply per-file hidden state to wrappers already in the DOM, syncing
|
|
1109
|
+
// both the wrapper class AND the toggle button so its visible state and
|
|
1110
|
+
// aria-pressed match the persisted hidden flag.
|
|
1111
|
+
for (const filePath of this.summariesHiddenFiles) {
|
|
1112
|
+
const wrapper = document.querySelector(
|
|
1113
|
+
`.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
|
|
1114
|
+
);
|
|
1115
|
+
if (!wrapper) continue;
|
|
1116
|
+
wrapper.classList.add('summaries-hidden-file');
|
|
1117
|
+
const btn = wrapper.querySelector('.file-header-summary-toggle');
|
|
1118
|
+
if (btn) this._syncFileSummaryToggleButton(btn, filePath);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// 3. Wire each anchor row to its server-supplied content hash. The
|
|
1123
|
+
// backend computes these from the canonical (unfiltered) diff so they
|
|
1124
|
+
// stay aligned with persisted summary keys regardless of `?w=1`.
|
|
1125
|
+
// Records missing `contentHash` indicate a server bug (or a hash array
|
|
1126
|
+
// length mismatch the renderPatch guard already dropped) — log + skip.
|
|
1127
|
+
for (const rec of records) {
|
|
1128
|
+
if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
|
|
1129
|
+
const hex = rec.contentHash;
|
|
1130
|
+
if (!hex) {
|
|
1131
|
+
console.warn(
|
|
1132
|
+
`[HunkSummary] no server contentHash for ${rec.file} ` +
|
|
1133
|
+
`hunk ${rec.header}; skipping (summaries will not anchor here).`
|
|
1134
|
+
);
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
rec.anchorRow.dataset.hunkStart = hex;
|
|
1138
|
+
this._summaryAnchorsByHash.set(hex, rec.anchorRow);
|
|
1139
|
+
let bucket = this._summaryHashesByFile.get(rec.file);
|
|
1140
|
+
if (!bucket) {
|
|
1141
|
+
bucket = new Set();
|
|
1142
|
+
this._summaryHashesByFile.set(rec.file, bucket);
|
|
1143
|
+
}
|
|
1144
|
+
bucket.add(hex);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// 4. Drain summaries that arrived (via WS) before hashing finished.
|
|
1148
|
+
if (this._pendingSummariesByHash.size > 0) {
|
|
1149
|
+
const filesWithMounts = new Set();
|
|
1150
|
+
for (const [hash, summary] of this._pendingSummariesByHash.entries()) {
|
|
1151
|
+
if (this._summaryAnchorsByHash.has(hash)) {
|
|
1152
|
+
const row = this._renderOneSummary(summary);
|
|
1153
|
+
if (row) {
|
|
1154
|
+
// Find the file this hash belongs to, so we can re-enable its
|
|
1155
|
+
// toggle button.
|
|
1156
|
+
for (const [filePath, bucket] of this._summaryHashesByFile.entries()) {
|
|
1157
|
+
if (bucket.has(hash)) {
|
|
1158
|
+
filesWithMounts.add(filePath);
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
this._pendingSummariesByHash.delete(hash);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// 5. Load existing summaries from the server.
|
|
1170
|
+
if (!this.currentPR?.id) return;
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
const resp = await fetch(`/api/reviews/${this.currentPR.id}/hunk-summaries`);
|
|
1174
|
+
if (gen !== this._renderGen) return;
|
|
1175
|
+
if (!resp.ok) return;
|
|
1176
|
+
const data = await resp.json();
|
|
1177
|
+
if (gen !== this._renderGen) return;
|
|
1178
|
+
const summaries = Array.isArray(data.summaries) ? data.summaries : [];
|
|
1179
|
+
// Group by file path so we can refresh each file's toggle button once.
|
|
1180
|
+
const byFile = new Map();
|
|
1181
|
+
for (const summary of summaries) {
|
|
1182
|
+
const fp = summary.file_path;
|
|
1183
|
+
if (!fp) continue;
|
|
1184
|
+
if (!byFile.has(fp)) byFile.set(fp, []);
|
|
1185
|
+
byFile.get(fp).push(summary);
|
|
1186
|
+
}
|
|
1187
|
+
// If summaries lack file_path, fall back to ungrouped rendering. Set
|
|
1188
|
+
// `_summariesGenerated` only after a summary actually mounts against the
|
|
1189
|
+
// current render anchors — never from the raw fetch count — so stale-hash
|
|
1190
|
+
// rows the anchor filter rejects can't flip the toolbar into Hide/Show
|
|
1191
|
+
// mode with nothing in the DOM. The grouped path defers this to
|
|
1192
|
+
// `_applyHunkSummaries`, which sets the flag on a successful mount.
|
|
1193
|
+
if (byFile.size === 0 && summaries.length > 0) {
|
|
1194
|
+
let mountedAny = false;
|
|
1195
|
+
for (const summary of summaries) {
|
|
1196
|
+
if (this._renderOneSummary(summary)) mountedAny = true;
|
|
1197
|
+
}
|
|
1198
|
+
if (mountedAny) {
|
|
1199
|
+
this._summariesGenerated = true;
|
|
1200
|
+
this._syncSummaryToolbarButton();
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
for (const [filePath, fileSummaries] of byFile.entries()) {
|
|
1204
|
+
this._applyHunkSummaries(filePath, fileSummaries);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
// Mirror the queue's view of whether summaries are still being generated.
|
|
1208
|
+
// The `review:background_job_finished` WS event clears this when the job
|
|
1209
|
+
// completes mid-session.
|
|
1210
|
+
this._summariesGenerating = data.generating === true;
|
|
1211
|
+
this._syncSummaryToolbarButton();
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
console.warn('[HunkSummary] failed to load summaries:', err);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Apply a batch of summaries delivered via the WS
|
|
1219
|
+
* `review:hunk_summaries_ready` event for a single file. Validates each
|
|
1220
|
+
* summary's hash against the per-file hash bucket so a hash collision
|
|
1221
|
+
* across files can't pull a summary into the wrong file's view, and
|
|
1222
|
+
* re-enables the per-file toggle button once a file has at least one
|
|
1223
|
+
* summary mounted.
|
|
1224
|
+
* @param {string} filePath - File path the summaries belong to
|
|
1225
|
+
* @param {Array<Object>} summaries - Summary rows for that file
|
|
1226
|
+
*/
|
|
1227
|
+
_applyHunkSummaries(filePath, summaries) {
|
|
1228
|
+
if (!Array.isArray(summaries)) return;
|
|
1229
|
+
const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
|
|
1230
|
+
let mountedAny = false;
|
|
1231
|
+
for (const summary of summaries) {
|
|
1232
|
+
if (!summary?.content_hash) continue;
|
|
1233
|
+
if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
|
|
1234
|
+
if (!this._warnedCrossFileHashMismatch) {
|
|
1235
|
+
this._warnedCrossFileHashMismatch = true;
|
|
1236
|
+
console.warn(
|
|
1237
|
+
`[HunkSummary] dropping summary for ${filePath}: hash ${summary.content_hash} ` +
|
|
1238
|
+
'not present in file hash bucket. Likely cross-file collision or stale render.'
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const row = this._renderOneSummary(summary);
|
|
1244
|
+
if (row) mountedAny = true;
|
|
1245
|
+
}
|
|
1246
|
+
if (mountedAny) {
|
|
1247
|
+
// At least one summary mounted — the feature now has data, so the
|
|
1248
|
+
// toolbar button can show its `.active` (blue) state.
|
|
1249
|
+
this._summariesGenerated = true;
|
|
1250
|
+
this._syncSummaryToolbarButton();
|
|
1251
|
+
}
|
|
1252
|
+
if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Refresh the per-file summary toggle button for `filePath` so it reflects
|
|
1257
|
+
* the current state: enabled iff there is at least one mounted summary in
|
|
1258
|
+
* that file's hash bucket.
|
|
1259
|
+
* @param {string} filePath
|
|
1260
|
+
*/
|
|
1261
|
+
_refreshFileSummaryToggle(filePath) {
|
|
1262
|
+
if (!filePath) return;
|
|
1263
|
+
const wrapper = document.querySelector(
|
|
1264
|
+
`.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
|
|
1265
|
+
);
|
|
1266
|
+
if (!wrapper) return;
|
|
1267
|
+
const btn = wrapper.querySelector('.file-header-summary-toggle');
|
|
1268
|
+
if (!btn) return;
|
|
1269
|
+
this._syncFileSummaryToggleButton(btn, filePath);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Apply the canonical per-file summary toggle button state derived from
|
|
1274
|
+
* `_summaryHashesByFile` and `summariesHiddenFiles`. Sets `disabled`,
|
|
1275
|
+
* `summaries-off`, `aria-pressed`, and `title` on the button.
|
|
1276
|
+
*
|
|
1277
|
+
* Used by three call sites that must agree on the button's visible state:
|
|
1278
|
+
* - createFileHeader (initial render)
|
|
1279
|
+
* - _kickOffHunkSummaries (rehydrate after localStorage restore)
|
|
1280
|
+
* - _refreshFileSummaryToggle (when summaries arrive late)
|
|
1281
|
+
* - toggleFileSummaries (user click)
|
|
1282
|
+
*
|
|
1283
|
+
* @param {HTMLButtonElement} btn
|
|
1284
|
+
* @param {string} filePath
|
|
1285
|
+
*/
|
|
1286
|
+
_syncFileSummaryToggleButton(btn, filePath) {
|
|
1287
|
+
if (!btn || !filePath) return;
|
|
1288
|
+
const hasSummaries = (this._summaryHashesByFile.get(filePath)?.size || 0) > 0;
|
|
1289
|
+
const isHidden = this.summariesHiddenFiles.has(filePath);
|
|
1290
|
+
btn.classList.toggle('summaries-off', isHidden);
|
|
1291
|
+
btn.setAttribute('aria-pressed', isHidden ? 'false' : 'true');
|
|
1292
|
+
if (!hasSummaries) {
|
|
1293
|
+
btn.disabled = true;
|
|
1294
|
+
btn.title = 'No summaries available';
|
|
1295
|
+
} else {
|
|
1296
|
+
btn.disabled = false;
|
|
1297
|
+
btn.title = isHidden ? 'Show file summaries' : 'Hide file summaries';
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Render a single summary row, or queue it if the matching hunk hasn't
|
|
1303
|
+
* been hashed yet (race between WS broadcast and post-render hashing).
|
|
1304
|
+
* Trivial / model-skipped / model-malformed rows are ignored.
|
|
1305
|
+
* @param {Object} summary - { content_hash, summary_text, trivial_reason }
|
|
1306
|
+
* @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
|
|
1307
|
+
*/
|
|
1308
|
+
_renderOneSummary(summary) {
|
|
1309
|
+
if (!summary || !summary.content_hash) return null;
|
|
1310
|
+
if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
|
|
1311
|
+
const hash = summary.content_hash;
|
|
1312
|
+
const anchor = this._summaryAnchorsByHash.get(hash);
|
|
1313
|
+
if (!anchor || !anchor.isConnected) {
|
|
1314
|
+
// Anchor missing or detached (stale render) → defer; the next render
|
|
1315
|
+
// pass that re-establishes the hash will drain this map.
|
|
1316
|
+
this._pendingSummariesByHash.set(hash, summary);
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
if (!this.hunkSummaryRenderer) return null;
|
|
1320
|
+
return this.hunkSummaryRenderer.renderInline(anchor, summary);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Storage key for per-file summary visibility. Mirrors the
|
|
1325
|
+
* `pair-review:summaries-hidden:${reviewId}` review-level key.
|
|
1326
|
+
* @param {number|string} reviewId
|
|
1327
|
+
* @returns {string}
|
|
1328
|
+
*/
|
|
1329
|
+
static summariesHiddenFilesStorageKey(reviewId) {
|
|
1330
|
+
return `pair-review:summaries-hidden-files:${reviewId}`;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Toggle the visibility of summaries for a single file. Updates the
|
|
1335
|
+
* `summariesHiddenFiles` set, the wrapper's CSS class, the per-file toggle
|
|
1336
|
+
* button's `summaries-off` class, and persists the set per-review.
|
|
1337
|
+
* @param {string} filePath
|
|
1338
|
+
* @param {HTMLElement} fileWrapper - The `.d2h-file-wrapper` element
|
|
1339
|
+
*/
|
|
1340
|
+
toggleFileSummaries(filePath, fileWrapper) {
|
|
1341
|
+
if (!filePath || !fileWrapper) return;
|
|
1342
|
+
const isHidden = this.summariesHiddenFiles.has(filePath);
|
|
1343
|
+
if (isHidden) {
|
|
1344
|
+
this.summariesHiddenFiles.delete(filePath);
|
|
1345
|
+
} else {
|
|
1346
|
+
this.summariesHiddenFiles.add(filePath);
|
|
1347
|
+
}
|
|
1348
|
+
fileWrapper.classList.toggle('summaries-hidden-file', !isHidden);
|
|
1349
|
+
const btn = fileWrapper.querySelector('.file-header-summary-toggle');
|
|
1350
|
+
if (btn) this._syncFileSummaryToggleButton(btn, filePath);
|
|
1351
|
+
if (this.currentPR?.id != null) {
|
|
1352
|
+
try {
|
|
1353
|
+
window.localStorage.setItem(
|
|
1354
|
+
PRManager.summariesHiddenFilesStorageKey(this.currentPR.id),
|
|
1355
|
+
JSON.stringify([...this.summariesHiddenFiles])
|
|
1356
|
+
);
|
|
1357
|
+
} catch {
|
|
1358
|
+
// localStorage unavailable; in-session state still applies.
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Hydrate `summariesHiddenFiles` from localStorage for the current review.
|
|
1365
|
+
* Safe to call multiple times — the state always reflects what's in storage.
|
|
1366
|
+
*/
|
|
1367
|
+
_restoreSummariesHiddenFiles() {
|
|
1368
|
+
if (!this.currentPR?.id) return;
|
|
1369
|
+
try {
|
|
1370
|
+
const raw = window.localStorage.getItem(
|
|
1371
|
+
PRManager.summariesHiddenFilesStorageKey(this.currentPR.id)
|
|
1372
|
+
);
|
|
1373
|
+
if (!raw) {
|
|
1374
|
+
this.summariesHiddenFiles = new Set();
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const arr = JSON.parse(raw);
|
|
1378
|
+
this.summariesHiddenFiles = new Set(Array.isArray(arr) ? arr : []);
|
|
1379
|
+
} catch {
|
|
1380
|
+
this.summariesHiddenFiles = new Set();
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Toggle review-level summary visibility. Persists per-review.
|
|
1386
|
+
*/
|
|
1387
|
+
toggleSummariesVisibility() {
|
|
1388
|
+
this._summariesHidden = !this._summariesHidden;
|
|
1389
|
+
document.body.classList.toggle('summaries-hidden', this._summariesHidden);
|
|
1390
|
+
if (this.currentPR?.id != null) {
|
|
1391
|
+
try {
|
|
1392
|
+
window.localStorage.setItem(
|
|
1393
|
+
`pair-review:summaries-hidden:${this.currentPR.id}`,
|
|
1394
|
+
this._summariesHidden ? '1' : '0'
|
|
1395
|
+
);
|
|
1396
|
+
} catch {
|
|
1397
|
+
// localStorage unavailable; in-session state still applies.
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
this._syncSummaryToolbarButton();
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Reflect the current state (visible / hidden / generating) on the
|
|
1405
|
+
* toolbar toggle button. The button gets:
|
|
1406
|
+
* - `.active` when summaries are visible
|
|
1407
|
+
* - `.generating` when a background summary job is in flight
|
|
1408
|
+
* - `title` + `aria-label` + `data-label` (CSS hover fallback) all kept
|
|
1409
|
+
* in sync so the user always knows what the button does.
|
|
1410
|
+
*/
|
|
1411
|
+
_syncSummaryToolbarButton() {
|
|
1412
|
+
const btn = document.getElementById('summary-toggle-btn');
|
|
1413
|
+
if (!btn) return;
|
|
1414
|
+
// `.active` (blue) only once summaries actually exist AND are visible.
|
|
1415
|
+
// Before any generation the button stays colorless so the pre-generated
|
|
1416
|
+
// state is visually distinct from "generated but hidden".
|
|
1417
|
+
btn.classList.toggle('active', this._summariesGenerated && !this._summariesHidden);
|
|
1418
|
+
btn.classList.toggle('generating', this._summariesGenerating === true);
|
|
1419
|
+
|
|
1420
|
+
let label;
|
|
1421
|
+
if (this._summariesGenerating) {
|
|
1422
|
+
// Hint at the cancel affordance — clicking the pulsing button now
|
|
1423
|
+
// opens a confirm dialog ("Cancel Summaries" / "OK") instead of
|
|
1424
|
+
// toggling visibility. See _handleSummaryToggleClick.
|
|
1425
|
+
label = 'Generating summaries… (click to cancel)';
|
|
1426
|
+
} else if (!this._summariesGenerated) {
|
|
1427
|
+
// Pre-generated state: nothing generated yet. Colorless button; a click
|
|
1428
|
+
// kicks off generation. See _handleSummaryToggleClick.
|
|
1429
|
+
label = 'Generate hunk summaries';
|
|
1430
|
+
} else {
|
|
1431
|
+
label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
|
|
1432
|
+
}
|
|
1433
|
+
btn.title = label;
|
|
1434
|
+
btn.setAttribute('aria-label', label);
|
|
1435
|
+
btn.dataset.label = label;
|
|
1436
|
+
btn.setAttribute(
|
|
1437
|
+
'aria-pressed',
|
|
1438
|
+
(this._summariesGenerated && !this._summariesHidden) ? 'true' : 'false'
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ===== Tour (Phase 8) ===================================================
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Whether a tour is currently mounted in the UI.
|
|
1446
|
+
* @returns {boolean}
|
|
1447
|
+
*/
|
|
1448
|
+
_tourIsActive() {
|
|
1449
|
+
return this._tourActiveIndex >= 0 && !!this._tourRenderer;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Reflect tour state on the toolbar toggle button. Mirrors the structure
|
|
1454
|
+
* of `_syncSummaryToolbarButton` so future tweaks stay in lockstep.
|
|
1455
|
+
*/
|
|
1456
|
+
_syncTourToolbarButton() {
|
|
1457
|
+
const btn = document.getElementById('tour-toggle-btn');
|
|
1458
|
+
if (!btn) return;
|
|
1459
|
+
const active = this._tourIsActive();
|
|
1460
|
+
const hasPending = active && Array.isArray(this._tourStopsPendingRestart);
|
|
1461
|
+
btn.classList.toggle('active', active);
|
|
1462
|
+
btn.classList.toggle('generating', this._tourGenerating === true);
|
|
1463
|
+
btn.classList.toggle('tour-updated-pending', hasPending);
|
|
1464
|
+
|
|
1465
|
+
let label;
|
|
1466
|
+
if (this._tourGenerating) {
|
|
1467
|
+
// Hint at the cancel affordance — see _handleTourToggleClick.
|
|
1468
|
+
label = active
|
|
1469
|
+
? 'Generating tour… (click to cancel)'
|
|
1470
|
+
: 'Generating guided tour… (click to cancel)';
|
|
1471
|
+
} else if (hasPending) {
|
|
1472
|
+
label = 'Tour updated — restart to apply new stops';
|
|
1473
|
+
} else if (active) {
|
|
1474
|
+
label = 'Exit guided tour';
|
|
1475
|
+
} else if (this._tourStops && this._tourStops.length > 0) {
|
|
1476
|
+
label = 'Start guided tour';
|
|
1477
|
+
} else {
|
|
1478
|
+
label = 'Start guided tour (none available yet)';
|
|
1479
|
+
}
|
|
1480
|
+
btn.title = label;
|
|
1481
|
+
btn.setAttribute('aria-label', label);
|
|
1482
|
+
btn.dataset.label = label;
|
|
1483
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// ===== Cancel flow (shared) =============================================
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Toolbar click handler for the summaries toggle. If a summary job is
|
|
1490
|
+
* in flight (`.generating` pulse), intercept and open the cancel-confirm
|
|
1491
|
+
* dialog instead of toggling visibility. Toggle visibility otherwise.
|
|
1492
|
+
*
|
|
1493
|
+
* Kept thin so `addEventListener` callers don't need to know about the
|
|
1494
|
+
* cancel flow — that lives in `_promptCancelJob`.
|
|
1495
|
+
* @returns {void}
|
|
1496
|
+
*/
|
|
1497
|
+
async _handleSummaryToggleClick() {
|
|
1498
|
+
if (this._summariesGenerating) {
|
|
1499
|
+
await this._promptCancelJob({
|
|
1500
|
+
kind: 'summaries',
|
|
1501
|
+
onCleared: () => {
|
|
1502
|
+
this._summariesGenerating = false;
|
|
1503
|
+
this._syncSummaryToolbarButton();
|
|
1504
|
+
},
|
|
1505
|
+
});
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (!this._summariesGenerated) {
|
|
1509
|
+
// Pre-generated state: a click triggers generation rather than toggling
|
|
1510
|
+
// visibility. `_startGenerationJob` sets the pulsing `.generating` state
|
|
1511
|
+
// optimistically (there is no `review:background_job_started` event).
|
|
1512
|
+
await this._startGenerationJob('summary');
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
this.toggleSummariesVisibility();
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Toolbar click handler for the tour toggle. If a tour job is in flight
|
|
1520
|
+
* (`.generating` pulse), intercept and open the cancel-confirm dialog
|
|
1521
|
+
* instead of opening/exiting the tour. Defer to `startOrToggleTour`
|
|
1522
|
+
* otherwise.
|
|
1523
|
+
* @returns {Promise<void>}
|
|
1524
|
+
*/
|
|
1525
|
+
async _handleTourToggleClick() {
|
|
1526
|
+
if (this._tourGenerating) {
|
|
1527
|
+
await this._promptCancelJob({
|
|
1528
|
+
kind: 'tour',
|
|
1529
|
+
onCleared: () => {
|
|
1530
|
+
this._tourGenerating = false;
|
|
1531
|
+
this._syncTourToolbarButton();
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
await this.startOrToggleTour();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Shared cancel-flow entrypoint: opens the right confirm dialog for the
|
|
1541
|
+
* given job kind, POSTs the cancel on confirm, and runs `onCleared` so
|
|
1542
|
+
* the caller can reset the pulse state. The corresponding broadcast
|
|
1543
|
+
* (`review:background_job_finished` with `cancelled: true`) will arrive
|
|
1544
|
+
* shortly after; that handler also clears the flag, so a double-clear
|
|
1545
|
+
* is harmless.
|
|
1546
|
+
*
|
|
1547
|
+
* @param {Object} opts
|
|
1548
|
+
* @param {'tour'|'summaries'} opts.kind
|
|
1549
|
+
* @param {Function} opts.onCleared - Called after the user confirms.
|
|
1550
|
+
* @returns {Promise<void>}
|
|
1551
|
+
*/
|
|
1552
|
+
async _promptCancelJob({ kind, onCleared }) {
|
|
1553
|
+
// Re-entry guard: the pulsing toolbar button stays clickable while the
|
|
1554
|
+
// confirm dialog is up. ConfirmDialog is a singleton — a second call to
|
|
1555
|
+
// .show() overwrites the first invocation's callbacks and orphans its
|
|
1556
|
+
// Promise. Drop the second click instead.
|
|
1557
|
+
if (this._cancelPromptOpen) return;
|
|
1558
|
+
const helper = typeof window !== 'undefined' ? window.CancelBackgroundJob : null;
|
|
1559
|
+
if (!helper) return;
|
|
1560
|
+
const reviewId = this.currentPR && this.currentPR.id;
|
|
1561
|
+
if (!reviewId) return;
|
|
1562
|
+
const show = kind === 'tour'
|
|
1563
|
+
? helper.showCancelTourDialog
|
|
1564
|
+
: helper.showCancelSummariesDialog;
|
|
1565
|
+
this._cancelPromptOpen = true;
|
|
1566
|
+
try {
|
|
1567
|
+
await show({ reviewId, onCancelled: onCleared });
|
|
1568
|
+
} finally {
|
|
1569
|
+
this._cancelPromptOpen = false;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Manually trigger a summary or tour generation job for the current review.
|
|
1575
|
+
* Used when `auto_generate` is off so generation does not kick off on load;
|
|
1576
|
+
* the user clicks the toolbar button to start it.
|
|
1577
|
+
*
|
|
1578
|
+
* Mode-aware: PR reviews POST to `/api/pr/...`, local reviews to
|
|
1579
|
+
* `/api/local/...`. The server enqueues the job with `trigger: 'manual'`
|
|
1580
|
+
* (bypassing the `auto_generate` gate) and responds with `{ started }` /
|
|
1581
|
+
* `{ alreadyRunning }`. There is no `review:background_job_started`
|
|
1582
|
+
* broadcast, so this method optimistically sets the matching `*Generating`
|
|
1583
|
+
* flag and pulses the button itself; `review:background_job_finished`
|
|
1584
|
+
* clears the flag when the job ends.
|
|
1585
|
+
*
|
|
1586
|
+
* @param {'summary'|'tour'} jobKey
|
|
1587
|
+
* @returns {Promise<void>}
|
|
1588
|
+
*/
|
|
1589
|
+
async _startGenerationJob(jobKey) {
|
|
1590
|
+
const pr = this.currentPR;
|
|
1591
|
+
if (!pr || pr.id == null) return;
|
|
1592
|
+
const isLocal = pr.reviewType === 'local'
|
|
1593
|
+
|| (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE === true);
|
|
1594
|
+
const url = isLocal
|
|
1595
|
+
? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
|
|
1596
|
+
: `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
|
|
1597
|
+
try {
|
|
1598
|
+
const resp = await fetch(url, { method: 'POST' });
|
|
1599
|
+
if (resp.status === 409) {
|
|
1600
|
+
// Feature disabled in config — shouldn't happen (the button is hidden
|
|
1601
|
+
// when disabled) but surface it rather than failing silently.
|
|
1602
|
+
// NOTE: the toast singleton is lowercase `window.toast` (see
|
|
1603
|
+
// cancel-background-job.js); `window.Toast` does not exist.
|
|
1604
|
+
if (window.toast?.error) window.toast.error('This feature is disabled in config.');
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
if (!resp.ok) {
|
|
1608
|
+
console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
// Optimistic UI: there is no `review:background_job_started` broadcast,
|
|
1612
|
+
// so set the generating flag now — when the server enqueued a job
|
|
1613
|
+
// (`started`) or one was already running (`alreadyRunning`) — to start
|
|
1614
|
+
// the pulse immediately. Results arrive via `review:hunk_summaries_ready`
|
|
1615
|
+
// / `review:tour_ready`; `review:background_job_finished` clears the flag
|
|
1616
|
+
// when the job ends.
|
|
1617
|
+
const payload = await resp.json().catch(() => ({}));
|
|
1618
|
+
if (payload.started || payload.alreadyRunning) {
|
|
1619
|
+
if (jobKey === 'summary') {
|
|
1620
|
+
this._summariesGenerating = true;
|
|
1621
|
+
this._syncSummaryToolbarButton();
|
|
1622
|
+
} else if (jobKey === 'tour') {
|
|
1623
|
+
this._tourGenerating = true;
|
|
1624
|
+
this._syncTourToolbarButton();
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Fetch /api/reviews/:reviewId/tour and stash the result in `_tourStops`
|
|
1634
|
+
* / `_tourGenerating`. Does NOT open the tour.
|
|
1635
|
+
*
|
|
1636
|
+
* If `deferIfActive` is true and a tour is currently mounted, the fetched
|
|
1637
|
+
* stops are stashed on `_tourStopsPendingRestart` instead of replacing
|
|
1638
|
+
* the active tour's stops. The pending stops apply on the next exit or
|
|
1639
|
+
* restart. This is the v1 simple approach — replacing the running tour
|
|
1640
|
+
* mid-flight is doable but adds complexity (mounted refs keyed by old
|
|
1641
|
+
* indices, current-stop drift, etc.) without a clear UX win.
|
|
1642
|
+
*
|
|
1643
|
+
* @param {Object} [opts]
|
|
1644
|
+
* @param {boolean} [opts.deferIfActive=false]
|
|
1645
|
+
* @param {boolean} [opts.cancelOnRender=true] - When true (default),
|
|
1646
|
+
* the probe captures `_renderGen` and aborts before mutating state
|
|
1647
|
+
* if a later render bumps the generation. Render-triggered probes
|
|
1648
|
+
* want this so a stale fetch can't clobber a fresh reset. One-shot
|
|
1649
|
+
* recovery callers (e.g. the deferred config-probe) pass `false`
|
|
1650
|
+
* so they don't self-cancel.
|
|
1651
|
+
* @returns {Promise<Array<Object>|null>} resolved stops, or null on miss.
|
|
1652
|
+
*/
|
|
1653
|
+
async _loadAndStashTour({ deferIfActive = false, cancelOnRender = true } = {}) {
|
|
1654
|
+
if (!this.currentPR?.id) return null;
|
|
1655
|
+
if (this._toursEnabled === false) return null;
|
|
1656
|
+
// Capture the current render generation; if a later renderDiff bumps
|
|
1657
|
+
// _renderGen between our awaits, bail before mutating state. Only
|
|
1658
|
+
// applied when cancelOnRender is true — the deferred config probe
|
|
1659
|
+
// and other one-shot recovery callers pass `cancelOnRender: false`.
|
|
1660
|
+
const gen = this._renderGen;
|
|
1661
|
+
const guardStale = () => cancelOnRender && gen !== this._renderGen;
|
|
1662
|
+
try {
|
|
1663
|
+
const resp = await fetch(`/api/reviews/${this.currentPR.id}/tour`);
|
|
1664
|
+
if (guardStale()) return null;
|
|
1665
|
+
if (!resp.ok) return null;
|
|
1666
|
+
const data = await resp.json();
|
|
1667
|
+
if (guardStale()) return null;
|
|
1668
|
+
this._tourGenerating = data.generating === true;
|
|
1669
|
+
const stops = Array.isArray(data.tour?.stops) ? data.tour.stops : null;
|
|
1670
|
+
if (deferIfActive && this._tourIsActive()) {
|
|
1671
|
+
this._tourStopsPendingRestart = stops;
|
|
1672
|
+
} else {
|
|
1673
|
+
this._tourStops = stops;
|
|
1674
|
+
this._tourStopsPendingRestart = null;
|
|
1675
|
+
}
|
|
1676
|
+
this._syncTourToolbarButton();
|
|
1677
|
+
return stops;
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
console.warn('[Tour] failed to load tour:', err);
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Toolbar click entrypoint. If a tour is active, exit. Otherwise fetch
|
|
1686
|
+
* stops if needed, then open from the first stop. No-ops when no stops
|
|
1687
|
+
* exist (toolbar button stays inert with the "none available yet" label).
|
|
1688
|
+
* @returns {Promise<void>}
|
|
1689
|
+
*/
|
|
1690
|
+
async startOrToggleTour() {
|
|
1691
|
+
if (this._tourIsActive()) {
|
|
1692
|
+
this._exitTour();
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (!this._tourStops || this._tourStops.length === 0) {
|
|
1696
|
+
await this._loadAndStashTour();
|
|
1697
|
+
}
|
|
1698
|
+
if (!this._tourStops || this._tourStops.length === 0) {
|
|
1699
|
+
// No tour stops available. When auto-generation is off and nothing is
|
|
1700
|
+
// already in flight, a click triggers manual generation (mirrors the
|
|
1701
|
+
// summaries button). `_startGenerationJob` sets the pulsing state
|
|
1702
|
+
// optimistically (there is no `review:background_job_started` event).
|
|
1703
|
+
// When `review:tour_ready` arrives the stops load and the button becomes
|
|
1704
|
+
// "Start guided tour" — the user clicks again to open it (no auto-open).
|
|
1705
|
+
if (this._toursAutoGenerate === false && !this._tourGenerating) {
|
|
1706
|
+
await this._startGenerationJob('tour');
|
|
1707
|
+
}
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
await this._openTourAtStart();
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Open the tour UI starting at stop 0. Lazy-creates the TourBar and
|
|
1715
|
+
* TourRenderer on first call so we pay zero cost for users who never
|
|
1716
|
+
* trigger a tour.
|
|
1717
|
+
*/
|
|
1718
|
+
async _openTourAtStart() {
|
|
1719
|
+
if (!this._tourStops || this._tourStops.length === 0) return;
|
|
1720
|
+
|
|
1721
|
+
// Drain any pending teardown from the previous tour BEFORE we read
|
|
1722
|
+
// wrappers below. Otherwise a fire-and-forget DELETE + its
|
|
1723
|
+
// loadContextFiles reload landing mid-open can rip the wrapper the
|
|
1724
|
+
// first stop is about to mount against. allSettled-wrapped so it
|
|
1725
|
+
// never rejects.
|
|
1726
|
+
if (this._tourCleanupPending) {
|
|
1727
|
+
const pending = this._tourCleanupPending;
|
|
1728
|
+
this._tourCleanupPending = null;
|
|
1729
|
+
await pending;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (!this._tourRenderer && typeof window !== 'undefined' && window.TourRenderer) {
|
|
1733
|
+
this._tourRenderer = new window.TourRenderer(this);
|
|
1734
|
+
}
|
|
1735
|
+
if (!this._tourBar && typeof window !== 'undefined' && window.TourBar) {
|
|
1736
|
+
this._tourBar = new window.TourBar({
|
|
1737
|
+
onPrev: () => this._advanceTour(-1),
|
|
1738
|
+
onNext: () => this._advanceTour(1),
|
|
1739
|
+
onExit: () => this._exitTour(),
|
|
1740
|
+
onRestart: () => this._restartTour(),
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (!this._tourRenderer || !this._tourBar) {
|
|
1744
|
+
console.warn('[Tour] TourRenderer/TourBar not available; cannot open tour');
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
this._tourRenderer.setStops(this._tourStops);
|
|
1749
|
+
this._tourRenderer.setActive(true);
|
|
1750
|
+
// Mount inside the diff-view scroll container so the bar (position:
|
|
1751
|
+
// sticky) spans only the diff width — the file-tree sidebar and its
|
|
1752
|
+
// controls stay visible.
|
|
1753
|
+
const diffView = document.querySelector('.main-layout .diff-view');
|
|
1754
|
+
this._tourBar.mount(diffView || undefined);
|
|
1755
|
+
this._tourBar.setStops(this._tourStops);
|
|
1756
|
+
this._tourBar.setCompleted(false);
|
|
1757
|
+
|
|
1758
|
+
this._tourActiveIndex = -1;
|
|
1759
|
+
// Bump the generation BEFORE the first _advanceTour call so it sees
|
|
1760
|
+
// the fresh value as its baseline. Subsequent exits bump it again,
|
|
1761
|
+
// making in-flight probes from this open detect the mismatch and bail.
|
|
1762
|
+
this._tourGen += 1;
|
|
1763
|
+
this._registerTourKeyboardHandlers();
|
|
1764
|
+
this._advanceTour(1);
|
|
1765
|
+
this._syncTourToolbarButton();
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Advance (or rewind) the active stop by `delta`. Going past the end of
|
|
1770
|
+
* the tour flips the bar into completion state; going before the start
|
|
1771
|
+
* clamps at 0.
|
|
1772
|
+
*
|
|
1773
|
+
* Async because each probe candidate is run through
|
|
1774
|
+
* `TourRenderer.prepareStop` first, which may need to await a file
|
|
1775
|
+
* fetch (adding a non-diff file as a context file) and/or a gap-expand
|
|
1776
|
+
* to surface folded rows the stop anchors on. Re-entrant calls (rapid
|
|
1777
|
+
* Next presses, keyboard mashing) are dropped via `_advanceInFlight`
|
|
1778
|
+
* so we never have two probe loops mutating tour state concurrently.
|
|
1779
|
+
*
|
|
1780
|
+
* @param {number} delta - Typically +1 (next) or -1 (prev).
|
|
1781
|
+
* @returns {Promise<void>}
|
|
1782
|
+
*/
|
|
1783
|
+
async _advanceTour(delta) {
|
|
1784
|
+
if (!this._tourRenderer || !this._tourBar || !this._tourStops) return;
|
|
1785
|
+
const total = this._tourStops.length;
|
|
1786
|
+
if (total === 0) return;
|
|
1787
|
+
|
|
1788
|
+
// Drop overlapping nav requests. The keyboard / button callbacks all
|
|
1789
|
+
// fire-and-forget, so a fast Next-Next-Next while a file fetch is in
|
|
1790
|
+
// flight would otherwise interleave probe loops on shared mutable
|
|
1791
|
+
// state (`_tourActiveIndex`, the renderer's `_mounted` map).
|
|
1792
|
+
//
|
|
1793
|
+
// Latch is generation-scoped: an in-flight call from a torn-down
|
|
1794
|
+
// generation no longer matches `_tourGen`, so a fresh reopen passes
|
|
1795
|
+
// the check without any teardown path having to remember to clear
|
|
1796
|
+
// the slot. Fixes the exit-then-reopen wedge where the boolean
|
|
1797
|
+
// latch survived `_exitTour` and silently dropped the next open's
|
|
1798
|
+
// first `_advanceTour`.
|
|
1799
|
+
if (this._advanceInFlightGen === this._tourGen) return;
|
|
1800
|
+
this._advanceInFlightGen = this._tourGen;
|
|
1801
|
+
// Capture the open-generation so we can detect a teardown (exit /
|
|
1802
|
+
// reopen) that happened while we were sitting on an await below.
|
|
1803
|
+
const startGen = this._tourGen;
|
|
1804
|
+
const isStale = () => this._tourGen !== startGen;
|
|
1805
|
+
try {
|
|
1806
|
+
const startIndex = this._tourActiveIndex + delta;
|
|
1807
|
+
const dir = delta >= 0 ? 1 : -1;
|
|
1808
|
+
|
|
1809
|
+
// Forward past the end (initial open uses delta=1 from -1, so this only
|
|
1810
|
+
// fires once we've actually reached the last stop and pressed Next again).
|
|
1811
|
+
if (startIndex >= total) {
|
|
1812
|
+
this._tourBar.setCompleted(true);
|
|
1813
|
+
this._tourBar.setActiveIndex(total - 1);
|
|
1814
|
+
this._syncTourToolbarButton();
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// Probe-then-mount: locate the next mountable index WITHOUT unmounting
|
|
1819
|
+
// the current one. Only swap once we have a confirmed replacement. This
|
|
1820
|
+
// avoids the wedge where the current stop is torn down and no successor
|
|
1821
|
+
// mounts (file filtered out, scope change, etc.).
|
|
1822
|
+
let probe = Math.max(0, startIndex);
|
|
1823
|
+
let nextRow = null;
|
|
1824
|
+
let nextIndex = -1;
|
|
1825
|
+
while (probe >= 0 && probe < total) {
|
|
1826
|
+
// Skip re-probing the index that's already mounted — `mountStop` is
|
|
1827
|
+
// idempotent and returns the existing row, but we want to keep going
|
|
1828
|
+
// past the current active when delta moves us off it.
|
|
1829
|
+
if (probe !== this._tourActiveIndex) {
|
|
1830
|
+
// Prepare the stop first: add the file as a context file if it
|
|
1831
|
+
// isn't in the diff, and unfold any gap covering its line range.
|
|
1832
|
+
// prepareStop returning true is no guarantee mountStop will
|
|
1833
|
+
// succeed — genuinely missing data still falls through.
|
|
1834
|
+
await this._tourRenderer.prepareStop(probe);
|
|
1835
|
+
// Tour could have been exited (or re-opened) while prepareStop
|
|
1836
|
+
// was awaiting a file fetch / loadContextFiles. Bail before
|
|
1837
|
+
// mounting against a torn-down tour.
|
|
1838
|
+
if (isStale()) return;
|
|
1839
|
+
const row = this._tourRenderer.mountStop(probe);
|
|
1840
|
+
if (row) {
|
|
1841
|
+
nextRow = row;
|
|
1842
|
+
nextIndex = probe;
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
} else if (dir > 0) {
|
|
1846
|
+
// Already-active probe under forward motion shouldn't count as a hit;
|
|
1847
|
+
// we want to advance past it.
|
|
1848
|
+
} else {
|
|
1849
|
+
// Backward delta landing on the current stop: nothing earlier mounted.
|
|
1850
|
+
break;
|
|
1851
|
+
}
|
|
1852
|
+
probe += dir;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (!nextRow) {
|
|
1856
|
+
if (dir > 0) {
|
|
1857
|
+
// Forward exhaustion: flip to completion using the last successfully
|
|
1858
|
+
// mounted index. If we never mounted anything (initial open found no
|
|
1859
|
+
// mountable stops), bail out cleanly so the toolbar resets.
|
|
1860
|
+
if (this._tourActiveIndex < 0) {
|
|
1861
|
+
console.warn('[Tour] no mountable stops found; exiting');
|
|
1862
|
+
this._exitTour();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
this._tourBar.setCompleted(true);
|
|
1866
|
+
this._tourBar.setActiveIndex(this._tourActiveIndex);
|
|
1867
|
+
this._syncTourToolbarButton();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
// Backward exhaustion: leave the current stop mounted/active untouched.
|
|
1871
|
+
console.debug('[Tour] no earlier mountable stop; staying put');
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Successful candidate — only now unmount the previous stop.
|
|
1876
|
+
if (this._tourActiveIndex >= 0 && this._tourActiveIndex !== nextIndex) {
|
|
1877
|
+
this._tourRenderer.unmountStop(this._tourActiveIndex);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
this._tourActiveIndex = nextIndex;
|
|
1881
|
+
this._tourRenderer.highlightActive(nextIndex);
|
|
1882
|
+
this._tourRenderer.scrollToStop(nextIndex);
|
|
1883
|
+
this._tourBar.setCompleted(false);
|
|
1884
|
+
this._tourBar.setActiveIndex(nextIndex);
|
|
1885
|
+
this._syncTourToolbarButton();
|
|
1886
|
+
// Suppress unused-var lints; nextRow exists for symmetry with future
|
|
1887
|
+
// post-mount work (focus management, telemetry).
|
|
1888
|
+
void nextRow;
|
|
1889
|
+
} finally {
|
|
1890
|
+
// Only release the latch if we still own the slot. A teardown that
|
|
1891
|
+
// bumped `_tourGen` between entry and now has already invalidated
|
|
1892
|
+
// our holder — and a fresh generation may have taken the slot for
|
|
1893
|
+
// its own call. Clobbering it with `-1` would let two _advanceTour
|
|
1894
|
+
// calls run concurrently on the new generation.
|
|
1895
|
+
if (this._advanceInFlightGen === startGen) {
|
|
1896
|
+
this._advanceInFlightGen = -1;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Tear down the tour: unmount every annotation, unmount the bar, drop the
|
|
1903
|
+
* body class, and unregister keyboard handlers.
|
|
1904
|
+
*/
|
|
1905
|
+
_exitTour() {
|
|
1906
|
+
// Bump generation FIRST so any in-flight `_advanceTour` (sitting on an
|
|
1907
|
+
// ensureContextFile / ensureLinesVisible await) sees the mismatch on
|
|
1908
|
+
// resume and bails instead of mutating state for a torn-down tour.
|
|
1909
|
+
this._tourGen += 1;
|
|
1910
|
+
let drain = Promise.resolve();
|
|
1911
|
+
if (this._tourRenderer) {
|
|
1912
|
+
// unmountAll fires-and-forgets context-file DELETEs but returns a
|
|
1913
|
+
// drain promise. Stash it on `_tourCleanupPending` so the next open
|
|
1914
|
+
// can await it before reading wrappers — otherwise the DELETE's
|
|
1915
|
+
// loadContextFiles reload can rip the new tour's wrapper out from
|
|
1916
|
+
// under an active stop.
|
|
1917
|
+
drain = this._tourRenderer.unmountAll();
|
|
1918
|
+
this._tourRenderer.setActive(false);
|
|
1919
|
+
}
|
|
1920
|
+
this._tourCleanupPending = drain;
|
|
1921
|
+
if (this._tourBar) {
|
|
1922
|
+
this._tourBar.unmount();
|
|
1923
|
+
}
|
|
1924
|
+
this._unregisterTourKeyboardHandlers();
|
|
1925
|
+
this._tourActiveIndex = -1;
|
|
1926
|
+
// Consume any pending tour stashed by `review:tour_ready` while we
|
|
1927
|
+
// were running. Next open uses the fresh stops.
|
|
1928
|
+
if (Array.isArray(this._tourStopsPendingRestart)) {
|
|
1929
|
+
this._tourStops = this._tourStopsPendingRestart;
|
|
1930
|
+
this._tourStopsPendingRestart = null;
|
|
1931
|
+
}
|
|
1932
|
+
this._syncTourToolbarButton();
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Exit, then re-open from stop 0. Async because `_openTourAtStart`
|
|
1937
|
+
* drains the prior tour's pending teardown (context-file DELETEs +
|
|
1938
|
+
* their loadContextFiles reloads) before reading wrappers, so the
|
|
1939
|
+
* fresh tour can't mount against a wrapper the old DELETE is about to
|
|
1940
|
+
* tear down. If a newer tour was stashed via `review:tour_ready`,
|
|
1941
|
+
* `_exitTour` swaps it in before we reopen.
|
|
1942
|
+
*
|
|
1943
|
+
* Caller (`onRestart` toolbar callback) is fire-and-forget; the
|
|
1944
|
+
* returned promise is for symmetry / testability.
|
|
1945
|
+
*
|
|
1946
|
+
* @returns {Promise<void>}
|
|
1947
|
+
*/
|
|
1948
|
+
async _restartTour() {
|
|
1949
|
+
this._exitTour();
|
|
1950
|
+
await this._openTourAtStart();
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* Install the keyboard shortcut handler. Bound to `document` so it fires
|
|
1955
|
+
* regardless of focus, with a guard that skips when the user is typing
|
|
1956
|
+
* in a text field.
|
|
1957
|
+
*/
|
|
1958
|
+
_registerTourKeyboardHandlers() {
|
|
1959
|
+
if (this._tourKeydownHandler) return;
|
|
1960
|
+
const handler = (e) => {
|
|
1961
|
+
if (!this._tourIsActive()) return;
|
|
1962
|
+
|
|
1963
|
+
// Skip when the user is typing — text fields, contenteditable, etc.
|
|
1964
|
+
// Arrow keys move the caret; Escape is owned by the surrounding form.
|
|
1965
|
+
const target = e.target;
|
|
1966
|
+
const tag = target && target.tagName;
|
|
1967
|
+
const isEditable = tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' ||
|
|
1968
|
+
(target && (target.isContentEditable || target.contentEditable === 'true'));
|
|
1969
|
+
if (isEditable) return;
|
|
1970
|
+
|
|
1971
|
+
// Skip when a modal is open. Modals own their own Escape ladder
|
|
1972
|
+
// (close dropdown, blur, dismiss); we don't want to compete. Defer
|
|
1973
|
+
// to the shared ModalDetection utility so the selector list stays
|
|
1974
|
+
// in sync with KeyboardShortcuts.
|
|
1975
|
+
if (window.ModalDetection?.isModalOpen()) return;
|
|
1976
|
+
|
|
1977
|
+
// Skip ALL tour shortcuts when the chat panel is open. ChatPanel
|
|
1978
|
+
// binds its own document-level Escape handler with a ladder of
|
|
1979
|
+
// states (provider/session dropdown, streaming stop, blur input,
|
|
1980
|
+
// close panel). Arrow keys may also be in use by chat surfaces.
|
|
1981
|
+
// Yanking the tour out from under the user — by advancing OR
|
|
1982
|
+
// exiting — when they have the chat panel open would be surprising.
|
|
1983
|
+
const chatPanel = document.querySelector('.chat-panel.chat-panel--open');
|
|
1984
|
+
if (chatPanel) return;
|
|
1985
|
+
|
|
1986
|
+
if (e.key === 'ArrowRight') {
|
|
1987
|
+
e.preventDefault();
|
|
1988
|
+
this._advanceTour(1);
|
|
1989
|
+
} else if (e.key === 'ArrowLeft') {
|
|
1990
|
+
e.preventDefault();
|
|
1991
|
+
this._advanceTour(-1);
|
|
1992
|
+
} else if (e.key === 'Escape') {
|
|
1993
|
+
e.preventDefault();
|
|
1994
|
+
// Stop propagation so other Escape-bound listeners (chat panel
|
|
1995
|
+
// when it's closed-but-bound, future keyboard shortcuts) don't
|
|
1996
|
+
// also fire for the same key event.
|
|
1997
|
+
e.stopImmediatePropagation();
|
|
1998
|
+
this._exitTour();
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
document.addEventListener('keydown', handler);
|
|
2002
|
+
this._tourKeydownHandler = handler;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
_unregisterTourKeyboardHandlers() {
|
|
2006
|
+
if (!this._tourKeydownHandler) return;
|
|
2007
|
+
document.removeEventListener('keydown', this._tourKeydownHandler);
|
|
2008
|
+
this._tourKeydownHandler = null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
906
2011
|
/**
|
|
907
2012
|
* Listen for review-scoped CustomEvents dispatched by ChatPanel's
|
|
908
2013
|
* WebSocket pub/sub connection.
|
|
@@ -984,6 +2089,50 @@ class PRManager {
|
|
|
984
2089
|
await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
|
|
985
2090
|
});
|
|
986
2091
|
|
|
2092
|
+
document.addEventListener('review:hunk_summaries_ready', (e) => {
|
|
2093
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2094
|
+
// Per-file completion implies the review-level job is still working,
|
|
2095
|
+
// so reflect "generating" until `background_job_finished` clears it.
|
|
2096
|
+
// (No-op when the toolbar button is already pulsing.)
|
|
2097
|
+
if (!this._summariesGenerating) {
|
|
2098
|
+
this._summariesGenerating = true;
|
|
2099
|
+
this._syncSummaryToolbarButton();
|
|
2100
|
+
}
|
|
2101
|
+
this._applyHunkSummaries(e.detail.filePath, e.detail.summaries || []);
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// Tour-ready broadcasts arrive after the tour-generation job persists a
|
|
2105
|
+
// new tour. We refresh the cached stops in the background but do NOT
|
|
2106
|
+
// auto-open the tour — user must click the toolbar button. If a tour is
|
|
2107
|
+
// already mounted, the new stops are stashed for restart so we don't
|
|
2108
|
+
// yank the active tour out from under the user (v1 simple approach).
|
|
2109
|
+
document.addEventListener('review:tour_ready', (e) => {
|
|
2110
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2111
|
+
this._loadAndStashTour({ deferIfActive: true }).catch(() => {});
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
document.addEventListener('review:background_job_finished', (e) => {
|
|
2115
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2116
|
+
const jobType = e.detail?.jobType || '';
|
|
2117
|
+
const isSummaries = jobType === 'summaries' || jobType.startsWith('summaries:');
|
|
2118
|
+
const isTour = jobType === 'tour' || jobType.startsWith('tour:');
|
|
2119
|
+
if (!isSummaries && !isTour) return;
|
|
2120
|
+
// The queue can host multiple `${type}:${digest}` jobs back-to-back
|
|
2121
|
+
// (refresh, scope change, whitespace toggle). The broadcast payload
|
|
2122
|
+
// carries `hasActiveForType` from the queue's view AFTER this job's
|
|
2123
|
+
// key was deleted, so a sibling job still in flight keeps the pulse
|
|
2124
|
+
// visible.
|
|
2125
|
+
if (e.detail?.hasActiveForType === true) return;
|
|
2126
|
+
if (isSummaries) {
|
|
2127
|
+
this._summariesGenerating = false;
|
|
2128
|
+
this._syncSummaryToolbarButton();
|
|
2129
|
+
}
|
|
2130
|
+
if (isTour) {
|
|
2131
|
+
this._tourGenerating = false;
|
|
2132
|
+
this._syncTourToolbarButton();
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
|
|
987
2136
|
document.addEventListener('visibilitychange', () => {
|
|
988
2137
|
if (document.hidden) return;
|
|
989
2138
|
if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
|
|
@@ -1844,8 +2993,39 @@ class PRManager {
|
|
|
1844
2993
|
const diffContainer = document.getElementById('diff-container');
|
|
1845
2994
|
if (!diffContainer) return;
|
|
1846
2995
|
|
|
2996
|
+
// Tear down any active tour BEFORE wiping the diff DOM: unmountAll()
|
|
2997
|
+
// re-collapses files the tour auto-expanded by looking them up via
|
|
2998
|
+
// `.d2h-file-wrapper[data-file-name=...]`. If we cleared innerHTML
|
|
2999
|
+
// first those lookups would all miss, and the user's pre-tour
|
|
3000
|
+
// collapse state would be silently lost. (Mirrors the rationale for
|
|
3001
|
+
// hunkSummaryRenderer.reset below — anchor-based DOM state cannot
|
|
3002
|
+
// survive a re-render.)
|
|
3003
|
+
if (this._tourIsActive && this._tourIsActive()) {
|
|
3004
|
+
this._exitTour();
|
|
3005
|
+
}
|
|
3006
|
+
|
|
1847
3007
|
diffContainer.innerHTML = '';
|
|
1848
3008
|
|
|
3009
|
+
// Reset hunk-summary tracking — `renderPatch` will populate this as it
|
|
3010
|
+
// walks each block, and we hash the records once render finishes.
|
|
3011
|
+
this._pendingHunkRecords = [];
|
|
3012
|
+
if (this.hunkSummaryRenderer) {
|
|
3013
|
+
this.hunkSummaryRenderer.reset();
|
|
3014
|
+
}
|
|
3015
|
+
this._tourStops = null;
|
|
3016
|
+
this._summaryAnchorsByHash = new Map();
|
|
3017
|
+
this._summaryHashesByFile = new Map();
|
|
3018
|
+
this._pendingSummariesByHash = new Map();
|
|
3019
|
+
// Reset alongside the other per-render summary state. Set true again only
|
|
3020
|
+
// when a summary actually mounts (see _applyHunkSummaries / the existing-
|
|
3021
|
+
// summary fetch). Without this, a re-render whose subsequent fetch returns
|
|
3022
|
+
// no matching rows keeps the stale `true`, leaving the toolbar stuck in
|
|
3023
|
+
// Hide/Show mode with nothing in the DOM and blocking click-to-generate.
|
|
3024
|
+
this._summariesGenerated = false;
|
|
3025
|
+
// Bump generation so any in-flight `_kickOffHunkSummaries` from the
|
|
3026
|
+
// previous render bails out instead of mutating maps we just reset.
|
|
3027
|
+
this._renderGen = (this._renderGen || 0) + 1;
|
|
3028
|
+
|
|
1849
3029
|
// Use changed_files array from API
|
|
1850
3030
|
const files = pr.changed_files || pr.files || [];
|
|
1851
3031
|
|
|
@@ -1880,6 +3060,21 @@ class PRManager {
|
|
|
1880
3060
|
// Load context files after diff is rendered
|
|
1881
3061
|
this.contextFiles = [];
|
|
1882
3062
|
this.loadContextFiles();
|
|
3063
|
+
|
|
3064
|
+
// Kick off hunk-summary hashing + load (Phase 5). Fire-and-forget — the
|
|
3065
|
+
// diff is fully usable while summaries arrive asynchronously.
|
|
3066
|
+
if (this.hunkSummaryRenderer) {
|
|
3067
|
+
this._kickOffHunkSummaries().catch((err) => {
|
|
3068
|
+
console.warn('[HunkSummary] kickoff failed:', err);
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// Probe tour endpoint after diff is rendered. `currentPR.id` is now
|
|
3073
|
+
// set (init()/LocalManager populates it before calling renderDiff),
|
|
3074
|
+
// so the toolbar button can reflect the right state.
|
|
3075
|
+
if (this._toursEnabled === true) {
|
|
3076
|
+
this._loadAndStashTour().catch(() => {});
|
|
3077
|
+
}
|
|
1883
3078
|
}
|
|
1884
3079
|
|
|
1885
3080
|
/**
|
|
@@ -1976,6 +3171,43 @@ class PRManager {
|
|
|
1976
3171
|
}
|
|
1977
3172
|
});
|
|
1978
3173
|
header.appendChild(fileChatBtn);
|
|
3174
|
+
|
|
3175
|
+
// Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
|
|
3176
|
+
// one file. Disabled (greyed) when no summaries exist yet for this file;
|
|
3177
|
+
// _applyHunkSummaries / _kickOffHunkSummaries re-enable it as soon as
|
|
3178
|
+
// hashes for this file are recorded (so a summary that arrives later
|
|
3179
|
+
// doesn't get hidden behind a permanently-disabled button).
|
|
3180
|
+
// Gated on `_summariesEnabled`: skipped entirely when /api/config has
|
|
3181
|
+
// already reported the feature is off; created hidden + revealed later
|
|
3182
|
+
// when config has not yet resolved.
|
|
3183
|
+
if (this._summariesEnabled !== false) {
|
|
3184
|
+
const summaryToggleBtn = document.createElement('button');
|
|
3185
|
+
summaryToggleBtn.className = 'file-header-summary-toggle';
|
|
3186
|
+
summaryToggleBtn.dataset.file = file.file;
|
|
3187
|
+
summaryToggleBtn.innerHTML = `
|
|
3188
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
|
3189
|
+
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
|
|
3190
|
+
</svg>
|
|
3191
|
+
`;
|
|
3192
|
+
|
|
3193
|
+
if (this._summariesEnabled !== true) {
|
|
3194
|
+
// Config still pending; hide until the gate resolves.
|
|
3195
|
+
summaryToggleBtn.classList.add('summary-toggle-pending');
|
|
3196
|
+
summaryToggleBtn.style.display = 'none';
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
const fileIsHidden = this.summariesHiddenFiles?.has(file.file) || false;
|
|
3200
|
+
if (fileIsHidden) {
|
|
3201
|
+
wrapper.classList.add('summaries-hidden-file');
|
|
3202
|
+
}
|
|
3203
|
+
this._syncFileSummaryToggleButton(summaryToggleBtn, file.file);
|
|
3204
|
+
|
|
3205
|
+
summaryToggleBtn.addEventListener('click', (e) => {
|
|
3206
|
+
e.stopPropagation();
|
|
3207
|
+
this.toggleFileSummaries(file.file, wrapper);
|
|
3208
|
+
});
|
|
3209
|
+
header.appendChild(summaryToggleBtn);
|
|
3210
|
+
}
|
|
1979
3211
|
}
|
|
1980
3212
|
|
|
1981
3213
|
// Create diff table
|
|
@@ -1986,7 +3218,7 @@ class PRManager {
|
|
|
1986
3218
|
|
|
1987
3219
|
// Parse the diff content
|
|
1988
3220
|
if (file.patch) {
|
|
1989
|
-
this.renderPatch(tbody, file.patch, file.file);
|
|
3221
|
+
this.renderPatch(tbody, file.patch, file.file, file.hunk_hashes || null);
|
|
1990
3222
|
} else if (file.binary) {
|
|
1991
3223
|
const row = document.createElement('tr');
|
|
1992
3224
|
row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
|
|
@@ -2010,14 +3242,39 @@ class PRManager {
|
|
|
2010
3242
|
* @param {HTMLElement} tbody - Table body element
|
|
2011
3243
|
* @param {string} patch - Unified diff patch string
|
|
2012
3244
|
* @param {string} fileName - File name
|
|
3245
|
+
* @param {string[]|null} [hunkHashes] - Per-hunk content hashes parallel
|
|
3246
|
+
* to the order `parseDiffIntoBlocks` returns hunks. When supplied, these
|
|
3247
|
+
* are used instead of computing client-side hashes. Computed by the
|
|
3248
|
+
* backend from the canonical (non-whitespace-filtered) diff so they
|
|
3249
|
+
* stay aligned with persisted summary keys.
|
|
2013
3250
|
*/
|
|
2014
|
-
renderPatch(tbody, patch, fileName) {
|
|
3251
|
+
renderPatch(tbody, patch, fileName, hunkHashes = null) {
|
|
2015
3252
|
let diffPosition = 0; // GitHub diff_position (1-indexed, consecutive)
|
|
2016
3253
|
let prevBlockEnd = { old: 0, new: 0 };
|
|
2017
3254
|
let isFirstHunk = true;
|
|
2018
3255
|
|
|
2019
3256
|
const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
|
|
2020
3257
|
|
|
3258
|
+
// Defend against length drift between server-supplied (canonical) hashes
|
|
3259
|
+
// and the rendered (possibly whitespace-filtered) blocks: under `?w=1`,
|
|
3260
|
+
// `git diff -w` can drop or merge whitespace-only hunks so the canonical
|
|
3261
|
+
// and rendered hunk counts diverge. Misaligned hashes would write the
|
|
3262
|
+
// wrong canonical hash onto every block after the first dropped hunk,
|
|
3263
|
+
// anchoring summaries to the wrong rendered hunk. Fail closed: drop the
|
|
3264
|
+
// hashes for this file. Summaries then simply won't anchor — visibly
|
|
3265
|
+
// missing rather than visibly wrong.
|
|
3266
|
+
if (Array.isArray(hunkHashes) && hunkHashes.length !== blocks.length) {
|
|
3267
|
+
if (!this._warnedHunkHashLengthMismatch) {
|
|
3268
|
+
this._warnedHunkHashLengthMismatch = true;
|
|
3269
|
+
console.warn(
|
|
3270
|
+
`[HunkSummary] hunk_hashes length mismatch for ${fileName}: ` +
|
|
3271
|
+
`${hunkHashes.length} canonical hashes, ${blocks.length} rendered ` +
|
|
3272
|
+
'blocks. Dropping hashes for this file.'
|
|
3273
|
+
);
|
|
3274
|
+
}
|
|
3275
|
+
hunkHashes = null;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
2021
3278
|
// Render blocks with gap sections
|
|
2022
3279
|
blocks.forEach((block, blockIndex) => {
|
|
2023
3280
|
diffPosition++; // Hunk header counts as a position
|
|
@@ -2097,6 +3354,7 @@ class PRManager {
|
|
|
2097
3354
|
let oldLineNum = block.oldStart;
|
|
2098
3355
|
let newLineNum = block.newStart;
|
|
2099
3356
|
|
|
3357
|
+
let firstLineRow = null;
|
|
2100
3358
|
block.lines.forEach(line => {
|
|
2101
3359
|
if (!line && line !== '') return; // Skip undefined
|
|
2102
3360
|
|
|
@@ -2126,8 +3384,24 @@ class PRManager {
|
|
|
2126
3384
|
};
|
|
2127
3385
|
|
|
2128
3386
|
this.renderDiffLine(tbody, lineData, fileName, diffPosition);
|
|
3387
|
+
if (!firstLineRow) firstLineRow = tbody.lastElementChild;
|
|
2129
3388
|
});
|
|
2130
3389
|
|
|
3390
|
+
// Record this hunk's first rendered code row as the anchor for any
|
|
3391
|
+
// inline summary annotation. The canonical hash comes from the
|
|
3392
|
+
// backend (`hunkHashes[blockIndex]`); _kickOffHunkSummaries mounts
|
|
3393
|
+
// it as `data-hunk-start` after render finishes so the summary
|
|
3394
|
+
// renderer can find the anchor and insert the annotation above it.
|
|
3395
|
+
if (this._pendingHunkRecords && firstLineRow) {
|
|
3396
|
+
const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
|
|
3397
|
+
this._pendingHunkRecords.push({
|
|
3398
|
+
file: fileName,
|
|
3399
|
+
header: block.header,
|
|
3400
|
+
anchorRow: firstLineRow,
|
|
3401
|
+
contentHash: serverHash
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
|
|
2131
3405
|
// Update previous block end coordinates
|
|
2132
3406
|
const endBounds = window.HunkParser.getBlockCoordinateBounds(
|
|
2133
3407
|
{ lines: this.parseBlockLines(block) },
|