@diegopetrucci/pi-annotate-git-diff 0.1.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/.pi-fleet-tested-version +1 -0
- package/README.md +43 -0
- package/clipboard.ts +143 -0
- package/git.ts +943 -0
- package/glimpseui.d.ts +89 -0
- package/index.ts +412 -0
- package/package.json +50 -0
- package/prompt.ts +65 -0
- package/quiet-glimpse.ts +156 -0
- package/types.ts +202 -0
- package/ui.ts +71 -0
- package/watch.ts +104 -0
- package/web/app.js +2381 -0
- package/web/index.html +913 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,2381 @@
|
|
|
1
|
+
/* global window, document, alert, requestAnimationFrame, ResizeObserver */
|
|
2
|
+
|
|
3
|
+
let suppressAutoSubmitOnClose = false;
|
|
4
|
+
|
|
5
|
+
const reviewData = JSON.parse(document.getElementById("annotate-git-diff-data").textContent || "{}");
|
|
6
|
+
const reviewAssetConfig = window.__reviewAssetConfig || {};
|
|
7
|
+
if (!Array.isArray(reviewData.files)) reviewData.files = [];
|
|
8
|
+
if (!Array.isArray(reviewData.commits)) reviewData.commits = [];
|
|
9
|
+
|
|
10
|
+
const defaultScope = reviewData.files.some((file) => file.inGitDiff)
|
|
11
|
+
? "branch"
|
|
12
|
+
: reviewData.commits.length > 0
|
|
13
|
+
? "commits"
|
|
14
|
+
: "all";
|
|
15
|
+
|
|
16
|
+
const state = {
|
|
17
|
+
activeFileId: null,
|
|
18
|
+
currentScope: defaultScope,
|
|
19
|
+
selectedCommitSha: reviewData.commits[0]?.sha ?? null,
|
|
20
|
+
commitFilesBySha: {}, // sha -> ReviewFile[]
|
|
21
|
+
commitRequestIds: {}, // sha -> requestId (in-flight)
|
|
22
|
+
commitErrors: {}, // sha -> error message
|
|
23
|
+
reviewDataRequestId: null,
|
|
24
|
+
reviewDataRequestStartedAt: null,
|
|
25
|
+
comments: [],
|
|
26
|
+
overallComment: "",
|
|
27
|
+
hideUnchanged: true,
|
|
28
|
+
wrapLines: true,
|
|
29
|
+
collapsedDirs: {},
|
|
30
|
+
reviewedFiles: {},
|
|
31
|
+
scrollPositions: {},
|
|
32
|
+
sidebarCollapsed: false,
|
|
33
|
+
fileFilter: "",
|
|
34
|
+
fileContents: {},
|
|
35
|
+
fileErrors: {},
|
|
36
|
+
pendingRequestIds: {},
|
|
37
|
+
lastWorkingTreeLoadAt: null,
|
|
38
|
+
localChangesDetected: false,
|
|
39
|
+
lastLocalChangeDetectedAt: null,
|
|
40
|
+
lastLocalChangeObservedAt: null,
|
|
41
|
+
assetInitFailed: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const sidebarEl = document.getElementById("sidebar");
|
|
45
|
+
const sidebarTitleEl = document.getElementById("sidebar-title");
|
|
46
|
+
const sidebarSearchInputEl = document.getElementById("sidebar-search-input");
|
|
47
|
+
const toggleSidebarButton = document.getElementById("toggle-sidebar-button");
|
|
48
|
+
const scopeBranchButton = document.getElementById("scope-branch-button");
|
|
49
|
+
const scopeCommitsButton = document.getElementById("scope-commits-button");
|
|
50
|
+
const scopeAllButton = document.getElementById("scope-all-button");
|
|
51
|
+
const commitPickerEl = document.getElementById("commit-picker");
|
|
52
|
+
const commitListEl = document.getElementById("commit-list");
|
|
53
|
+
const windowTitleEl = document.getElementById("window-title");
|
|
54
|
+
const repoRootEl = document.getElementById("repo-root");
|
|
55
|
+
const fileTreeEl = document.getElementById("file-tree");
|
|
56
|
+
const summaryEl = document.getElementById("summary");
|
|
57
|
+
const currentFileLabelEl = document.getElementById("current-file-label");
|
|
58
|
+
const modeHintEl = document.getElementById("mode-hint");
|
|
59
|
+
const fileCommentsContainer = document.getElementById("file-comments-container");
|
|
60
|
+
const editorContainerEl = document.getElementById("editor-container");
|
|
61
|
+
const diffEditorHostEl = document.getElementById("diff-editor-host");
|
|
62
|
+
const singleEditorHostEl = document.getElementById("single-editor-host");
|
|
63
|
+
const refreshReviewButton = document.getElementById("refresh-review-button");
|
|
64
|
+
const submitButton = document.getElementById("submit-button");
|
|
65
|
+
const cancelButton = document.getElementById("cancel-button");
|
|
66
|
+
const overallCommentButton = document.getElementById("overall-comment-button");
|
|
67
|
+
const fileCommentButton = document.getElementById("file-comment-button");
|
|
68
|
+
const toggleReviewedButton = document.getElementById("toggle-reviewed-button");
|
|
69
|
+
const toggleUnchangedButton = document.getElementById("toggle-unchanged-button");
|
|
70
|
+
const toggleWrapButton = document.getElementById("toggle-wrap-button");
|
|
71
|
+
const fileStatusBadgeEl = document.getElementById("file-status-badge");
|
|
72
|
+
const fileDiffStatsEl = document.getElementById("file-diff-stats");
|
|
73
|
+
const editorCoverEl = document.getElementById("editor-cover");
|
|
74
|
+
const binaryPreviewEl = document.getElementById("binary-preview");
|
|
75
|
+
const assetFailurePanelEl = document.getElementById("asset-failure-panel");
|
|
76
|
+
const assetFailureTitleEl = document.getElementById("asset-failure-title");
|
|
77
|
+
const assetFailureMessageEl = document.getElementById("asset-failure-message");
|
|
78
|
+
const assetFailureDetailEl = document.getElementById("asset-failure-detail");
|
|
79
|
+
|
|
80
|
+
// Octicon-style inline SVGs used by the GitHub-style sidebar tree.
|
|
81
|
+
const OCTICON_CHEVRON_DOWN =
|
|
82
|
+
'<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M12.78 6.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 7.28a.749.749 0 1 1 1.06-1.06L8 9.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/></svg>';
|
|
83
|
+
const OCTICON_CHEVRON_RIGHT =
|
|
84
|
+
'<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>';
|
|
85
|
+
const OCTICON_FOLDER =
|
|
86
|
+
'<svg class="gh-dir-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 1H1.75Z"/></svg>';
|
|
87
|
+
const OCTICON_FILE =
|
|
88
|
+
'<svg class="gh-file-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25V1.75Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5H3.75Zm6.75.5v2.25c0 .138.112.25.25.25h2.25a.25.25 0 0 0 .177-.427L10.927 1.573a.25.25 0 0 0-.427.177Z"/></svg>';
|
|
89
|
+
|
|
90
|
+
repoRootEl.textContent = reviewData.repoRoot || "";
|
|
91
|
+
windowTitleEl.textContent = "Review";
|
|
92
|
+
|
|
93
|
+
let monacoApi = null;
|
|
94
|
+
let diffEditor = null;
|
|
95
|
+
let singleEditor = null;
|
|
96
|
+
let originalModel = null;
|
|
97
|
+
let modifiedModel = null;
|
|
98
|
+
let singleModel = null;
|
|
99
|
+
let pendingDiffReveal = null; // { dispose: Disposable, timeoutId: number }
|
|
100
|
+
let originalDecorations = [];
|
|
101
|
+
let modifiedDecorations = [];
|
|
102
|
+
let singleDecorations = [];
|
|
103
|
+
let activeViewZones = [];
|
|
104
|
+
let editorResizeObserver = null;
|
|
105
|
+
let requestSequence = 0;
|
|
106
|
+
|
|
107
|
+
function escapeHtml(value) {
|
|
108
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatAssetErrorDetail(error) {
|
|
112
|
+
if (error == null) return "";
|
|
113
|
+
if (typeof error === "string") return error;
|
|
114
|
+
if (error instanceof Error) return error.stack || error.message;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(error, null, 2);
|
|
117
|
+
} catch {
|
|
118
|
+
return String(error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function showAssetFailure(title, message, detail = "") {
|
|
123
|
+
state.assetInitFailed = true;
|
|
124
|
+
if (editorContainerEl) {
|
|
125
|
+
editorContainerEl.dataset.assetFailure = "true";
|
|
126
|
+
}
|
|
127
|
+
if (assetFailurePanelEl) {
|
|
128
|
+
assetFailurePanelEl.setAttribute("aria-hidden", "false");
|
|
129
|
+
}
|
|
130
|
+
if (assetFailureTitleEl) {
|
|
131
|
+
assetFailureTitleEl.textContent = title;
|
|
132
|
+
}
|
|
133
|
+
if (assetFailureMessageEl) {
|
|
134
|
+
assetFailureMessageEl.textContent = message;
|
|
135
|
+
}
|
|
136
|
+
if (assetFailureDetailEl) {
|
|
137
|
+
const detailText = String(detail || "").trim();
|
|
138
|
+
assetFailureDetailEl.textContent = detailText;
|
|
139
|
+
assetFailureDetailEl.hidden = detailText.length === 0;
|
|
140
|
+
}
|
|
141
|
+
hideBinaryPreview();
|
|
142
|
+
modeHintEl.textContent = message;
|
|
143
|
+
overallCommentButton.disabled = true;
|
|
144
|
+
fileCommentButton.disabled = true;
|
|
145
|
+
toggleReviewedButton.disabled = true;
|
|
146
|
+
toggleWrapButton.disabled = true;
|
|
147
|
+
toggleUnchangedButton.style.display = "none";
|
|
148
|
+
submitButton.disabled = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function clearAssetFailure() {
|
|
152
|
+
state.assetInitFailed = false;
|
|
153
|
+
if (editorContainerEl) {
|
|
154
|
+
editorContainerEl.dataset.assetFailure = "false";
|
|
155
|
+
}
|
|
156
|
+
if (assetFailurePanelEl) {
|
|
157
|
+
assetFailurePanelEl.setAttribute("aria-hidden", "true");
|
|
158
|
+
}
|
|
159
|
+
if (assetFailureDetailEl) {
|
|
160
|
+
assetFailureDetailEl.textContent = "";
|
|
161
|
+
assetFailureDetailEl.hidden = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function inferLanguage(path) {
|
|
166
|
+
if (!path) return "plaintext";
|
|
167
|
+
const lower = path.toLowerCase();
|
|
168
|
+
const fileName = lower.split(/[\\/]/).pop() || lower;
|
|
169
|
+
|
|
170
|
+
if (fileName === "dockerfile" || fileName === "containerfile") return "dockerfile";
|
|
171
|
+
|
|
172
|
+
const extensionLanguagePairs = [
|
|
173
|
+
[[".ts", ".tsx", ".mts", ".cts"], "typescript"],
|
|
174
|
+
[[".js", ".jsx", ".mjs", ".cjs"], "javascript"],
|
|
175
|
+
[[".json", ".jsonc", ".json5"], "json"],
|
|
176
|
+
[[".md"], "markdown"],
|
|
177
|
+
[[".mdx"], "mdx"],
|
|
178
|
+
[[".css"], "css"],
|
|
179
|
+
[[".scss", ".sass"], "scss"],
|
|
180
|
+
[[".less"], "less"],
|
|
181
|
+
[[".html", ".htm", ".vue", ".svelte"], "html"],
|
|
182
|
+
[[".xml", ".xhtml", ".svg"], "xml"],
|
|
183
|
+
[[".sh", ".bash", ".zsh", ".fish", ".env"], "shell"],
|
|
184
|
+
[[".ps1", ".psm1", ".psd1"], "powershell"],
|
|
185
|
+
[[".bat", ".cmd"], "bat"],
|
|
186
|
+
[[".yml", ".yaml"], "yaml"],
|
|
187
|
+
[[".rs"], "rust"],
|
|
188
|
+
[[".java"], "java"],
|
|
189
|
+
[[".kt", ".kts"], "kotlin"],
|
|
190
|
+
[[".swift"], "swift"],
|
|
191
|
+
[[".m", ".mm"], "objective-c"],
|
|
192
|
+
[[".py", ".pyw"], "python"],
|
|
193
|
+
[[".go"], "go"],
|
|
194
|
+
[[".c"], "c"],
|
|
195
|
+
[[".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"], "cpp"],
|
|
196
|
+
[[".cs"], "csharp"],
|
|
197
|
+
[[".php"], "php"],
|
|
198
|
+
[[".rb"], "ruby"],
|
|
199
|
+
[[".sql"], "sql"],
|
|
200
|
+
[[".mysql"], "mysql"],
|
|
201
|
+
[[".pgsql"], "pgsql"],
|
|
202
|
+
[[".graphql", ".gql"], "graphql"],
|
|
203
|
+
[[".dart"], "dart"],
|
|
204
|
+
[[".lua"], "lua"],
|
|
205
|
+
[[".r"], "r"],
|
|
206
|
+
[[".pl", ".pm"], "perl"],
|
|
207
|
+
[[".scala", ".sc"], "scala"],
|
|
208
|
+
[[".clj", ".cljs", ".cljc"], "clojure"],
|
|
209
|
+
[[".ex", ".exs"], "elixir"],
|
|
210
|
+
[[".fs", ".fsx", ".fsi"], "fsharp"],
|
|
211
|
+
[[".vb"], "vb"],
|
|
212
|
+
[[".tf", ".tfvars", ".hcl"], "hcl"],
|
|
213
|
+
[[".proto"], "protobuf"],
|
|
214
|
+
[[".sol"], "solidity"],
|
|
215
|
+
[[".wgsl"], "wgsl"],
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
for (const [extensions, language] of extensionLanguagePairs) {
|
|
219
|
+
if (extensions.some((extension) => lower.endsWith(extension))) return language;
|
|
220
|
+
}
|
|
221
|
+
return "plaintext";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function scopeLabel(scope) {
|
|
225
|
+
switch (scope) {
|
|
226
|
+
case "branch":
|
|
227
|
+
return "Branch";
|
|
228
|
+
case "commits":
|
|
229
|
+
return "Commits";
|
|
230
|
+
default:
|
|
231
|
+
return "All files";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function commitInfoBySha(sha) {
|
|
236
|
+
if (!sha) return null;
|
|
237
|
+
return reviewData.commits.find((c) => c.sha === sha) ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function selectedCommitInfo() {
|
|
241
|
+
return commitInfoBySha(state.selectedCommitSha);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function selectedCommitKind() {
|
|
245
|
+
return selectedCommitInfo()?.kind ?? "commit";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasRepositoryHead() {
|
|
249
|
+
return reviewData.repositoryHasHead === true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function workingTreeOriginalLabel() {
|
|
253
|
+
return hasRepositoryHead() ? "HEAD" : "Empty tree";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function workingTreeRangeLabel() {
|
|
257
|
+
return hasRepositoryHead() ? "HEAD → working tree" : "Empty tree → working tree";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isWorkingTreeCommit(sha) {
|
|
261
|
+
return commitInfoBySha(sha)?.kind === "working-tree";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isSelectedWorkingTreeCommit() {
|
|
265
|
+
return state.currentScope === "commits" && isWorkingTreeCommit(state.selectedCommitSha);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function scopeHint(scope) {
|
|
269
|
+
if (state.localChangesDetected) {
|
|
270
|
+
return "Local file changes detected. Click Refresh changes to reload the diff when you are ready.";
|
|
271
|
+
}
|
|
272
|
+
const baseRefLabel = reviewData.branchBaseRef || "the selected base";
|
|
273
|
+
switch (scope) {
|
|
274
|
+
case "branch":
|
|
275
|
+
return `Review current branch changes against ${baseRefLabel}. This view can include uncommitted working tree edits. Hover or click line numbers in the gutter to add an inline comment.`;
|
|
276
|
+
case "commits": {
|
|
277
|
+
const info = selectedCommitInfo();
|
|
278
|
+
if (!info) return "Pick a branch commit from the list to review its diff.";
|
|
279
|
+
if (info.kind === "working-tree") {
|
|
280
|
+
return hasRepositoryHead()
|
|
281
|
+
? "Review uncommitted working tree changes against HEAD. Use Refresh changes when local edits are detected."
|
|
282
|
+
: "Review uncommitted working tree changes against the empty tree in this new repository. Use Refresh changes when local edits are detected.";
|
|
283
|
+
}
|
|
284
|
+
return `Review commit ${info.shortSha} — ${info.subject}`;
|
|
285
|
+
}
|
|
286
|
+
default:
|
|
287
|
+
return "Review repository file snapshots across tracked and untracked files. Unchanged files open as single-file snapshots; changed files remain available in the Branch and Commits scopes for diff-based comments.";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function currentModeHint() {
|
|
292
|
+
const file = activeFile();
|
|
293
|
+
if (!file) return scopeHint(state.currentScope);
|
|
294
|
+
if (file.kind === "image") {
|
|
295
|
+
return state.currentScope === "all"
|
|
296
|
+
? "Review the current image snapshot. File-level comments are supported; inline line comments are unavailable for image previews."
|
|
297
|
+
: "Review image changes side by side. File-level comments are supported; inline line comments are unavailable for image previews.";
|
|
298
|
+
}
|
|
299
|
+
if (file.kind === "binary") {
|
|
300
|
+
return "Binary files show side existence instead of a text diff. File-level comments are supported; inline line comments are unavailable.";
|
|
301
|
+
}
|
|
302
|
+
return scopeHint(state.currentScope);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function statusLabel(status) {
|
|
306
|
+
if (!status) return "";
|
|
307
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function statusBadgeClass(status) {
|
|
311
|
+
switch (status) {
|
|
312
|
+
case "added":
|
|
313
|
+
return "gh-status gh-status-added";
|
|
314
|
+
case "deleted":
|
|
315
|
+
return "gh-status gh-status-deleted";
|
|
316
|
+
case "renamed":
|
|
317
|
+
return "gh-status gh-status-renamed";
|
|
318
|
+
case "modified":
|
|
319
|
+
return "gh-status gh-status-modified";
|
|
320
|
+
default:
|
|
321
|
+
return "gh-status gh-status-modified";
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function statusCode(status) {
|
|
326
|
+
if (!status) return "";
|
|
327
|
+
if (status === "renamed") return "R";
|
|
328
|
+
return status.charAt(0).toUpperCase();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isFileReviewed(fileId) {
|
|
332
|
+
return state.reviewedFiles[fileId] === true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function annotateCommentWithCommit(comment) {
|
|
336
|
+
if (comment.scope !== "commits") return comment;
|
|
337
|
+
const info = selectedCommitInfo();
|
|
338
|
+
if (!info) return comment;
|
|
339
|
+
return { ...comment, commitSha: info.sha, commitShort: info.shortSha, commitKind: info.kind };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function activeFileList() {
|
|
343
|
+
if (state.currentScope === "commits") {
|
|
344
|
+
const sha = state.selectedCommitSha;
|
|
345
|
+
if (!sha) return [];
|
|
346
|
+
return state.commitFilesBySha[sha] ?? [];
|
|
347
|
+
}
|
|
348
|
+
return reviewData.files;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getScopedFiles() {
|
|
352
|
+
switch (state.currentScope) {
|
|
353
|
+
case "branch":
|
|
354
|
+
return reviewData.files.filter((file) => file.inGitDiff);
|
|
355
|
+
case "commits":
|
|
356
|
+
return activeFileList();
|
|
357
|
+
default:
|
|
358
|
+
return reviewData.files;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function ensureActiveFileForScope() {
|
|
363
|
+
const scopedFiles = getScopedFiles();
|
|
364
|
+
if (scopedFiles.length === 0) {
|
|
365
|
+
state.activeFileId = null;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (scopedFiles.some((file) => file.id === state.activeFileId)) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
state.activeFileId = scopedFiles[0].id;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function activeFile() {
|
|
375
|
+
const list = activeFileList();
|
|
376
|
+
return list.find((file) => file.id === state.activeFileId) ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getScopeComparison(file, scope = state.currentScope) {
|
|
380
|
+
if (!file) return null;
|
|
381
|
+
if (scope === "branch" || scope === "commits") return file.gitDiff;
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function activeComparison() {
|
|
386
|
+
return getScopeComparison(activeFile(), state.currentScope);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isAddedOnlyComparison(comparison) {
|
|
390
|
+
return comparison != null && comparison.status === "added";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function activeFileIsAddedOnly() {
|
|
394
|
+
return isAddedOnlyComparison(activeComparison());
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function activeFileShowsDiff() {
|
|
398
|
+
const file = activeFile();
|
|
399
|
+
if (file == null || file.kind !== "text") return false;
|
|
400
|
+
const comparison = activeComparison();
|
|
401
|
+
if (comparison == null) return false;
|
|
402
|
+
if (isAddedOnlyComparison(comparison)) return false;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function activeFileUsesSingleEditor() {
|
|
407
|
+
const file = activeFile();
|
|
408
|
+
return file != null && file.kind === "text" && activeFileIsAddedOnly();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function setTextEditorMode(mode) {
|
|
412
|
+
if (!editorContainerEl) return;
|
|
413
|
+
editorContainerEl.dataset.textEditorMode = mode;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function activeFileUsesBinaryPreview() {
|
|
417
|
+
const file = activeFile();
|
|
418
|
+
return file != null && file.kind !== "text";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function fileKindBadgeMarkup(file) {
|
|
422
|
+
if (!file || file.kind === "text") return "";
|
|
423
|
+
const label = file.kind === "image" ? "IMG" : "BIN";
|
|
424
|
+
return `<span class="gh-kind-badge">${label}</span>`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function hideBinaryPreview() {
|
|
428
|
+
if (!editorContainerEl || !binaryPreviewEl) return;
|
|
429
|
+
editorContainerEl.dataset.previewActive = "false";
|
|
430
|
+
binaryPreviewEl.dataset.active = "false";
|
|
431
|
+
binaryPreviewEl.innerHTML = "";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function previewSideLabel(file, side) {
|
|
435
|
+
if (state.currentScope === "commits") {
|
|
436
|
+
return selectedCommitKind() === "working-tree"
|
|
437
|
+
? side === "original"
|
|
438
|
+
? workingTreeOriginalLabel()
|
|
439
|
+
: "Working tree"
|
|
440
|
+
: side === "original"
|
|
441
|
+
? "Parent"
|
|
442
|
+
: "Commit";
|
|
443
|
+
}
|
|
444
|
+
if (state.currentScope === "branch") {
|
|
445
|
+
if (side === "original") return reviewData.branchBaseRef ? `Base (${reviewData.branchBaseRef})` : "Base";
|
|
446
|
+
return file.hasWorkingTreeFile ? "Working tree" : "HEAD";
|
|
447
|
+
}
|
|
448
|
+
return "Snapshot";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderBinaryPreview(file, contents) {
|
|
452
|
+
if (!editorContainerEl || !binaryPreviewEl) return;
|
|
453
|
+
editorContainerEl.dataset.previewActive = "true";
|
|
454
|
+
binaryPreviewEl.dataset.active = "true";
|
|
455
|
+
const requestState = getRequestState(file.id, state.currentScope);
|
|
456
|
+
if (requestState.error) {
|
|
457
|
+
binaryPreviewEl.innerHTML = `<div class="binary-preview-note">Failed to load ${escapeHtml(getScopeDisplayPath(file, state.currentScope))}<br><br>${escapeHtml(requestState.error)}</div>`;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (requestState.requestId != null && requestState.contents == null) {
|
|
461
|
+
binaryPreviewEl.innerHTML = `<div class="binary-preview-note">Loading preview for ${escapeHtml(getScopeDisplayPath(file, state.currentScope))}...</div>`;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const showComparison = state.currentScope !== "all";
|
|
466
|
+
const cards = [];
|
|
467
|
+
const pushCard = (label, exists, previewUrl) => {
|
|
468
|
+
const body = exists
|
|
469
|
+
? file.kind === "image" && previewUrl
|
|
470
|
+
? `<img class="binary-preview-image" src="${previewUrl}" alt="${escapeHtml(label)} preview">`
|
|
471
|
+
: `<div class="binary-preview-empty">Binary file present</div>`
|
|
472
|
+
: `<div class="binary-preview-empty">Not present in this side</div>`;
|
|
473
|
+
cards.push(`
|
|
474
|
+
<div class="binary-preview-card">
|
|
475
|
+
<div class="binary-preview-card-header">
|
|
476
|
+
<span>${escapeHtml(label)}</span>
|
|
477
|
+
<span>${exists ? "present" : "absent"}</span>
|
|
478
|
+
</div>
|
|
479
|
+
<div class="binary-preview-card-body">${body}</div>
|
|
480
|
+
</div>
|
|
481
|
+
`);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
if (showComparison) {
|
|
485
|
+
pushCard(previewSideLabel(file, "original"), contents.originalExists, contents.originalPreviewUrl);
|
|
486
|
+
pushCard(previewSideLabel(file, "modified"), contents.modifiedExists, contents.modifiedPreviewUrl);
|
|
487
|
+
} else {
|
|
488
|
+
pushCard(previewSideLabel(file, "modified"), contents.modifiedExists, contents.modifiedPreviewUrl);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const note =
|
|
492
|
+
file.kind === "image"
|
|
493
|
+
? "Image files are rendered directly in the review pane."
|
|
494
|
+
: "Binary files do not have a text diff here. The review pane shows whether each side exists.";
|
|
495
|
+
binaryPreviewEl.innerHTML = `<div class="binary-preview-grid">${cards.join("")}</div><div class="binary-preview-note">${escapeHtml(note)}</div>`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function getScopeFilePath(file) {
|
|
499
|
+
const comparison = getScopeComparison(file, state.currentScope);
|
|
500
|
+
return comparison?.newPath || comparison?.oldPath || file?.path || "";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getScopeDisplayPath(file, scope = state.currentScope) {
|
|
504
|
+
const comparison = getScopeComparison(file, scope);
|
|
505
|
+
return comparison?.displayPath || file?.path || "";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getFileSearchPath(file) {
|
|
509
|
+
return file?.path || "";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function getBaseName(path) {
|
|
513
|
+
const parts = path.split("/");
|
|
514
|
+
return parts[parts.length - 1] || path;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function getActiveStatus(file) {
|
|
518
|
+
const comparison = getScopeComparison(file, state.currentScope);
|
|
519
|
+
return comparison?.status ?? file?.worktreeStatus ?? null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeQuery(query) {
|
|
523
|
+
return String(query || "")
|
|
524
|
+
.trim()
|
|
525
|
+
.toLowerCase()
|
|
526
|
+
.replace(/\s+/g, "");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function scoreSubsequence(query, candidate) {
|
|
530
|
+
if (!query) return 0;
|
|
531
|
+
let queryIndex = 0;
|
|
532
|
+
let score = 0;
|
|
533
|
+
let firstMatchIndex = -1;
|
|
534
|
+
let previousMatchIndex = -2;
|
|
535
|
+
|
|
536
|
+
for (let i = 0; i < candidate.length && queryIndex < query.length; i += 1) {
|
|
537
|
+
if (candidate[i] !== query[queryIndex]) continue;
|
|
538
|
+
|
|
539
|
+
if (firstMatchIndex === -1) firstMatchIndex = i;
|
|
540
|
+
score += 10;
|
|
541
|
+
|
|
542
|
+
if (i === previousMatchIndex + 1) {
|
|
543
|
+
score += 8;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const previousChar = i > 0 ? candidate[i - 1] : "";
|
|
547
|
+
if (i === 0 || previousChar === "/" || previousChar === "_" || previousChar === "-" || previousChar === ".") {
|
|
548
|
+
score += 12;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
previousMatchIndex = i;
|
|
552
|
+
queryIndex += 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (queryIndex !== query.length) return -1;
|
|
556
|
+
if (firstMatchIndex >= 0) score += Math.max(0, 20 - firstMatchIndex);
|
|
557
|
+
return score;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function getFileSearchScore(query, file) {
|
|
561
|
+
const normalizedQuery = normalizeQuery(query);
|
|
562
|
+
if (!normalizedQuery) return 0;
|
|
563
|
+
|
|
564
|
+
const path = getFileSearchPath(file).toLowerCase();
|
|
565
|
+
const baseName = getBaseName(path);
|
|
566
|
+
const pathScore = scoreSubsequence(normalizedQuery, path);
|
|
567
|
+
const baseScore = scoreSubsequence(normalizedQuery, baseName);
|
|
568
|
+
let score = Math.max(pathScore, baseScore >= 0 ? baseScore + 40 : -1);
|
|
569
|
+
|
|
570
|
+
if (score < 0) return -1;
|
|
571
|
+
if (baseName === normalizedQuery) score += 200;
|
|
572
|
+
else if (baseName.startsWith(normalizedQuery)) score += 120;
|
|
573
|
+
else if (path.includes(normalizedQuery)) score += 35;
|
|
574
|
+
|
|
575
|
+
return score;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function getFilteredFiles() {
|
|
579
|
+
const scopedFiles = getScopedFiles();
|
|
580
|
+
const query = state.fileFilter.trim();
|
|
581
|
+
if (!query) return [...scopedFiles];
|
|
582
|
+
|
|
583
|
+
return scopedFiles
|
|
584
|
+
.map((file) => ({ file, score: getFileSearchScore(query, file) }))
|
|
585
|
+
.filter((entry) => entry.score >= 0)
|
|
586
|
+
.sort((a, b) => {
|
|
587
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
588
|
+
return getFileSearchPath(a.file).localeCompare(getFileSearchPath(b.file));
|
|
589
|
+
})
|
|
590
|
+
.map((entry) => entry.file);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function collapseTreeNode(node, isRoot = false) {
|
|
594
|
+
if (node.kind === "file") return node;
|
|
595
|
+
|
|
596
|
+
const collapsedChildren = [...node.children.values()].map((child) => collapseTreeNode(child));
|
|
597
|
+
let collapsed = {
|
|
598
|
+
...node,
|
|
599
|
+
children: new Map(collapsedChildren.map((child) => [child.name, child])),
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
if (isRoot) return collapsed;
|
|
603
|
+
|
|
604
|
+
while (collapsed.children.size === 1) {
|
|
605
|
+
const [onlyChild] = collapsed.children.values();
|
|
606
|
+
if (!onlyChild || onlyChild.kind !== "dir") break;
|
|
607
|
+
collapsed = {
|
|
608
|
+
name: `${collapsed.name}/${onlyChild.name}`,
|
|
609
|
+
path: onlyChild.path,
|
|
610
|
+
kind: "dir",
|
|
611
|
+
children: onlyChild.children,
|
|
612
|
+
file: null,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return collapsed;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function buildTree(files) {
|
|
620
|
+
const root = { name: "", path: "", kind: "dir", children: new Map(), file: null };
|
|
621
|
+
for (const file of files) {
|
|
622
|
+
const path = getFileSearchPath(file);
|
|
623
|
+
const parts = path.split("/");
|
|
624
|
+
let node = root;
|
|
625
|
+
let currentPath = "";
|
|
626
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
627
|
+
const part = parts[i];
|
|
628
|
+
const isLeaf = i === parts.length - 1;
|
|
629
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
630
|
+
if (!node.children.has(part)) {
|
|
631
|
+
node.children.set(part, {
|
|
632
|
+
name: part,
|
|
633
|
+
path: currentPath,
|
|
634
|
+
kind: isLeaf ? "file" : "dir",
|
|
635
|
+
children: new Map(),
|
|
636
|
+
file: isLeaf ? file : null,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
node = node.children.get(part);
|
|
640
|
+
if (isLeaf) node.file = file;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return collapseTreeNode(root, true);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function cacheKey(scope, fileId, commitSha = null) {
|
|
647
|
+
if (scope === "commits") {
|
|
648
|
+
return `commits:${commitSha ?? state.selectedCommitSha ?? ""}:${fileId}`;
|
|
649
|
+
}
|
|
650
|
+
return `${scope}:${fileId}`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function scrollKey(scope, fileId, commitSha = null) {
|
|
654
|
+
if (scope === "commits") {
|
|
655
|
+
return `commits:${commitSha ?? state.selectedCommitSha ?? ""}:${fileId}`;
|
|
656
|
+
}
|
|
657
|
+
return `${scope}:${fileId}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function saveCurrentScrollPosition() {
|
|
661
|
+
if (!diffEditor || !state.activeFileId) return;
|
|
662
|
+
const originalEditor = diffEditor.getOriginalEditor();
|
|
663
|
+
const modifiedEditor = diffEditor.getModifiedEditor();
|
|
664
|
+
state.scrollPositions[scrollKey(state.currentScope, state.activeFileId)] = {
|
|
665
|
+
originalTop: originalEditor.getScrollTop(),
|
|
666
|
+
originalLeft: originalEditor.getScrollLeft(),
|
|
667
|
+
modifiedTop: modifiedEditor.getScrollTop(),
|
|
668
|
+
modifiedLeft: modifiedEditor.getScrollLeft(),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function restoreFileScrollPosition() {
|
|
673
|
+
if (!diffEditor || !state.activeFileId) return;
|
|
674
|
+
const scrollState = state.scrollPositions[scrollKey(state.currentScope, state.activeFileId)];
|
|
675
|
+
if (!scrollState) return;
|
|
676
|
+
const originalEditor = diffEditor.getOriginalEditor();
|
|
677
|
+
const modifiedEditor = diffEditor.getModifiedEditor();
|
|
678
|
+
originalEditor.setScrollTop(scrollState.originalTop);
|
|
679
|
+
originalEditor.setScrollLeft(scrollState.originalLeft);
|
|
680
|
+
modifiedEditor.setScrollTop(scrollState.modifiedTop);
|
|
681
|
+
modifiedEditor.setScrollLeft(scrollState.modifiedLeft);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function captureScrollState() {
|
|
685
|
+
if (!diffEditor) return null;
|
|
686
|
+
const originalEditor = diffEditor.getOriginalEditor();
|
|
687
|
+
const modifiedEditor = diffEditor.getModifiedEditor();
|
|
688
|
+
return {
|
|
689
|
+
originalTop: originalEditor.getScrollTop(),
|
|
690
|
+
originalLeft: originalEditor.getScrollLeft(),
|
|
691
|
+
modifiedTop: modifiedEditor.getScrollTop(),
|
|
692
|
+
modifiedLeft: modifiedEditor.getScrollLeft(),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function restoreScrollState(scrollState) {
|
|
697
|
+
if (!diffEditor || !scrollState) return;
|
|
698
|
+
const originalEditor = diffEditor.getOriginalEditor();
|
|
699
|
+
const modifiedEditor = diffEditor.getModifiedEditor();
|
|
700
|
+
originalEditor.setScrollTop(scrollState.originalTop);
|
|
701
|
+
originalEditor.setScrollLeft(scrollState.originalLeft);
|
|
702
|
+
modifiedEditor.setScrollTop(scrollState.modifiedTop);
|
|
703
|
+
modifiedEditor.setScrollLeft(scrollState.modifiedLeft);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function getRequestState(fileId, scope = state.currentScope, commitSha = null) {
|
|
707
|
+
const key = cacheKey(scope, fileId, commitSha);
|
|
708
|
+
return {
|
|
709
|
+
contents: state.fileContents[key],
|
|
710
|
+
error: state.fileErrors[key],
|
|
711
|
+
requestId: state.pendingRequestIds[key],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function ensureFileLoaded(fileId, scope = state.currentScope, commitSha = null, options = {}) {
|
|
716
|
+
if (!fileId) return;
|
|
717
|
+
const forceRefresh = options.forceRefresh === true;
|
|
718
|
+
const resolvedCommitSha = scope === "commits" ? (commitSha ?? state.selectedCommitSha ?? null) : null;
|
|
719
|
+
const key = cacheKey(scope, fileId, resolvedCommitSha);
|
|
720
|
+
if (!forceRefresh) {
|
|
721
|
+
if (state.fileContents[key] != null) return;
|
|
722
|
+
if (state.fileErrors[key] != null) return;
|
|
723
|
+
if (state.pendingRequestIds[key] != null) return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const requestId = `request:${Date.now()}:${++requestSequence}`;
|
|
727
|
+
state.pendingRequestIds[key] = requestId;
|
|
728
|
+
renderTree();
|
|
729
|
+
if (window.glimpse?.send) {
|
|
730
|
+
const payload = { type: "request-file", requestId, fileId, scope };
|
|
731
|
+
if (scope === "commits" && resolvedCommitSha) {
|
|
732
|
+
payload.commitSha = resolvedCommitSha;
|
|
733
|
+
}
|
|
734
|
+
window.glimpse.send(payload);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function ensureCommitFilesLoaded(sha, options = {}) {
|
|
739
|
+
if (!sha) return;
|
|
740
|
+
const forceRefresh = options.forceRefresh === true;
|
|
741
|
+
if (!forceRefresh) {
|
|
742
|
+
if (state.commitFilesBySha[sha] != null) return;
|
|
743
|
+
if (state.commitErrors[sha] != null) return;
|
|
744
|
+
}
|
|
745
|
+
if (state.commitRequestIds[sha] != null) return;
|
|
746
|
+
|
|
747
|
+
if (forceRefresh) {
|
|
748
|
+
delete state.commitErrors[sha];
|
|
749
|
+
}
|
|
750
|
+
const requestId = `commit-request:${Date.now()}:${++requestSequence}`;
|
|
751
|
+
state.commitRequestIds[sha] = requestId;
|
|
752
|
+
if (window.glimpse?.send) {
|
|
753
|
+
window.glimpse.send({ type: "request-commit", requestId, sha });
|
|
754
|
+
}
|
|
755
|
+
renderCommitList();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function formatWorkingTreeLoadLabel(timestamp) {
|
|
759
|
+
if (!timestamp) return "Not loaded yet";
|
|
760
|
+
const date = new Date(timestamp);
|
|
761
|
+
if (Number.isNaN(date.getTime())) return "Not loaded yet";
|
|
762
|
+
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function formatLocalChangeLabel(timestamp) {
|
|
766
|
+
if (!timestamp) return "just now";
|
|
767
|
+
const date = new Date(timestamp);
|
|
768
|
+
if (Number.isNaN(date.getTime())) return "just now";
|
|
769
|
+
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function updateReviewRefreshButton() {
|
|
773
|
+
if (!refreshReviewButton) return;
|
|
774
|
+
const shouldShow = state.localChangesDetected || state.currentScope === "commits";
|
|
775
|
+
if (!shouldShow) {
|
|
776
|
+
refreshReviewButton.style.display = "none";
|
|
777
|
+
refreshReviewButton.disabled = true;
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const sha = state.currentScope === "commits" ? state.selectedCommitSha : null;
|
|
781
|
+
const commitLoading = sha ? state.commitRequestIds[sha] != null : false;
|
|
782
|
+
const reviewDataLoading = state.reviewDataRequestId != null;
|
|
783
|
+
const loading = commitLoading || reviewDataLoading;
|
|
784
|
+
refreshReviewButton.style.display = "inline-flex";
|
|
785
|
+
refreshReviewButton.disabled = loading;
|
|
786
|
+
refreshReviewButton.style.borderColor = state.localChangesDetected && !loading ? "rgba(210,153,34,0.65)" : "";
|
|
787
|
+
refreshReviewButton.style.color = state.localChangesDetected && !loading ? "#d29922" : "";
|
|
788
|
+
refreshReviewButton.textContent = loading
|
|
789
|
+
? "Refreshing…"
|
|
790
|
+
: state.localChangesDetected
|
|
791
|
+
? "Changes detected · Refresh"
|
|
792
|
+
: isSelectedWorkingTreeCommit()
|
|
793
|
+
? "Refresh live diff"
|
|
794
|
+
: "Refresh review";
|
|
795
|
+
refreshReviewButton.title = state.localChangesDetected
|
|
796
|
+
? `Local file changes detected at ${formatLocalChangeLabel(state.lastLocalChangeDetectedAt)}. Click to reload review data.`
|
|
797
|
+
: isSelectedWorkingTreeCommit()
|
|
798
|
+
? `Working tree diff. Last loaded ${formatWorkingTreeLoadLabel(state.lastWorkingTreeLoadAt)}.`
|
|
799
|
+
: "Refresh review data for all scopes.";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function clearFileRequestStateByPrefixes(prefixes) {
|
|
803
|
+
const matchesPrefix = (key) => prefixes.some((prefix) => key.startsWith(prefix));
|
|
804
|
+
for (const key of Object.keys(state.fileContents)) {
|
|
805
|
+
if (matchesPrefix(key)) delete state.fileContents[key];
|
|
806
|
+
}
|
|
807
|
+
for (const key of Object.keys(state.fileErrors)) {
|
|
808
|
+
if (matchesPrefix(key)) delete state.fileErrors[key];
|
|
809
|
+
}
|
|
810
|
+
for (const key of Object.keys(state.pendingRequestIds)) {
|
|
811
|
+
if (matchesPrefix(key)) delete state.pendingRequestIds[key];
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function clearCommitScopedState(sha) {
|
|
816
|
+
clearFileRequestStateByPrefixes([`commits:${sha}:`]);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function clearRefreshableFileState() {
|
|
820
|
+
const prefixes = ["branch:", "all:"];
|
|
821
|
+
for (const commit of reviewData.commits) {
|
|
822
|
+
if (commit.kind === "working-tree") {
|
|
823
|
+
prefixes.push(`commits:${commit.sha}:`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
clearFileRequestStateByPrefixes(prefixes);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function clearWorkingTreeReviewArtifacts(sha) {
|
|
830
|
+
state.comments = state.comments.filter((comment) => !(comment.scope === "commits" && comment.commitSha === sha));
|
|
831
|
+
const previousFiles = state.commitFilesBySha[sha] ?? [];
|
|
832
|
+
for (const file of previousFiles) {
|
|
833
|
+
delete state.reviewedFiles[file.id];
|
|
834
|
+
delete state.scrollPositions[scrollKey("commits", file.id, sha)];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function clearWorkingTreeCommitState() {
|
|
839
|
+
for (const commit of reviewData.commits) {
|
|
840
|
+
if (commit.kind !== "working-tree") continue;
|
|
841
|
+
clearWorkingTreeReviewArtifacts(commit.sha);
|
|
842
|
+
clearCommitScopedState(commit.sha);
|
|
843
|
+
delete state.commitFilesBySha[commit.sha];
|
|
844
|
+
delete state.commitErrors[commit.sha];
|
|
845
|
+
delete state.commitRequestIds[commit.sha];
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function requestLatestReviewData() {
|
|
850
|
+
if (!window.glimpse?.send) return;
|
|
851
|
+
if (state.reviewDataRequestId != null) return;
|
|
852
|
+
const requestStartedAt = Date.now();
|
|
853
|
+
const requestId = `review-data-request:${requestStartedAt}:${++requestSequence}`;
|
|
854
|
+
state.reviewDataRequestId = requestId;
|
|
855
|
+
state.reviewDataRequestStartedAt = requestStartedAt;
|
|
856
|
+
updateReviewRefreshButton();
|
|
857
|
+
window.glimpse.send({ type: "request-review-data", requestId });
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function refreshReviewData() {
|
|
861
|
+
requestLatestReviewData();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function openFile(fileId) {
|
|
865
|
+
if (state.activeFileId === fileId) {
|
|
866
|
+
ensureFileLoaded(fileId, state.currentScope);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
saveCurrentScrollPosition();
|
|
870
|
+
state.activeFileId = fileId;
|
|
871
|
+
renderAll({ restoreFileScroll: true });
|
|
872
|
+
ensureFileLoaded(fileId, state.currentScope);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderTreeNode(node, depth) {
|
|
876
|
+
const children = [...node.children.values()].sort((a, b) => {
|
|
877
|
+
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
|
|
878
|
+
return a.name.localeCompare(b.name);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
const indentPx = 12;
|
|
882
|
+
|
|
883
|
+
for (const child of children) {
|
|
884
|
+
if (child.kind === "dir") {
|
|
885
|
+
const collapsed = state.collapsedDirs[child.path] === true;
|
|
886
|
+
const row = document.createElement("button");
|
|
887
|
+
row.type = "button";
|
|
888
|
+
row.className = "gh-tree-row";
|
|
889
|
+
row.style.paddingLeft = `${depth * indentPx + 8}px`;
|
|
890
|
+
row.innerHTML = `
|
|
891
|
+
<span class="flex h-3 w-3 items-center justify-center text-review-muted">${collapsed ? OCTICON_CHEVRON_RIGHT : OCTICON_CHEVRON_DOWN}</span>
|
|
892
|
+
${OCTICON_FOLDER}
|
|
893
|
+
<span class="gh-row-name">${escapeHtml(child.name)}</span>
|
|
894
|
+
`;
|
|
895
|
+
row.addEventListener("click", () => {
|
|
896
|
+
state.collapsedDirs[child.path] = !collapsed;
|
|
897
|
+
renderTree();
|
|
898
|
+
});
|
|
899
|
+
fileTreeEl.appendChild(row);
|
|
900
|
+
if (!collapsed) renderTreeNode(child, depth + 1);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const file = child.file;
|
|
905
|
+
const count = state.comments.filter(
|
|
906
|
+
(comment) => comment.fileId === file.id && comment.scope === state.currentScope,
|
|
907
|
+
).length;
|
|
908
|
+
const reviewed = isFileReviewed(file.id);
|
|
909
|
+
const requestState = getRequestState(file.id, state.currentScope);
|
|
910
|
+
const loading = requestState.requestId != null && requestState.contents == null;
|
|
911
|
+
const errored = requestState.error != null;
|
|
912
|
+
const status = getActiveStatus(file);
|
|
913
|
+
const button = document.createElement("button");
|
|
914
|
+
button.type = "button";
|
|
915
|
+
button.className = "gh-tree-row";
|
|
916
|
+
button.dataset.selected = file.id === state.activeFileId ? "true" : "false";
|
|
917
|
+
if (reviewed) button.style.opacity = "0.6";
|
|
918
|
+
button.style.paddingLeft = `${depth * indentPx + 26}px`;
|
|
919
|
+
const loadingBadge = loading
|
|
920
|
+
? '<span class="text-[10px] text-review-accent">…</span>'
|
|
921
|
+
: errored
|
|
922
|
+
? '<span class="text-[10px] text-review-danger">!</span>'
|
|
923
|
+
: "";
|
|
924
|
+
button.innerHTML = `
|
|
925
|
+
${OCTICON_FILE}
|
|
926
|
+
<span class="gh-row-name">${escapeHtml(child.name)}</span>
|
|
927
|
+
<span class="gh-row-trail">
|
|
928
|
+
${fileKindBadgeMarkup(file)}
|
|
929
|
+
${loadingBadge}
|
|
930
|
+
${count > 0 ? `<span class="gh-comment-count">${count}</span>` : ""}
|
|
931
|
+
${status ? `<span class="${statusBadgeClass(status)}">${escapeHtml(statusCode(status))}</span>` : ""}
|
|
932
|
+
</span>
|
|
933
|
+
`;
|
|
934
|
+
button.addEventListener("click", () => openFile(file.id));
|
|
935
|
+
fileTreeEl.appendChild(button);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function renderSearchResults(files) {
|
|
940
|
+
files.forEach((file) => {
|
|
941
|
+
const path = getFileSearchPath(file);
|
|
942
|
+
const baseName = getBaseName(path);
|
|
943
|
+
const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
944
|
+
const count = state.comments.filter(
|
|
945
|
+
(comment) => comment.fileId === file.id && comment.scope === state.currentScope,
|
|
946
|
+
).length;
|
|
947
|
+
const reviewed = isFileReviewed(file.id);
|
|
948
|
+
const requestState = getRequestState(file.id, state.currentScope);
|
|
949
|
+
const loading = requestState.requestId != null && requestState.contents == null;
|
|
950
|
+
const errored = requestState.error != null;
|
|
951
|
+
const status = getActiveStatus(file);
|
|
952
|
+
const button = document.createElement("button");
|
|
953
|
+
button.type = "button";
|
|
954
|
+
button.className = "gh-tree-row";
|
|
955
|
+
button.dataset.selected = file.id === state.activeFileId ? "true" : "false";
|
|
956
|
+
if (reviewed) button.style.opacity = "0.6";
|
|
957
|
+
button.style.alignItems = "flex-start";
|
|
958
|
+
button.style.padding = "6px 8px";
|
|
959
|
+
const loadingBadge = loading
|
|
960
|
+
? '<span class="text-[10px] text-review-accent">…</span>'
|
|
961
|
+
: errored
|
|
962
|
+
? '<span class="text-[10px] text-review-danger">!</span>'
|
|
963
|
+
: "";
|
|
964
|
+
button.innerHTML = `
|
|
965
|
+
${OCTICON_FILE}
|
|
966
|
+
<span class="min-w-0 flex-1">
|
|
967
|
+
<span class="block truncate text-[13px]">${escapeHtml(baseName)}</span>
|
|
968
|
+
<span class="block truncate text-[11px] text-review-muted">${escapeHtml(parentPath || path)}</span>
|
|
969
|
+
</span>
|
|
970
|
+
<span class="gh-row-trail">
|
|
971
|
+
${fileKindBadgeMarkup(file)}
|
|
972
|
+
${loadingBadge}
|
|
973
|
+
${count > 0 ? `<span class="gh-comment-count">${count}</span>` : ""}
|
|
974
|
+
${status ? `<span class="${statusBadgeClass(status)}">${escapeHtml(statusCode(status))}</span>` : ""}
|
|
975
|
+
</span>
|
|
976
|
+
`;
|
|
977
|
+
button.addEventListener("click", () => openFile(file.id));
|
|
978
|
+
fileTreeEl.appendChild(button);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function updateSidebarLayout() {
|
|
983
|
+
const collapsed = state.sidebarCollapsed;
|
|
984
|
+
sidebarEl.style.width = collapsed ? "0px" : "280px";
|
|
985
|
+
sidebarEl.style.minWidth = collapsed ? "0px" : "280px";
|
|
986
|
+
sidebarEl.style.flexBasis = collapsed ? "0px" : "280px";
|
|
987
|
+
sidebarEl.style.borderRightWidth = collapsed ? "0px" : "1px";
|
|
988
|
+
sidebarEl.style.pointerEvents = collapsed ? "none" : "auto";
|
|
989
|
+
toggleSidebarButton.dataset.active = collapsed ? "false" : "true";
|
|
990
|
+
toggleSidebarButton.title = collapsed ? "Show sidebar" : "Hide sidebar";
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function updateScopeButtons() {
|
|
994
|
+
const counts = {
|
|
995
|
+
branch: reviewData.files.filter((file) => file.inGitDiff).length,
|
|
996
|
+
commits: reviewData.commits.length,
|
|
997
|
+
all: reviewData.files.length,
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const applyButtonClasses = (button, active, disabled) => {
|
|
1001
|
+
button.disabled = disabled;
|
|
1002
|
+
button.className = disabled
|
|
1003
|
+
? "cursor-default rounded-md border border-review-border bg-review-bg px-2.5 py-1 text-[11px] font-medium text-review-muted opacity-60"
|
|
1004
|
+
: active
|
|
1005
|
+
? "cursor-pointer rounded-md border border-review-success-emphasis/40 bg-review-success-emphasis/15 px-2.5 py-1 text-[11px] font-medium text-review-success hover:bg-review-success-emphasis/25"
|
|
1006
|
+
: "cursor-pointer rounded-md border border-review-border bg-review-panel px-2.5 py-1 text-[11px] font-medium text-review-text hover:bg-[#1f242c]";
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
scopeBranchButton.textContent = `Branch${counts.branch > 0 ? ` (${counts.branch})` : ""}`;
|
|
1010
|
+
scopeCommitsButton.textContent = `Commits${counts.commits > 0 ? ` (${counts.commits})` : ""}`;
|
|
1011
|
+
scopeAllButton.textContent = `All${counts.all > 0 ? ` (${counts.all})` : ""}`;
|
|
1012
|
+
|
|
1013
|
+
applyButtonClasses(scopeBranchButton, state.currentScope === "branch", counts.branch === 0);
|
|
1014
|
+
applyButtonClasses(scopeCommitsButton, state.currentScope === "commits", counts.commits === 0);
|
|
1015
|
+
applyButtonClasses(scopeAllButton, state.currentScope === "all", counts.all === 0);
|
|
1016
|
+
|
|
1017
|
+
if (commitPickerEl) {
|
|
1018
|
+
commitPickerEl.style.display = state.currentScope === "commits" ? "" : "none";
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderCommitList() {
|
|
1023
|
+
if (!commitListEl) return;
|
|
1024
|
+
commitListEl.innerHTML = "";
|
|
1025
|
+
if (reviewData.commits.length === 0) {
|
|
1026
|
+
commitListEl.innerHTML = '<div class="px-3 py-2 text-[11px] text-review-muted">No commits to review.</div>';
|
|
1027
|
+
updateReviewRefreshButton();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
for (const commit of reviewData.commits) {
|
|
1031
|
+
const row = document.createElement("button");
|
|
1032
|
+
row.type = "button";
|
|
1033
|
+
row.className = "commit-row";
|
|
1034
|
+
row.dataset.selected = commit.sha === state.selectedCommitSha ? "true" : "false";
|
|
1035
|
+
const loading = state.commitRequestIds[commit.sha] != null && state.commitFilesBySha[commit.sha] == null;
|
|
1036
|
+
const errored = state.commitErrors[commit.sha] != null;
|
|
1037
|
+
const isWorkingTreeCommit = commit.kind === "working-tree";
|
|
1038
|
+
const date = !isWorkingTreeCommit && commit.authorDate ? new Date(commit.authorDate) : null;
|
|
1039
|
+
const dateLabel =
|
|
1040
|
+
date && !Number.isNaN(date.getTime())
|
|
1041
|
+
? date.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
1042
|
+
: "";
|
|
1043
|
+
const metaLabel = isWorkingTreeCommit
|
|
1044
|
+
? `Live · ${workingTreeRangeLabel()}`
|
|
1045
|
+
: `${commit.authorName || ""}${dateLabel ? ` · ${dateLabel}` : ""}`;
|
|
1046
|
+
row.innerHTML = `
|
|
1047
|
+
<span class="commit-row-sha">${escapeHtml(commit.shortSha)}</span>
|
|
1048
|
+
<span class="commit-row-body">
|
|
1049
|
+
<span class="commit-row-subject">${escapeHtml(commit.subject)}</span>
|
|
1050
|
+
<span class="commit-row-meta">${escapeHtml(metaLabel)}</span>
|
|
1051
|
+
</span>
|
|
1052
|
+
<span class="commit-row-status">${loading ? "…" : errored ? "!" : ""}</span>
|
|
1053
|
+
`;
|
|
1054
|
+
row.addEventListener("click", () => selectCommit(commit.sha));
|
|
1055
|
+
commitListEl.appendChild(row);
|
|
1056
|
+
}
|
|
1057
|
+
updateReviewRefreshButton();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function selectCommit(sha) {
|
|
1061
|
+
if (!sha) return;
|
|
1062
|
+
if (state.selectedCommitSha === sha && state.commitFilesBySha[sha] != null) return;
|
|
1063
|
+
saveCurrentScrollPosition();
|
|
1064
|
+
state.selectedCommitSha = sha;
|
|
1065
|
+
state.activeFileId = null;
|
|
1066
|
+
ensureCommitFilesLoaded(sha);
|
|
1067
|
+
renderAll({ restoreFileScroll: false });
|
|
1068
|
+
const file = activeFile();
|
|
1069
|
+
if (file) ensureFileLoaded(file.id, state.currentScope);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function updateToggleButtons() {
|
|
1073
|
+
const file = activeFile();
|
|
1074
|
+
const reviewed = file ? isFileReviewed(file.id) : false;
|
|
1075
|
+
const usesBinaryPreview = activeFileUsesBinaryPreview();
|
|
1076
|
+
toggleReviewedButton.dataset.active = reviewed ? "true" : "false";
|
|
1077
|
+
toggleReviewedButton.title = reviewed ? "Viewed (click to unmark)" : "Mark this file as viewed";
|
|
1078
|
+
if (state.assetInitFailed) {
|
|
1079
|
+
overallCommentButton.disabled = true;
|
|
1080
|
+
toggleReviewedButton.disabled = true;
|
|
1081
|
+
fileCommentButton.disabled = true;
|
|
1082
|
+
toggleWrapButton.disabled = true;
|
|
1083
|
+
toggleUnchangedButton.style.display = "none";
|
|
1084
|
+
updateScopeButtons();
|
|
1085
|
+
updateReviewRefreshButton();
|
|
1086
|
+
submitButton.disabled = true;
|
|
1087
|
+
updateFileHeaderMeta(file);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
overallCommentButton.disabled = false;
|
|
1091
|
+
toggleReviewedButton.disabled = !file;
|
|
1092
|
+
fileCommentButton.disabled = !file;
|
|
1093
|
+
toggleWrapButton.dataset.active = !usesBinaryPreview && state.wrapLines ? "true" : "false";
|
|
1094
|
+
toggleWrapButton.disabled = !file || usesBinaryPreview;
|
|
1095
|
+
toggleWrapButton.title = usesBinaryPreview
|
|
1096
|
+
? "Wrap is unavailable for binary previews"
|
|
1097
|
+
: `Wrap long lines (${state.wrapLines ? "on" : "off"})`;
|
|
1098
|
+
toggleUnchangedButton.dataset.active = state.hideUnchanged ? "true" : "false";
|
|
1099
|
+
toggleUnchangedButton.title = state.hideUnchanged
|
|
1100
|
+
? "Showing changed areas only — click to show full file"
|
|
1101
|
+
: "Show changed areas only";
|
|
1102
|
+
toggleUnchangedButton.style.display = !usesBinaryPreview && activeFileShowsDiff() ? "inline-flex" : "none";
|
|
1103
|
+
updateScopeButtons();
|
|
1104
|
+
updateReviewRefreshButton();
|
|
1105
|
+
modeHintEl.textContent = currentModeHint();
|
|
1106
|
+
submitButton.disabled = false;
|
|
1107
|
+
updateFileHeaderMeta(file);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function applyEditorOptions() {
|
|
1111
|
+
const addedOnly = activeFileIsAddedOnly();
|
|
1112
|
+
const showDiff = activeFileShowsDiff();
|
|
1113
|
+
const wrapMode = state.wrapLines ? "on" : "off";
|
|
1114
|
+
editorContainerEl.dataset.addedOnly = addedOnly ? "true" : "false";
|
|
1115
|
+
if (singleEditor) {
|
|
1116
|
+
singleEditor.updateOptions({
|
|
1117
|
+
wordWrap: wrapMode,
|
|
1118
|
+
wordWrapOverride1: wrapMode,
|
|
1119
|
+
wordWrapOverride2: wrapMode,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
if (!diffEditor) return;
|
|
1123
|
+
// Why both `wordWrap` and `wordWrapOverride1/2`:
|
|
1124
|
+
// Monaco's diff editor internally applies `wordWrapOverride1` (and sometimes
|
|
1125
|
+
// `wordWrapOverride2`) on the original/modified sides to enforce diff-specific
|
|
1126
|
+
// behavior. Those overrides take precedence over `wordWrap`, so setting only
|
|
1127
|
+
// `wordWrap` on each side leaves the effective wrapping column out of sync
|
|
1128
|
+
// (original keeps wrap off, modified honors wrap on -> asymmetric rendering).
|
|
1129
|
+
// Setting both overrides to the same value forces the effective wrap to match.
|
|
1130
|
+
diffEditor.updateOptions({
|
|
1131
|
+
renderSideBySide: showDiff,
|
|
1132
|
+
diffWordWrap: "inherit",
|
|
1133
|
+
hideUnchangedRegions: {
|
|
1134
|
+
enabled: showDiff && state.hideUnchanged,
|
|
1135
|
+
contextLineCount: 4,
|
|
1136
|
+
minimumLineCount: 2,
|
|
1137
|
+
revealLineCount: 12,
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
// For added-only files, hide the original editor's line numbers so the inline diff
|
|
1141
|
+
// gutter doesn't paint a redundant two-column layout alongside the modified numbers.
|
|
1142
|
+
diffEditor.getOriginalEditor().updateOptions({
|
|
1143
|
+
wordWrap: wrapMode,
|
|
1144
|
+
wordWrapOverride1: wrapMode,
|
|
1145
|
+
wordWrapOverride2: wrapMode,
|
|
1146
|
+
lineNumbers: addedOnly ? "off" : "on",
|
|
1147
|
+
});
|
|
1148
|
+
diffEditor.getModifiedEditor().updateOptions({
|
|
1149
|
+
wordWrap: wrapMode,
|
|
1150
|
+
wordWrapOverride1: wrapMode,
|
|
1151
|
+
wordWrapOverride2: wrapMode,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function renderTree() {
|
|
1156
|
+
ensureActiveFileForScope();
|
|
1157
|
+
fileTreeEl.innerHTML = "";
|
|
1158
|
+
const scopedFiles = getScopedFiles();
|
|
1159
|
+
const visibleFiles = getFilteredFiles();
|
|
1160
|
+
|
|
1161
|
+
if (visibleFiles.length === 0) {
|
|
1162
|
+
const message = state.fileFilter.trim()
|
|
1163
|
+
? `No files match <span class="text-review-text">${escapeHtml(state.fileFilter.trim())}</span>.`
|
|
1164
|
+
: `No files in <span class="text-review-text">${escapeHtml(scopeLabel(state.currentScope).toLowerCase())}</span>.`;
|
|
1165
|
+
fileTreeEl.innerHTML = `
|
|
1166
|
+
<div class="px-3 py-4 text-sm text-review-muted">
|
|
1167
|
+
${message}
|
|
1168
|
+
</div>
|
|
1169
|
+
`;
|
|
1170
|
+
} else if (state.fileFilter.trim()) {
|
|
1171
|
+
renderSearchResults(visibleFiles);
|
|
1172
|
+
} else {
|
|
1173
|
+
renderTreeNode(buildTree(visibleFiles), 0);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
sidebarTitleEl.textContent = scopeLabel(state.currentScope);
|
|
1177
|
+
const comments = state.comments.length;
|
|
1178
|
+
const filteredSuffix = state.fileFilter.trim() ? ` • ${visibleFiles.length} shown` : "";
|
|
1179
|
+
const liveSuffix = isSelectedWorkingTreeCommit() ? " • live working tree" : "";
|
|
1180
|
+
const staleSuffix = state.localChangesDetected ? " • local changes detected" : "";
|
|
1181
|
+
summaryEl.textContent = `${scopedFiles.length} file(s) • ${comments} comment(s)${state.overallComment ? " • overall note" : ""}${filteredSuffix}${liveSuffix}${staleSuffix}`;
|
|
1182
|
+
updateToggleButtons();
|
|
1183
|
+
updateSidebarLayout();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
let activePopover = null;
|
|
1187
|
+
|
|
1188
|
+
function closeActivePopover() {
|
|
1189
|
+
if (!activePopover) return;
|
|
1190
|
+
activePopover.dispose();
|
|
1191
|
+
activePopover = null;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function showTextPopover(trigger, options) {
|
|
1195
|
+
if (activePopover && activePopover.trigger === trigger) {
|
|
1196
|
+
closeActivePopover();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
closeActivePopover();
|
|
1200
|
+
|
|
1201
|
+
const popover = document.createElement("div");
|
|
1202
|
+
popover.className = "review-popover";
|
|
1203
|
+
popover.innerHTML = `
|
|
1204
|
+
<div class="mb-1 text-[13px] font-semibold text-review-text">${escapeHtml(options.title)}</div>
|
|
1205
|
+
<div class="mb-2 text-[11px] text-review-muted">${escapeHtml(options.description)}</div>
|
|
1206
|
+
<textarea id="review-popover-text" class="scrollbar-thin w-full resize-y rounded-md border border-review-border bg-[#010409] px-2.5 py-1.5 text-[13px] text-review-text outline-none focus:border-review-accent focus:ring-1 focus:ring-review-accent" placeholder="${escapeHtml(options.placeholder ?? "")}">${escapeHtml(options.initialValue ?? "")}</textarea>
|
|
1207
|
+
<div class="mt-2 flex items-center justify-between gap-2">
|
|
1208
|
+
<div class="text-[10px] text-review-subtle">↵ to save · Esc to close</div>
|
|
1209
|
+
<div class="flex gap-2">
|
|
1210
|
+
<button id="review-popover-cancel" class="cursor-pointer rounded-md border border-review-border bg-review-panel px-3 py-1 text-[12px] font-medium text-review-text hover:bg-review-panel-hover">Cancel</button>
|
|
1211
|
+
<button id="review-popover-save" class="cursor-pointer rounded-md border border-[rgba(240,246,252,0.1)] bg-review-success-emphasis px-3 py-1 text-[12px] font-medium text-white hover:bg-review-success">${escapeHtml(options.saveLabel ?? "Save")}</button>
|
|
1212
|
+
</div>
|
|
1213
|
+
</div>
|
|
1214
|
+
`;
|
|
1215
|
+
document.body.appendChild(popover);
|
|
1216
|
+
|
|
1217
|
+
// Position anchored to trigger button, preferring below-right alignment.
|
|
1218
|
+
const rect = trigger.getBoundingClientRect();
|
|
1219
|
+
const popRect = popover.getBoundingClientRect();
|
|
1220
|
+
const viewportW = document.documentElement.clientWidth;
|
|
1221
|
+
const margin = 8;
|
|
1222
|
+
const gap = 6;
|
|
1223
|
+
const top = rect.bottom + gap;
|
|
1224
|
+
let left = rect.right - popRect.width;
|
|
1225
|
+
if (left < margin) left = margin;
|
|
1226
|
+
if (left + popRect.width > viewportW - margin) left = viewportW - margin - popRect.width;
|
|
1227
|
+
popover.style.top = `${top}px`;
|
|
1228
|
+
popover.style.left = `${left}px`;
|
|
1229
|
+
popover.dataset.arrow = rect.right - left > popRect.width / 2 ? "right" : "left";
|
|
1230
|
+
|
|
1231
|
+
const textarea = popover.querySelector("#review-popover-text");
|
|
1232
|
+
const save = () => {
|
|
1233
|
+
const value = textarea.value.trim();
|
|
1234
|
+
options.onSave(value);
|
|
1235
|
+
closePopover();
|
|
1236
|
+
};
|
|
1237
|
+
const closePopover = () => {
|
|
1238
|
+
popover.remove();
|
|
1239
|
+
document.removeEventListener("mousedown", onOutside, true);
|
|
1240
|
+
document.removeEventListener("keydown", onKey, true);
|
|
1241
|
+
if (activePopover && activePopover.element === popover) activePopover = null;
|
|
1242
|
+
};
|
|
1243
|
+
const onOutside = (event) => {
|
|
1244
|
+
if (popover.contains(event.target) || trigger.contains(event.target)) return;
|
|
1245
|
+
closePopover();
|
|
1246
|
+
};
|
|
1247
|
+
const onKey = (event) => {
|
|
1248
|
+
if (event.key === "Escape") {
|
|
1249
|
+
event.preventDefault();
|
|
1250
|
+
closePopover();
|
|
1251
|
+
trigger.focus?.();
|
|
1252
|
+
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
1253
|
+
event.preventDefault();
|
|
1254
|
+
save();
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
popover.querySelector("#review-popover-cancel").addEventListener("click", closePopover);
|
|
1258
|
+
popover.querySelector("#review-popover-save").addEventListener("click", save);
|
|
1259
|
+
document.addEventListener("mousedown", onOutside, true);
|
|
1260
|
+
document.addEventListener("keydown", onKey, true);
|
|
1261
|
+
|
|
1262
|
+
activePopover = { trigger, element: popover, dispose: closePopover };
|
|
1263
|
+
requestAnimationFrame(() => textarea.focus());
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function showOverallCommentPopover() {
|
|
1267
|
+
showTextPopover(overallCommentButton, {
|
|
1268
|
+
title: "Overall review note",
|
|
1269
|
+
description: "Prepended to the generated prompt above inline comments.",
|
|
1270
|
+
initialValue: state.overallComment,
|
|
1271
|
+
placeholder: "High-level notes for this review...",
|
|
1272
|
+
saveLabel: "Save note",
|
|
1273
|
+
onSave: (value) => {
|
|
1274
|
+
state.overallComment = value;
|
|
1275
|
+
renderTree();
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function showFileCommentPopover() {
|
|
1281
|
+
const file = activeFile();
|
|
1282
|
+
if (!file) return;
|
|
1283
|
+
showTextPopover(fileCommentButton, {
|
|
1284
|
+
title: `File comment — ${getScopeDisplayPath(file, state.currentScope)}`,
|
|
1285
|
+
description: `Applies to the whole file in ${scopeLabel(state.currentScope).toLowerCase()}.`,
|
|
1286
|
+
initialValue: "",
|
|
1287
|
+
placeholder: "Comment on the whole file...",
|
|
1288
|
+
saveLabel: "Add comment",
|
|
1289
|
+
onSave: (value) => {
|
|
1290
|
+
if (!value) return;
|
|
1291
|
+
state.comments.push(
|
|
1292
|
+
annotateCommentWithCommit({
|
|
1293
|
+
id: `${Date.now()}:${Math.random().toString(16).slice(2)}`,
|
|
1294
|
+
fileId: file.id,
|
|
1295
|
+
scope: state.currentScope,
|
|
1296
|
+
side: "file",
|
|
1297
|
+
startLine: null,
|
|
1298
|
+
endLine: null,
|
|
1299
|
+
body: value,
|
|
1300
|
+
}),
|
|
1301
|
+
);
|
|
1302
|
+
submitButton.disabled = false;
|
|
1303
|
+
updateCommentsUI();
|
|
1304
|
+
},
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function layoutEditor() {
|
|
1309
|
+
const width = editorContainerEl.clientWidth;
|
|
1310
|
+
const height = editorContainerEl.clientHeight;
|
|
1311
|
+
if (width <= 0 || height <= 0) return;
|
|
1312
|
+
if (diffEditor) diffEditor.layout({ width, height });
|
|
1313
|
+
if (singleEditor) singleEditor.layout({ width, height });
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function clearViewZones() {
|
|
1317
|
+
if (activeViewZones.length === 0) return;
|
|
1318
|
+
const zonesByEditor = new Map();
|
|
1319
|
+
for (const zone of activeViewZones) {
|
|
1320
|
+
if (!zone.editor) continue;
|
|
1321
|
+
const zones = zonesByEditor.get(zone.editor) ?? [];
|
|
1322
|
+
zones.push(zone.id);
|
|
1323
|
+
zonesByEditor.set(zone.editor, zones);
|
|
1324
|
+
}
|
|
1325
|
+
for (const [editor, zoneIds] of zonesByEditor.entries()) {
|
|
1326
|
+
editor.changeViewZones((accessor) => {
|
|
1327
|
+
for (const id of zoneIds) accessor.removeZone(id);
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
activeViewZones = [];
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function renderCommentDOM(comment, onDelete) {
|
|
1334
|
+
const container = document.createElement("div");
|
|
1335
|
+
container.className = "view-zone-container";
|
|
1336
|
+
const title =
|
|
1337
|
+
comment.side === "file"
|
|
1338
|
+
? `File comment • ${scopeLabel(comment.scope)}`
|
|
1339
|
+
: `${comment.side === "original" ? "Original" : "Modified"} line ${comment.startLine} • ${scopeLabel(comment.scope)}`;
|
|
1340
|
+
|
|
1341
|
+
container.innerHTML = `
|
|
1342
|
+
<div class="mb-2 flex items-center justify-between gap-3">
|
|
1343
|
+
<div class="text-xs font-semibold text-review-text">${escapeHtml(title)}</div>
|
|
1344
|
+
<button data-action="delete" class="cursor-pointer rounded-md border border-transparent bg-transparent px-2 py-1 text-xs font-medium text-review-muted hover:bg-red-500/10 hover:text-red-400">Delete</button>
|
|
1345
|
+
</div>
|
|
1346
|
+
<textarea data-comment-id="${escapeHtml(comment.id)}" class="scrollbar-thin min-h-[76px] w-full resize-y rounded-md border border-review-border bg-[#010409] px-3 py-2 text-sm text-review-text outline-none focus:border-review-accent focus:ring-1 focus:ring-review-accent" placeholder="Leave a comment"></textarea>
|
|
1347
|
+
`;
|
|
1348
|
+
const textarea = container.querySelector("textarea");
|
|
1349
|
+
textarea.value = comment.body || "";
|
|
1350
|
+
textarea.addEventListener("input", () => {
|
|
1351
|
+
comment.body = textarea.value;
|
|
1352
|
+
});
|
|
1353
|
+
container.querySelector("[data-action='delete']").addEventListener("click", onDelete);
|
|
1354
|
+
if (!comment.body) setTimeout(() => textarea.focus(), 50);
|
|
1355
|
+
return container;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function canCommentOnSide(file, side) {
|
|
1359
|
+
if (!file || file.kind !== "text") return false;
|
|
1360
|
+
const comparison = activeComparison();
|
|
1361
|
+
if (side === "original") {
|
|
1362
|
+
return comparison?.hasOriginal ?? false;
|
|
1363
|
+
}
|
|
1364
|
+
return comparison != null ? comparison.hasModified : file.hasWorkingTreeFile;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function isActiveFileReady() {
|
|
1368
|
+
const file = activeFile();
|
|
1369
|
+
if (!file) return false;
|
|
1370
|
+
const requestState = getRequestState(file.id, state.currentScope);
|
|
1371
|
+
return requestState.contents != null && requestState.error == null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function syncViewZones() {
|
|
1375
|
+
clearViewZones();
|
|
1376
|
+
if (!isActiveFileReady()) return;
|
|
1377
|
+
const file = activeFile();
|
|
1378
|
+
if (!file) return;
|
|
1379
|
+
|
|
1380
|
+
const usingSingleEditor = activeFileUsesSingleEditor();
|
|
1381
|
+
const originalEditor = usingSingleEditor ? null : diffEditor?.getOriginalEditor?.();
|
|
1382
|
+
const modifiedEditor = usingSingleEditor ? singleEditor : diffEditor?.getModifiedEditor?.();
|
|
1383
|
+
if (!modifiedEditor || (!usingSingleEditor && !originalEditor)) return;
|
|
1384
|
+
|
|
1385
|
+
const inlineComments = state.comments.filter(
|
|
1386
|
+
(comment) => comment.fileId === file.id && comment.scope === state.currentScope && comment.side !== "file",
|
|
1387
|
+
);
|
|
1388
|
+
|
|
1389
|
+
inlineComments.forEach((item) => {
|
|
1390
|
+
const editor = item.side === "original" ? originalEditor : modifiedEditor;
|
|
1391
|
+
if (!editor) return;
|
|
1392
|
+
const domNode = renderCommentDOM(item, () => {
|
|
1393
|
+
state.comments = state.comments.filter((comment) => comment.id !== item.id);
|
|
1394
|
+
updateCommentsUI();
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
editor.changeViewZones((accessor) => {
|
|
1398
|
+
const lineCount = typeof item.body === "string" && item.body.length > 0 ? item.body.split("\n").length : 1;
|
|
1399
|
+
const id = accessor.addZone({
|
|
1400
|
+
afterLineNumber: item.startLine,
|
|
1401
|
+
heightInPx: Math.max(150, lineCount * 22 + 86),
|
|
1402
|
+
domNode,
|
|
1403
|
+
});
|
|
1404
|
+
activeViewZones.push({ id, editor });
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function updateDecorations() {
|
|
1410
|
+
if (!monacoApi) return;
|
|
1411
|
+
const file = activeFile();
|
|
1412
|
+
const comments = file
|
|
1413
|
+
? state.comments.filter(
|
|
1414
|
+
(comment) => comment.fileId === file.id && comment.scope === state.currentScope && comment.side !== "file",
|
|
1415
|
+
)
|
|
1416
|
+
: [];
|
|
1417
|
+
const originalRanges = [];
|
|
1418
|
+
const modifiedRanges = [];
|
|
1419
|
+
|
|
1420
|
+
for (const comment of comments) {
|
|
1421
|
+
const range = {
|
|
1422
|
+
range: new monacoApi.Range(comment.startLine, 1, comment.startLine, 1),
|
|
1423
|
+
options: {
|
|
1424
|
+
isWholeLine: true,
|
|
1425
|
+
className: comment.side === "original" ? "review-comment-line-original" : "review-comment-line-modified",
|
|
1426
|
+
glyphMarginClassName:
|
|
1427
|
+
comment.side === "original" ? "review-comment-glyph-original" : "review-comment-glyph-modified",
|
|
1428
|
+
},
|
|
1429
|
+
};
|
|
1430
|
+
if (comment.side === "original") originalRanges.push(range);
|
|
1431
|
+
else modifiedRanges.push(range);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
if (diffEditor) {
|
|
1435
|
+
originalDecorations = diffEditor.getOriginalEditor().deltaDecorations(originalDecorations, originalRanges);
|
|
1436
|
+
modifiedDecorations = diffEditor
|
|
1437
|
+
.getModifiedEditor()
|
|
1438
|
+
.deltaDecorations(modifiedDecorations, activeFileUsesSingleEditor() ? [] : modifiedRanges);
|
|
1439
|
+
}
|
|
1440
|
+
singleDecorations = singleEditor
|
|
1441
|
+
? singleEditor.deltaDecorations(singleDecorations, activeFileUsesSingleEditor() ? modifiedRanges : [])
|
|
1442
|
+
: [];
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function renderFileComments() {
|
|
1446
|
+
fileCommentsContainer.innerHTML = "";
|
|
1447
|
+
const file = activeFile();
|
|
1448
|
+
if (!file) {
|
|
1449
|
+
fileCommentsContainer.className = "hidden overflow-hidden px-0 py-0";
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const fileComments = state.comments.filter(
|
|
1454
|
+
(comment) => comment.fileId === file.id && comment.scope === state.currentScope && comment.side === "file",
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
if (fileComments.length === 0) {
|
|
1458
|
+
fileCommentsContainer.className = "hidden overflow-hidden px-0 py-0";
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
fileCommentsContainer.className = "border-b border-review-border bg-[#0d1117] px-4 py-4 space-y-4";
|
|
1463
|
+
fileComments.forEach((comment) => {
|
|
1464
|
+
const dom = renderCommentDOM(comment, () => {
|
|
1465
|
+
state.comments = state.comments.filter((item) => item.id !== comment.id);
|
|
1466
|
+
updateCommentsUI();
|
|
1467
|
+
});
|
|
1468
|
+
dom.className = "rounded-lg border border-review-border bg-review-panel p-4";
|
|
1469
|
+
fileCommentsContainer.appendChild(dom);
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function getPlaceholderContents(file, scope) {
|
|
1474
|
+
const path = getScopeDisplayPath(file, scope);
|
|
1475
|
+
const requestState = getRequestState(file.id, scope);
|
|
1476
|
+
if (requestState.error) {
|
|
1477
|
+
const body = `Failed to load ${path}\n\n${requestState.error}`;
|
|
1478
|
+
return {
|
|
1479
|
+
originalContent: body,
|
|
1480
|
+
modifiedContent: body,
|
|
1481
|
+
kind: file.kind,
|
|
1482
|
+
mimeType: file.mimeType,
|
|
1483
|
+
originalExists: false,
|
|
1484
|
+
modifiedExists: false,
|
|
1485
|
+
originalPreviewUrl: null,
|
|
1486
|
+
modifiedPreviewUrl: null,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
const body = `Loading ${path}...`;
|
|
1490
|
+
return {
|
|
1491
|
+
originalContent: body,
|
|
1492
|
+
modifiedContent: body,
|
|
1493
|
+
kind: file.kind,
|
|
1494
|
+
mimeType: file.mimeType,
|
|
1495
|
+
originalExists: false,
|
|
1496
|
+
modifiedExists: false,
|
|
1497
|
+
originalPreviewUrl: null,
|
|
1498
|
+
modifiedPreviewUrl: null,
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function getMountedContents(file, scope = state.currentScope) {
|
|
1503
|
+
return getRequestState(file.id, scope).contents || getPlaceholderContents(file, scope);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function renderFilePathLabel(path) {
|
|
1507
|
+
if (!path) {
|
|
1508
|
+
currentFileLabelEl.textContent = "";
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
const idx = path.lastIndexOf("/");
|
|
1512
|
+
if (idx < 0) {
|
|
1513
|
+
currentFileLabelEl.textContent = path;
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
const dir = path.slice(0, idx + 1);
|
|
1517
|
+
const base = path.slice(idx + 1);
|
|
1518
|
+
currentFileLabelEl.innerHTML = `<span class="gh-file-path-dir">${escapeHtml(dir)}</span>${escapeHtml(base)}`;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function updateFileHeaderMeta(file) {
|
|
1522
|
+
if (!file) {
|
|
1523
|
+
fileStatusBadgeEl.style.display = "none";
|
|
1524
|
+
fileDiffStatsEl.style.display = "none";
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const status = getActiveStatus(file);
|
|
1528
|
+
if (status) {
|
|
1529
|
+
fileStatusBadgeEl.style.display = "inline-flex";
|
|
1530
|
+
fileStatusBadgeEl.className = statusBadgeClass(status);
|
|
1531
|
+
fileStatusBadgeEl.textContent = statusCode(status);
|
|
1532
|
+
fileStatusBadgeEl.title = statusLabel(status);
|
|
1533
|
+
} else {
|
|
1534
|
+
fileStatusBadgeEl.style.display = "none";
|
|
1535
|
+
}
|
|
1536
|
+
if (file.kind !== "text") {
|
|
1537
|
+
fileDiffStatsEl.style.display = "none";
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const contents = getRequestState(file.id, state.currentScope).contents;
|
|
1541
|
+
if (!contents || !activeFileShowsDiff()) {
|
|
1542
|
+
fileDiffStatsEl.style.display = "none";
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const originalLines = contents.originalContent ? contents.originalContent.split("\n").length : 0;
|
|
1546
|
+
const modifiedLines = contents.modifiedContent ? contents.modifiedContent.split("\n").length : 0;
|
|
1547
|
+
// Cheap estimate — we don't compute a full diff; the counts are naive line
|
|
1548
|
+
// deltas, which is enough for the GitHub-style badge.
|
|
1549
|
+
const added = Math.max(0, modifiedLines - originalLines);
|
|
1550
|
+
const removed = Math.max(0, originalLines - modifiedLines);
|
|
1551
|
+
if (added === 0 && removed === 0) {
|
|
1552
|
+
fileDiffStatsEl.style.display = "none";
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
fileDiffStatsEl.style.display = "inline-flex";
|
|
1556
|
+
fileDiffStatsEl.innerHTML = `${added > 0 ? `<span class="gh-diff-stats-plus">+${added}</span>` : ""}${removed > 0 ? `<span class="gh-diff-stats-minus">−${removed}</span>` : ""}`;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Hide the diff editor while Monaco is computing the diff, then reveal once
|
|
1560
|
+
// `onDidUpdateDiff` fires AND Monaco has painted the folded layout. Monaco paints
|
|
1561
|
+
// models asynchronously relative to diff computation — before the diff is ready
|
|
1562
|
+
// `hideUnchangedRegions` has nothing to fold, so the first frame always shows the
|
|
1563
|
+
// full file and only collapses on the next tick. That's the layout shift.
|
|
1564
|
+
function cancelPendingReveal() {
|
|
1565
|
+
if (!pendingDiffReveal) return;
|
|
1566
|
+
try {
|
|
1567
|
+
pendingDiffReveal.dispose?.dispose();
|
|
1568
|
+
} catch {
|
|
1569
|
+
// Ignore stale Monaco disposables during remounts.
|
|
1570
|
+
}
|
|
1571
|
+
clearTimeout(pendingDiffReveal.timeoutId);
|
|
1572
|
+
pendingDiffReveal = null;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function hideEditorForMount() {
|
|
1576
|
+
// Paint an opaque cover OVER Monaco (not on the container itself). This keeps
|
|
1577
|
+
// Monaco fully visible to the browser's layout engine so it renders, computes
|
|
1578
|
+
// its diff, inserts hide-unchanged view zones and paints the final folded
|
|
1579
|
+
// geometry at normal speed. Hiding the container via opacity:0 can make WebKit
|
|
1580
|
+
// skip paints/compositing for Monaco's nested absolute children, which in turn
|
|
1581
|
+
// makes `onDidUpdateDiff` + RAF reveal land while the fold is still settling.
|
|
1582
|
+
if (editorCoverEl) editorCoverEl.dataset.active = "true";
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function armDiffRevealListener(onSettled) {
|
|
1586
|
+
// Attach listener BEFORE setModel to avoid missing a synchronously-fired
|
|
1587
|
+
// onDidUpdateDiff event (Monaco can compute diffs synchronously for small files).
|
|
1588
|
+
cancelPendingReveal();
|
|
1589
|
+
if (!diffEditor || typeof diffEditor.onDidUpdateDiff !== "function") {
|
|
1590
|
+
pendingDiffReveal = { dispose: null, timeoutId: setTimeout(onSettled, 0) };
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
let fired = false;
|
|
1594
|
+
const settleOnce = () => {
|
|
1595
|
+
if (fired) return;
|
|
1596
|
+
fired = true;
|
|
1597
|
+
onSettled();
|
|
1598
|
+
};
|
|
1599
|
+
const dispose = diffEditor.onDidUpdateDiff(() => {
|
|
1600
|
+
// onDidUpdateDiff only signals that diff DATA is ready. Monaco still needs
|
|
1601
|
+
// follow-up frames to insert hide-unchanged view zones and repaint the
|
|
1602
|
+
// folded geometry. Wait RAF → force layout → RAF → small setTimeout so the
|
|
1603
|
+
// paint pipeline is guaranteed to have flushed before we drop the cover.
|
|
1604
|
+
requestAnimationFrame(() => {
|
|
1605
|
+
try {
|
|
1606
|
+
layoutEditor();
|
|
1607
|
+
} catch {
|
|
1608
|
+
// Ignore transient layout failures while Monaco settles.
|
|
1609
|
+
}
|
|
1610
|
+
requestAnimationFrame(() => {
|
|
1611
|
+
setTimeout(settleOnce, 40);
|
|
1612
|
+
});
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
// Safety net: force-reveal after 600ms so the UI never stays hidden on edge
|
|
1616
|
+
// cases where onDidUpdateDiff doesn't fire (identical models, Monaco quirks).
|
|
1617
|
+
const timeoutId = setTimeout(settleOnce, 600);
|
|
1618
|
+
pendingDiffReveal = { dispose: { dispose: () => dispose.dispose() }, timeoutId };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function revealEditorNow() {
|
|
1622
|
+
if (editorCoverEl) editorCoverEl.dataset.active = "false";
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function mountFile(options = {}) {
|
|
1626
|
+
if (!diffEditor || !singleEditor || !monacoApi) return;
|
|
1627
|
+
const file = activeFile();
|
|
1628
|
+
if (!file) {
|
|
1629
|
+
hideBinaryPreview();
|
|
1630
|
+
setTextEditorMode("diff");
|
|
1631
|
+
currentFileLabelEl.textContent = "No file selected";
|
|
1632
|
+
updateFileHeaderMeta(null);
|
|
1633
|
+
clearViewZones();
|
|
1634
|
+
if (singleModel) {
|
|
1635
|
+
singleEditor.setModel(null);
|
|
1636
|
+
singleModel.dispose();
|
|
1637
|
+
singleModel = null;
|
|
1638
|
+
}
|
|
1639
|
+
if (originalModel) originalModel.dispose();
|
|
1640
|
+
if (modifiedModel) modifiedModel.dispose();
|
|
1641
|
+
originalModel = null;
|
|
1642
|
+
modifiedModel = null;
|
|
1643
|
+
originalModel = monacoApi.editor.createModel("", "plaintext");
|
|
1644
|
+
modifiedModel = monacoApi.editor.createModel("", "plaintext");
|
|
1645
|
+
// Apply options BEFORE setModel so Monaco renders the first frame with the
|
|
1646
|
+
// correct hideUnchangedRegions / renderSideBySide / added-only state.
|
|
1647
|
+
applyEditorOptions();
|
|
1648
|
+
hideEditorForMount();
|
|
1649
|
+
armDiffRevealListener(() => {
|
|
1650
|
+
cancelPendingReveal();
|
|
1651
|
+
revealEditorNow();
|
|
1652
|
+
});
|
|
1653
|
+
diffEditor.setModel({ original: originalModel, modified: modifiedModel });
|
|
1654
|
+
updateDecorations();
|
|
1655
|
+
renderFileComments();
|
|
1656
|
+
requestAnimationFrame(layoutEditor);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
ensureFileLoaded(file.id, state.currentScope);
|
|
1661
|
+
|
|
1662
|
+
const preserveScroll = options.preserveScroll === true;
|
|
1663
|
+
const scrollState = preserveScroll ? captureScrollState() : null;
|
|
1664
|
+
const language = inferLanguage(getScopeFilePath(file) || file.path);
|
|
1665
|
+
const contents = getMountedContents(file, state.currentScope);
|
|
1666
|
+
|
|
1667
|
+
clearViewZones();
|
|
1668
|
+
renderFilePathLabel(getScopeDisplayPath(file, state.currentScope));
|
|
1669
|
+
updateFileHeaderMeta(file);
|
|
1670
|
+
|
|
1671
|
+
if (file.kind !== "text") {
|
|
1672
|
+
cancelPendingReveal();
|
|
1673
|
+
revealEditorNow();
|
|
1674
|
+
renderBinaryPreview(file, contents);
|
|
1675
|
+
renderFileComments();
|
|
1676
|
+
requestAnimationFrame(layoutEditor);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
hideBinaryPreview();
|
|
1681
|
+
if (originalModel) originalModel.dispose();
|
|
1682
|
+
if (modifiedModel) modifiedModel.dispose();
|
|
1683
|
+
originalModel = null;
|
|
1684
|
+
modifiedModel = null;
|
|
1685
|
+
|
|
1686
|
+
if (activeFileUsesSingleEditor()) {
|
|
1687
|
+
cancelPendingReveal();
|
|
1688
|
+
setTextEditorMode("single");
|
|
1689
|
+
if (singleModel) {
|
|
1690
|
+
singleEditor.setModel(null);
|
|
1691
|
+
singleModel.dispose();
|
|
1692
|
+
}
|
|
1693
|
+
singleModel = monacoApi.editor.createModel(contents.modifiedContent, language);
|
|
1694
|
+
singleEditor.setModel(singleModel);
|
|
1695
|
+
applyEditorOptions();
|
|
1696
|
+
hideEditorForMount();
|
|
1697
|
+
syncViewZones();
|
|
1698
|
+
updateDecorations();
|
|
1699
|
+
renderFileComments();
|
|
1700
|
+
requestAnimationFrame(() => {
|
|
1701
|
+
layoutEditor();
|
|
1702
|
+
requestAnimationFrame(() => {
|
|
1703
|
+
layoutEditor();
|
|
1704
|
+
revealEditorNow();
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
setTextEditorMode("diff");
|
|
1711
|
+
if (singleModel) {
|
|
1712
|
+
singleEditor.setModel(null);
|
|
1713
|
+
singleModel.dispose();
|
|
1714
|
+
singleModel = null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
originalModel = monacoApi.editor.createModel(contents.originalContent, language);
|
|
1718
|
+
modifiedModel = monacoApi.editor.createModel(contents.modifiedContent, language);
|
|
1719
|
+
|
|
1720
|
+
// Apply options BEFORE setModel. Otherwise Monaco first renders the new diff
|
|
1721
|
+
// using the PREVIOUS file's options (e.g. hideUnchangedRegions off because the
|
|
1722
|
+
// last file was added-only, or a stale renderSideBySide value) and only collapses
|
|
1723
|
+
// once updateOptions runs a frame later — causing a visible layout shift.
|
|
1724
|
+
applyEditorOptions();
|
|
1725
|
+
hideEditorForMount();
|
|
1726
|
+
// Arm the reveal listener BEFORE setModel so a synchronously-fired
|
|
1727
|
+
// onDidUpdateDiff (possible for small files) can't be missed.
|
|
1728
|
+
armDiffRevealListener(() => {
|
|
1729
|
+
cancelPendingReveal();
|
|
1730
|
+
revealEditorNow();
|
|
1731
|
+
});
|
|
1732
|
+
diffEditor.setModel({ original: originalModel, modified: modifiedModel });
|
|
1733
|
+
// Re-apply editor options AFTER setModel: Monaco can derive original-side
|
|
1734
|
+
// wrap overrides (`wordWrapOverride1`) from the swapped-in model, leaving the
|
|
1735
|
+
// pre-setModel `applyEditorOptions()` call partially overridden. A second
|
|
1736
|
+
// call ensures both sides observe the user's wrap toggle symmetrically.
|
|
1737
|
+
applyEditorOptions();
|
|
1738
|
+
syncViewZones();
|
|
1739
|
+
updateDecorations();
|
|
1740
|
+
renderFileComments();
|
|
1741
|
+
requestAnimationFrame(() => {
|
|
1742
|
+
layoutEditor();
|
|
1743
|
+
if (options.restoreFileScroll) restoreFileScrollPosition();
|
|
1744
|
+
if (options.preserveScroll) restoreScrollState(scrollState);
|
|
1745
|
+
setTimeout(() => {
|
|
1746
|
+
layoutEditor();
|
|
1747
|
+
if (options.restoreFileScroll) restoreFileScrollPosition();
|
|
1748
|
+
if (options.preserveScroll) restoreScrollState(scrollState);
|
|
1749
|
+
}, 50);
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function syncCommentBodiesFromDOM() {
|
|
1754
|
+
const textareas = document.querySelectorAll("textarea[data-comment-id]");
|
|
1755
|
+
textareas.forEach((textarea) => {
|
|
1756
|
+
const commentId = textarea.getAttribute("data-comment-id");
|
|
1757
|
+
const comment = state.comments.find((item) => item.id === commentId);
|
|
1758
|
+
if (comment) comment.body = textarea.value;
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function buildSubmitPayload() {
|
|
1763
|
+
syncCommentBodiesFromDOM();
|
|
1764
|
+
return {
|
|
1765
|
+
type: "submit",
|
|
1766
|
+
overallComment: state.overallComment.trim(),
|
|
1767
|
+
comments: state.comments
|
|
1768
|
+
.map((comment) => ({ ...comment, body: comment.body.trim() }))
|
|
1769
|
+
.filter((comment) => comment.body.length > 0),
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function hasSubmitPayloadContent(payload) {
|
|
1774
|
+
return payload.overallComment.length > 0 || payload.comments.length > 0;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function submitDraftOnClose() {
|
|
1778
|
+
if (suppressAutoSubmitOnClose) return;
|
|
1779
|
+
const payload = buildSubmitPayload();
|
|
1780
|
+
if (!hasSubmitPayloadContent(payload)) return;
|
|
1781
|
+
suppressAutoSubmitOnClose = true;
|
|
1782
|
+
window.glimpse?.send?.(payload);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function updateCommentsUI() {
|
|
1786
|
+
renderTree();
|
|
1787
|
+
syncViewZones();
|
|
1788
|
+
updateDecorations();
|
|
1789
|
+
renderFileComments();
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function renderAll(options = {}) {
|
|
1793
|
+
renderTree();
|
|
1794
|
+
renderCommitList();
|
|
1795
|
+
submitButton.disabled = state.assetInitFailed;
|
|
1796
|
+
if (state.assetInitFailed) {
|
|
1797
|
+
renderFileComments();
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
if (diffEditor && singleEditor && monacoApi) {
|
|
1801
|
+
mountFile(options);
|
|
1802
|
+
requestAnimationFrame(() => {
|
|
1803
|
+
layoutEditor();
|
|
1804
|
+
setTimeout(layoutEditor, 50);
|
|
1805
|
+
});
|
|
1806
|
+
} else {
|
|
1807
|
+
renderFileComments();
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function getFocusedTextEditor() {
|
|
1812
|
+
const editors = [singleEditor, diffEditor?.getModifiedEditor?.(), diffEditor?.getOriginalEditor?.()].filter(Boolean);
|
|
1813
|
+
return editors.find((editor) => editor?.hasTextFocus?.() || editor?.hasWidgetFocus?.()) ?? null;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function getEditorSelectionText(editor) {
|
|
1817
|
+
const model = editor?.getModel?.();
|
|
1818
|
+
const selections = editor?.getSelections?.() ?? [];
|
|
1819
|
+
if (!model || selections.length === 0) return "";
|
|
1820
|
+
return selections
|
|
1821
|
+
.filter((selection) => !selection.isEmpty())
|
|
1822
|
+
.map((selection) => model.getValueInRange(selection))
|
|
1823
|
+
.join("\n");
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
window.__reviewClipboard = {
|
|
1827
|
+
getSelectedText() {
|
|
1828
|
+
const editor = getFocusedTextEditor();
|
|
1829
|
+
return editor ? getEditorSelectionText(editor) : "";
|
|
1830
|
+
},
|
|
1831
|
+
selectAll() {
|
|
1832
|
+
const editor = getFocusedTextEditor();
|
|
1833
|
+
const model = editor?.getModel?.();
|
|
1834
|
+
if (!editor || !model) return false;
|
|
1835
|
+
editor.setSelection(model.getFullModelRange());
|
|
1836
|
+
return true;
|
|
1837
|
+
},
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
function createGlyphHoverActions(editor, side) {
|
|
1841
|
+
let hoverDecoration = [];
|
|
1842
|
+
|
|
1843
|
+
function openDraftAtLine(line) {
|
|
1844
|
+
const file = activeFile();
|
|
1845
|
+
if (!file || !canCommentOnSide(file, side) || !isActiveFileReady()) return;
|
|
1846
|
+
state.comments.push(
|
|
1847
|
+
annotateCommentWithCommit({
|
|
1848
|
+
id: `${Date.now()}:${Math.random().toString(16).slice(2)}`,
|
|
1849
|
+
fileId: file.id,
|
|
1850
|
+
scope: state.currentScope,
|
|
1851
|
+
side,
|
|
1852
|
+
startLine: line,
|
|
1853
|
+
endLine: line,
|
|
1854
|
+
body: "",
|
|
1855
|
+
}),
|
|
1856
|
+
);
|
|
1857
|
+
updateCommentsUI();
|
|
1858
|
+
editor.revealLineInCenter(line);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
editor.onMouseMove((event) => {
|
|
1862
|
+
const file = activeFile();
|
|
1863
|
+
if (!file || !canCommentOnSide(file, side) || !isActiveFileReady()) {
|
|
1864
|
+
hoverDecoration = editor.deltaDecorations(hoverDecoration, []);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const target = event.target;
|
|
1869
|
+
if (
|
|
1870
|
+
target.type === monacoApi.editor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
|
|
1871
|
+
target.type === monacoApi.editor.MouseTargetType.GUTTER_LINE_NUMBERS
|
|
1872
|
+
) {
|
|
1873
|
+
const line = target.position?.lineNumber;
|
|
1874
|
+
if (!line) return;
|
|
1875
|
+
hoverDecoration = editor.deltaDecorations(hoverDecoration, [
|
|
1876
|
+
{
|
|
1877
|
+
range: new monacoApi.Range(line, 1, line, 1),
|
|
1878
|
+
options: { glyphMarginClassName: "review-glyph-plus" },
|
|
1879
|
+
},
|
|
1880
|
+
]);
|
|
1881
|
+
} else {
|
|
1882
|
+
hoverDecoration = editor.deltaDecorations(hoverDecoration, []);
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
editor.onMouseLeave(() => {
|
|
1887
|
+
hoverDecoration = editor.deltaDecorations(hoverDecoration, []);
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
editor.onMouseDown((event) => {
|
|
1891
|
+
const file = activeFile();
|
|
1892
|
+
if (!file || !canCommentOnSide(file, side) || !isActiveFileReady()) return;
|
|
1893
|
+
|
|
1894
|
+
const target = event.target;
|
|
1895
|
+
if (
|
|
1896
|
+
target.type === monacoApi.editor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
|
|
1897
|
+
target.type === monacoApi.editor.MouseTargetType.GUTTER_LINE_NUMBERS
|
|
1898
|
+
) {
|
|
1899
|
+
const line = target.position?.lineNumber;
|
|
1900
|
+
if (!line) return;
|
|
1901
|
+
openDraftAtLine(line);
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
window.__reviewReceive = (message) => {
|
|
1907
|
+
if (!message || typeof message !== "object") return;
|
|
1908
|
+
|
|
1909
|
+
if (message.type === "clipboard-data") {
|
|
1910
|
+
window.__reviewReceiveClipboardData?.(message);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (message.type === "working-tree-changed") {
|
|
1915
|
+
const observedAt = Date.now();
|
|
1916
|
+
state.localChangesDetected = true;
|
|
1917
|
+
state.lastLocalChangeDetectedAt = message.changedAt ?? observedAt;
|
|
1918
|
+
state.lastLocalChangeObservedAt = observedAt;
|
|
1919
|
+
renderTree();
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (message.type === "review-data") {
|
|
1924
|
+
if (state.reviewDataRequestId !== message.requestId) return;
|
|
1925
|
+
clearRefreshableFileState();
|
|
1926
|
+
clearWorkingTreeCommitState();
|
|
1927
|
+
reviewData.files = Array.isArray(message.files) ? message.files : [];
|
|
1928
|
+
reviewData.commits = Array.isArray(message.commits) ? message.commits : [];
|
|
1929
|
+
reviewData.branchBaseRef = message.branchBaseRef ?? null;
|
|
1930
|
+
reviewData.branchMergeBaseSha = message.branchMergeBaseSha ?? null;
|
|
1931
|
+
reviewData.repositoryHasHead = message.repositoryHasHead === true;
|
|
1932
|
+
const requestStartedAt = state.reviewDataRequestStartedAt;
|
|
1933
|
+
state.reviewDataRequestId = null;
|
|
1934
|
+
state.reviewDataRequestStartedAt = null;
|
|
1935
|
+
state.lastWorkingTreeLoadAt = null;
|
|
1936
|
+
const localChangeAfterRequest =
|
|
1937
|
+
requestStartedAt == null ||
|
|
1938
|
+
(state.lastLocalChangeObservedAt != null && state.lastLocalChangeObservedAt > requestStartedAt);
|
|
1939
|
+
if (!localChangeAfterRequest) {
|
|
1940
|
+
state.localChangesDetected = false;
|
|
1941
|
+
state.lastLocalChangeDetectedAt = null;
|
|
1942
|
+
state.lastLocalChangeObservedAt = null;
|
|
1943
|
+
}
|
|
1944
|
+
if (!reviewData.commits.some((commit) => commit.sha === state.selectedCommitSha)) {
|
|
1945
|
+
state.selectedCommitSha = reviewData.commits[0]?.sha ?? null;
|
|
1946
|
+
}
|
|
1947
|
+
renderCommitList();
|
|
1948
|
+
renderAll({ restoreFileScroll: false });
|
|
1949
|
+
if (state.currentScope === "commits" && state.selectedCommitSha) {
|
|
1950
|
+
ensureCommitFilesLoaded(state.selectedCommitSha, {
|
|
1951
|
+
forceRefresh: isWorkingTreeCommit(state.selectedCommitSha),
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
if (message.type === "review-data-error") {
|
|
1958
|
+
if (state.reviewDataRequestId !== message.requestId) return;
|
|
1959
|
+
state.reviewDataRequestId = null;
|
|
1960
|
+
state.reviewDataRequestStartedAt = null;
|
|
1961
|
+
updateReviewRefreshButton();
|
|
1962
|
+
alert(`Failed to refresh review data: ${message.message || "Unknown error"}`);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
if (message.type === "commit-data") {
|
|
1967
|
+
if (state.commitRequestIds[message.sha] !== message.requestId) return;
|
|
1968
|
+
state.commitFilesBySha[message.sha] = Array.isArray(message.files) ? message.files : [];
|
|
1969
|
+
delete state.commitErrors[message.sha];
|
|
1970
|
+
delete state.commitRequestIds[message.sha];
|
|
1971
|
+
if (isWorkingTreeCommit(message.sha)) {
|
|
1972
|
+
state.lastWorkingTreeLoadAt = Date.now();
|
|
1973
|
+
}
|
|
1974
|
+
renderCommitList();
|
|
1975
|
+
if (state.currentScope === "commits" && state.selectedCommitSha === message.sha) {
|
|
1976
|
+
ensureActiveFileForScope();
|
|
1977
|
+
renderAll({ restoreFileScroll: false });
|
|
1978
|
+
const file = activeFile();
|
|
1979
|
+
if (file) {
|
|
1980
|
+
ensureFileLoaded(file.id, state.currentScope, message.sha, {
|
|
1981
|
+
forceRefresh: isWorkingTreeCommit(message.sha),
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (message.type === "commit-error") {
|
|
1989
|
+
if (state.commitRequestIds[message.sha] !== message.requestId) return;
|
|
1990
|
+
state.commitErrors[message.sha] = message.message || "Unknown error";
|
|
1991
|
+
delete state.commitRequestIds[message.sha];
|
|
1992
|
+
renderCommitList();
|
|
1993
|
+
updateReviewRefreshButton();
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const key = cacheKey(message.scope, message.fileId, message.commitSha ?? null);
|
|
1998
|
+
|
|
1999
|
+
if (message.type === "file-data") {
|
|
2000
|
+
if (state.pendingRequestIds[key] !== message.requestId) return;
|
|
2001
|
+
state.fileContents[key] = {
|
|
2002
|
+
originalContent: message.originalContent,
|
|
2003
|
+
modifiedContent: message.modifiedContent,
|
|
2004
|
+
kind: message.kind,
|
|
2005
|
+
mimeType: message.mimeType,
|
|
2006
|
+
originalExists: message.originalExists,
|
|
2007
|
+
modifiedExists: message.modifiedExists,
|
|
2008
|
+
originalPreviewUrl: message.originalPreviewUrl,
|
|
2009
|
+
modifiedPreviewUrl: message.modifiedPreviewUrl,
|
|
2010
|
+
};
|
|
2011
|
+
delete state.fileErrors[key];
|
|
2012
|
+
delete state.pendingRequestIds[key];
|
|
2013
|
+
renderTree();
|
|
2014
|
+
if (state.activeFileId === message.fileId && state.currentScope === message.scope) {
|
|
2015
|
+
mountFile({ restoreFileScroll: true });
|
|
2016
|
+
}
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
if (message.type === "file-error") {
|
|
2021
|
+
if (state.pendingRequestIds[key] !== message.requestId) return;
|
|
2022
|
+
state.fileErrors[key] = message.message || "Unknown error";
|
|
2023
|
+
delete state.pendingRequestIds[key];
|
|
2024
|
+
renderTree();
|
|
2025
|
+
if (state.activeFileId === message.fileId && state.currentScope === message.scope) {
|
|
2026
|
+
mountFile({ preserveScroll: false });
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
function resolveMonacoWorkerPath(vsBaseUrl, label) {
|
|
2032
|
+
switch (label) {
|
|
2033
|
+
case "json":
|
|
2034
|
+
return `${vsBaseUrl}/language/json/jsonWorker.js`;
|
|
2035
|
+
case "css":
|
|
2036
|
+
case "scss":
|
|
2037
|
+
case "less":
|
|
2038
|
+
return `${vsBaseUrl}/language/css/cssWorker.js`;
|
|
2039
|
+
case "html":
|
|
2040
|
+
case "handlebars":
|
|
2041
|
+
case "razor":
|
|
2042
|
+
return `${vsBaseUrl}/language/html/htmlWorker.js`;
|
|
2043
|
+
case "typescript":
|
|
2044
|
+
case "javascript":
|
|
2045
|
+
return `${vsBaseUrl}/language/typescript/tsWorker.js`;
|
|
2046
|
+
default:
|
|
2047
|
+
return `${vsBaseUrl}/base/worker/workerMain.js`;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function setupMonaco() {
|
|
2052
|
+
if (reviewAssetConfig.bootstrapError) {
|
|
2053
|
+
showAssetFailure(
|
|
2054
|
+
"The review window could not finish loading.",
|
|
2055
|
+
"TLH could not load its packaged review assets. Close this window and rerun /annotate-git-diff after reinstalling or updating TLH.",
|
|
2056
|
+
reviewAssetConfig.bootstrapError,
|
|
2057
|
+
);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const vsBaseUrl = typeof reviewAssetConfig.monacoVsBaseUrl === "string" ? reviewAssetConfig.monacoVsBaseUrl : "";
|
|
2062
|
+
if (!vsBaseUrl) {
|
|
2063
|
+
showAssetFailure(
|
|
2064
|
+
"The review window could not finish loading.",
|
|
2065
|
+
"TLH could not locate its packaged Monaco assets. Close this window and rerun /annotate-git-diff after reinstalling or updating TLH.",
|
|
2066
|
+
);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
if (!window.require || typeof window.require.config !== "function") {
|
|
2071
|
+
showAssetFailure(
|
|
2072
|
+
"The review window could not finish loading.",
|
|
2073
|
+
"TLH could not initialize the packaged Monaco loader. Close this window and rerun /annotate-git-diff.",
|
|
2074
|
+
);
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const workerBaseUrl = vsBaseUrl.endsWith("/") ? vsBaseUrl : `${vsBaseUrl}/`;
|
|
2079
|
+
const workerUrls = new Map();
|
|
2080
|
+
window.MonacoEnvironment = {
|
|
2081
|
+
baseUrl: workerBaseUrl,
|
|
2082
|
+
getWorkerUrl(_moduleId, label) {
|
|
2083
|
+
const cacheKey = label || "default";
|
|
2084
|
+
if (!workerUrls.has(cacheKey)) {
|
|
2085
|
+
const workerSource = [
|
|
2086
|
+
`self.MonacoEnvironment = { baseUrl: ${JSON.stringify(workerBaseUrl)} };`,
|
|
2087
|
+
`importScripts(${JSON.stringify(resolveMonacoWorkerPath(vsBaseUrl, label))});`,
|
|
2088
|
+
].join("\n");
|
|
2089
|
+
workerUrls.set(
|
|
2090
|
+
cacheKey,
|
|
2091
|
+
URL.createObjectURL(new Blob([workerSource], { type: "text/javascript" })),
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
return workerUrls.get(cacheKey);
|
|
2095
|
+
},
|
|
2096
|
+
};
|
|
2097
|
+
window.addEventListener(
|
|
2098
|
+
"unload",
|
|
2099
|
+
() => {
|
|
2100
|
+
for (const workerUrl of workerUrls.values()) {
|
|
2101
|
+
URL.revokeObjectURL(workerUrl);
|
|
2102
|
+
}
|
|
2103
|
+
workerUrls.clear();
|
|
2104
|
+
},
|
|
2105
|
+
{ once: true },
|
|
2106
|
+
);
|
|
2107
|
+
|
|
2108
|
+
let settled = false;
|
|
2109
|
+
const settleFailure = (message, error) => {
|
|
2110
|
+
if (settled) return;
|
|
2111
|
+
settled = true;
|
|
2112
|
+
showAssetFailure(
|
|
2113
|
+
"The review window could not finish loading.",
|
|
2114
|
+
message,
|
|
2115
|
+
formatAssetErrorDetail(error),
|
|
2116
|
+
);
|
|
2117
|
+
updateToggleButtons();
|
|
2118
|
+
};
|
|
2119
|
+
const settleSuccess = () => {
|
|
2120
|
+
if (settled) return false;
|
|
2121
|
+
settled = true;
|
|
2122
|
+
clearTimeout(loadTimeoutId);
|
|
2123
|
+
clearAssetFailure();
|
|
2124
|
+
return true;
|
|
2125
|
+
};
|
|
2126
|
+
const loadTimeoutId = setTimeout(() => {
|
|
2127
|
+
settleFailure(
|
|
2128
|
+
"TLH timed out while loading the packaged Monaco editor. Close this window and rerun /annotate-git-diff.",
|
|
2129
|
+
"Timed out waiting for Monaco to finish loading.",
|
|
2130
|
+
);
|
|
2131
|
+
}, 10000);
|
|
2132
|
+
|
|
2133
|
+
try {
|
|
2134
|
+
window.require.config({
|
|
2135
|
+
paths: {
|
|
2136
|
+
vs: vsBaseUrl,
|
|
2137
|
+
},
|
|
2138
|
+
});
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
clearTimeout(loadTimeoutId);
|
|
2141
|
+
settleFailure(
|
|
2142
|
+
"TLH could not configure the packaged Monaco editor. Close this window and rerun /annotate-git-diff.",
|
|
2143
|
+
error,
|
|
2144
|
+
);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
window.require(
|
|
2149
|
+
["vs/editor/editor.main"],
|
|
2150
|
+
() => {
|
|
2151
|
+
if (!settleSuccess()) return;
|
|
2152
|
+
monacoApi = window.monaco;
|
|
2153
|
+
|
|
2154
|
+
// GitHub-style diff colors.
|
|
2155
|
+
monacoApi.editor.defineTheme("review-dark", {
|
|
2156
|
+
base: "vs-dark",
|
|
2157
|
+
inherit: true,
|
|
2158
|
+
rules: [],
|
|
2159
|
+
colors: {
|
|
2160
|
+
"editor.background": "#0d1117",
|
|
2161
|
+
"editor.foreground": "#f0f6fc",
|
|
2162
|
+
"editorLineNumber.foreground": "#6e7681",
|
|
2163
|
+
"editorLineNumber.activeForeground": "#c9d1d9",
|
|
2164
|
+
"editorGutter.background": "#0d1117",
|
|
2165
|
+
"diffEditor.insertedLineBackground": "#1c7c3c33",
|
|
2166
|
+
"diffEditor.insertedTextBackground": "#2ea04366",
|
|
2167
|
+
"diffEditor.removedLineBackground": "#b62a2233",
|
|
2168
|
+
"diffEditor.removedTextBackground": "#f8514966",
|
|
2169
|
+
"diffEditor.border": "#30363d",
|
|
2170
|
+
"editorOverviewRuler.border": "#30363d",
|
|
2171
|
+
},
|
|
2172
|
+
});
|
|
2173
|
+
monacoApi.editor.setTheme("review-dark");
|
|
2174
|
+
|
|
2175
|
+
diffEditor = monacoApi.editor.createDiffEditor(diffEditorHostEl, {
|
|
2176
|
+
automaticLayout: true,
|
|
2177
|
+
renderSideBySide: activeFileShowsDiff(),
|
|
2178
|
+
readOnly: true,
|
|
2179
|
+
originalEditable: false,
|
|
2180
|
+
// GitHub-style review: no minimap and no diff overview ruler on the side.
|
|
2181
|
+
// Those were the panels that visibly flashed during remount because Monaco
|
|
2182
|
+
// repaints them as hide-unchanged view zones settle.
|
|
2183
|
+
minimap: { enabled: false },
|
|
2184
|
+
renderOverviewRuler: false,
|
|
2185
|
+
overviewRulerLanes: 0,
|
|
2186
|
+
diffWordWrap: "on",
|
|
2187
|
+
scrollBeyondLastLine: false,
|
|
2188
|
+
lineNumbersMinChars: 4,
|
|
2189
|
+
glyphMargin: true,
|
|
2190
|
+
folding: true,
|
|
2191
|
+
lineDecorationsWidth: 10,
|
|
2192
|
+
overviewRulerBorder: false,
|
|
2193
|
+
wordWrap: "on",
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
singleEditor = monacoApi.editor.create(singleEditorHostEl, {
|
|
2197
|
+
automaticLayout: true,
|
|
2198
|
+
readOnly: true,
|
|
2199
|
+
minimap: { enabled: false },
|
|
2200
|
+
renderOverviewRuler: false,
|
|
2201
|
+
overviewRulerLanes: 0,
|
|
2202
|
+
scrollBeyondLastLine: false,
|
|
2203
|
+
lineNumbersMinChars: 4,
|
|
2204
|
+
glyphMargin: true,
|
|
2205
|
+
folding: true,
|
|
2206
|
+
lineDecorationsWidth: 10,
|
|
2207
|
+
overviewRulerBorder: false,
|
|
2208
|
+
wordWrap: state.wrapLines ? "on" : "off",
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
createGlyphHoverActions(diffEditor.getOriginalEditor(), "original");
|
|
2212
|
+
createGlyphHoverActions(diffEditor.getModifiedEditor(), "modified");
|
|
2213
|
+
createGlyphHoverActions(singleEditor, "modified");
|
|
2214
|
+
|
|
2215
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2216
|
+
editorResizeObserver = new ResizeObserver(() => {
|
|
2217
|
+
layoutEditor();
|
|
2218
|
+
});
|
|
2219
|
+
editorResizeObserver.observe(editorContainerEl);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
requestAnimationFrame(() => {
|
|
2223
|
+
layoutEditor();
|
|
2224
|
+
setTimeout(layoutEditor, 50);
|
|
2225
|
+
setTimeout(layoutEditor, 150);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
mountFile();
|
|
2229
|
+
updateToggleButtons();
|
|
2230
|
+
},
|
|
2231
|
+
(error) => {
|
|
2232
|
+
clearTimeout(loadTimeoutId);
|
|
2233
|
+
settleFailure(
|
|
2234
|
+
"TLH could not load the packaged Monaco editor. Close this window and rerun /annotate-git-diff.",
|
|
2235
|
+
error,
|
|
2236
|
+
);
|
|
2237
|
+
},
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function switchScope(scope) {
|
|
2242
|
+
const hasScopeFiles = {
|
|
2243
|
+
branch: reviewData.files.some((file) => file.inGitDiff),
|
|
2244
|
+
commits: reviewData.commits.length > 0,
|
|
2245
|
+
all: reviewData.files.length > 0,
|
|
2246
|
+
};
|
|
2247
|
+
if (!hasScopeFiles[scope] || state.currentScope === scope) return;
|
|
2248
|
+
saveCurrentScrollPosition();
|
|
2249
|
+
state.currentScope = scope;
|
|
2250
|
+
state.activeFileId = null;
|
|
2251
|
+
if (scope === "commits") {
|
|
2252
|
+
if (!state.selectedCommitSha && reviewData.commits[0]) {
|
|
2253
|
+
state.selectedCommitSha = reviewData.commits[0].sha;
|
|
2254
|
+
}
|
|
2255
|
+
if (state.selectedCommitSha) ensureCommitFilesLoaded(state.selectedCommitSha);
|
|
2256
|
+
}
|
|
2257
|
+
renderAll({ restoreFileScroll: true });
|
|
2258
|
+
const file = activeFile();
|
|
2259
|
+
if (file) ensureFileLoaded(file.id, state.currentScope);
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
window.addEventListener("beforeunload", submitDraftOnClose);
|
|
2263
|
+
window.addEventListener("pagehide", submitDraftOnClose);
|
|
2264
|
+
|
|
2265
|
+
submitButton.addEventListener("click", () => {
|
|
2266
|
+
const payload = buildSubmitPayload();
|
|
2267
|
+
suppressAutoSubmitOnClose = true;
|
|
2268
|
+
window.glimpse.send(payload);
|
|
2269
|
+
window.glimpse.close();
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
cancelButton.addEventListener("click", () => {
|
|
2273
|
+
suppressAutoSubmitOnClose = true;
|
|
2274
|
+
window.glimpse.send({ type: "cancel" });
|
|
2275
|
+
window.glimpse.close();
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
overallCommentButton.addEventListener("click", () => {
|
|
2279
|
+
showOverallCommentPopover();
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
fileCommentButton.addEventListener("click", () => {
|
|
2283
|
+
showFileCommentPopover();
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
if (refreshReviewButton) {
|
|
2287
|
+
refreshReviewButton.addEventListener("click", () => {
|
|
2288
|
+
refreshReviewData();
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
toggleUnchangedButton.addEventListener("click", () => {
|
|
2293
|
+
state.hideUnchanged = !state.hideUnchanged;
|
|
2294
|
+
applyEditorOptions();
|
|
2295
|
+
updateToggleButtons();
|
|
2296
|
+
requestAnimationFrame(layoutEditor);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
toggleWrapButton.addEventListener("click", () => {
|
|
2300
|
+
state.wrapLines = !state.wrapLines;
|
|
2301
|
+
applyEditorOptions();
|
|
2302
|
+
updateToggleButtons();
|
|
2303
|
+
requestAnimationFrame(() => {
|
|
2304
|
+
layoutEditor();
|
|
2305
|
+
setTimeout(layoutEditor, 50);
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
toggleReviewedButton.addEventListener("click", () => {
|
|
2310
|
+
const file = activeFile();
|
|
2311
|
+
if (!file) return;
|
|
2312
|
+
state.reviewedFiles[file.id] = !isFileReviewed(file.id);
|
|
2313
|
+
renderTree();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
scopeBranchButton.addEventListener("click", () => {
|
|
2317
|
+
switchScope("branch");
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
scopeCommitsButton.addEventListener("click", () => {
|
|
2321
|
+
switchScope("commits");
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
scopeAllButton.addEventListener("click", () => {
|
|
2325
|
+
switchScope("all");
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
toggleSidebarButton.addEventListener("click", () => {
|
|
2329
|
+
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
2330
|
+
updateSidebarLayout();
|
|
2331
|
+
requestAnimationFrame(() => {
|
|
2332
|
+
layoutEditor();
|
|
2333
|
+
setTimeout(layoutEditor, 50);
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
const fileCollapseButton = document.getElementById("file-collapse-button");
|
|
2338
|
+
if (fileCollapseButton) {
|
|
2339
|
+
fileCollapseButton.addEventListener("click", () => {
|
|
2340
|
+
const collapsed = editorContainerEl.style.display === "none";
|
|
2341
|
+
editorContainerEl.style.display = collapsed ? "" : "none";
|
|
2342
|
+
fileCollapseButton.dataset.active = collapsed ? "true" : "false";
|
|
2343
|
+
fileCollapseButton.title = collapsed ? "Collapse file" : "Expand file";
|
|
2344
|
+
const svg = fileCollapseButton.querySelector("svg path");
|
|
2345
|
+
if (svg) {
|
|
2346
|
+
svg.setAttribute(
|
|
2347
|
+
"d",
|
|
2348
|
+
collapsed
|
|
2349
|
+
? "M12.78 6.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 7.28a.749.749 0 1 1 1.06-1.06L8 9.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"
|
|
2350
|
+
: "M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z",
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
requestAnimationFrame(() => {
|
|
2354
|
+
layoutEditor();
|
|
2355
|
+
setTimeout(layoutEditor, 50);
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
sidebarSearchInputEl.addEventListener("input", () => {
|
|
2361
|
+
state.fileFilter = sidebarSearchInputEl.value;
|
|
2362
|
+
renderTree();
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
sidebarSearchInputEl.addEventListener("keydown", (event) => {
|
|
2366
|
+
if (event.key === "Escape") {
|
|
2367
|
+
sidebarSearchInputEl.value = "";
|
|
2368
|
+
state.fileFilter = "";
|
|
2369
|
+
renderTree();
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
if (state.currentScope === "commits" && state.selectedCommitSha) {
|
|
2374
|
+
ensureCommitFilesLoaded(state.selectedCommitSha);
|
|
2375
|
+
}
|
|
2376
|
+
ensureActiveFileForScope();
|
|
2377
|
+
renderTree();
|
|
2378
|
+
renderCommitList();
|
|
2379
|
+
renderFileComments();
|
|
2380
|
+
updateSidebarLayout();
|
|
2381
|
+
setupMonaco();
|