@in-the-loop-labs/pair-review 3.6.0 → 3.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +20 -15
- 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/analysis-config.css +1807 -0
- package/public/css/pr.css +17 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +89 -44
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +11 -1
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/SuggestionNavigator.js +55 -10
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +45 -5
- package/public/js/pr.js +703 -171
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/scroll-into-view.js +164 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +10 -0
- package/public/pr.html +10 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/claude-provider.js +31 -3
- package/src/config.js +664 -10
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +13 -4
- package/src/main.js +136 -32
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
package/public/js/pr.js
CHANGED
|
@@ -80,18 +80,17 @@ class PRManager {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Generate a safe localStorage key for repository-specific settings
|
|
84
|
-
*
|
|
83
|
+
* Generate a safe localStorage key for repository-specific settings.
|
|
84
|
+
* Delegates to the shared helper in public/js/utils/storage-keys.js
|
|
85
|
+
* (loaded before pr.js) so keys stay byte-identical with the index/bulk page,
|
|
86
|
+
* which writes the same per-repo keys this page reads.
|
|
85
87
|
* @param {string} prefix - Key prefix (e.g., 'pair-review-model')
|
|
86
88
|
* @param {string} owner - Repository owner
|
|
87
89
|
* @param {string} repo - Repository name
|
|
88
90
|
* @returns {string} Safe localStorage key
|
|
89
91
|
*/
|
|
90
92
|
static getRepoStorageKey(prefix, owner, repo) {
|
|
91
|
-
|
|
92
|
-
// btoa() only accepts Latin1, so we encode Unicode first
|
|
93
|
-
const repoId = btoa(unescape(encodeURIComponent(`${owner}/${repo}`))).replace(/=/g, '');
|
|
94
|
-
return `${prefix}:${repoId}`;
|
|
93
|
+
return window.getRepoStorageKey(prefix, owner, repo);
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
constructor() {
|
|
@@ -140,18 +139,40 @@ class PRManager {
|
|
|
140
139
|
this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
|
|
141
140
|
// Hunk summary renderer (Phase 5) — inline natural-language summaries
|
|
142
141
|
this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
|
|
143
|
-
// Per-render anchor map
|
|
142
|
+
// Per-render anchor map, file-scoped so a content-hash shared across two
|
|
143
|
+
// files (renamed-with-tiny-edits, copy-pasted boilerplate, identical
|
|
144
|
+
// stubs) can't let the later file's anchor overwrite the earlier one's:
|
|
145
|
+
// Map<filePath, Map<contentHash, anchorRow>>
|
|
146
|
+
// where anchorRow is the first code-line <tr> of the hunk. Symmetric with
|
|
147
|
+
// `_pendingSummariesByHash` so both sides of the queue/anchor handshake
|
|
148
|
+
// key on (filePath, hash).
|
|
144
149
|
this._summaryAnchorsByHash = new Map();
|
|
145
150
|
// Per-render file map: filePath -> Set<contentHash>
|
|
146
151
|
this._summaryHashesByFile = new Map();
|
|
147
|
-
// Summaries that arrived (via WS or fetch) before their hunk had been
|
|
152
|
+
// Summaries that arrived (via WS or fetch) before their hunk had been
|
|
153
|
+
// hashed, scoped by file path so a content-hash collision across files
|
|
154
|
+
// can't let one file's anchor consume another file's queued summary:
|
|
155
|
+
// Map<filePath, Map<contentHash, summary>>
|
|
156
|
+
// The '' bucket holds summaries queued without a file path (the legacy
|
|
157
|
+
// ungrouped-fetch fallback in _fetchHunkSummaryMap).
|
|
148
158
|
this._pendingSummariesByHash = new Map();
|
|
149
159
|
// Per-file summary visibility (persisted in localStorage per-review)
|
|
150
160
|
this.summariesHiddenFiles = new Set();
|
|
151
161
|
// Render-generation token — incremented at the top of renderDiff() so any
|
|
152
|
-
// fire-and-forget
|
|
162
|
+
// fire-and-forget _fetchHunkSummaryMap() from a prior render can detect
|
|
153
163
|
// it's stale and bail (refresh / whitespace toggle / scope change race).
|
|
154
164
|
this._renderGen = 0;
|
|
165
|
+
// ---- Lazy diff-body rendering (perf for very large PRs) -----------
|
|
166
|
+
// Each file's wrapper + header render eagerly, but the <tbody> of diff
|
|
167
|
+
// lines is built on demand: when the body scrolls near the viewport
|
|
168
|
+
// (IntersectionObserver), when the file is expanded, or when a code path
|
|
169
|
+
// needs to anchor into it (see ensureFileBodyRendered). Collapsed bodies
|
|
170
|
+
// are `display:none`, so they never intersect and stay unrendered until
|
|
171
|
+
// expanded. Key = file path; value = lazy entry (see renderFileDiff).
|
|
172
|
+
this._lazyFileBodies = new Map();
|
|
173
|
+
// The IntersectionObserver watching `.d2h-file-body` elements for the
|
|
174
|
+
// current render generation. Recreated on every renderDiff().
|
|
175
|
+
this._fileBodyObserver = null;
|
|
155
176
|
// Cached /api/config response (lazy-loaded)
|
|
156
177
|
this._appConfigPromise = null;
|
|
157
178
|
// Review-level summary visibility (persisted in localStorage per-review)
|
|
@@ -165,6 +186,15 @@ class PRManager {
|
|
|
165
186
|
// user can tell nothing has been generated yet. Set true by
|
|
166
187
|
// `_applyHunkSummaries` and the initial existing-summaries fetch.
|
|
167
188
|
this._summariesGenerated = false;
|
|
189
|
+
// Whether any non-trivial hunk summary EXISTS for this review — mounted
|
|
190
|
+
// OR merely queued because its lazy file body hasn't rendered yet. This is
|
|
191
|
+
// deliberately separate from `_summariesGenerated` (which gates the
|
|
192
|
+
// `.active` blue styling and tracks summaries actually in the DOM): with
|
|
193
|
+
// lazy bodies a valid summary can arrive before its anchor exists, and the
|
|
194
|
+
// toolbar must still treat the feature as "has data" so a click toggles
|
|
195
|
+
// visibility instead of dispatching a duplicate generation job. Set by
|
|
196
|
+
// `_applyHunkSummaries` / `_fetchHunkSummaryMap`; reset in renderDiff.
|
|
197
|
+
this._summariesAvailable = false;
|
|
168
198
|
// Tri-state: true when /api/config reports summaries.enabled, false when
|
|
169
199
|
// disabled, null until /api/config resolves. Per-file toggle buttons are
|
|
170
200
|
// gated on this so users on disabled deployments don't see them flicker
|
|
@@ -372,6 +402,22 @@ class PRManager {
|
|
|
372
402
|
}
|
|
373
403
|
}
|
|
374
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Keep --diff-file-header-height in sync with the rendered sticky file
|
|
407
|
+
* header so navigation (block:'start' + scroll-margin-top in pr.css) lands
|
|
408
|
+
* targets just below the header rather than hidden behind it. Headers are
|
|
409
|
+
* single-line and uniform, so measuring the first one is representative.
|
|
410
|
+
* Call after renderDiff appends the headers.
|
|
411
|
+
*/
|
|
412
|
+
_measureFileHeaderHeight() {
|
|
413
|
+
const header = document.querySelector('.d2h-file-wrapper .d2h-file-header');
|
|
414
|
+
if (header && header.offsetHeight) {
|
|
415
|
+
document.documentElement.style.setProperty(
|
|
416
|
+
'--diff-file-header-height', header.offsetHeight + 'px'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
375
421
|
/**
|
|
376
422
|
* Set up event handlers
|
|
377
423
|
*/
|
|
@@ -588,7 +634,7 @@ class PRManager {
|
|
|
588
634
|
* @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
|
|
589
635
|
* @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
|
|
590
636
|
*/
|
|
591
|
-
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
|
|
637
|
+
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
|
|
592
638
|
const defaultTab = repoSettings?.default_tab || 'single';
|
|
593
639
|
const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
|
|
594
640
|
|
|
@@ -617,13 +663,44 @@ class PRManager {
|
|
|
617
663
|
};
|
|
618
664
|
}
|
|
619
665
|
|
|
666
|
+
// Resolve provider and model as a MATCHED pair. Resolving each half
|
|
667
|
+
// independently (repo || app || hardcoded) can mix a provider from one
|
|
668
|
+
// scope with a model from another, yielding an invalid pair (e.g.
|
|
669
|
+
// gemini/opus) that startAnalysis would forward to the backend as-is.
|
|
670
|
+
const providers = providersInfo || await this._getProvidersInfo();
|
|
671
|
+
const { provider, model } = window.resolveProviderModelPair([
|
|
672
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
673
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
674
|
+
], providers);
|
|
675
|
+
|
|
620
676
|
return {
|
|
621
|
-
provider
|
|
622
|
-
model
|
|
677
|
+
provider,
|
|
678
|
+
model,
|
|
623
679
|
customInstructions: null
|
|
624
680
|
};
|
|
625
681
|
}
|
|
626
682
|
|
|
683
|
+
async _fetchAutoAnalysisConfigFromUrl() {
|
|
684
|
+
const params = new URLSearchParams(window.location.search);
|
|
685
|
+
const configId = params.get('analysisConfigId');
|
|
686
|
+
if (!configId) return { requested: false, config: null, error: null };
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
const response = await fetch(`/api/bulk-analysis-configs/${encodeURIComponent(configId)}`);
|
|
690
|
+
if (!response.ok) {
|
|
691
|
+
throw new Error('Stored analysis settings were not found');
|
|
692
|
+
}
|
|
693
|
+
const data = await response.json();
|
|
694
|
+
if (!data.analysisConfig) {
|
|
695
|
+
throw new Error('Stored analysis settings response was empty');
|
|
696
|
+
}
|
|
697
|
+
return { requested: true, config: data.analysisConfig, error: null };
|
|
698
|
+
} catch (error) {
|
|
699
|
+
console.warn('Failed to fetch bulk analysis config:', error);
|
|
700
|
+
return { requested: true, config: null, error };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
627
704
|
/**
|
|
628
705
|
* Auto-trigger analysis if ?analyze=true is present in the URL.
|
|
629
706
|
* Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
|
|
@@ -638,6 +715,7 @@ class PRManager {
|
|
|
638
715
|
const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
|
|
639
716
|
if (autoAnalyze === 'true' && !this.isAnalyzing) {
|
|
640
717
|
this._autoAnalyzeRequested = true;
|
|
718
|
+
let shouldCleanUrl = true;
|
|
641
719
|
try {
|
|
642
720
|
// Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
|
|
643
721
|
// Otherwise, refresh to ensure we have the latest PR data in case the worktree
|
|
@@ -654,19 +732,39 @@ class PRManager {
|
|
|
654
732
|
}
|
|
655
733
|
}
|
|
656
734
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
735
|
+
const storedConfig = await this._fetchAutoAnalysisConfigFromUrl();
|
|
736
|
+
let config;
|
|
737
|
+
if (storedConfig.requested) {
|
|
738
|
+
if (!storedConfig.config) {
|
|
739
|
+
// The stored bulk-analysis config expired (TTL/eviction/restart).
|
|
740
|
+
// The PR diff has already rendered, so don't replace it with a
|
|
741
|
+
// full-screen error whose Retry button would just re-trigger the
|
|
742
|
+
// same failed lookup. Warn, strip the stale params (so a refresh
|
|
743
|
+
// won't re-trigger), and leave the PR usable for manual analysis.
|
|
744
|
+
const message = 'Could not load the selected bulk analysis settings. Start analysis manually to choose new settings.';
|
|
745
|
+
if (window.toast) window.toast.showWarning(message);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
config = storedConfig.config;
|
|
749
|
+
} else {
|
|
750
|
+
// Fetch repo settings so we honour the repository's default provider/council
|
|
751
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
752
|
+
this.fetchRepoSettings().catch(() => null),
|
|
753
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
754
|
+
this._getAppConfig()
|
|
755
|
+
]);
|
|
756
|
+
config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
|
|
757
|
+
}
|
|
663
758
|
|
|
664
759
|
await this.startAnalysis(owner, repo, prNumber, null, config);
|
|
665
760
|
} finally {
|
|
666
761
|
this._autoAnalyzeRequested = false;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
762
|
+
if (shouldCleanUrl) {
|
|
763
|
+
const cleanUrl = new URL(window.location);
|
|
764
|
+
cleanUrl.searchParams.delete('analyze');
|
|
765
|
+
cleanUrl.searchParams.delete('analysisConfigId');
|
|
766
|
+
history.replaceState(null, '', cleanUrl);
|
|
767
|
+
}
|
|
670
768
|
}
|
|
671
769
|
}
|
|
672
770
|
}
|
|
@@ -1067,31 +1165,111 @@ class PRManager {
|
|
|
1067
1165
|
}
|
|
1068
1166
|
|
|
1069
1167
|
/**
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1168
|
+
* Fetch and cache the provider/model metadata from /api/providers. Used to
|
|
1169
|
+
* resolve a coherent provider/model pair for the non-modal auto-analyze path
|
|
1170
|
+
* (the modal loads its own copy). Resolves to the `providers` array, or [] on
|
|
1171
|
+
* failure so callers can fall back to provider-agnostic defaults.
|
|
1172
|
+
*/
|
|
1173
|
+
_getProvidersInfo() {
|
|
1174
|
+
if (!this._providersInfoPromise) {
|
|
1175
|
+
this._providersInfoPromise = fetch('/api/providers')
|
|
1176
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
1177
|
+
.then((data) => (Array.isArray(data.providers) ? data.providers : []))
|
|
1178
|
+
.catch(() => []);
|
|
1179
|
+
}
|
|
1180
|
+
return this._providersInfoPromise;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Wire one file's hunk anchor rows to their server-supplied content hashes
|
|
1185
|
+
* as that file's body renders (called from _renderFileBodyNow), then mount
|
|
1186
|
+
* any summary that already arrived for those hunks.
|
|
1187
|
+
*
|
|
1188
|
+
* Pre-lazy-render this happened once globally in _kickOffHunkSummaries;
|
|
1189
|
+
* with lazy bodies a file's anchor rows don't exist until it renders, so
|
|
1190
|
+
* anchoring is now incremental. The server computes hashes from the
|
|
1191
|
+
* canonical (non-whitespace-filtered) diff so they stay aligned with
|
|
1192
|
+
* persisted summary keys regardless of `?w=1`; records lacking a
|
|
1193
|
+
* `contentHash` are logged-and-skipped.
|
|
1194
|
+
*
|
|
1195
|
+
* The bridge for "summary arrived before its anchor existed" is the existing
|
|
1196
|
+
* `_pendingSummariesByHash` map: _renderOneSummary / _applyHunkSummaries
|
|
1197
|
+
* queue there when no anchor is found, and we drain matching entries here.
|
|
1198
|
+
* @param {Array<{file,header,anchorRow,contentHash}>} records
|
|
1199
|
+
*/
|
|
1200
|
+
_registerHunkAnchorsForFile(records) {
|
|
1201
|
+
if (!Array.isArray(records) || records.length === 0) return;
|
|
1202
|
+
const filesWithMounts = new Set();
|
|
1203
|
+
for (const rec of records) {
|
|
1204
|
+
if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
|
|
1205
|
+
const hex = rec.contentHash;
|
|
1206
|
+
if (!hex) {
|
|
1207
|
+
console.warn(
|
|
1208
|
+
`[HunkSummary] no server contentHash for ${rec.file} ` +
|
|
1209
|
+
`hunk ${rec.header}; skipping (summaries will not anchor here).`
|
|
1210
|
+
);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
rec.anchorRow.dataset.hunkStart = hex;
|
|
1214
|
+
// Scope the anchor by file so a hash collision across files can't let
|
|
1215
|
+
// the later file's anchor clobber the earlier file's (which would mount
|
|
1216
|
+
// a summary against the wrong hunk). Symmetric with the file-scoped
|
|
1217
|
+
// pending-summary queue.
|
|
1218
|
+
let anchors = this._summaryAnchorsByHash.get(rec.file);
|
|
1219
|
+
if (!anchors) {
|
|
1220
|
+
anchors = new Map();
|
|
1221
|
+
this._summaryAnchorsByHash.set(rec.file, anchors);
|
|
1222
|
+
}
|
|
1223
|
+
anchors.set(hex, rec.anchorRow);
|
|
1224
|
+
let bucket = this._summaryHashesByFile.get(rec.file);
|
|
1225
|
+
if (!bucket) {
|
|
1226
|
+
bucket = new Set();
|
|
1227
|
+
this._summaryHashesByFile.set(rec.file, bucket);
|
|
1228
|
+
}
|
|
1229
|
+
bucket.add(hex);
|
|
1230
|
+
|
|
1231
|
+
// Mount a summary that arrived before this anchor existed. Look it up by
|
|
1232
|
+
// THIS file + hash so a content-hash that also appears in another file
|
|
1233
|
+
// (renamed-with-tiny-edits, copy-pasted boilerplate, identical stubs)
|
|
1234
|
+
// can't let this file's anchor consume the other file's queued summary.
|
|
1235
|
+
const pending = this._takePendingSummary(rec.file, hex);
|
|
1236
|
+
if (pending) {
|
|
1237
|
+
const row = this._renderOneSummary(pending, rec.file);
|
|
1238
|
+
if (row) {
|
|
1239
|
+
this._summariesGenerated = true;
|
|
1240
|
+
this._summariesAvailable = true;
|
|
1241
|
+
filesWithMounts.add(rec.file);
|
|
1242
|
+
}
|
|
1243
|
+
// If it couldn't mount (anchor not connected after all),
|
|
1244
|
+
// _renderOneSummary re-queued it scoped to rec.file for a later pass.
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (this._summariesGenerated) this._syncSummaryToolbarButton();
|
|
1248
|
+
for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Load the review's hunk summaries from the server and apply them to the
|
|
1253
|
+
* anchors that exist so far (gated by `summaries.enabled` in `/api/config`).
|
|
1254
|
+
*
|
|
1255
|
+
* With lazy bodies, anchor wiring is incremental (_registerHunkAnchorsForFile
|
|
1256
|
+
* runs as each body renders), so this method no longer walks render records.
|
|
1257
|
+
* Summaries whose file hasn't rendered yet are queued in
|
|
1258
|
+
* `_pendingSummariesByHash` (via _applyHunkSummaries / _renderOneSummary) and
|
|
1259
|
+
* drained when that body renders.
|
|
1074
1260
|
*
|
|
1075
|
-
* Order
|
|
1076
|
-
* 1. Config gate first — bail before paying any
|
|
1077
|
-
* feature is off.
|
|
1261
|
+
* Order:
|
|
1262
|
+
* 1. Config gate first — bail before paying any cost when the feature is off.
|
|
1078
1263
|
* 2. Restore localStorage visibility state.
|
|
1079
|
-
* 3.
|
|
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.
|
|
1264
|
+
* 3. Fetch existing summaries and apply/queue them.
|
|
1085
1265
|
*
|
|
1086
|
-
* Race-safety: `_renderGen` is captured at entry and rechecked after
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1266
|
+
* Race-safety: `_renderGen` is captured at entry and rechecked after every
|
|
1267
|
+
* `await`. If `renderDiff()` ran again mid-flight, we stop touching the (now
|
|
1268
|
+
* stale) maps and DOM.
|
|
1089
1269
|
* @returns {Promise<void>}
|
|
1090
1270
|
*/
|
|
1091
|
-
async
|
|
1271
|
+
async _fetchHunkSummaryMap() {
|
|
1092
1272
|
const gen = this._renderGen;
|
|
1093
|
-
const records = this._pendingHunkRecords || [];
|
|
1094
|
-
this._pendingHunkRecords = null; // single-use; renderDiff resets
|
|
1095
1273
|
|
|
1096
1274
|
// 1. Config gate — bail before doing any work when the feature is off.
|
|
1097
1275
|
const cfg = await this._getAppConfig();
|
|
@@ -1119,54 +1297,7 @@ class PRManager {
|
|
|
1119
1297
|
}
|
|
1120
1298
|
}
|
|
1121
1299
|
|
|
1122
|
-
// 3.
|
|
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.
|
|
1300
|
+
// 3. Load existing summaries from the server.
|
|
1170
1301
|
if (!this.currentPR?.id) return;
|
|
1171
1302
|
|
|
1172
1303
|
try {
|
|
@@ -1192,13 +1323,14 @@ class PRManager {
|
|
|
1192
1323
|
// `_applyHunkSummaries`, which sets the flag on a successful mount.
|
|
1193
1324
|
if (byFile.size === 0 && summaries.length > 0) {
|
|
1194
1325
|
let mountedAny = false;
|
|
1326
|
+
let availableAny = false;
|
|
1195
1327
|
for (const summary of summaries) {
|
|
1328
|
+
if (summary.summary_text) availableAny = true;
|
|
1196
1329
|
if (this._renderOneSummary(summary)) mountedAny = true;
|
|
1197
1330
|
}
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1331
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1332
|
+
if (mountedAny) this._summariesGenerated = true;
|
|
1333
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1202
1334
|
} else {
|
|
1203
1335
|
for (const [filePath, fileSummaries] of byFile.entries()) {
|
|
1204
1336
|
this._applyHunkSummaries(filePath, fileSummaries);
|
|
@@ -1228,6 +1360,7 @@ class PRManager {
|
|
|
1228
1360
|
if (!Array.isArray(summaries)) return;
|
|
1229
1361
|
const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
|
|
1230
1362
|
let mountedAny = false;
|
|
1363
|
+
let availableAny = false;
|
|
1231
1364
|
for (const summary of summaries) {
|
|
1232
1365
|
if (!summary?.content_hash) continue;
|
|
1233
1366
|
if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
|
|
@@ -1240,15 +1373,26 @@ class PRManager {
|
|
|
1240
1373
|
}
|
|
1241
1374
|
continue;
|
|
1242
1375
|
}
|
|
1243
|
-
|
|
1376
|
+
// A non-trivial summary that belongs to this render (it passed the hash
|
|
1377
|
+
// filter) means the feature has data even if its body hasn't rendered
|
|
1378
|
+
// yet — _renderOneSummary will queue it rather than mount it in that
|
|
1379
|
+
// case. Track availability separately from mounted rows.
|
|
1380
|
+
if (summary.summary_text) availableAny = true;
|
|
1381
|
+
const row = this._renderOneSummary(summary, filePath);
|
|
1244
1382
|
if (row) mountedAny = true;
|
|
1245
1383
|
}
|
|
1384
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1246
1385
|
if (mountedAny) {
|
|
1247
|
-
// At least one summary mounted — the feature now has data, so
|
|
1248
|
-
// toolbar button can show its `.active` (blue) state.
|
|
1386
|
+
// At least one summary mounted — the feature now has visible data, so
|
|
1387
|
+
// the toolbar button can show its `.active` (blue) state.
|
|
1249
1388
|
this._summariesGenerated = true;
|
|
1250
|
-
this._syncSummaryToolbarButton();
|
|
1251
1389
|
}
|
|
1390
|
+
// Refresh the toolbar when either flag changed: `.active` tracks
|
|
1391
|
+
// `_summariesGenerated` (rows in the DOM) while the Generate-vs-Show/Hide
|
|
1392
|
+
// decision tracks `_summariesAvailable` (mounted OR queued). Refreshing on
|
|
1393
|
+
// availableAny prevents a not-yet-rendered file from leaving the toolbar
|
|
1394
|
+
// in "Generate" mode, where a click would start a duplicate job.
|
|
1395
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1252
1396
|
if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
|
|
1253
1397
|
}
|
|
1254
1398
|
|
|
@@ -1276,7 +1420,7 @@ class PRManager {
|
|
|
1276
1420
|
*
|
|
1277
1421
|
* Used by three call sites that must agree on the button's visible state:
|
|
1278
1422
|
* - createFileHeader (initial render)
|
|
1279
|
-
* -
|
|
1423
|
+
* - _fetchHunkSummaryMap (rehydrate after localStorage restore)
|
|
1280
1424
|
* - _refreshFileSummaryToggle (when summaries arrive late)
|
|
1281
1425
|
* - toggleFileSummaries (user click)
|
|
1282
1426
|
*
|
|
@@ -1298,22 +1442,91 @@ class PRManager {
|
|
|
1298
1442
|
}
|
|
1299
1443
|
}
|
|
1300
1444
|
|
|
1445
|
+
/**
|
|
1446
|
+
* Queue a summary that arrived before its hunk anchor existed, scoped by
|
|
1447
|
+
* file path so a content-hash collision across files can't let one file's
|
|
1448
|
+
* anchor consume another file's queued summary. Summaries arriving without
|
|
1449
|
+
* a file path (legacy ungrouped fetch) land in the '' bucket.
|
|
1450
|
+
* @param {string|undefined} filePath
|
|
1451
|
+
* @param {Object} summary - must have `content_hash`
|
|
1452
|
+
*/
|
|
1453
|
+
_queuePendingSummary(filePath, summary) {
|
|
1454
|
+
const key = filePath || '';
|
|
1455
|
+
let bucket = this._pendingSummariesByHash.get(key);
|
|
1456
|
+
if (!bucket) {
|
|
1457
|
+
bucket = new Map();
|
|
1458
|
+
this._pendingSummariesByHash.set(key, bucket);
|
|
1459
|
+
}
|
|
1460
|
+
bucket.set(summary.content_hash, summary);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Pull (and remove) a queued summary for (filePath, hash). Checks the
|
|
1465
|
+
* file-scoped bucket first, then the '' bucket that holds summaries queued
|
|
1466
|
+
* without a file path (legacy ungrouped fetch). Returns null when nothing
|
|
1467
|
+
* is queued for that pair.
|
|
1468
|
+
* @param {string|undefined} filePath
|
|
1469
|
+
* @param {string} hash
|
|
1470
|
+
* @returns {Object|null}
|
|
1471
|
+
*/
|
|
1472
|
+
_takePendingSummary(filePath, hash) {
|
|
1473
|
+
for (const key of [filePath || '', '']) {
|
|
1474
|
+
const bucket = this._pendingSummariesByHash.get(key);
|
|
1475
|
+
if (bucket && bucket.has(hash)) {
|
|
1476
|
+
const summary = bucket.get(hash);
|
|
1477
|
+
bucket.delete(hash);
|
|
1478
|
+
if (bucket.size === 0) this._pendingSummariesByHash.delete(key);
|
|
1479
|
+
return summary;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Resolve the anchor row for (filePath, hash) against the file-scoped
|
|
1487
|
+
* `_summaryAnchorsByHash` map.
|
|
1488
|
+
*
|
|
1489
|
+
* When `filePath` is known (the grouped path), the lookup is strictly
|
|
1490
|
+
* scoped to that file so a content-hash shared across files can't pull a
|
|
1491
|
+
* summary onto the wrong file's anchor. When it's absent — the legacy
|
|
1492
|
+
* ungrouped-fetch fallback where summaries arrive without a `file_path` —
|
|
1493
|
+
* there's no file to scope by, so we accept the first file whose bucket
|
|
1494
|
+
* carries the hash. This mirrors `_takePendingSummary`'s `''` fallback
|
|
1495
|
+
* bucket: file-scoped first, best-effort otherwise.
|
|
1496
|
+
* @param {string|undefined} filePath
|
|
1497
|
+
* @param {string} hash
|
|
1498
|
+
* @returns {HTMLTableRowElement|null}
|
|
1499
|
+
*/
|
|
1500
|
+
_findSummaryAnchor(filePath, hash) {
|
|
1501
|
+
if (filePath) {
|
|
1502
|
+
return this._summaryAnchorsByHash.get(filePath)?.get(hash) || null;
|
|
1503
|
+
}
|
|
1504
|
+
for (const anchors of this._summaryAnchorsByHash.values()) {
|
|
1505
|
+
const anchor = anchors.get(hash);
|
|
1506
|
+
if (anchor) return anchor;
|
|
1507
|
+
}
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1301
1511
|
/**
|
|
1302
1512
|
* Render a single summary row, or queue it if the matching hunk hasn't
|
|
1303
1513
|
* been hashed yet (race between WS broadcast and post-render hashing).
|
|
1304
1514
|
* Trivial / model-skipped / model-malformed rows are ignored.
|
|
1305
1515
|
* @param {Object} summary - { content_hash, summary_text, trivial_reason }
|
|
1516
|
+
* @param {string} [filePath] - File the summary belongs to. Used to scope
|
|
1517
|
+
* both the anchor lookup and the pending-queue key so a cross-file hash
|
|
1518
|
+
* collision can't misroute it to the wrong file's hunk.
|
|
1306
1519
|
* @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
|
|
1307
1520
|
*/
|
|
1308
|
-
_renderOneSummary(summary) {
|
|
1521
|
+
_renderOneSummary(summary, filePath) {
|
|
1309
1522
|
if (!summary || !summary.content_hash) return null;
|
|
1310
1523
|
if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
|
|
1311
1524
|
const hash = summary.content_hash;
|
|
1312
|
-
const anchor = this.
|
|
1525
|
+
const anchor = this._findSummaryAnchor(filePath, hash);
|
|
1313
1526
|
if (!anchor || !anchor.isConnected) {
|
|
1314
1527
|
// Anchor missing or detached (stale render) → defer; the next render
|
|
1315
1528
|
// pass that re-establishes the hash will drain this map.
|
|
1316
|
-
this.
|
|
1529
|
+
this._queuePendingSummary(filePath, summary);
|
|
1317
1530
|
return null;
|
|
1318
1531
|
}
|
|
1319
1532
|
if (!this.hunkSummaryRenderer) return null;
|
|
@@ -1423,9 +1636,12 @@ class PRManager {
|
|
|
1423
1636
|
// opens a confirm dialog ("Cancel Summaries" / "OK") instead of
|
|
1424
1637
|
// toggling visibility. See _handleSummaryToggleClick.
|
|
1425
1638
|
label = 'Generating summaries… (click to cancel)';
|
|
1426
|
-
} else if (!this.
|
|
1427
|
-
// Pre-generated state: nothing generated yet
|
|
1428
|
-
// kicks off generation.
|
|
1639
|
+
} else if (!this._summariesAvailable) {
|
|
1640
|
+
// Pre-generated state: nothing generated yet (mounted or queued).
|
|
1641
|
+
// Colorless button; a click kicks off generation. Gated on
|
|
1642
|
+
// `_summariesAvailable` (not `_summariesGenerated`) so a review whose
|
|
1643
|
+
// summaries exist but haven't mounted shows Show/Hide, matching the
|
|
1644
|
+
// click behavior in _handleSummaryToggleClick.
|
|
1429
1645
|
label = 'Generate hunk summaries';
|
|
1430
1646
|
} else {
|
|
1431
1647
|
label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
|
|
@@ -1505,10 +1721,14 @@ class PRManager {
|
|
|
1505
1721
|
});
|
|
1506
1722
|
return;
|
|
1507
1723
|
}
|
|
1508
|
-
if (!this.
|
|
1509
|
-
//
|
|
1510
|
-
// visibility. `_startGenerationJob` sets the pulsing
|
|
1511
|
-
// optimistically (there is no
|
|
1724
|
+
if (!this._summariesAvailable) {
|
|
1725
|
+
// Nothing generated yet (mounted OR queued): a click triggers generation
|
|
1726
|
+
// rather than toggling visibility. `_startGenerationJob` sets the pulsing
|
|
1727
|
+
// `.generating` state optimistically (there is no
|
|
1728
|
+
// `review:background_job_started` event). We gate on `_summariesAvailable`
|
|
1729
|
+
// rather than `_summariesGenerated` so summaries that exist server-side
|
|
1730
|
+
// but haven't mounted (their lazy file body isn't rendered yet) toggle
|
|
1731
|
+
// visibility instead of kicking off a duplicate generation job.
|
|
1512
1732
|
await this._startGenerationJob('summary');
|
|
1513
1733
|
return;
|
|
1514
1734
|
}
|
|
@@ -1594,18 +1814,23 @@ class PRManager {
|
|
|
1594
1814
|
const url = isLocal
|
|
1595
1815
|
? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
|
|
1596
1816
|
: `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
|
|
1817
|
+
// Phrasing varies by job kind. `featureLabel` goes into the HTTP/decline
|
|
1818
|
+
// error messages; `noDiffMessage` is the dedicated "nothing to do" line.
|
|
1819
|
+
// NOTE: the toast singleton is lowercase `window.toast` (see
|
|
1820
|
+
// cancel-background-job.js); `window.Toast` does not exist.
|
|
1821
|
+
const featureLabel = jobKey === 'tour' ? 'tour' : 'summary';
|
|
1822
|
+
const noDiffMessage = jobKey === 'tour' ? 'No tour to generate.' : 'No summaries to generate.';
|
|
1597
1823
|
try {
|
|
1598
1824
|
const resp = await fetch(url, { method: 'POST' });
|
|
1599
1825
|
if (resp.status === 409) {
|
|
1600
1826
|
// Feature disabled in config — shouldn't happen (the button is hidden
|
|
1601
1827
|
// when disabled) but surface it rather than failing silently.
|
|
1602
|
-
|
|
1603
|
-
// cancel-background-job.js); `window.Toast` does not exist.
|
|
1604
|
-
if (window.toast?.error) window.toast.error('This feature is disabled in config.');
|
|
1828
|
+
window.toast?.showError?.('This feature is disabled in config.');
|
|
1605
1829
|
return;
|
|
1606
1830
|
}
|
|
1607
1831
|
if (!resp.ok) {
|
|
1608
1832
|
console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
|
|
1833
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
|
|
1609
1834
|
return;
|
|
1610
1835
|
}
|
|
1611
1836
|
// Optimistic UI: there is no `review:background_job_started` broadcast,
|
|
@@ -1623,9 +1848,20 @@ class PRManager {
|
|
|
1623
1848
|
this._tourGenerating = true;
|
|
1624
1849
|
this._syncTourToolbarButton();
|
|
1625
1850
|
}
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
// Server accepted the request but declined to enqueue. The known
|
|
1854
|
+
// reason today is `'no-diff'` — review has no changes to act on. Tell
|
|
1855
|
+
// the user so the button doesn't appear inert. Unknown decline
|
|
1856
|
+
// reasons fall through to a generic message rather than silence.
|
|
1857
|
+
if (payload.reason === 'no-diff') {
|
|
1858
|
+
window.toast?.showInfo?.(noDiffMessage);
|
|
1859
|
+
} else {
|
|
1860
|
+
window.toast?.showError?.(`Could not start ${featureLabel} generation.`);
|
|
1626
1861
|
}
|
|
1627
1862
|
} catch (err) {
|
|
1628
1863
|
console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
|
|
1864
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
|
|
1629
1865
|
}
|
|
1630
1866
|
}
|
|
1631
1867
|
|
|
@@ -1836,7 +2072,11 @@ class PRManager {
|
|
|
1836
2072
|
// was awaiting a file fetch / loadContextFiles. Bail before
|
|
1837
2073
|
// mounting against a torn-down tour.
|
|
1838
2074
|
if (isStale()) return;
|
|
1839
|
-
|
|
2075
|
+
// mountStop is async (it awaits toggleFileCollapse so a collapsed
|
|
2076
|
+
// file is rendered + visibly expanded before we scroll to it).
|
|
2077
|
+
const row = await this._tourRenderer.mountStop(probe);
|
|
2078
|
+
// Re-check staleness: the await above is a suspension window.
|
|
2079
|
+
if (isStale()) return;
|
|
1840
2080
|
if (row) {
|
|
1841
2081
|
nextRow = row;
|
|
1842
2082
|
nextIndex = probe;
|
|
@@ -2437,13 +2677,32 @@ class PRManager {
|
|
|
2437
2677
|
|
|
2438
2678
|
// Update Graphite link (gated on enable_graphite config)
|
|
2439
2679
|
const graphiteLink = document.getElementById('graphite-link');
|
|
2440
|
-
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2680
|
+
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2681
|
+
&& graphiteLink.dataset.suppressed !== 'true') {
|
|
2441
2682
|
// Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
|
|
2442
2683
|
const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
|
|
2443
2684
|
graphiteLink.href = graphiteUrl;
|
|
2444
2685
|
graphiteLink.style.display = '';
|
|
2445
2686
|
}
|
|
2446
2687
|
|
|
2688
|
+
if (window.RepoLinks && pr.owner && pr.repo) {
|
|
2689
|
+
const linksApplied = window.RepoLinks.fetchAndApplyRepoLinks(pr.owner, pr.repo, {
|
|
2690
|
+
owner: pr.owner,
|
|
2691
|
+
repo: pr.repo,
|
|
2692
|
+
number: pr.number,
|
|
2693
|
+
branch: pr.head_branch,
|
|
2694
|
+
base_branch: pr.base_branch,
|
|
2695
|
+
head_sha: pr.head_sha,
|
|
2696
|
+
});
|
|
2697
|
+
// The repo links (incl. the configured host name) resolve asynchronously
|
|
2698
|
+
// after this synchronous render. Re-render the pending-draft indicator
|
|
2699
|
+
// once they land so it shows the configured host name (e.g. "Meteorite")
|
|
2700
|
+
// instead of the "GitHub" fallback it would otherwise bake in below.
|
|
2701
|
+
if (linksApplied && typeof linksApplied.then === 'function') {
|
|
2702
|
+
linksApplied.then(() => this.updatePendingDraftIndicator(pr.pendingDraft));
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2447
2706
|
// Update settings link
|
|
2448
2707
|
const settingsLink = document.getElementById('settings-link');
|
|
2449
2708
|
if (settingsLink && pr.owner && pr.repo) {
|
|
@@ -2744,13 +3003,20 @@ class PRManager {
|
|
|
2744
3003
|
}
|
|
2745
3004
|
|
|
2746
3005
|
// Fetch settings in parallel
|
|
2747
|
-
const [repoSettings, reviewSettings] = await Promise.all([
|
|
3006
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
2748
3007
|
this.fetchRepoSettings().catch(() => null),
|
|
2749
|
-
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
|
|
3008
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
3009
|
+
this._getAppConfig()
|
|
2750
3010
|
]);
|
|
2751
3011
|
|
|
2752
|
-
|
|
2753
|
-
|
|
3012
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
3013
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
3014
|
+
// would blank the model <select> and be rejected by the backend.
|
|
3015
|
+
const providersInfo = await this._getProvidersInfo();
|
|
3016
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
3017
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
3018
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
3019
|
+
], providersInfo);
|
|
2754
3020
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
2755
3021
|
const rememberedTab = localStorage.getItem(tabStorageKey);
|
|
2756
3022
|
const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
|
|
@@ -2957,14 +3223,22 @@ class PRManager {
|
|
|
2957
3223
|
// Don't show if no pending draft
|
|
2958
3224
|
if (!pendingDraft) return;
|
|
2959
3225
|
|
|
3226
|
+
// Resolve the configured host name + URL (alt-host aware). Prefer the
|
|
3227
|
+
// URL built from the repo's url_template over the server-reported
|
|
3228
|
+
// github_url, which some alt-hosts return as a wrong-host github.com URL.
|
|
3229
|
+
const hostName = (window.RepoLinks && typeof window.RepoLinks.hostName === 'function')
|
|
3230
|
+
? window.RepoLinks.hostName() : 'GitHub';
|
|
3231
|
+
const externalUrl = (window.RepoLinks && typeof window.RepoLinks.externalUrl === 'function')
|
|
3232
|
+
? window.RepoLinks.externalUrl() : null;
|
|
3233
|
+
|
|
2960
3234
|
// Create the indicator
|
|
2961
3235
|
const indicator = document.createElement('a');
|
|
2962
3236
|
indicator.id = 'pending-draft-indicator';
|
|
2963
3237
|
indicator.className = 'pending-draft-indicator';
|
|
2964
|
-
indicator.href = pendingDraft.github_url || '#';
|
|
3238
|
+
indicator.href = externalUrl || pendingDraft.github_url || '#';
|
|
2965
3239
|
indicator.target = '_blank';
|
|
2966
3240
|
indicator.rel = 'noopener noreferrer';
|
|
2967
|
-
indicator.title =
|
|
3241
|
+
indicator.title = `View your pending draft review on ${hostName}`;
|
|
2968
3242
|
|
|
2969
3243
|
const commentCount = pendingDraft.comments_count || 0;
|
|
2970
3244
|
const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
|
|
@@ -2973,7 +3247,7 @@ class PRManager {
|
|
|
2973
3247
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
2974
3248
|
<path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm5.03 2.22a.75.75 0 0 1 0 1.06L5.31 6.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06Z"/>
|
|
2975
3249
|
</svg>
|
|
2976
|
-
<span class="pending-draft-text">Draft on
|
|
3250
|
+
<span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
|
|
2977
3251
|
`;
|
|
2978
3252
|
|
|
2979
3253
|
// Insert after the commit element (or at the end of toolbar-meta)
|
|
@@ -3022,10 +3296,23 @@ class PRManager {
|
|
|
3022
3296
|
// no matching rows keeps the stale `true`, leaving the toolbar stuck in
|
|
3023
3297
|
// Hide/Show mode with nothing in the DOM and blocking click-to-generate.
|
|
3024
3298
|
this._summariesGenerated = false;
|
|
3025
|
-
//
|
|
3299
|
+
// Reset alongside `_summariesGenerated`. Set true again only when a fetch
|
|
3300
|
+
// (or WS event) accepts/queues a non-trivial summary for this render.
|
|
3301
|
+
this._summariesAvailable = false;
|
|
3302
|
+
// Bump generation so any in-flight `_fetchHunkSummaryMap` from the
|
|
3026
3303
|
// previous render bails out instead of mutating maps we just reset.
|
|
3027
3304
|
this._renderGen = (this._renderGen || 0) + 1;
|
|
3028
3305
|
|
|
3306
|
+
// Reset lazy-body state and (re)create the IntersectionObserver. The
|
|
3307
|
+
// `innerHTML = ''` above detached every previously-observed body, so a
|
|
3308
|
+
// stale observer would hold dead references and never fire — tear it down
|
|
3309
|
+
// and start fresh for this render generation. This runs for every render
|
|
3310
|
+
// path (initial load, whitespace toggle, scope change) since they all
|
|
3311
|
+
// funnel through renderDiff().
|
|
3312
|
+
this._teardownFileBodyObserver();
|
|
3313
|
+
this._lazyFileBodies = new Map();
|
|
3314
|
+
this._fileBodyObserver = this._createFileBodyObserver();
|
|
3315
|
+
|
|
3029
3316
|
// Use changed_files array from API
|
|
3030
3317
|
const files = pr.changed_files || pr.files || [];
|
|
3031
3318
|
|
|
@@ -3050,9 +3337,12 @@ class PRManager {
|
|
|
3050
3337
|
}
|
|
3051
3338
|
});
|
|
3052
3339
|
|
|
3053
|
-
//
|
|
3054
|
-
//
|
|
3055
|
-
|
|
3340
|
+
// NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
|
|
3341
|
+
// now (bodies render lazily), not once globally here.
|
|
3342
|
+
|
|
3343
|
+
// Measure the now-rendered sticky file header so navigation can offset
|
|
3344
|
+
// targets below it (scroll-margin-top in pr.css).
|
|
3345
|
+
this._measureFileHeaderHeight();
|
|
3056
3346
|
} else {
|
|
3057
3347
|
diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
|
|
3058
3348
|
}
|
|
@@ -3061,11 +3351,13 @@ class PRManager {
|
|
|
3061
3351
|
this.contextFiles = [];
|
|
3062
3352
|
this.loadContextFiles();
|
|
3063
3353
|
|
|
3064
|
-
//
|
|
3065
|
-
//
|
|
3354
|
+
// Fetch hunk summaries (Phase 5). Fire-and-forget — the diff is fully
|
|
3355
|
+
// usable while summaries arrive asynchronously. Anchors are wired lazily
|
|
3356
|
+
// as each file body renders (_registerHunkAnchorsForFile); this just loads
|
|
3357
|
+
// the server's summary map and applies it to whatever has rendered so far.
|
|
3066
3358
|
if (this.hunkSummaryRenderer) {
|
|
3067
|
-
this.
|
|
3068
|
-
console.warn('[HunkSummary]
|
|
3359
|
+
this._fetchHunkSummaryMap().catch((err) => {
|
|
3360
|
+
console.warn('[HunkSummary] summary fetch failed:', err);
|
|
3069
3361
|
});
|
|
3070
3362
|
}
|
|
3071
3363
|
|
|
@@ -3174,8 +3466,8 @@ class PRManager {
|
|
|
3174
3466
|
|
|
3175
3467
|
// Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
|
|
3176
3468
|
// one file. Disabled (greyed) when no summaries exist yet for this file;
|
|
3177
|
-
// _applyHunkSummaries /
|
|
3178
|
-
// hashes for this file are recorded (so a summary that arrives later
|
|
3469
|
+
// _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
|
|
3470
|
+
// as hashes for this file are recorded (so a summary that arrives later
|
|
3179
3471
|
// doesn't get hidden behind a permanently-disabled button).
|
|
3180
3472
|
// Gated on `_summariesEnabled`: skipped entirely when /api/config has
|
|
3181
3473
|
// already reported the feature is off; created hidden + revealed later
|
|
@@ -3210,21 +3502,13 @@ class PRManager {
|
|
|
3210
3502
|
}
|
|
3211
3503
|
}
|
|
3212
3504
|
|
|
3213
|
-
// Create diff table
|
|
3505
|
+
// Create diff table with an EMPTY tbody. The rows are NOT rendered here —
|
|
3506
|
+
// see the lazy-body machinery below. This is the core large-PR perf fix:
|
|
3507
|
+
// building + syntax-highlighting every line of every (often collapsed)
|
|
3508
|
+
// file up front froze the browser on big diffs.
|
|
3214
3509
|
const table = document.createElement('table');
|
|
3215
3510
|
table.className = 'd2h-diff-table';
|
|
3216
|
-
|
|
3217
3511
|
const tbody = document.createElement('tbody');
|
|
3218
|
-
|
|
3219
|
-
// Parse the diff content
|
|
3220
|
-
if (file.patch) {
|
|
3221
|
-
this.renderPatch(tbody, file.patch, file.file, file.hunk_hashes || null);
|
|
3222
|
-
} else if (file.binary) {
|
|
3223
|
-
const row = document.createElement('tr');
|
|
3224
|
-
row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
|
|
3225
|
-
tbody.appendChild(row);
|
|
3226
|
-
}
|
|
3227
|
-
|
|
3228
3512
|
table.appendChild(tbody);
|
|
3229
3513
|
|
|
3230
3514
|
// Wrap table in a scrollable container for horizontal scroll of long code lines
|
|
@@ -3234,9 +3518,197 @@ class PRManager {
|
|
|
3234
3518
|
fileBody.appendChild(table);
|
|
3235
3519
|
wrapper.appendChild(fileBody);
|
|
3236
3520
|
|
|
3521
|
+
// Reserve approximate height for EXPANDED-but-not-yet-rendered bodies so
|
|
3522
|
+
// the scrollbar stays roughly stable as the user scrolls and bodies fill
|
|
3523
|
+
// in. Collapsed bodies are `display:none`, so they contribute no height
|
|
3524
|
+
// and need no placeholder. Cleared once the body actually renders.
|
|
3525
|
+
if (!isCollapsed && file.patch) {
|
|
3526
|
+
const approxLines = file.patch.split('\n').length;
|
|
3527
|
+
fileBody.style.minHeight = (approxLines * PRManager.APPROX_DIFF_LINE_PX) + 'px';
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
// Register the lazy entry. `gen` lets _renderFileBodyNow detect a body
|
|
3531
|
+
// left over from a superseded render and skip anchor registration.
|
|
3532
|
+
this._lazyFileBodies.set(file.file, {
|
|
3533
|
+
fileName: file.file,
|
|
3534
|
+
patch: file.patch || null,
|
|
3535
|
+
binary: !!file.binary,
|
|
3536
|
+
hunkHashes: file.hunk_hashes || null,
|
|
3537
|
+
fileBody,
|
|
3538
|
+
wrapper,
|
|
3539
|
+
rendered: false,
|
|
3540
|
+
renderPromise: null,
|
|
3541
|
+
gen: this._renderGen
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
// Observe the body so it renders as it nears the viewport. Collapsed
|
|
3545
|
+
// bodies (display:none) never intersect → stay unrendered until expanded.
|
|
3546
|
+
if ((file.patch || file.binary) && this._fileBodyObserver) {
|
|
3547
|
+
this._fileBodyObserver.observe(fileBody);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3237
3550
|
return wrapper;
|
|
3238
3551
|
}
|
|
3239
3552
|
|
|
3553
|
+
/**
|
|
3554
|
+
* Approximate rendered height (px) of one diff line row. Used only to
|
|
3555
|
+
* reserve placeholder height for expanded-but-unrendered file bodies so the
|
|
3556
|
+
* scrollbar doesn't jump as lazy bodies fill in. Errs slightly high.
|
|
3557
|
+
*/
|
|
3558
|
+
static get APPROX_DIFF_LINE_PX() { return 20; }
|
|
3559
|
+
|
|
3560
|
+
/**
|
|
3561
|
+
* Create the IntersectionObserver that renders file bodies as they approach
|
|
3562
|
+
* the viewport. One instance per render generation; recreated in renderDiff.
|
|
3563
|
+
* Returns null where IntersectionObserver is unavailable (e.g. jsdom unit
|
|
3564
|
+
* tests) — bodies then render only on demand via ensureFileBodyRendered().
|
|
3565
|
+
* @returns {IntersectionObserver|null}
|
|
3566
|
+
*/
|
|
3567
|
+
_createFileBodyObserver() {
|
|
3568
|
+
if (typeof IntersectionObserver === 'undefined') return null;
|
|
3569
|
+
// `.diff-view` is the vertical scroll container (see pr.css). Fall back to
|
|
3570
|
+
// the viewport when it can't be resolved.
|
|
3571
|
+
const root = document.querySelector('.diff-view') || null;
|
|
3572
|
+
return new IntersectionObserver((entries, observer) => {
|
|
3573
|
+
for (const ioEntry of entries) {
|
|
3574
|
+
if (!ioEntry.isIntersecting) continue;
|
|
3575
|
+
const lazyEntry = this._lazyFileBodyForElement(ioEntry.target);
|
|
3576
|
+
observer.unobserve(ioEntry.target);
|
|
3577
|
+
if (lazyEntry) this._renderFileBodyNow(lazyEntry);
|
|
3578
|
+
}
|
|
3579
|
+
}, { root, rootMargin: '800px 0px', threshold: 0 });
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
/**
|
|
3583
|
+
* Disconnect and drop the current file-body observer, if any.
|
|
3584
|
+
*/
|
|
3585
|
+
_teardownFileBodyObserver() {
|
|
3586
|
+
if (this._fileBodyObserver) {
|
|
3587
|
+
this._fileBodyObserver.disconnect();
|
|
3588
|
+
this._fileBodyObserver = null;
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/**
|
|
3593
|
+
* Resolve the lazy entry for a `.d2h-file-body` element via its wrapper's
|
|
3594
|
+
* data-file-name. Returns null if not a known lazy body.
|
|
3595
|
+
* @param {Element} bodyEl
|
|
3596
|
+
* @returns {object|null}
|
|
3597
|
+
*/
|
|
3598
|
+
_lazyFileBodyForElement(bodyEl) {
|
|
3599
|
+
if (!this._lazyFileBodies) return null;
|
|
3600
|
+
const wrapper = bodyEl?.closest?.('.d2h-file-wrapper');
|
|
3601
|
+
const filePath = wrapper?.dataset?.fileName;
|
|
3602
|
+
if (!filePath) return null;
|
|
3603
|
+
return this._lazyFileBodies.get(filePath) || null;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
/**
|
|
3607
|
+
* Ensure a file's diff-line body is rendered into the DOM, rendering it now
|
|
3608
|
+
* if it hasn't been. Idempotent and cheap when already rendered. Every code
|
|
3609
|
+
* path that scans a file's `<tr>` rows (comment/suggestion anchoring, gap
|
|
3610
|
+
* expansion, scroll-to-file, expand) must await this first, because a lazy
|
|
3611
|
+
* body has zero rows until rendered.
|
|
3612
|
+
* @param {string|Element} fileOrWrapper - file path, file body, or wrapper
|
|
3613
|
+
* @returns {Promise<HTMLElement|null>} the file body, or null if unknown
|
|
3614
|
+
*/
|
|
3615
|
+
async ensureFileBodyRendered(fileOrWrapper) {
|
|
3616
|
+
// No lazy map (e.g. called before renderDiff, or a non-lazy render path) →
|
|
3617
|
+
// treat the body as already present and let callers scan as before.
|
|
3618
|
+
if (!this._lazyFileBodies) return null;
|
|
3619
|
+
let entry = null;
|
|
3620
|
+
if (typeof fileOrWrapper === 'string') {
|
|
3621
|
+
entry = this._lazyFileBodies.get(fileOrWrapper) || null;
|
|
3622
|
+
if (!entry) {
|
|
3623
|
+
// The map is keyed by the canonical `file.file` value, but callers
|
|
3624
|
+
// (AI suggestions, external comments, tour stops) may pass a
|
|
3625
|
+
// non-canonical path form. findFileElement normalizes './', '/', and
|
|
3626
|
+
// git rename syntax ('{old => new}') against data-file-name, so
|
|
3627
|
+
// resolve the wrapper first and retry with its canonical name. This
|
|
3628
|
+
// keeps the strict Map.get fast path while tolerating the same path
|
|
3629
|
+
// variants the rest of the diff UI accepts — without it the body
|
|
3630
|
+
// stays unrendered and the downstream row scan sees zero <tr> rows.
|
|
3631
|
+
const wrapper = this.findFileElement?.(fileOrWrapper);
|
|
3632
|
+
const canonicalFile = wrapper?.dataset?.fileName;
|
|
3633
|
+
if (canonicalFile) entry = this._lazyFileBodies.get(canonicalFile) || null;
|
|
3634
|
+
}
|
|
3635
|
+
} else if (fileOrWrapper && fileOrWrapper.nodeType === 1) {
|
|
3636
|
+
entry = this._lazyFileBodyForElement(fileOrWrapper)
|
|
3637
|
+
|| (this._lazyFileBodies.get(fileOrWrapper.dataset?.fileName) || null);
|
|
3638
|
+
}
|
|
3639
|
+
if (!entry) return null;
|
|
3640
|
+
if (entry.rendered) return entry.fileBody;
|
|
3641
|
+
if (entry.renderPromise) return entry.renderPromise;
|
|
3642
|
+
// _renderFileBodyNow is synchronous; wrapping in a resolved promise keeps
|
|
3643
|
+
// the signature async and lets concurrent callers (observer + on-demand)
|
|
3644
|
+
// share one promise so renderPatch runs exactly once.
|
|
3645
|
+
entry.renderPromise = Promise.resolve().then(() => this._renderFileBodyNow(entry));
|
|
3646
|
+
return entry.renderPromise;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* Synchronously build a file's diff-line body: run renderPatch (or the
|
|
3651
|
+
* binary placeholder), clear the height placeholder, wire hunk-summary
|
|
3652
|
+
* anchors, and validate this file's EOF gap. Idempotent.
|
|
3653
|
+
*
|
|
3654
|
+
* Invariant: there must be NO `await` between the `_pendingHunkRecords`
|
|
3655
|
+
* save and restore below — renderPatch pushes per-hunk anchor records into
|
|
3656
|
+
* the shared `_pendingHunkRecords`, and JS single-threading only guarantees
|
|
3657
|
+
* non-interleaving with other file renders while this stays synchronous.
|
|
3658
|
+
* @param {object} entry - a _lazyFileBodies value
|
|
3659
|
+
* @returns {HTMLElement} the file body element
|
|
3660
|
+
*/
|
|
3661
|
+
_renderFileBodyNow(entry) {
|
|
3662
|
+
if (entry.rendered) return entry.fileBody;
|
|
3663
|
+
if (this._fileBodyObserver) this._fileBodyObserver.unobserve(entry.fileBody);
|
|
3664
|
+
|
|
3665
|
+
const tbody = entry.fileBody.querySelector('tbody');
|
|
3666
|
+
|
|
3667
|
+
// Capture only THIS file's hunk anchor records (renderPatch appends to
|
|
3668
|
+
// this._pendingHunkRecords; see invariant above). The swap is wrapped in
|
|
3669
|
+
// try/finally so a renderPatch throw can't leak the temporary buffer: were
|
|
3670
|
+
// the restore skipped, every subsequent file render would append its
|
|
3671
|
+
// anchor records into the stale array, silently cross-wiring
|
|
3672
|
+
// _summaryAnchorsByHash / _summaryHashesByFile for the rest of the session.
|
|
3673
|
+
const prevPending = this._pendingHunkRecords;
|
|
3674
|
+
this._pendingHunkRecords = [];
|
|
3675
|
+
let records;
|
|
3676
|
+
try {
|
|
3677
|
+
if (entry.patch && tbody) {
|
|
3678
|
+
this.renderPatch(tbody, entry.patch, entry.fileName, entry.hunkHashes);
|
|
3679
|
+
} else if (entry.binary && tbody) {
|
|
3680
|
+
const row = document.createElement('tr');
|
|
3681
|
+
row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
|
|
3682
|
+
tbody.appendChild(row);
|
|
3683
|
+
}
|
|
3684
|
+
} finally {
|
|
3685
|
+
records = this._pendingHunkRecords;
|
|
3686
|
+
this._pendingHunkRecords = prevPending;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
entry.fileBody.style.minHeight = '';
|
|
3690
|
+
entry.rendered = true;
|
|
3691
|
+
entry.renderPromise = null;
|
|
3692
|
+
|
|
3693
|
+
// Skip post-render wiring for a body left over from a superseded render
|
|
3694
|
+
// (its maps were reset and the body is detached). Both anchor registration
|
|
3695
|
+
// and EOF-gap validation are pointless/wasteful for a stale body.
|
|
3696
|
+
if (entry.gen === this._renderGen) {
|
|
3697
|
+
this._registerHunkAnchorsForFile(records);
|
|
3698
|
+
// Validate this file's pending EOF gaps. Pre-lazy-render this was a
|
|
3699
|
+
// single global pass at the end of renderDiff; now it runs per-file as
|
|
3700
|
+
// bodies render. Cheap pre-check first: most files have no pending EOF
|
|
3701
|
+
// gap, so skip the async work (Array.from + Promise.all + per-gap
|
|
3702
|
+
// /file-content fetches) entirely when this body has none. The selector
|
|
3703
|
+
// mirrors the one validatePendingEofGaps scans for.
|
|
3704
|
+
if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
|
|
3705
|
+
this.validatePendingEofGaps(entry.fileBody);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
return entry.fileBody;
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3240
3712
|
/**
|
|
3241
3713
|
* Parse and render a unified diff patch
|
|
3242
3714
|
* @param {HTMLElement} tbody - Table body element
|
|
@@ -3389,8 +3861,8 @@ class PRManager {
|
|
|
3389
3861
|
|
|
3390
3862
|
// Record this hunk's first rendered code row as the anchor for any
|
|
3391
3863
|
// inline summary annotation. The canonical hash comes from the
|
|
3392
|
-
// backend (`hunkHashes[blockIndex]`);
|
|
3393
|
-
// it as `data-hunk-start`
|
|
3864
|
+
// backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
|
|
3865
|
+
// it as `data-hunk-start` when this file's body renders so the summary
|
|
3394
3866
|
// renderer can find the anchor and insert the annotation above it.
|
|
3395
3867
|
if (this._pendingHunkRecords && firstLineRow) {
|
|
3396
3868
|
const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
|
|
@@ -3574,7 +4046,7 @@ class PRManager {
|
|
|
3574
4046
|
* Toggle collapse state of a file diff
|
|
3575
4047
|
* @param {string} filePath - Path of the file
|
|
3576
4048
|
*/
|
|
3577
|
-
toggleFileCollapse(filePath) {
|
|
4049
|
+
async toggleFileCollapse(filePath) {
|
|
3578
4050
|
const wrapper = this.findFileElement(filePath);
|
|
3579
4051
|
if (!wrapper) return;
|
|
3580
4052
|
|
|
@@ -3582,6 +4054,8 @@ class PRManager {
|
|
|
3582
4054
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
3583
4055
|
|
|
3584
4056
|
if (isCollapsed) {
|
|
4057
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4058
|
+
await this.ensureFileBodyRendered(filePath);
|
|
3585
4059
|
wrapper.classList.remove('collapsed');
|
|
3586
4060
|
this.collapsedFiles.delete(filePath);
|
|
3587
4061
|
} else {
|
|
@@ -3600,7 +4074,7 @@ class PRManager {
|
|
|
3600
4074
|
* @param {string} filePath - Path of the file
|
|
3601
4075
|
* @param {boolean} isViewed - Whether the file is now viewed
|
|
3602
4076
|
*/
|
|
3603
|
-
toggleFileViewed(filePath, isViewed) {
|
|
4077
|
+
async toggleFileViewed(filePath, isViewed) {
|
|
3604
4078
|
const wrapper = this.findFileElement(filePath);
|
|
3605
4079
|
|
|
3606
4080
|
if (isViewed) {
|
|
@@ -3618,6 +4092,8 @@ class PRManager {
|
|
|
3618
4092
|
this.viewedFiles.delete(filePath);
|
|
3619
4093
|
// Auto-expand when unchecking viewed (match GitHub behavior)
|
|
3620
4094
|
if (wrapper && wrapper.classList.contains('collapsed')) {
|
|
4095
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4096
|
+
await this.ensureFileBodyRendered(filePath);
|
|
3621
4097
|
wrapper.classList.remove('collapsed');
|
|
3622
4098
|
this.collapsedFiles.delete(filePath);
|
|
3623
4099
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
@@ -3743,7 +4219,7 @@ class PRManager {
|
|
|
3743
4219
|
* @deprecated Use toggleFileCollapse instead - kept for backward compatibility
|
|
3744
4220
|
*/
|
|
3745
4221
|
toggleGeneratedFile(filePath) {
|
|
3746
|
-
this.toggleFileCollapse(filePath);
|
|
4222
|
+
return this.toggleFileCollapse(filePath);
|
|
3747
4223
|
}
|
|
3748
4224
|
|
|
3749
4225
|
/**
|
|
@@ -3772,9 +4248,12 @@ class PRManager {
|
|
|
3772
4248
|
* Validate pending end-of-file gaps asynchronously
|
|
3773
4249
|
* Removes gap rows where there are no trailing lines to expand
|
|
3774
4250
|
* This ensures users don't see expand buttons that do nothing
|
|
4251
|
+
* @param {ParentNode} [root=document] - Scope the search. With lazy bodies,
|
|
4252
|
+
* _renderFileBodyNow passes the just-rendered file body so each file's EOF
|
|
4253
|
+
* gap is validated as it renders (rather than one global post-render pass).
|
|
3775
4254
|
*/
|
|
3776
|
-
async validatePendingEofGaps() {
|
|
3777
|
-
const pendingGaps =
|
|
4255
|
+
async validatePendingEofGaps(root = document) {
|
|
4256
|
+
const pendingGaps = root.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
|
|
3778
4257
|
|
|
3779
4258
|
// Process all pending gaps in parallel for efficiency
|
|
3780
4259
|
const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
|
|
@@ -4139,11 +4618,14 @@ class PRManager {
|
|
|
4139
4618
|
return false;
|
|
4140
4619
|
}
|
|
4141
4620
|
|
|
4621
|
+
// Render the body first — gap rows (and code rows) only exist once the
|
|
4622
|
+
// lazy body has rendered. Without this the gap query below returns nothing.
|
|
4623
|
+
await this.ensureFileBodyRendered(file);
|
|
4624
|
+
|
|
4142
4625
|
// Check if file is collapsed (generated files)
|
|
4143
4626
|
if (fileElement.classList.contains('collapsed')) {
|
|
4144
4627
|
debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
|
|
4145
|
-
this.toggleGeneratedFile(file);
|
|
4146
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
4628
|
+
await this.toggleGeneratedFile(file);
|
|
4147
4629
|
}
|
|
4148
4630
|
|
|
4149
4631
|
// Find the gap section containing the target lines using the shared module
|
|
@@ -4227,6 +4709,12 @@ class PRManager {
|
|
|
4227
4709
|
const fileElement = this.findFileElement(file);
|
|
4228
4710
|
if (!fileElement) continue;
|
|
4229
4711
|
|
|
4712
|
+
// Render the file body first — with lazy rendering an unrendered file
|
|
4713
|
+
// has zero rows, so the visibility scan below would always miss and the
|
|
4714
|
+
// line would be treated as "hidden in a gap" (then gap-expanded against
|
|
4715
|
+
// zero gap rows → silent anchor failure).
|
|
4716
|
+
await this.ensureFileBodyRendered(file);
|
|
4717
|
+
|
|
4230
4718
|
// Check if any line in the range is already visible
|
|
4231
4719
|
let anyLineVisible = false;
|
|
4232
4720
|
const lineRows = fileElement.querySelectorAll('tr');
|
|
@@ -5901,10 +6389,30 @@ class PRManager {
|
|
|
5901
6389
|
collapsedBtn.addEventListener('click', () => toggleSidebar(false));
|
|
5902
6390
|
}
|
|
5903
6391
|
|
|
5904
|
-
scrollToFile(filePath) {
|
|
6392
|
+
async scrollToFile(filePath) {
|
|
5905
6393
|
const fileWrapper = this.findFileElement(filePath);
|
|
5906
6394
|
if (fileWrapper) {
|
|
5907
|
-
|
|
6395
|
+
// Render the body so the scroll target has its real height (an empty
|
|
6396
|
+
// lazy body would land the scroll at the wrong offset for expanded
|
|
6397
|
+
// files). Skip it for collapsed files: their body is display:none
|
|
6398
|
+
// (zero height) and `block: 'start'` aligns the header regardless, so
|
|
6399
|
+
// force-rendering would only pay the full renderPatch + per-line
|
|
6400
|
+
// highlight cost for content that stays hidden. scrollToFile does no
|
|
6401
|
+
// post-render row scan, so gating on `!collapsed` is safe; expanding
|
|
6402
|
+
// later still renders on demand via toggleFileCollapse.
|
|
6403
|
+
if (!fileWrapper.classList.contains('collapsed')) {
|
|
6404
|
+
await this.ensureFileBodyRendered(filePath);
|
|
6405
|
+
}
|
|
6406
|
+
// Stable variant: lazy bodies between here and the target render as
|
|
6407
|
+
// the smooth scroll passes them, shifting layout mid-flight. The
|
|
6408
|
+
// helper re-corrects after the scroll settles so the first attempt
|
|
6409
|
+
// lands where the second used to.
|
|
6410
|
+
const scrollOptions = { behavior: 'smooth', block: 'start' };
|
|
6411
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
6412
|
+
await window.ScrollUtils.scrollIntoViewStable(fileWrapper, scrollOptions);
|
|
6413
|
+
} else {
|
|
6414
|
+
fileWrapper.scrollIntoView(scrollOptions);
|
|
6415
|
+
}
|
|
5908
6416
|
}
|
|
5909
6417
|
}
|
|
5910
6418
|
|
|
@@ -6328,11 +6836,15 @@ class PRManager {
|
|
|
6328
6836
|
: this._fetchStaleness(owner, repo, number);
|
|
6329
6837
|
this._stalenessPromise = null; // consume it
|
|
6330
6838
|
|
|
6839
|
+
// Pass owner+repo to /api/config so has_github_token reflects the
|
|
6840
|
+
// repo's actual auth (covers repo-scoped tokens, token_command, and
|
|
6841
|
+
// alt-host bindings — not just the global github_token).
|
|
6842
|
+
const configUrl = `/api/config?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
6331
6843
|
const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
6332
6844
|
staleCheckWithTimeout,
|
|
6333
6845
|
this.fetchRepoSettings(),
|
|
6334
6846
|
this.fetchLastReviewSettings(),
|
|
6335
|
-
fetch(
|
|
6847
|
+
fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
|
|
6336
6848
|
]);
|
|
6337
6849
|
console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
|
|
6338
6850
|
|
|
@@ -6378,9 +6890,14 @@ class PRManager {
|
|
|
6378
6890
|
|
|
6379
6891
|
const lastCouncilId = reviewSettings.last_council_id;
|
|
6380
6892
|
|
|
6381
|
-
//
|
|
6382
|
-
|
|
6383
|
-
|
|
6893
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
6894
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
6895
|
+
// would blank the model <select> and be rejected by the backend.
|
|
6896
|
+
const providersInfo = await this._getProvidersInfo();
|
|
6897
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
6898
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
6899
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
6900
|
+
], providersInfo);
|
|
6384
6901
|
|
|
6385
6902
|
// Determine default tab (priority: localStorage > repo settings > 'single')
|
|
6386
6903
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
@@ -6408,7 +6925,12 @@ class PRManager {
|
|
|
6408
6925
|
lastCouncilId,
|
|
6409
6926
|
defaultCouncilId: repoSettings?.default_council_id || null,
|
|
6410
6927
|
hasPr: true,
|
|
6411
|
-
|
|
6928
|
+
// Use the repo-aware field (we passed owner+repo to /api/config).
|
|
6929
|
+
// Fall back to the global field only if the response was malformed
|
|
6930
|
+
// or the params were rejected — defensive, should not happen.
|
|
6931
|
+
hasGithubToken: Boolean(
|
|
6932
|
+
appConfig.has_github_token ?? appConfig.has_global_github_token
|
|
6933
|
+
)
|
|
6412
6934
|
});
|
|
6413
6935
|
|
|
6414
6936
|
// If user cancelled, do nothing
|
|
@@ -6554,7 +7076,10 @@ class PRManager {
|
|
|
6554
7076
|
showWorktreeNotFoundError(owner, repo, number) {
|
|
6555
7077
|
let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
|
|
6556
7078
|
if (this._autoAnalyzeRequested) {
|
|
6557
|
-
|
|
7079
|
+
const params = new URLSearchParams({ analyze: 'true' });
|
|
7080
|
+
const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
|
|
7081
|
+
if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
|
|
7082
|
+
setupUrl += `?${params.toString()}`;
|
|
6558
7083
|
}
|
|
6559
7084
|
const container = document.getElementById('pr-container');
|
|
6560
7085
|
if (container) {
|
|
@@ -7254,7 +7779,7 @@ class PRManager {
|
|
|
7254
7779
|
* @param {string} file - File path
|
|
7255
7780
|
* @param {number} [lineStart] - Optional line number to highlight
|
|
7256
7781
|
*/
|
|
7257
|
-
scrollToContextFile(file, lineStart, contextId) {
|
|
7782
|
+
async scrollToContextFile(file, lineStart, contextId) {
|
|
7258
7783
|
// Use contextId to find a specific chunk tbody within a merged wrapper,
|
|
7259
7784
|
// or fall back to a standalone wrapper or the file-level wrapper.
|
|
7260
7785
|
let target;
|
|
@@ -7273,23 +7798,30 @@ class PRManager {
|
|
|
7273
7798
|
}
|
|
7274
7799
|
if (!target) return;
|
|
7275
7800
|
|
|
7276
|
-
|
|
7801
|
+
// Stable variant ensures the target's lazy body is rendered and
|
|
7802
|
+
// re-corrects after lazy renders along the scroll path shift layout.
|
|
7803
|
+
const scrollOptions = { behavior: 'smooth', block: 'start' };
|
|
7804
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
7805
|
+
await window.ScrollUtils.scrollIntoViewStable(target, scrollOptions);
|
|
7806
|
+
} else {
|
|
7807
|
+
target.scrollIntoView(scrollOptions);
|
|
7808
|
+
}
|
|
7277
7809
|
|
|
7278
7810
|
if (lineStart) {
|
|
7279
7811
|
// Search for the line row within the wrapper (not just the target chunk)
|
|
7280
7812
|
const wrapper = target.closest('.d2h-file-wrapper') || target;
|
|
7281
|
-
//
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7813
|
+
// The awaited stable scroll has already settled (and rendered the lazy
|
|
7814
|
+
// body), so the row exists now — highlight it immediately rather than
|
|
7815
|
+
// pulsing on a stale timer that would fire after the scroll completes.
|
|
7816
|
+
const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
7817
|
+
if (row) {
|
|
7818
|
+
row.classList.remove('chat-line-highlight');
|
|
7819
|
+
void row.offsetWidth;
|
|
7820
|
+
row.classList.add('chat-line-highlight');
|
|
7821
|
+
row.addEventListener('animationend', () => {
|
|
7285
7822
|
row.classList.remove('chat-line-highlight');
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
row.addEventListener('animationend', () => {
|
|
7289
|
-
row.classList.remove('chat-line-highlight');
|
|
7290
|
-
}, { once: true });
|
|
7291
|
-
}
|
|
7292
|
-
}, 400);
|
|
7823
|
+
}, { once: true });
|
|
7824
|
+
}
|
|
7293
7825
|
}
|
|
7294
7826
|
}
|
|
7295
7827
|
|