@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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