@in-the-loop-labs/pair-review 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +0 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ReviewModal.js +135 -13
- 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 +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- 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 +133 -30
- 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
|
|
@@ -588,7 +618,7 @@ class PRManager {
|
|
|
588
618
|
* @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
|
|
589
619
|
* @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
|
|
590
620
|
*/
|
|
591
|
-
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
|
|
621
|
+
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
|
|
592
622
|
const defaultTab = repoSettings?.default_tab || 'single';
|
|
593
623
|
const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
|
|
594
624
|
|
|
@@ -617,13 +647,44 @@ class PRManager {
|
|
|
617
647
|
};
|
|
618
648
|
}
|
|
619
649
|
|
|
650
|
+
// Resolve provider and model as a MATCHED pair. Resolving each half
|
|
651
|
+
// independently (repo || app || hardcoded) can mix a provider from one
|
|
652
|
+
// scope with a model from another, yielding an invalid pair (e.g.
|
|
653
|
+
// gemini/opus) that startAnalysis would forward to the backend as-is.
|
|
654
|
+
const providers = providersInfo || await this._getProvidersInfo();
|
|
655
|
+
const { provider, model } = window.resolveProviderModelPair([
|
|
656
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
657
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
658
|
+
], providers);
|
|
659
|
+
|
|
620
660
|
return {
|
|
621
|
-
provider
|
|
622
|
-
model
|
|
661
|
+
provider,
|
|
662
|
+
model,
|
|
623
663
|
customInstructions: null
|
|
624
664
|
};
|
|
625
665
|
}
|
|
626
666
|
|
|
667
|
+
async _fetchAutoAnalysisConfigFromUrl() {
|
|
668
|
+
const params = new URLSearchParams(window.location.search);
|
|
669
|
+
const configId = params.get('analysisConfigId');
|
|
670
|
+
if (!configId) return { requested: false, config: null, error: null };
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
const response = await fetch(`/api/bulk-analysis-configs/${encodeURIComponent(configId)}`);
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
throw new Error('Stored analysis settings were not found');
|
|
676
|
+
}
|
|
677
|
+
const data = await response.json();
|
|
678
|
+
if (!data.analysisConfig) {
|
|
679
|
+
throw new Error('Stored analysis settings response was empty');
|
|
680
|
+
}
|
|
681
|
+
return { requested: true, config: data.analysisConfig, error: null };
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.warn('Failed to fetch bulk analysis config:', error);
|
|
684
|
+
return { requested: true, config: null, error };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
627
688
|
/**
|
|
628
689
|
* Auto-trigger analysis if ?analyze=true is present in the URL.
|
|
629
690
|
* Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
|
|
@@ -638,6 +699,7 @@ class PRManager {
|
|
|
638
699
|
const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
|
|
639
700
|
if (autoAnalyze === 'true' && !this.isAnalyzing) {
|
|
640
701
|
this._autoAnalyzeRequested = true;
|
|
702
|
+
let shouldCleanUrl = true;
|
|
641
703
|
try {
|
|
642
704
|
// Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
|
|
643
705
|
// Otherwise, refresh to ensure we have the latest PR data in case the worktree
|
|
@@ -654,19 +716,39 @@ class PRManager {
|
|
|
654
716
|
}
|
|
655
717
|
}
|
|
656
718
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
719
|
+
const storedConfig = await this._fetchAutoAnalysisConfigFromUrl();
|
|
720
|
+
let config;
|
|
721
|
+
if (storedConfig.requested) {
|
|
722
|
+
if (!storedConfig.config) {
|
|
723
|
+
// The stored bulk-analysis config expired (TTL/eviction/restart).
|
|
724
|
+
// The PR diff has already rendered, so don't replace it with a
|
|
725
|
+
// full-screen error whose Retry button would just re-trigger the
|
|
726
|
+
// same failed lookup. Warn, strip the stale params (so a refresh
|
|
727
|
+
// won't re-trigger), and leave the PR usable for manual analysis.
|
|
728
|
+
const message = 'Could not load the selected bulk analysis settings. Start analysis manually to choose new settings.';
|
|
729
|
+
if (window.toast) window.toast.showWarning(message);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
config = storedConfig.config;
|
|
733
|
+
} else {
|
|
734
|
+
// Fetch repo settings so we honour the repository's default provider/council
|
|
735
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
736
|
+
this.fetchRepoSettings().catch(() => null),
|
|
737
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
738
|
+
this._getAppConfig()
|
|
739
|
+
]);
|
|
740
|
+
config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
|
|
741
|
+
}
|
|
663
742
|
|
|
664
743
|
await this.startAnalysis(owner, repo, prNumber, null, config);
|
|
665
744
|
} finally {
|
|
666
745
|
this._autoAnalyzeRequested = false;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
746
|
+
if (shouldCleanUrl) {
|
|
747
|
+
const cleanUrl = new URL(window.location);
|
|
748
|
+
cleanUrl.searchParams.delete('analyze');
|
|
749
|
+
cleanUrl.searchParams.delete('analysisConfigId');
|
|
750
|
+
history.replaceState(null, '', cleanUrl);
|
|
751
|
+
}
|
|
670
752
|
}
|
|
671
753
|
}
|
|
672
754
|
}
|
|
@@ -1067,31 +1149,111 @@ class PRManager {
|
|
|
1067
1149
|
}
|
|
1068
1150
|
|
|
1069
1151
|
/**
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1152
|
+
* Fetch and cache the provider/model metadata from /api/providers. Used to
|
|
1153
|
+
* resolve a coherent provider/model pair for the non-modal auto-analyze path
|
|
1154
|
+
* (the modal loads its own copy). Resolves to the `providers` array, or [] on
|
|
1155
|
+
* failure so callers can fall back to provider-agnostic defaults.
|
|
1156
|
+
*/
|
|
1157
|
+
_getProvidersInfo() {
|
|
1158
|
+
if (!this._providersInfoPromise) {
|
|
1159
|
+
this._providersInfoPromise = fetch('/api/providers')
|
|
1160
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
1161
|
+
.then((data) => (Array.isArray(data.providers) ? data.providers : []))
|
|
1162
|
+
.catch(() => []);
|
|
1163
|
+
}
|
|
1164
|
+
return this._providersInfoPromise;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Wire one file's hunk anchor rows to their server-supplied content hashes
|
|
1169
|
+
* as that file's body renders (called from _renderFileBodyNow), then mount
|
|
1170
|
+
* any summary that already arrived for those hunks.
|
|
1171
|
+
*
|
|
1172
|
+
* Pre-lazy-render this happened once globally in _kickOffHunkSummaries;
|
|
1173
|
+
* with lazy bodies a file's anchor rows don't exist until it renders, so
|
|
1174
|
+
* anchoring is now incremental. The server computes hashes from the
|
|
1175
|
+
* canonical (non-whitespace-filtered) diff so they stay aligned with
|
|
1176
|
+
* persisted summary keys regardless of `?w=1`; records lacking a
|
|
1177
|
+
* `contentHash` are logged-and-skipped.
|
|
1178
|
+
*
|
|
1179
|
+
* The bridge for "summary arrived before its anchor existed" is the existing
|
|
1180
|
+
* `_pendingSummariesByHash` map: _renderOneSummary / _applyHunkSummaries
|
|
1181
|
+
* queue there when no anchor is found, and we drain matching entries here.
|
|
1182
|
+
* @param {Array<{file,header,anchorRow,contentHash}>} records
|
|
1183
|
+
*/
|
|
1184
|
+
_registerHunkAnchorsForFile(records) {
|
|
1185
|
+
if (!Array.isArray(records) || records.length === 0) return;
|
|
1186
|
+
const filesWithMounts = new Set();
|
|
1187
|
+
for (const rec of records) {
|
|
1188
|
+
if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
|
|
1189
|
+
const hex = rec.contentHash;
|
|
1190
|
+
if (!hex) {
|
|
1191
|
+
console.warn(
|
|
1192
|
+
`[HunkSummary] no server contentHash for ${rec.file} ` +
|
|
1193
|
+
`hunk ${rec.header}; skipping (summaries will not anchor here).`
|
|
1194
|
+
);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
rec.anchorRow.dataset.hunkStart = hex;
|
|
1198
|
+
// Scope the anchor by file so a hash collision across files can't let
|
|
1199
|
+
// the later file's anchor clobber the earlier file's (which would mount
|
|
1200
|
+
// a summary against the wrong hunk). Symmetric with the file-scoped
|
|
1201
|
+
// pending-summary queue.
|
|
1202
|
+
let anchors = this._summaryAnchorsByHash.get(rec.file);
|
|
1203
|
+
if (!anchors) {
|
|
1204
|
+
anchors = new Map();
|
|
1205
|
+
this._summaryAnchorsByHash.set(rec.file, anchors);
|
|
1206
|
+
}
|
|
1207
|
+
anchors.set(hex, rec.anchorRow);
|
|
1208
|
+
let bucket = this._summaryHashesByFile.get(rec.file);
|
|
1209
|
+
if (!bucket) {
|
|
1210
|
+
bucket = new Set();
|
|
1211
|
+
this._summaryHashesByFile.set(rec.file, bucket);
|
|
1212
|
+
}
|
|
1213
|
+
bucket.add(hex);
|
|
1214
|
+
|
|
1215
|
+
// Mount a summary that arrived before this anchor existed. Look it up by
|
|
1216
|
+
// THIS file + hash so a content-hash that also appears in another file
|
|
1217
|
+
// (renamed-with-tiny-edits, copy-pasted boilerplate, identical stubs)
|
|
1218
|
+
// can't let this file's anchor consume the other file's queued summary.
|
|
1219
|
+
const pending = this._takePendingSummary(rec.file, hex);
|
|
1220
|
+
if (pending) {
|
|
1221
|
+
const row = this._renderOneSummary(pending, rec.file);
|
|
1222
|
+
if (row) {
|
|
1223
|
+
this._summariesGenerated = true;
|
|
1224
|
+
this._summariesAvailable = true;
|
|
1225
|
+
filesWithMounts.add(rec.file);
|
|
1226
|
+
}
|
|
1227
|
+
// If it couldn't mount (anchor not connected after all),
|
|
1228
|
+
// _renderOneSummary re-queued it scoped to rec.file for a later pass.
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (this._summariesGenerated) this._syncSummaryToolbarButton();
|
|
1232
|
+
for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Load the review's hunk summaries from the server and apply them to the
|
|
1237
|
+
* anchors that exist so far (gated by `summaries.enabled` in `/api/config`).
|
|
1074
1238
|
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1239
|
+
* With lazy bodies, anchor wiring is incremental (_registerHunkAnchorsForFile
|
|
1240
|
+
* runs as each body renders), so this method no longer walks render records.
|
|
1241
|
+
* Summaries whose file hasn't rendered yet are queued in
|
|
1242
|
+
* `_pendingSummariesByHash` (via _applyHunkSummaries / _renderOneSummary) and
|
|
1243
|
+
* drained when that body renders.
|
|
1244
|
+
*
|
|
1245
|
+
* Order:
|
|
1246
|
+
* 1. Config gate first — bail before paying any cost when the feature is off.
|
|
1078
1247
|
* 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.
|
|
1248
|
+
* 3. Fetch existing summaries and apply/queue them.
|
|
1085
1249
|
*
|
|
1086
|
-
* Race-safety: `_renderGen` is captured at entry and rechecked after
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1250
|
+
* Race-safety: `_renderGen` is captured at entry and rechecked after every
|
|
1251
|
+
* `await`. If `renderDiff()` ran again mid-flight, we stop touching the (now
|
|
1252
|
+
* stale) maps and DOM.
|
|
1089
1253
|
* @returns {Promise<void>}
|
|
1090
1254
|
*/
|
|
1091
|
-
async
|
|
1255
|
+
async _fetchHunkSummaryMap() {
|
|
1092
1256
|
const gen = this._renderGen;
|
|
1093
|
-
const records = this._pendingHunkRecords || [];
|
|
1094
|
-
this._pendingHunkRecords = null; // single-use; renderDiff resets
|
|
1095
1257
|
|
|
1096
1258
|
// 1. Config gate — bail before doing any work when the feature is off.
|
|
1097
1259
|
const cfg = await this._getAppConfig();
|
|
@@ -1119,54 +1281,7 @@ class PRManager {
|
|
|
1119
1281
|
}
|
|
1120
1282
|
}
|
|
1121
1283
|
|
|
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.
|
|
1284
|
+
// 3. Load existing summaries from the server.
|
|
1170
1285
|
if (!this.currentPR?.id) return;
|
|
1171
1286
|
|
|
1172
1287
|
try {
|
|
@@ -1192,13 +1307,14 @@ class PRManager {
|
|
|
1192
1307
|
// `_applyHunkSummaries`, which sets the flag on a successful mount.
|
|
1193
1308
|
if (byFile.size === 0 && summaries.length > 0) {
|
|
1194
1309
|
let mountedAny = false;
|
|
1310
|
+
let availableAny = false;
|
|
1195
1311
|
for (const summary of summaries) {
|
|
1312
|
+
if (summary.summary_text) availableAny = true;
|
|
1196
1313
|
if (this._renderOneSummary(summary)) mountedAny = true;
|
|
1197
1314
|
}
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1315
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1316
|
+
if (mountedAny) this._summariesGenerated = true;
|
|
1317
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1202
1318
|
} else {
|
|
1203
1319
|
for (const [filePath, fileSummaries] of byFile.entries()) {
|
|
1204
1320
|
this._applyHunkSummaries(filePath, fileSummaries);
|
|
@@ -1228,6 +1344,7 @@ class PRManager {
|
|
|
1228
1344
|
if (!Array.isArray(summaries)) return;
|
|
1229
1345
|
const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
|
|
1230
1346
|
let mountedAny = false;
|
|
1347
|
+
let availableAny = false;
|
|
1231
1348
|
for (const summary of summaries) {
|
|
1232
1349
|
if (!summary?.content_hash) continue;
|
|
1233
1350
|
if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
|
|
@@ -1240,15 +1357,26 @@ class PRManager {
|
|
|
1240
1357
|
}
|
|
1241
1358
|
continue;
|
|
1242
1359
|
}
|
|
1243
|
-
|
|
1360
|
+
// A non-trivial summary that belongs to this render (it passed the hash
|
|
1361
|
+
// filter) means the feature has data even if its body hasn't rendered
|
|
1362
|
+
// yet — _renderOneSummary will queue it rather than mount it in that
|
|
1363
|
+
// case. Track availability separately from mounted rows.
|
|
1364
|
+
if (summary.summary_text) availableAny = true;
|
|
1365
|
+
const row = this._renderOneSummary(summary, filePath);
|
|
1244
1366
|
if (row) mountedAny = true;
|
|
1245
1367
|
}
|
|
1368
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1246
1369
|
if (mountedAny) {
|
|
1247
|
-
// At least one summary mounted — the feature now has data, so
|
|
1248
|
-
// toolbar button can show its `.active` (blue) state.
|
|
1370
|
+
// At least one summary mounted — the feature now has visible data, so
|
|
1371
|
+
// the toolbar button can show its `.active` (blue) state.
|
|
1249
1372
|
this._summariesGenerated = true;
|
|
1250
|
-
this._syncSummaryToolbarButton();
|
|
1251
1373
|
}
|
|
1374
|
+
// Refresh the toolbar when either flag changed: `.active` tracks
|
|
1375
|
+
// `_summariesGenerated` (rows in the DOM) while the Generate-vs-Show/Hide
|
|
1376
|
+
// decision tracks `_summariesAvailable` (mounted OR queued). Refreshing on
|
|
1377
|
+
// availableAny prevents a not-yet-rendered file from leaving the toolbar
|
|
1378
|
+
// in "Generate" mode, where a click would start a duplicate job.
|
|
1379
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1252
1380
|
if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
|
|
1253
1381
|
}
|
|
1254
1382
|
|
|
@@ -1276,7 +1404,7 @@ class PRManager {
|
|
|
1276
1404
|
*
|
|
1277
1405
|
* Used by three call sites that must agree on the button's visible state:
|
|
1278
1406
|
* - createFileHeader (initial render)
|
|
1279
|
-
* -
|
|
1407
|
+
* - _fetchHunkSummaryMap (rehydrate after localStorage restore)
|
|
1280
1408
|
* - _refreshFileSummaryToggle (when summaries arrive late)
|
|
1281
1409
|
* - toggleFileSummaries (user click)
|
|
1282
1410
|
*
|
|
@@ -1298,22 +1426,91 @@ class PRManager {
|
|
|
1298
1426
|
}
|
|
1299
1427
|
}
|
|
1300
1428
|
|
|
1429
|
+
/**
|
|
1430
|
+
* Queue a summary that arrived before its hunk anchor existed, scoped by
|
|
1431
|
+
* file path so a content-hash collision across files can't let one file's
|
|
1432
|
+
* anchor consume another file's queued summary. Summaries arriving without
|
|
1433
|
+
* a file path (legacy ungrouped fetch) land in the '' bucket.
|
|
1434
|
+
* @param {string|undefined} filePath
|
|
1435
|
+
* @param {Object} summary - must have `content_hash`
|
|
1436
|
+
*/
|
|
1437
|
+
_queuePendingSummary(filePath, summary) {
|
|
1438
|
+
const key = filePath || '';
|
|
1439
|
+
let bucket = this._pendingSummariesByHash.get(key);
|
|
1440
|
+
if (!bucket) {
|
|
1441
|
+
bucket = new Map();
|
|
1442
|
+
this._pendingSummariesByHash.set(key, bucket);
|
|
1443
|
+
}
|
|
1444
|
+
bucket.set(summary.content_hash, summary);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Pull (and remove) a queued summary for (filePath, hash). Checks the
|
|
1449
|
+
* file-scoped bucket first, then the '' bucket that holds summaries queued
|
|
1450
|
+
* without a file path (legacy ungrouped fetch). Returns null when nothing
|
|
1451
|
+
* is queued for that pair.
|
|
1452
|
+
* @param {string|undefined} filePath
|
|
1453
|
+
* @param {string} hash
|
|
1454
|
+
* @returns {Object|null}
|
|
1455
|
+
*/
|
|
1456
|
+
_takePendingSummary(filePath, hash) {
|
|
1457
|
+
for (const key of [filePath || '', '']) {
|
|
1458
|
+
const bucket = this._pendingSummariesByHash.get(key);
|
|
1459
|
+
if (bucket && bucket.has(hash)) {
|
|
1460
|
+
const summary = bucket.get(hash);
|
|
1461
|
+
bucket.delete(hash);
|
|
1462
|
+
if (bucket.size === 0) this._pendingSummariesByHash.delete(key);
|
|
1463
|
+
return summary;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Resolve the anchor row for (filePath, hash) against the file-scoped
|
|
1471
|
+
* `_summaryAnchorsByHash` map.
|
|
1472
|
+
*
|
|
1473
|
+
* When `filePath` is known (the grouped path), the lookup is strictly
|
|
1474
|
+
* scoped to that file so a content-hash shared across files can't pull a
|
|
1475
|
+
* summary onto the wrong file's anchor. When it's absent — the legacy
|
|
1476
|
+
* ungrouped-fetch fallback where summaries arrive without a `file_path` —
|
|
1477
|
+
* there's no file to scope by, so we accept the first file whose bucket
|
|
1478
|
+
* carries the hash. This mirrors `_takePendingSummary`'s `''` fallback
|
|
1479
|
+
* bucket: file-scoped first, best-effort otherwise.
|
|
1480
|
+
* @param {string|undefined} filePath
|
|
1481
|
+
* @param {string} hash
|
|
1482
|
+
* @returns {HTMLTableRowElement|null}
|
|
1483
|
+
*/
|
|
1484
|
+
_findSummaryAnchor(filePath, hash) {
|
|
1485
|
+
if (filePath) {
|
|
1486
|
+
return this._summaryAnchorsByHash.get(filePath)?.get(hash) || null;
|
|
1487
|
+
}
|
|
1488
|
+
for (const anchors of this._summaryAnchorsByHash.values()) {
|
|
1489
|
+
const anchor = anchors.get(hash);
|
|
1490
|
+
if (anchor) return anchor;
|
|
1491
|
+
}
|
|
1492
|
+
return null;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1301
1495
|
/**
|
|
1302
1496
|
* Render a single summary row, or queue it if the matching hunk hasn't
|
|
1303
1497
|
* been hashed yet (race between WS broadcast and post-render hashing).
|
|
1304
1498
|
* Trivial / model-skipped / model-malformed rows are ignored.
|
|
1305
1499
|
* @param {Object} summary - { content_hash, summary_text, trivial_reason }
|
|
1500
|
+
* @param {string} [filePath] - File the summary belongs to. Used to scope
|
|
1501
|
+
* both the anchor lookup and the pending-queue key so a cross-file hash
|
|
1502
|
+
* collision can't misroute it to the wrong file's hunk.
|
|
1306
1503
|
* @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
|
|
1307
1504
|
*/
|
|
1308
|
-
_renderOneSummary(summary) {
|
|
1505
|
+
_renderOneSummary(summary, filePath) {
|
|
1309
1506
|
if (!summary || !summary.content_hash) return null;
|
|
1310
1507
|
if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
|
|
1311
1508
|
const hash = summary.content_hash;
|
|
1312
|
-
const anchor = this.
|
|
1509
|
+
const anchor = this._findSummaryAnchor(filePath, hash);
|
|
1313
1510
|
if (!anchor || !anchor.isConnected) {
|
|
1314
1511
|
// Anchor missing or detached (stale render) → defer; the next render
|
|
1315
1512
|
// pass that re-establishes the hash will drain this map.
|
|
1316
|
-
this.
|
|
1513
|
+
this._queuePendingSummary(filePath, summary);
|
|
1317
1514
|
return null;
|
|
1318
1515
|
}
|
|
1319
1516
|
if (!this.hunkSummaryRenderer) return null;
|
|
@@ -1423,9 +1620,12 @@ class PRManager {
|
|
|
1423
1620
|
// opens a confirm dialog ("Cancel Summaries" / "OK") instead of
|
|
1424
1621
|
// toggling visibility. See _handleSummaryToggleClick.
|
|
1425
1622
|
label = 'Generating summaries… (click to cancel)';
|
|
1426
|
-
} else if (!this.
|
|
1427
|
-
// Pre-generated state: nothing generated yet
|
|
1428
|
-
// kicks off generation.
|
|
1623
|
+
} else if (!this._summariesAvailable) {
|
|
1624
|
+
// Pre-generated state: nothing generated yet (mounted or queued).
|
|
1625
|
+
// Colorless button; a click kicks off generation. Gated on
|
|
1626
|
+
// `_summariesAvailable` (not `_summariesGenerated`) so a review whose
|
|
1627
|
+
// summaries exist but haven't mounted shows Show/Hide, matching the
|
|
1628
|
+
// click behavior in _handleSummaryToggleClick.
|
|
1429
1629
|
label = 'Generate hunk summaries';
|
|
1430
1630
|
} else {
|
|
1431
1631
|
label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
|
|
@@ -1505,10 +1705,14 @@ class PRManager {
|
|
|
1505
1705
|
});
|
|
1506
1706
|
return;
|
|
1507
1707
|
}
|
|
1508
|
-
if (!this.
|
|
1509
|
-
//
|
|
1510
|
-
// visibility. `_startGenerationJob` sets the pulsing
|
|
1511
|
-
// optimistically (there is no
|
|
1708
|
+
if (!this._summariesAvailable) {
|
|
1709
|
+
// Nothing generated yet (mounted OR queued): a click triggers generation
|
|
1710
|
+
// rather than toggling visibility. `_startGenerationJob` sets the pulsing
|
|
1711
|
+
// `.generating` state optimistically (there is no
|
|
1712
|
+
// `review:background_job_started` event). We gate on `_summariesAvailable`
|
|
1713
|
+
// rather than `_summariesGenerated` so summaries that exist server-side
|
|
1714
|
+
// but haven't mounted (their lazy file body isn't rendered yet) toggle
|
|
1715
|
+
// visibility instead of kicking off a duplicate generation job.
|
|
1512
1716
|
await this._startGenerationJob('summary');
|
|
1513
1717
|
return;
|
|
1514
1718
|
}
|
|
@@ -1594,18 +1798,23 @@ class PRManager {
|
|
|
1594
1798
|
const url = isLocal
|
|
1595
1799
|
? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
|
|
1596
1800
|
: `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
|
|
1801
|
+
// Phrasing varies by job kind. `featureLabel` goes into the HTTP/decline
|
|
1802
|
+
// error messages; `noDiffMessage` is the dedicated "nothing to do" line.
|
|
1803
|
+
// NOTE: the toast singleton is lowercase `window.toast` (see
|
|
1804
|
+
// cancel-background-job.js); `window.Toast` does not exist.
|
|
1805
|
+
const featureLabel = jobKey === 'tour' ? 'tour' : 'summary';
|
|
1806
|
+
const noDiffMessage = jobKey === 'tour' ? 'No tour to generate.' : 'No summaries to generate.';
|
|
1597
1807
|
try {
|
|
1598
1808
|
const resp = await fetch(url, { method: 'POST' });
|
|
1599
1809
|
if (resp.status === 409) {
|
|
1600
1810
|
// Feature disabled in config — shouldn't happen (the button is hidden
|
|
1601
1811
|
// 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.');
|
|
1812
|
+
window.toast?.showError?.('This feature is disabled in config.');
|
|
1605
1813
|
return;
|
|
1606
1814
|
}
|
|
1607
1815
|
if (!resp.ok) {
|
|
1608
1816
|
console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
|
|
1817
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
|
|
1609
1818
|
return;
|
|
1610
1819
|
}
|
|
1611
1820
|
// Optimistic UI: there is no `review:background_job_started` broadcast,
|
|
@@ -1623,9 +1832,20 @@ class PRManager {
|
|
|
1623
1832
|
this._tourGenerating = true;
|
|
1624
1833
|
this._syncTourToolbarButton();
|
|
1625
1834
|
}
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
// Server accepted the request but declined to enqueue. The known
|
|
1838
|
+
// reason today is `'no-diff'` — review has no changes to act on. Tell
|
|
1839
|
+
// the user so the button doesn't appear inert. Unknown decline
|
|
1840
|
+
// reasons fall through to a generic message rather than silence.
|
|
1841
|
+
if (payload.reason === 'no-diff') {
|
|
1842
|
+
window.toast?.showInfo?.(noDiffMessage);
|
|
1843
|
+
} else {
|
|
1844
|
+
window.toast?.showError?.(`Could not start ${featureLabel} generation.`);
|
|
1626
1845
|
}
|
|
1627
1846
|
} catch (err) {
|
|
1628
1847
|
console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
|
|
1848
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
|
|
1629
1849
|
}
|
|
1630
1850
|
}
|
|
1631
1851
|
|
|
@@ -1836,7 +2056,11 @@ class PRManager {
|
|
|
1836
2056
|
// was awaiting a file fetch / loadContextFiles. Bail before
|
|
1837
2057
|
// mounting against a torn-down tour.
|
|
1838
2058
|
if (isStale()) return;
|
|
1839
|
-
|
|
2059
|
+
// mountStop is async (it awaits toggleFileCollapse so a collapsed
|
|
2060
|
+
// file is rendered + visibly expanded before we scroll to it).
|
|
2061
|
+
const row = await this._tourRenderer.mountStop(probe);
|
|
2062
|
+
// Re-check staleness: the await above is a suspension window.
|
|
2063
|
+
if (isStale()) return;
|
|
1840
2064
|
if (row) {
|
|
1841
2065
|
nextRow = row;
|
|
1842
2066
|
nextIndex = probe;
|
|
@@ -2437,13 +2661,32 @@ class PRManager {
|
|
|
2437
2661
|
|
|
2438
2662
|
// Update Graphite link (gated on enable_graphite config)
|
|
2439
2663
|
const graphiteLink = document.getElementById('graphite-link');
|
|
2440
|
-
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2664
|
+
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2665
|
+
&& graphiteLink.dataset.suppressed !== 'true') {
|
|
2441
2666
|
// Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
|
|
2442
2667
|
const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
|
|
2443
2668
|
graphiteLink.href = graphiteUrl;
|
|
2444
2669
|
graphiteLink.style.display = '';
|
|
2445
2670
|
}
|
|
2446
2671
|
|
|
2672
|
+
if (window.RepoLinks && pr.owner && pr.repo) {
|
|
2673
|
+
const linksApplied = window.RepoLinks.fetchAndApplyRepoLinks(pr.owner, pr.repo, {
|
|
2674
|
+
owner: pr.owner,
|
|
2675
|
+
repo: pr.repo,
|
|
2676
|
+
number: pr.number,
|
|
2677
|
+
branch: pr.head_branch,
|
|
2678
|
+
base_branch: pr.base_branch,
|
|
2679
|
+
head_sha: pr.head_sha,
|
|
2680
|
+
});
|
|
2681
|
+
// The repo links (incl. the configured host name) resolve asynchronously
|
|
2682
|
+
// after this synchronous render. Re-render the pending-draft indicator
|
|
2683
|
+
// once they land so it shows the configured host name (e.g. "Meteorite")
|
|
2684
|
+
// instead of the "GitHub" fallback it would otherwise bake in below.
|
|
2685
|
+
if (linksApplied && typeof linksApplied.then === 'function') {
|
|
2686
|
+
linksApplied.then(() => this.updatePendingDraftIndicator(pr.pendingDraft));
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2447
2690
|
// Update settings link
|
|
2448
2691
|
const settingsLink = document.getElementById('settings-link');
|
|
2449
2692
|
if (settingsLink && pr.owner && pr.repo) {
|
|
@@ -2744,13 +2987,20 @@ class PRManager {
|
|
|
2744
2987
|
}
|
|
2745
2988
|
|
|
2746
2989
|
// Fetch settings in parallel
|
|
2747
|
-
const [repoSettings, reviewSettings] = await Promise.all([
|
|
2990
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
2748
2991
|
this.fetchRepoSettings().catch(() => null),
|
|
2749
|
-
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
|
|
2992
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
2993
|
+
this._getAppConfig()
|
|
2750
2994
|
]);
|
|
2751
2995
|
|
|
2752
|
-
|
|
2753
|
-
|
|
2996
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
2997
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
2998
|
+
// would blank the model <select> and be rejected by the backend.
|
|
2999
|
+
const providersInfo = await this._getProvidersInfo();
|
|
3000
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
3001
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
3002
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
3003
|
+
], providersInfo);
|
|
2754
3004
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
2755
3005
|
const rememberedTab = localStorage.getItem(tabStorageKey);
|
|
2756
3006
|
const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
|
|
@@ -2957,14 +3207,22 @@ class PRManager {
|
|
|
2957
3207
|
// Don't show if no pending draft
|
|
2958
3208
|
if (!pendingDraft) return;
|
|
2959
3209
|
|
|
3210
|
+
// Resolve the configured host name + URL (alt-host aware). Prefer the
|
|
3211
|
+
// URL built from the repo's url_template over the server-reported
|
|
3212
|
+
// github_url, which some alt-hosts return as a wrong-host github.com URL.
|
|
3213
|
+
const hostName = (window.RepoLinks && typeof window.RepoLinks.hostName === 'function')
|
|
3214
|
+
? window.RepoLinks.hostName() : 'GitHub';
|
|
3215
|
+
const externalUrl = (window.RepoLinks && typeof window.RepoLinks.externalUrl === 'function')
|
|
3216
|
+
? window.RepoLinks.externalUrl() : null;
|
|
3217
|
+
|
|
2960
3218
|
// Create the indicator
|
|
2961
3219
|
const indicator = document.createElement('a');
|
|
2962
3220
|
indicator.id = 'pending-draft-indicator';
|
|
2963
3221
|
indicator.className = 'pending-draft-indicator';
|
|
2964
|
-
indicator.href = pendingDraft.github_url || '#';
|
|
3222
|
+
indicator.href = externalUrl || pendingDraft.github_url || '#';
|
|
2965
3223
|
indicator.target = '_blank';
|
|
2966
3224
|
indicator.rel = 'noopener noreferrer';
|
|
2967
|
-
indicator.title =
|
|
3225
|
+
indicator.title = `View your pending draft review on ${hostName}`;
|
|
2968
3226
|
|
|
2969
3227
|
const commentCount = pendingDraft.comments_count || 0;
|
|
2970
3228
|
const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
|
|
@@ -2973,7 +3231,7 @@ class PRManager {
|
|
|
2973
3231
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
2974
3232
|
<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
3233
|
</svg>
|
|
2976
|
-
<span class="pending-draft-text">Draft on
|
|
3234
|
+
<span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
|
|
2977
3235
|
`;
|
|
2978
3236
|
|
|
2979
3237
|
// Insert after the commit element (or at the end of toolbar-meta)
|
|
@@ -3022,10 +3280,23 @@ class PRManager {
|
|
|
3022
3280
|
// no matching rows keeps the stale `true`, leaving the toolbar stuck in
|
|
3023
3281
|
// Hide/Show mode with nothing in the DOM and blocking click-to-generate.
|
|
3024
3282
|
this._summariesGenerated = false;
|
|
3025
|
-
//
|
|
3283
|
+
// Reset alongside `_summariesGenerated`. Set true again only when a fetch
|
|
3284
|
+
// (or WS event) accepts/queues a non-trivial summary for this render.
|
|
3285
|
+
this._summariesAvailable = false;
|
|
3286
|
+
// Bump generation so any in-flight `_fetchHunkSummaryMap` from the
|
|
3026
3287
|
// previous render bails out instead of mutating maps we just reset.
|
|
3027
3288
|
this._renderGen = (this._renderGen || 0) + 1;
|
|
3028
3289
|
|
|
3290
|
+
// Reset lazy-body state and (re)create the IntersectionObserver. The
|
|
3291
|
+
// `innerHTML = ''` above detached every previously-observed body, so a
|
|
3292
|
+
// stale observer would hold dead references and never fire — tear it down
|
|
3293
|
+
// and start fresh for this render generation. This runs for every render
|
|
3294
|
+
// path (initial load, whitespace toggle, scope change) since they all
|
|
3295
|
+
// funnel through renderDiff().
|
|
3296
|
+
this._teardownFileBodyObserver();
|
|
3297
|
+
this._lazyFileBodies = new Map();
|
|
3298
|
+
this._fileBodyObserver = this._createFileBodyObserver();
|
|
3299
|
+
|
|
3029
3300
|
// Use changed_files array from API
|
|
3030
3301
|
const files = pr.changed_files || pr.files || [];
|
|
3031
3302
|
|
|
@@ -3050,9 +3321,8 @@ class PRManager {
|
|
|
3050
3321
|
}
|
|
3051
3322
|
});
|
|
3052
3323
|
|
|
3053
|
-
//
|
|
3054
|
-
//
|
|
3055
|
-
this.validatePendingEofGaps();
|
|
3324
|
+
// NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
|
|
3325
|
+
// now (bodies render lazily), not once globally here.
|
|
3056
3326
|
} else {
|
|
3057
3327
|
diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
|
|
3058
3328
|
}
|
|
@@ -3061,11 +3331,13 @@ class PRManager {
|
|
|
3061
3331
|
this.contextFiles = [];
|
|
3062
3332
|
this.loadContextFiles();
|
|
3063
3333
|
|
|
3064
|
-
//
|
|
3065
|
-
//
|
|
3334
|
+
// Fetch hunk summaries (Phase 5). Fire-and-forget — the diff is fully
|
|
3335
|
+
// usable while summaries arrive asynchronously. Anchors are wired lazily
|
|
3336
|
+
// as each file body renders (_registerHunkAnchorsForFile); this just loads
|
|
3337
|
+
// the server's summary map and applies it to whatever has rendered so far.
|
|
3066
3338
|
if (this.hunkSummaryRenderer) {
|
|
3067
|
-
this.
|
|
3068
|
-
console.warn('[HunkSummary]
|
|
3339
|
+
this._fetchHunkSummaryMap().catch((err) => {
|
|
3340
|
+
console.warn('[HunkSummary] summary fetch failed:', err);
|
|
3069
3341
|
});
|
|
3070
3342
|
}
|
|
3071
3343
|
|
|
@@ -3174,8 +3446,8 @@ class PRManager {
|
|
|
3174
3446
|
|
|
3175
3447
|
// Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
|
|
3176
3448
|
// 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
|
|
3449
|
+
// _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
|
|
3450
|
+
// as hashes for this file are recorded (so a summary that arrives later
|
|
3179
3451
|
// doesn't get hidden behind a permanently-disabled button).
|
|
3180
3452
|
// Gated on `_summariesEnabled`: skipped entirely when /api/config has
|
|
3181
3453
|
// already reported the feature is off; created hidden + revealed later
|
|
@@ -3210,21 +3482,13 @@ class PRManager {
|
|
|
3210
3482
|
}
|
|
3211
3483
|
}
|
|
3212
3484
|
|
|
3213
|
-
// Create diff table
|
|
3485
|
+
// Create diff table with an EMPTY tbody. The rows are NOT rendered here —
|
|
3486
|
+
// see the lazy-body machinery below. This is the core large-PR perf fix:
|
|
3487
|
+
// building + syntax-highlighting every line of every (often collapsed)
|
|
3488
|
+
// file up front froze the browser on big diffs.
|
|
3214
3489
|
const table = document.createElement('table');
|
|
3215
3490
|
table.className = 'd2h-diff-table';
|
|
3216
|
-
|
|
3217
3491
|
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
3492
|
table.appendChild(tbody);
|
|
3229
3493
|
|
|
3230
3494
|
// Wrap table in a scrollable container for horizontal scroll of long code lines
|
|
@@ -3234,9 +3498,197 @@ class PRManager {
|
|
|
3234
3498
|
fileBody.appendChild(table);
|
|
3235
3499
|
wrapper.appendChild(fileBody);
|
|
3236
3500
|
|
|
3501
|
+
// Reserve approximate height for EXPANDED-but-not-yet-rendered bodies so
|
|
3502
|
+
// the scrollbar stays roughly stable as the user scrolls and bodies fill
|
|
3503
|
+
// in. Collapsed bodies are `display:none`, so they contribute no height
|
|
3504
|
+
// and need no placeholder. Cleared once the body actually renders.
|
|
3505
|
+
if (!isCollapsed && file.patch) {
|
|
3506
|
+
const approxLines = file.patch.split('\n').length;
|
|
3507
|
+
fileBody.style.minHeight = (approxLines * PRManager.APPROX_DIFF_LINE_PX) + 'px';
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// Register the lazy entry. `gen` lets _renderFileBodyNow detect a body
|
|
3511
|
+
// left over from a superseded render and skip anchor registration.
|
|
3512
|
+
this._lazyFileBodies.set(file.file, {
|
|
3513
|
+
fileName: file.file,
|
|
3514
|
+
patch: file.patch || null,
|
|
3515
|
+
binary: !!file.binary,
|
|
3516
|
+
hunkHashes: file.hunk_hashes || null,
|
|
3517
|
+
fileBody,
|
|
3518
|
+
wrapper,
|
|
3519
|
+
rendered: false,
|
|
3520
|
+
renderPromise: null,
|
|
3521
|
+
gen: this._renderGen
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
// Observe the body so it renders as it nears the viewport. Collapsed
|
|
3525
|
+
// bodies (display:none) never intersect → stay unrendered until expanded.
|
|
3526
|
+
if ((file.patch || file.binary) && this._fileBodyObserver) {
|
|
3527
|
+
this._fileBodyObserver.observe(fileBody);
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3237
3530
|
return wrapper;
|
|
3238
3531
|
}
|
|
3239
3532
|
|
|
3533
|
+
/**
|
|
3534
|
+
* Approximate rendered height (px) of one diff line row. Used only to
|
|
3535
|
+
* reserve placeholder height for expanded-but-unrendered file bodies so the
|
|
3536
|
+
* scrollbar doesn't jump as lazy bodies fill in. Errs slightly high.
|
|
3537
|
+
*/
|
|
3538
|
+
static get APPROX_DIFF_LINE_PX() { return 20; }
|
|
3539
|
+
|
|
3540
|
+
/**
|
|
3541
|
+
* Create the IntersectionObserver that renders file bodies as they approach
|
|
3542
|
+
* the viewport. One instance per render generation; recreated in renderDiff.
|
|
3543
|
+
* Returns null where IntersectionObserver is unavailable (e.g. jsdom unit
|
|
3544
|
+
* tests) — bodies then render only on demand via ensureFileBodyRendered().
|
|
3545
|
+
* @returns {IntersectionObserver|null}
|
|
3546
|
+
*/
|
|
3547
|
+
_createFileBodyObserver() {
|
|
3548
|
+
if (typeof IntersectionObserver === 'undefined') return null;
|
|
3549
|
+
// `.diff-view` is the vertical scroll container (see pr.css). Fall back to
|
|
3550
|
+
// the viewport when it can't be resolved.
|
|
3551
|
+
const root = document.querySelector('.diff-view') || null;
|
|
3552
|
+
return new IntersectionObserver((entries, observer) => {
|
|
3553
|
+
for (const ioEntry of entries) {
|
|
3554
|
+
if (!ioEntry.isIntersecting) continue;
|
|
3555
|
+
const lazyEntry = this._lazyFileBodyForElement(ioEntry.target);
|
|
3556
|
+
observer.unobserve(ioEntry.target);
|
|
3557
|
+
if (lazyEntry) this._renderFileBodyNow(lazyEntry);
|
|
3558
|
+
}
|
|
3559
|
+
}, { root, rootMargin: '800px 0px', threshold: 0 });
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/**
|
|
3563
|
+
* Disconnect and drop the current file-body observer, if any.
|
|
3564
|
+
*/
|
|
3565
|
+
_teardownFileBodyObserver() {
|
|
3566
|
+
if (this._fileBodyObserver) {
|
|
3567
|
+
this._fileBodyObserver.disconnect();
|
|
3568
|
+
this._fileBodyObserver = null;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
/**
|
|
3573
|
+
* Resolve the lazy entry for a `.d2h-file-body` element via its wrapper's
|
|
3574
|
+
* data-file-name. Returns null if not a known lazy body.
|
|
3575
|
+
* @param {Element} bodyEl
|
|
3576
|
+
* @returns {object|null}
|
|
3577
|
+
*/
|
|
3578
|
+
_lazyFileBodyForElement(bodyEl) {
|
|
3579
|
+
if (!this._lazyFileBodies) return null;
|
|
3580
|
+
const wrapper = bodyEl?.closest?.('.d2h-file-wrapper');
|
|
3581
|
+
const filePath = wrapper?.dataset?.fileName;
|
|
3582
|
+
if (!filePath) return null;
|
|
3583
|
+
return this._lazyFileBodies.get(filePath) || null;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
/**
|
|
3587
|
+
* Ensure a file's diff-line body is rendered into the DOM, rendering it now
|
|
3588
|
+
* if it hasn't been. Idempotent and cheap when already rendered. Every code
|
|
3589
|
+
* path that scans a file's `<tr>` rows (comment/suggestion anchoring, gap
|
|
3590
|
+
* expansion, scroll-to-file, expand) must await this first, because a lazy
|
|
3591
|
+
* body has zero rows until rendered.
|
|
3592
|
+
* @param {string|Element} fileOrWrapper - file path, file body, or wrapper
|
|
3593
|
+
* @returns {Promise<HTMLElement|null>} the file body, or null if unknown
|
|
3594
|
+
*/
|
|
3595
|
+
async ensureFileBodyRendered(fileOrWrapper) {
|
|
3596
|
+
// No lazy map (e.g. called before renderDiff, or a non-lazy render path) →
|
|
3597
|
+
// treat the body as already present and let callers scan as before.
|
|
3598
|
+
if (!this._lazyFileBodies) return null;
|
|
3599
|
+
let entry = null;
|
|
3600
|
+
if (typeof fileOrWrapper === 'string') {
|
|
3601
|
+
entry = this._lazyFileBodies.get(fileOrWrapper) || null;
|
|
3602
|
+
if (!entry) {
|
|
3603
|
+
// The map is keyed by the canonical `file.file` value, but callers
|
|
3604
|
+
// (AI suggestions, external comments, tour stops) may pass a
|
|
3605
|
+
// non-canonical path form. findFileElement normalizes './', '/', and
|
|
3606
|
+
// git rename syntax ('{old => new}') against data-file-name, so
|
|
3607
|
+
// resolve the wrapper first and retry with its canonical name. This
|
|
3608
|
+
// keeps the strict Map.get fast path while tolerating the same path
|
|
3609
|
+
// variants the rest of the diff UI accepts — without it the body
|
|
3610
|
+
// stays unrendered and the downstream row scan sees zero <tr> rows.
|
|
3611
|
+
const wrapper = this.findFileElement?.(fileOrWrapper);
|
|
3612
|
+
const canonicalFile = wrapper?.dataset?.fileName;
|
|
3613
|
+
if (canonicalFile) entry = this._lazyFileBodies.get(canonicalFile) || null;
|
|
3614
|
+
}
|
|
3615
|
+
} else if (fileOrWrapper && fileOrWrapper.nodeType === 1) {
|
|
3616
|
+
entry = this._lazyFileBodyForElement(fileOrWrapper)
|
|
3617
|
+
|| (this._lazyFileBodies.get(fileOrWrapper.dataset?.fileName) || null);
|
|
3618
|
+
}
|
|
3619
|
+
if (!entry) return null;
|
|
3620
|
+
if (entry.rendered) return entry.fileBody;
|
|
3621
|
+
if (entry.renderPromise) return entry.renderPromise;
|
|
3622
|
+
// _renderFileBodyNow is synchronous; wrapping in a resolved promise keeps
|
|
3623
|
+
// the signature async and lets concurrent callers (observer + on-demand)
|
|
3624
|
+
// share one promise so renderPatch runs exactly once.
|
|
3625
|
+
entry.renderPromise = Promise.resolve().then(() => this._renderFileBodyNow(entry));
|
|
3626
|
+
return entry.renderPromise;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* Synchronously build a file's diff-line body: run renderPatch (or the
|
|
3631
|
+
* binary placeholder), clear the height placeholder, wire hunk-summary
|
|
3632
|
+
* anchors, and validate this file's EOF gap. Idempotent.
|
|
3633
|
+
*
|
|
3634
|
+
* Invariant: there must be NO `await` between the `_pendingHunkRecords`
|
|
3635
|
+
* save and restore below — renderPatch pushes per-hunk anchor records into
|
|
3636
|
+
* the shared `_pendingHunkRecords`, and JS single-threading only guarantees
|
|
3637
|
+
* non-interleaving with other file renders while this stays synchronous.
|
|
3638
|
+
* @param {object} entry - a _lazyFileBodies value
|
|
3639
|
+
* @returns {HTMLElement} the file body element
|
|
3640
|
+
*/
|
|
3641
|
+
_renderFileBodyNow(entry) {
|
|
3642
|
+
if (entry.rendered) return entry.fileBody;
|
|
3643
|
+
if (this._fileBodyObserver) this._fileBodyObserver.unobserve(entry.fileBody);
|
|
3644
|
+
|
|
3645
|
+
const tbody = entry.fileBody.querySelector('tbody');
|
|
3646
|
+
|
|
3647
|
+
// Capture only THIS file's hunk anchor records (renderPatch appends to
|
|
3648
|
+
// this._pendingHunkRecords; see invariant above). The swap is wrapped in
|
|
3649
|
+
// try/finally so a renderPatch throw can't leak the temporary buffer: were
|
|
3650
|
+
// the restore skipped, every subsequent file render would append its
|
|
3651
|
+
// anchor records into the stale array, silently cross-wiring
|
|
3652
|
+
// _summaryAnchorsByHash / _summaryHashesByFile for the rest of the session.
|
|
3653
|
+
const prevPending = this._pendingHunkRecords;
|
|
3654
|
+
this._pendingHunkRecords = [];
|
|
3655
|
+
let records;
|
|
3656
|
+
try {
|
|
3657
|
+
if (entry.patch && tbody) {
|
|
3658
|
+
this.renderPatch(tbody, entry.patch, entry.fileName, entry.hunkHashes);
|
|
3659
|
+
} else if (entry.binary && tbody) {
|
|
3660
|
+
const row = document.createElement('tr');
|
|
3661
|
+
row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
|
|
3662
|
+
tbody.appendChild(row);
|
|
3663
|
+
}
|
|
3664
|
+
} finally {
|
|
3665
|
+
records = this._pendingHunkRecords;
|
|
3666
|
+
this._pendingHunkRecords = prevPending;
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
entry.fileBody.style.minHeight = '';
|
|
3670
|
+
entry.rendered = true;
|
|
3671
|
+
entry.renderPromise = null;
|
|
3672
|
+
|
|
3673
|
+
// Skip post-render wiring for a body left over from a superseded render
|
|
3674
|
+
// (its maps were reset and the body is detached). Both anchor registration
|
|
3675
|
+
// and EOF-gap validation are pointless/wasteful for a stale body.
|
|
3676
|
+
if (entry.gen === this._renderGen) {
|
|
3677
|
+
this._registerHunkAnchorsForFile(records);
|
|
3678
|
+
// Validate this file's pending EOF gaps. Pre-lazy-render this was a
|
|
3679
|
+
// single global pass at the end of renderDiff; now it runs per-file as
|
|
3680
|
+
// bodies render. Cheap pre-check first: most files have no pending EOF
|
|
3681
|
+
// gap, so skip the async work (Array.from + Promise.all + per-gap
|
|
3682
|
+
// /file-content fetches) entirely when this body has none. The selector
|
|
3683
|
+
// mirrors the one validatePendingEofGaps scans for.
|
|
3684
|
+
if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
|
|
3685
|
+
this.validatePendingEofGaps(entry.fileBody);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
return entry.fileBody;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3240
3692
|
/**
|
|
3241
3693
|
* Parse and render a unified diff patch
|
|
3242
3694
|
* @param {HTMLElement} tbody - Table body element
|
|
@@ -3389,8 +3841,8 @@ class PRManager {
|
|
|
3389
3841
|
|
|
3390
3842
|
// Record this hunk's first rendered code row as the anchor for any
|
|
3391
3843
|
// inline summary annotation. The canonical hash comes from the
|
|
3392
|
-
// backend (`hunkHashes[blockIndex]`);
|
|
3393
|
-
// it as `data-hunk-start`
|
|
3844
|
+
// backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
|
|
3845
|
+
// it as `data-hunk-start` when this file's body renders so the summary
|
|
3394
3846
|
// renderer can find the anchor and insert the annotation above it.
|
|
3395
3847
|
if (this._pendingHunkRecords && firstLineRow) {
|
|
3396
3848
|
const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
|
|
@@ -3574,7 +4026,7 @@ class PRManager {
|
|
|
3574
4026
|
* Toggle collapse state of a file diff
|
|
3575
4027
|
* @param {string} filePath - Path of the file
|
|
3576
4028
|
*/
|
|
3577
|
-
toggleFileCollapse(filePath) {
|
|
4029
|
+
async toggleFileCollapse(filePath) {
|
|
3578
4030
|
const wrapper = this.findFileElement(filePath);
|
|
3579
4031
|
if (!wrapper) return;
|
|
3580
4032
|
|
|
@@ -3582,6 +4034,8 @@ class PRManager {
|
|
|
3582
4034
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
3583
4035
|
|
|
3584
4036
|
if (isCollapsed) {
|
|
4037
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4038
|
+
await this.ensureFileBodyRendered(filePath);
|
|
3585
4039
|
wrapper.classList.remove('collapsed');
|
|
3586
4040
|
this.collapsedFiles.delete(filePath);
|
|
3587
4041
|
} else {
|
|
@@ -3600,7 +4054,7 @@ class PRManager {
|
|
|
3600
4054
|
* @param {string} filePath - Path of the file
|
|
3601
4055
|
* @param {boolean} isViewed - Whether the file is now viewed
|
|
3602
4056
|
*/
|
|
3603
|
-
toggleFileViewed(filePath, isViewed) {
|
|
4057
|
+
async toggleFileViewed(filePath, isViewed) {
|
|
3604
4058
|
const wrapper = this.findFileElement(filePath);
|
|
3605
4059
|
|
|
3606
4060
|
if (isViewed) {
|
|
@@ -3618,6 +4072,8 @@ class PRManager {
|
|
|
3618
4072
|
this.viewedFiles.delete(filePath);
|
|
3619
4073
|
// Auto-expand when unchecking viewed (match GitHub behavior)
|
|
3620
4074
|
if (wrapper && wrapper.classList.contains('collapsed')) {
|
|
4075
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4076
|
+
await this.ensureFileBodyRendered(filePath);
|
|
3621
4077
|
wrapper.classList.remove('collapsed');
|
|
3622
4078
|
this.collapsedFiles.delete(filePath);
|
|
3623
4079
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
@@ -3743,7 +4199,7 @@ class PRManager {
|
|
|
3743
4199
|
* @deprecated Use toggleFileCollapse instead - kept for backward compatibility
|
|
3744
4200
|
*/
|
|
3745
4201
|
toggleGeneratedFile(filePath) {
|
|
3746
|
-
this.toggleFileCollapse(filePath);
|
|
4202
|
+
return this.toggleFileCollapse(filePath);
|
|
3747
4203
|
}
|
|
3748
4204
|
|
|
3749
4205
|
/**
|
|
@@ -3772,9 +4228,12 @@ class PRManager {
|
|
|
3772
4228
|
* Validate pending end-of-file gaps asynchronously
|
|
3773
4229
|
* Removes gap rows where there are no trailing lines to expand
|
|
3774
4230
|
* This ensures users don't see expand buttons that do nothing
|
|
4231
|
+
* @param {ParentNode} [root=document] - Scope the search. With lazy bodies,
|
|
4232
|
+
* _renderFileBodyNow passes the just-rendered file body so each file's EOF
|
|
4233
|
+
* gap is validated as it renders (rather than one global post-render pass).
|
|
3775
4234
|
*/
|
|
3776
|
-
async validatePendingEofGaps() {
|
|
3777
|
-
const pendingGaps =
|
|
4235
|
+
async validatePendingEofGaps(root = document) {
|
|
4236
|
+
const pendingGaps = root.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
|
|
3778
4237
|
|
|
3779
4238
|
// Process all pending gaps in parallel for efficiency
|
|
3780
4239
|
const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
|
|
@@ -4139,11 +4598,14 @@ class PRManager {
|
|
|
4139
4598
|
return false;
|
|
4140
4599
|
}
|
|
4141
4600
|
|
|
4601
|
+
// Render the body first — gap rows (and code rows) only exist once the
|
|
4602
|
+
// lazy body has rendered. Without this the gap query below returns nothing.
|
|
4603
|
+
await this.ensureFileBodyRendered(file);
|
|
4604
|
+
|
|
4142
4605
|
// Check if file is collapsed (generated files)
|
|
4143
4606
|
if (fileElement.classList.contains('collapsed')) {
|
|
4144
4607
|
debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
|
|
4145
|
-
this.toggleGeneratedFile(file);
|
|
4146
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
4608
|
+
await this.toggleGeneratedFile(file);
|
|
4147
4609
|
}
|
|
4148
4610
|
|
|
4149
4611
|
// Find the gap section containing the target lines using the shared module
|
|
@@ -4227,6 +4689,12 @@ class PRManager {
|
|
|
4227
4689
|
const fileElement = this.findFileElement(file);
|
|
4228
4690
|
if (!fileElement) continue;
|
|
4229
4691
|
|
|
4692
|
+
// Render the file body first — with lazy rendering an unrendered file
|
|
4693
|
+
// has zero rows, so the visibility scan below would always miss and the
|
|
4694
|
+
// line would be treated as "hidden in a gap" (then gap-expanded against
|
|
4695
|
+
// zero gap rows → silent anchor failure).
|
|
4696
|
+
await this.ensureFileBodyRendered(file);
|
|
4697
|
+
|
|
4230
4698
|
// Check if any line in the range is already visible
|
|
4231
4699
|
let anyLineVisible = false;
|
|
4232
4700
|
const lineRows = fileElement.querySelectorAll('tr');
|
|
@@ -5901,9 +6369,20 @@ class PRManager {
|
|
|
5901
6369
|
collapsedBtn.addEventListener('click', () => toggleSidebar(false));
|
|
5902
6370
|
}
|
|
5903
6371
|
|
|
5904
|
-
scrollToFile(filePath) {
|
|
6372
|
+
async scrollToFile(filePath) {
|
|
5905
6373
|
const fileWrapper = this.findFileElement(filePath);
|
|
5906
6374
|
if (fileWrapper) {
|
|
6375
|
+
// Render the body so the scroll target has its real height (an empty
|
|
6376
|
+
// lazy body would land the scroll at the wrong offset for expanded
|
|
6377
|
+
// files). Skip it for collapsed files: their body is display:none
|
|
6378
|
+
// (zero height) and `block: 'start'` aligns the header regardless, so
|
|
6379
|
+
// force-rendering would only pay the full renderPatch + per-line
|
|
6380
|
+
// highlight cost for content that stays hidden. scrollToFile does no
|
|
6381
|
+
// post-render row scan, so gating on `!collapsed` is safe; expanding
|
|
6382
|
+
// later still renders on demand via toggleFileCollapse.
|
|
6383
|
+
if (!fileWrapper.classList.contains('collapsed')) {
|
|
6384
|
+
await this.ensureFileBodyRendered(filePath);
|
|
6385
|
+
}
|
|
5907
6386
|
fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
5908
6387
|
}
|
|
5909
6388
|
}
|
|
@@ -6328,11 +6807,15 @@ class PRManager {
|
|
|
6328
6807
|
: this._fetchStaleness(owner, repo, number);
|
|
6329
6808
|
this._stalenessPromise = null; // consume it
|
|
6330
6809
|
|
|
6810
|
+
// Pass owner+repo to /api/config so has_github_token reflects the
|
|
6811
|
+
// repo's actual auth (covers repo-scoped tokens, token_command, and
|
|
6812
|
+
// alt-host bindings — not just the global github_token).
|
|
6813
|
+
const configUrl = `/api/config?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
6331
6814
|
const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
6332
6815
|
staleCheckWithTimeout,
|
|
6333
6816
|
this.fetchRepoSettings(),
|
|
6334
6817
|
this.fetchLastReviewSettings(),
|
|
6335
|
-
fetch(
|
|
6818
|
+
fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
|
|
6336
6819
|
]);
|
|
6337
6820
|
console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
|
|
6338
6821
|
|
|
@@ -6378,9 +6861,14 @@ class PRManager {
|
|
|
6378
6861
|
|
|
6379
6862
|
const lastCouncilId = reviewSettings.last_council_id;
|
|
6380
6863
|
|
|
6381
|
-
//
|
|
6382
|
-
|
|
6383
|
-
|
|
6864
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
6865
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
6866
|
+
// would blank the model <select> and be rejected by the backend.
|
|
6867
|
+
const providersInfo = await this._getProvidersInfo();
|
|
6868
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
6869
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
6870
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
6871
|
+
], providersInfo);
|
|
6384
6872
|
|
|
6385
6873
|
// Determine default tab (priority: localStorage > repo settings > 'single')
|
|
6386
6874
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
@@ -6408,7 +6896,12 @@ class PRManager {
|
|
|
6408
6896
|
lastCouncilId,
|
|
6409
6897
|
defaultCouncilId: repoSettings?.default_council_id || null,
|
|
6410
6898
|
hasPr: true,
|
|
6411
|
-
|
|
6899
|
+
// Use the repo-aware field (we passed owner+repo to /api/config).
|
|
6900
|
+
// Fall back to the global field only if the response was malformed
|
|
6901
|
+
// or the params were rejected — defensive, should not happen.
|
|
6902
|
+
hasGithubToken: Boolean(
|
|
6903
|
+
appConfig.has_github_token ?? appConfig.has_global_github_token
|
|
6904
|
+
)
|
|
6412
6905
|
});
|
|
6413
6906
|
|
|
6414
6907
|
// If user cancelled, do nothing
|
|
@@ -6554,7 +7047,10 @@ class PRManager {
|
|
|
6554
7047
|
showWorktreeNotFoundError(owner, repo, number) {
|
|
6555
7048
|
let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
|
|
6556
7049
|
if (this._autoAnalyzeRequested) {
|
|
6557
|
-
|
|
7050
|
+
const params = new URLSearchParams({ analyze: 'true' });
|
|
7051
|
+
const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
|
|
7052
|
+
if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
|
|
7053
|
+
setupUrl += `?${params.toString()}`;
|
|
6558
7054
|
}
|
|
6559
7055
|
const container = document.getElementById('pr-container');
|
|
6560
7056
|
if (container) {
|