diffninja 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,2853 @@
1
+ import { BRAND_MARK, BRAND_MARK_STYLES } from "./brand.js";
2
+ import { PALETTE_STYLES } from "./palette.js";
3
+ import { escapeHtml } from "./escape-html.js";
4
+ /**
5
+ * The one page the connected session serves.
6
+ *
7
+ * The page owns no data: it renders whatever `GET /api/state` returns, posts a
8
+ * draft to `/api/preview`, and posts the same payload to `/api/submit`. Every
9
+ * string that comes from GitHub (paths, diff lines, the diff text itself, error
10
+ * messages) reaches the DOM through `textContent`; nothing is interpolated into
11
+ * markup or into the script. Style and script carry the session CSRF token as
12
+ * their CSP nonce, so the server can serve a `default-src 'none'` policy with
13
+ * no inline handlers and no inline styles.
14
+ *
15
+ * Drafts live in `sessionStorage`, keyed per pull request and stamped with the
16
+ * snapshot id. Restoring a draft revalidates every comment anchor against the
17
+ * snapshot on screen: a comment whose line no longer exists in the new revision
18
+ * is dropped, never silently carried over.
19
+ */
20
+ export function renderConnectedPage(csrf) {
21
+ const nonce = escapeHtml(csrf);
22
+ return [
23
+ "<!doctype html>",
24
+ '<html lang="en">',
25
+ "<head>",
26
+ '<meta charset="utf-8">',
27
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
28
+ '<meta name="color-scheme" content="light dark">',
29
+ '<meta name="robots" content="noindex, nofollow">',
30
+ "<title>diffninja connected review</title>",
31
+ `<style nonce="${nonce}">${STYLES}</style>`,
32
+ "</head>",
33
+ "<body>",
34
+ '<div class="wrap">',
35
+ '<header class="masthead">',
36
+ `<p class="brand">${BRAND_MARK}<span>diffninja</span></p>`,
37
+ '<h1 id="page-title">Pull request review</h1>',
38
+ '<p id="page-meta" class="page-meta" hidden></p>',
39
+ '<p id="lede" class="lede">Load a GitHub pull request, read its diff in a recommended order, and post your own review through the <span class="mono">gh</span> CLI. Nothing is posted until you press Submit.</p>',
40
+ '<p id="message-note" class="note" role="status" aria-live="polite" hidden></p>',
41
+ "</header>",
42
+ '<section id="load-section" class="panel" aria-labelledby="load-heading">',
43
+ '<h2 id="load-heading">Load a pull request</h2>',
44
+ '<form id="load-form">',
45
+ '<div class="field">',
46
+ '<label for="pr-url">Pull request URL</label>',
47
+ '<input id="pr-url" name="url" type="url" required inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://github.com/owner/repo/pull/123" aria-describedby="pr-url-help">',
48
+ '<p class="hint" id="pr-url-help">A full github.com pull request URL. No other host is accepted, and no other GitHub data is reachable from this page.</p>',
49
+ "</div>",
50
+ '<button type="submit" id="load-button">Load pull request</button>',
51
+ "</form>",
52
+ '<p class="note">diffninja reads the pull request and posts reviews through your <span class="mono">gh</span> login. This page never sees a token.</p>',
53
+ "</section>",
54
+ '<p id="status-line" class="status" role="status" aria-live="polite"></p>',
55
+ '<p id="empty-note" class="empty">No pull request loaded. Paste a pull request URL above to begin.</p>',
56
+ '<div id="error-box" class="error" role="alert" hidden>',
57
+ '<p class="error-title">Request failed</p>',
58
+ '<p id="error-text" class="error-text"></p>',
59
+ '<p class="hint">Your draft is kept. Check GitHub state to re-read the pull request and the account before deciding what to do next.</p>',
60
+ '<button type="button" id="refresh-button" data-action="refresh">Check GitHub state</button>',
61
+ "</div>",
62
+ '<nav id="analysis-section" class="rail" aria-labelledby="analysis-heading" hidden>',
63
+ '<header class="rail-head">',
64
+ '<h2 id="analysis-heading">Reading order</h2>',
65
+ '<p id="analysis-sub" class="rail-sub"></p>',
66
+ '<p id="rail-progress" class="rail-progress" hidden><span id="rail-at"></span><span class="rail-keys"><kbd>j</kbd> <kbd>k</kbd> next and previous</span></p>',
67
+ '<div id="analysis-actions" class="rail-actions"></div>',
68
+ "</header>",
69
+ '<div id="analysis-body" class="rail-body"></div>',
70
+ '<footer class="rail-foot">',
71
+ '<p id="rail-draft" class="rail-draft">No comments yet</p>',
72
+ '<a id="rail-finish" class="btn btn-primary btn-sm" href="#compose-heading">Finish review</a>',
73
+ "</footer>",
74
+ "</nav>",
75
+ '<section id="diff-section" class="panel card" aria-labelledby="diff-heading" hidden>',
76
+ '<header class="card-head">',
77
+ '<div class="card-titles"><h2 id="diff-heading">Changes</h2><p id="diff-sub" class="card-sub">Hover a line and press + to comment. Comments stay in this tab until you submit.</p></div>',
78
+ '<div id="view-switch" class="view-switch" role="group" aria-label="Diff layout" hidden>',
79
+ '<button type="button" data-action="view" data-view="guided" aria-pressed="true">Reading order</button>',
80
+ '<button type="button" data-action="view" data-view="file" aria-pressed="false">By file</button>',
81
+ "</div>",
82
+ "</header>",
83
+ '<div id="diff-body" class="card-body"></div>',
84
+ "</section>",
85
+ '<section id="compose-section" class="panel card" aria-labelledby="compose-heading" hidden>',
86
+ '<header class="card-head">',
87
+ '<div class="card-titles"><h2 id="compose-heading">Your review</h2><p id="draft-count" class="card-sub"></p></div>',
88
+ "</header>",
89
+ '<div class="card-body stack">',
90
+ '<p id="anchor-notice" class="status" role="status" aria-live="polite" hidden></p>',
91
+ '<div class="field">',
92
+ '<label for="review-body">Summary</label>',
93
+ '<textarea id="review-body" rows="4" placeholder="Leave an overall comment (optional when you have line comments)" aria-describedby="review-body-help"></textarea>',
94
+ '<p class="hint" id="review-body-help">Written by you. Comments your agent suggests join your draft only when you add them.</p>',
95
+ "</div>",
96
+ '<fieldset class="event-fieldset">',
97
+ '<legend class="sr-only">Review action</legend>',
98
+ '<div class="event-options">',
99
+ '<label class="event-option"><input type="radio" name="event" value="COMMENT" checked><span class="event-name">Comment</span><span class="event-help">General feedback</span></label>',
100
+ '<label class="event-option"><input type="radio" name="event" value="APPROVE"><span class="event-name">Approve</span><span class="event-help">Approve merging these changes</span></label>',
101
+ '<label class="event-option"><input type="radio" name="event" value="REQUEST_CHANGES"><span class="event-name">Request changes</span><span class="event-help">Feedback that must be addressed</span></label>',
102
+ "</div>",
103
+ "</fieldset>",
104
+ '<p id="event-note" class="status" role="status" aria-live="polite" hidden></p>',
105
+ '<div id="revalidate-section" class="revalidate-section" hidden>',
106
+ '<h3 class="subhead" id="revalidate-heading">Awaiting revalidation</h3>',
107
+ '<p id="revalidate-note" class="status" role="status" aria-live="polite"></p>',
108
+ '<div id="revalidate-list"></div>',
109
+ "</div>",
110
+ '<h3 class="subhead">Line comments</h3>',
111
+ '<div id="draft-list"></div>',
112
+ "</div>",
113
+ "</section>",
114
+ '<section id="preview-section" class="panel card" aria-labelledby="preview-heading" hidden>',
115
+ '<header class="card-head">',
116
+ '<div class="card-titles"><h2 id="preview-heading">Submit</h2><p class="card-sub">Check the exact request, then post it to GitHub from your <span class="mono">gh</span> login.</p></div>',
117
+ "</header>",
118
+ '<div class="card-body stack">',
119
+ '<div class="submit-row">',
120
+ '<button type="button" id="preview-button" class="btn" data-action="preview">Check the review</button>',
121
+ '<button type="button" id="submit-button" class="btn btn-primary" data-action="submit" disabled>Submit review</button>',
122
+ '<p id="preview-state" class="status" role="status" aria-live="polite"></p>',
123
+ "</div>",
124
+ '<p id="submit-hint" class="hint" role="status" aria-live="polite"></p>',
125
+ '<details class="payload-details">',
126
+ '<summary>Request GitHub will receive</summary>',
127
+ '<pre id="preview-json" class="payload" tabindex="0" aria-label="Previewed GitHub review payload">Nothing checked yet.</pre>',
128
+ '<p class="hint">The review is pinned to the commit in <span class="mono">commit_id</span>. The pull request is checked again right before posting; GitHub has no atomic check-and-post, so a push in that instant can still land first. The receipt names the commit reviewed.</p>',
129
+ "</details>",
130
+ "</div>",
131
+ "</section>",
132
+ '<section id="receipt-section" class="panel" aria-labelledby="receipt-heading" hidden>',
133
+ '<h2 id="receipt-heading">Receipt</h2>',
134
+ '<div id="receipt-body"></div>',
135
+ "</section>",
136
+ '<details id="details-section" class="panel details-panel" hidden>',
137
+ '<summary>Pull request and review details</summary>',
138
+ '<div id="details-analysis"></div>',
139
+ '<section id="snapshot-section" aria-labelledby="snapshot-heading">',
140
+ '<h3 id="snapshot-heading" class="subhead">This revision</h3>',
141
+ '<div id="snapshot-body"></div>',
142
+ "</section>",
143
+ '<section id="identity-section" aria-labelledby="identity-heading" hidden>',
144
+ '<h3 id="identity-heading" class="subhead">Reviewing as</h3>',
145
+ '<div id="identity-body"></div>',
146
+ "</section>",
147
+ '<p><button type="button" class="link-button" data-action="show-load">Load a different pull request</button></p>',
148
+ "</details>",
149
+ '<footer class="foot">',
150
+ '<p class="note">Reports and this page contain source code. Keep them private. Diffninja gives no automatic approval.</p>',
151
+ "</footer>",
152
+ "</div>",
153
+ '<aside id="flow-drawer" class="flow-drawer" aria-labelledby="flow-title" hidden>',
154
+ '<div class="flow-bar">',
155
+ '<h2 id="flow-title" class="flow-title">Call flow</h2>',
156
+ '<button type="button" id="flow-close" class="btn btn-quiet btn-sm" data-action="close-flow" aria-label="Close the call flow">Close</button>',
157
+ "</div>",
158
+ '<iframe id="flow-frame" class="flow-frame" title="Call flow"></iframe>',
159
+ "</aside>",
160
+ `<script nonce="${nonce}">${script(csrf)}</script>`,
161
+ "</body>",
162
+ "</html>",
163
+ "",
164
+ ].join("\n");
165
+ }
166
+ function script(csrf) {
167
+ // The nonce doubles as a JS string literal; `\u003c` keeps a hostile value
168
+ // from closing the surrounding script element.
169
+ return `
170
+ (function () {
171
+ 'use strict';
172
+
173
+ var CSRF = ${JSON.stringify(csrf).replaceAll("<", "\\u003c")};
174
+ var DRAFT_PREFIX = 'diffninja.connected.draft.v1';
175
+
176
+ var state = null;
177
+ var comments = [];
178
+ var body = '';
179
+ var event = 'COMMENT';
180
+ var previewPayload = null;
181
+ var previewSignature = '';
182
+ var busy = false;
183
+ var submitting = false;
184
+ var lastError = '';
185
+ var serverError = false;
186
+ var submitUncertain = false;
187
+ var anchorNotice = '';
188
+ var lastRestored = '';
189
+ var editorSeq = 0;
190
+ var commentSeq = 0;
191
+ var reattachIndex = -1;
192
+ var draftOwner = '';
193
+ var analysis = null;
194
+ var showLoad = false;
195
+ var closedFiles = Object.create(null);
196
+ var settled = Object.create(null);
197
+ var busyAction = '';
198
+ var placeAnchor = null;
199
+ var revealReceipt = false;
200
+ var flowSnapshot = '';
201
+ var flowReturn = null;
202
+ var analysisFor = '';
203
+ var analysisLoading = false;
204
+ var analysisTimer = null;
205
+ var VIEW_KEY = 'diffninja.connected.view';
206
+ var view = 'guided';
207
+ var stopsNow = [];
208
+ var currentRank = 0;
209
+ var followedRank = 0;
210
+ var seenRanks = Object.create(null);
211
+ var seenFor = '';
212
+ var el = {};
213
+
214
+ function byId(id) { return document.getElementById(id); }
215
+
216
+ function make(tag, className, textValue) {
217
+ var node = document.createElement(tag);
218
+ if (className) node.className = className;
219
+ if (textValue !== undefined && textValue !== null) node.textContent = String(textValue);
220
+ return node;
221
+ }
222
+
223
+ function setText(node, value) {
224
+ node.textContent = value === undefined || value === null ? '' : String(value);
225
+ }
226
+
227
+ function show(node, visible) { node.hidden = !visible; }
228
+
229
+ function snapshot() { return state && state.snapshot ? state.snapshot : null; }
230
+
231
+ function lines() {
232
+ var snap = snapshot();
233
+ return snap && Array.isArray(snap.lines) ? snap.lines : [];
234
+ }
235
+
236
+ function shortSha(value) {
237
+ return typeof value === 'string' && value.length > 0 ? value.slice(0, 8) : 'unknown';
238
+ }
239
+
240
+ function sideLabel(side) { return side === 'LEFT' ? 'old' : 'new'; }
241
+
242
+ function githubLink(url, text) {
243
+ var safe = typeof url === 'string' && url.indexOf('https://github.com/') === 0;
244
+ if (!safe) return make('span', null, text);
245
+ var link = make('a', null, text);
246
+ link.href = url;
247
+ link.target = '_blank';
248
+ link.rel = 'noopener noreferrer';
249
+ return link;
250
+ }
251
+
252
+ /* ---------------------------------------------------------------- state -- */
253
+
254
+ /**
255
+ * The GitHub payload draft. Only comments attached to the loaded revision are
256
+ * included: a comment awaiting revalidation names a line of an older
257
+ * revision, so sending it would place a human's words on unrelated code.
258
+ */
259
+ function draftInput() {
260
+ var snap = snapshot();
261
+ var anchored = [];
262
+ for (var i = 0; i < comments.length; i += 1) {
263
+ var c = comments[i];
264
+ if (c.needsRevalidation) continue;
265
+ anchored.push({ path: c.path, line: c.line, side: c.side, body: c.body });
266
+ }
267
+ return { snapshotId: snap ? snap.id : '', event: event, body: body, comments: anchored };
268
+ }
269
+
270
+ /** The loaded revision's own line at that coordinate, or null when absent. */
271
+ function currentLine(anchor) {
272
+ var list = lines();
273
+ for (var i = 0; i < list.length; i += 1) {
274
+ var line = list[i];
275
+ if (line.path === anchor.path && line.line === anchor.line && line.side === anchor.side) return line;
276
+ }
277
+ return null;
278
+ }
279
+
280
+ function commentIndexAt(anchor) {
281
+ for (var i = 0; i < comments.length; i += 1) {
282
+ var c = comments[i];
283
+ if (c.needsRevalidation) continue;
284
+ if (c.path === anchor.path && c.line === anchor.line && c.side === anchor.side) return i;
285
+ }
286
+ return -1;
287
+ }
288
+
289
+ function unvalidatedCount() {
290
+ var total = 0;
291
+ for (var i = 0; i < comments.length; i += 1) {
292
+ if (comments[i].needsRevalidation) total += 1;
293
+ }
294
+ return total;
295
+ }
296
+
297
+ function setState(next) {
298
+ if (!next || typeof next !== 'object' || Array.isArray(next)) {
299
+ lastError = 'The server answered with a state this page could not read.';
300
+ serverError = true;
301
+ return;
302
+ }
303
+ state = next;
304
+ var snap = snapshot();
305
+ if (snap) restoreDraft(snap);
306
+ }
307
+
308
+ /* -------------------------------------------------------------- storage -- */
309
+
310
+ function storage() {
311
+ try { return window.sessionStorage; } catch (error) { return null; }
312
+ }
313
+
314
+ function draftKey(snap) {
315
+ return DRAFT_PREFIX + '|' + snap.owner + '/' + snap.repo + '#' + snap.number;
316
+ }
317
+
318
+ function saveDraft() {
319
+ var snap = snapshot();
320
+ var store = storage();
321
+ if (!snap || !store) return;
322
+ try {
323
+ store.setItem(draftKey(snap), JSON.stringify({
324
+ snapshotId: snap.id,
325
+ headSha: snap.headSha,
326
+ event: event,
327
+ body: body,
328
+ comments: comments,
329
+ settled: Object.keys(settled)
330
+ }));
331
+ } catch (error) {
332
+ /* Storage disabled or full: the draft simply stays in memory. */
333
+ }
334
+ }
335
+
336
+ /**
337
+ * The stored draft for a snapshot: null when this pull request genuinely has
338
+ * none, undefined when storage is unavailable or the stored value is
339
+ * unreadable. The distinction matters: only a definite absence may reset the
340
+ * draft, so a browser that blocks sessionStorage never loses an in-memory one.
341
+ */
342
+ function readDraft(snap) {
343
+ var store = storage();
344
+ if (!store) return undefined;
345
+ var raw = null;
346
+ try { raw = store.getItem(draftKey(snap)); } catch (error) { return undefined; }
347
+ if (typeof raw !== 'string' || raw === '') return null;
348
+ try {
349
+ var parsed = JSON.parse(raw);
350
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
351
+ } catch (error) {
352
+ return undefined;
353
+ }
354
+ }
355
+
356
+ function clearDraft() {
357
+ var snap = snapshot();
358
+ var store = storage();
359
+ if (!snap || !store) return;
360
+ try { store.removeItem(draftKey(snap)); } catch (error) { /* nothing to clear */ }
361
+ }
362
+
363
+ /**
364
+ * Flag every comment that was written against a different revision and write
365
+ * the flags back. Nothing is dropped: a matching path and line number is not
366
+ * proof of the same code, so a comment from another revision must be
367
+ * re-attached or confirmed by a human before it may be submitted.
368
+ */
369
+ function invalidateForSnapshot(snap) {
370
+ var flagged = 0;
371
+ for (var i = 0; i < comments.length; i += 1) {
372
+ var c = comments[i];
373
+ if (c.snapshotId === snap.id) continue;
374
+ if (!c.needsRevalidation) flagged += 1;
375
+ c.needsRevalidation = true;
376
+ }
377
+ saveDraft();
378
+ return flagged;
379
+ }
380
+
381
+ /** Restore this pull request's own draft and revalidate it against the snapshot. */
382
+ function restoreDraft(snap) {
383
+ if (lastRestored === snap.id) return;
384
+ lastRestored = snap.id;
385
+ var owner = snap.owner + '/' + snap.repo + '#' + snap.number;
386
+ var stored = readDraft(snap);
387
+ var record = stored && typeof stored === 'object' ? stored : null;
388
+ if (!record) {
389
+ if (draftOwner !== owner) {
390
+ // Another pull request's draft must not follow the reviewer here.
391
+ comments = [];
392
+ body = '';
393
+ event = 'COMMENT';
394
+ reattachIndex = -1;
395
+ anchorNotice = '';
396
+ settled = Object.create(null);
397
+ draftOwner = owner;
398
+ return;
399
+ }
400
+ // Storage unusable, or memory already holds this pull request: invalidate
401
+ // in memory rather than returning early and silently keeping stale anchors.
402
+ anchorNotice = invalidateForSnapshot(snap) > 0
403
+ ? 'The pull request changed since this draft was written. Comment text was kept, but every line comment must be attached or confirmed against the current revision before it can be submitted.'
404
+ : '';
405
+ return;
406
+ }
407
+ // The stored record is what this pull request owns: adopt it whole.
408
+ comments = [];
409
+ body = '';
410
+ event = 'COMMENT';
411
+ reattachIndex = -1;
412
+ draftOwner = owner;
413
+ settled = Object.create(null);
414
+ var storedSettled = Array.isArray(record.settled) ? record.settled : [];
415
+ for (var k = 0; k < storedSettled.length; k += 1) {
416
+ if (typeof storedSettled[k] === 'string') settled[storedSettled[k]] = true;
417
+ }
418
+ var storedComments = Array.isArray(record.comments) ? record.comments : [];
419
+ for (var i = 0; i < storedComments.length; i += 1) {
420
+ var c = storedComments[i];
421
+ var shaped = c && typeof c === 'object' && !Array.isArray(c)
422
+ && typeof c.path === 'string'
423
+ && typeof c.line === 'number'
424
+ && (c.side === 'LEFT' || c.side === 'RIGHT')
425
+ && typeof c.body === 'string';
426
+ if (!shaped) continue;
427
+ comments.push({
428
+ id: typeof c.id === 'string' && c.id !== '' ? c.id : 'c' + (commentSeq += 1),
429
+ path: c.path,
430
+ line: c.line,
431
+ side: c.side,
432
+ body: c.body,
433
+ snapshotId: typeof c.snapshotId === 'string' ? c.snapshotId : '',
434
+ needsRevalidation: c.needsRevalidation === true,
435
+ suggestedBy: typeof c.suggestedBy === 'string' ? c.suggestedBy : ''
436
+ });
437
+ }
438
+ if (typeof record.body === 'string') body = record.body;
439
+ if (record.event === 'COMMENT' || record.event === 'APPROVE' || record.event === 'REQUEST_CHANGES') event = record.event;
440
+ var moved = typeof record.snapshotId === 'string' && record.snapshotId !== snap.id;
441
+ invalidateForSnapshot(snap);
442
+ var hasDraft = comments.length > 0 || body !== '';
443
+ anchorNotice = '';
444
+ if (!hasDraft) return;
445
+ if (!moved) {
446
+ anchorNotice = 'Draft restored from this browser tab for ' + owner + '. Nothing is sent to GitHub until you submit.';
447
+ return;
448
+ }
449
+ anchorNotice = 'The pull request changed (' + shortSha(record.headSha) + ' to ' + shortSha(snap.headSha) + ') since this draft was saved. Comment text was kept, but every line comment must be attached or confirmed against the current revision before it can be submitted.';
450
+ }
451
+
452
+ /* ------------------------------------------------------------------ api -- */
453
+
454
+ /**
455
+ * A failed request. The uncertain flag separates the honest "no answer came
456
+ * back" from a definite refusal: only the latter carries a state the page may
457
+ * trust.
458
+ */
459
+ function requestFailure(message, state, uncertain) {
460
+ var failure = new Error(message);
461
+ if (state) failure.state = state;
462
+ if (uncertain) failure.uncertain = true;
463
+ return failure;
464
+ }
465
+
466
+ function api(method, path, payload) {
467
+ var init = { method: method, credentials: 'same-origin', headers: { Accept: 'application/json' } };
468
+ if (method !== 'GET') {
469
+ init.headers['Content-Type'] = 'application/json';
470
+ init.headers['X-Diffninja-CSRF'] = CSRF;
471
+ init.body = JSON.stringify(payload === undefined ? {} : payload);
472
+ }
473
+ return fetch(path, init).then(function (response) {
474
+ return response.text().then(function (raw) {
475
+ var data = null;
476
+ if (raw) {
477
+ try { data = JSON.parse(raw); } catch (error) { data = null; }
478
+ }
479
+ var message = data && typeof data === 'object' && typeof data.error === 'string' ? data.error : '';
480
+ var state = data && typeof data === 'object' && data.state ? data.state : null;
481
+ // A structured error and state is a definite answer from diffninja.
482
+ if (message !== '') throw requestFailure(message, state, false);
483
+ if (!response.ok) {
484
+ throw requestFailure('The server answered with status ' + response.status + ' and no readable result.', null, true);
485
+ }
486
+ if (data === null) throw requestFailure('The server answered with a response this page could not read.', null, true);
487
+ return data;
488
+ });
489
+ }, function () {
490
+ throw requestFailure('Could not reach the local diffninja server. Check that diffninja is still running.', null, true);
491
+ });
492
+ }
493
+
494
+ function run(work, options) {
495
+ if (busy) return Promise.resolve();
496
+ var isSubmit = Boolean(options && options.submit);
497
+ busy = true;
498
+ lastError = '';
499
+ serverError = false;
500
+ render();
501
+ return work().then(function () {}, function (error) {
502
+ var uncertain = Boolean(error && error.uncertain);
503
+ if (uncertain && isSubmit) {
504
+ // The write may have reached GitHub: no retry, no new-session advice,
505
+ // and a state check is the only way forward.
506
+ submitUncertain = true;
507
+ serverError = true;
508
+ return;
509
+ }
510
+ lastError = error && error.message ? error.message : 'The request failed.';
511
+ serverError = true;
512
+ if (error && error.state) setState(error.state);
513
+ }).then(function () {
514
+ busy = false;
515
+ busyAction = '';
516
+ render();
517
+ });
518
+ }
519
+
520
+ /* -------------------------------------------------------------- actions -- */
521
+
522
+ function loadPullRequest(url) {
523
+ return run(function () {
524
+ return api('POST', '/api/load', { url: url }).then(function (data) {
525
+ // A load invalidates the previewed payload; the draft itself is decided
526
+ // by setState, which restores this pull request's own saved draft.
527
+ previewPayload = null;
528
+ previewSignature = '';
529
+ lastRestored = '';
530
+ setState(data);
531
+ });
532
+ });
533
+ }
534
+
535
+ function previewReview() {
536
+ if (!snapshot() || composeDisabled() || unvalidatedCount() > 0) return;
537
+ var payload = draftInput();
538
+ busyAction = 'preview';
539
+ run(function () {
540
+ return api('POST', '/api/preview', payload).then(function (data) {
541
+ previewPayload = data;
542
+ previewSignature = JSON.stringify(payload);
543
+ });
544
+ });
545
+ }
546
+
547
+ function submitReview() {
548
+ if (busy || submitting || validationHint() !== '') return;
549
+ var payload = draftInput();
550
+ submitting = true;
551
+ busyAction = 'submit';
552
+ // The backend drops its previewed payload after any submission that did not
553
+ // confirm, and github.ts refuses a submit without a fresh preview. Mirror
554
+ // that here so the button cannot offer a submission GitHub will refuse.
555
+ previewPayload = null;
556
+ previewSignature = '';
557
+ render();
558
+ run(function () {
559
+ return api('POST', '/api/submit', payload).then(function (data) {
560
+ setState(data);
561
+ // Only a confirmed review clears the draft. An unknown or refused
562
+ // outcome keeps it, because the review may never have been posted.
563
+ if (data && data.status === 'submitted') {
564
+ revealReceipt = true;
565
+ comments = [];
566
+ body = '';
567
+ event = 'COMMENT';
568
+ reattachIndex = -1;
569
+ var snap = snapshot();
570
+ lastRestored = snap ? snap.id : '';
571
+ clearDraft();
572
+ }
573
+ });
574
+ }, { submit: true }).then(function () {
575
+ submitting = false;
576
+ render();
577
+ });
578
+ }
579
+
580
+ /** Recover server state first; reconcile uncertain writes, refresh stale reads. */
581
+ function refreshState() {
582
+ lastError = '';
583
+ serverError = false;
584
+ run(function () {
585
+ return api('GET', '/api/state', null).then(function (current) {
586
+ if (current.status === 'unknown' || current.status === 'submitting') {
587
+ return api('POST', '/api/reconcile', {});
588
+ }
589
+ if (current.status !== 'submitted' && current.snapshot) {
590
+ return api('POST', '/api/load', { url: current.snapshot.url });
591
+ }
592
+ return current;
593
+ }).then(function (data) {
594
+ submitUncertain = false;
595
+ previewPayload = null;
596
+ previewSignature = '';
597
+ setState(data);
598
+ });
599
+ });
600
+ }
601
+
602
+ /**
603
+ * A Comment click either attaches a new comment to the line under it or
604
+ * re-attaches the comment the reviewer armed for revalidation. Nothing is
605
+ * ever bound implicitly: a comment from another revision moves only when a
606
+ * human points at the line it now belongs to.
607
+ */
608
+ function addComment(node) {
609
+ var snap = snapshot();
610
+ if (!snap || composeDisabled()) return;
611
+ var anchor = {
612
+ path: node.getAttribute('data-path') || '',
613
+ line: Number(node.getAttribute('data-line')),
614
+ side: node.getAttribute('data-side')
615
+ };
616
+ if (!Number.isInteger(anchor.line) || (anchor.side !== 'LEFT' && anchor.side !== 'RIGHT')) return;
617
+ if (!currentLine(anchor)) {
618
+ lastError = 'That line is not part of the loaded revision, so no comment can be attached to it.';
619
+ serverError = false;
620
+ render();
621
+ return;
622
+ }
623
+ var armed = reattachIndex >= 0 ? comments[reattachIndex] : null;
624
+ if (armed && armed.needsRevalidation) {
625
+ var clash = commentIndexAt(anchor);
626
+ if (clash >= 0) {
627
+ lastError = 'A line comment is already attached to that line. Remove it before re-attaching another.';
628
+ serverError = false;
629
+ render();
630
+ return;
631
+ }
632
+ armed.path = anchor.path;
633
+ armed.line = anchor.line;
634
+ armed.side = anchor.side;
635
+ armed.snapshotId = snap.id;
636
+ armed.needsRevalidation = false;
637
+ reattachIndex = -1;
638
+ saveDraft();
639
+ render();
640
+ focusEditor(commentIndexAt(anchor));
641
+ return;
642
+ }
643
+ var existing = commentIndexAt(anchor);
644
+ if (existing >= 0) { focusEditor(existing); return; }
645
+ commentSeq += 1;
646
+ comments.push({
647
+ id: 'c' + commentSeq,
648
+ path: anchor.path,
649
+ line: anchor.line,
650
+ side: anchor.side,
651
+ body: '',
652
+ snapshotId: snap.id,
653
+ needsRevalidation: false
654
+ });
655
+ saveDraft();
656
+ render();
657
+ focusEditor(comments.length - 1);
658
+ }
659
+
660
+ /** Confirm that a comment's own coordinate is still where its author meant it. */
661
+ function confirmComment(node) {
662
+ if (busy || submitting) return;
663
+ var index = Number(node.getAttribute('data-index'));
664
+ var comment = comments[index];
665
+ if (!comment || !comment.needsRevalidation) return;
666
+ var line = currentLine(comment);
667
+ if (!line) {
668
+ lastError = 'That line is not part of the loaded revision, so it cannot be confirmed. Attach the comment to a line instead.';
669
+ serverError = false;
670
+ render();
671
+ return;
672
+ }
673
+ if (commentIndexAt(comment) >= 0) {
674
+ lastError = 'A line comment is already attached to that line. Remove it before confirming another.';
675
+ serverError = false;
676
+ render();
677
+ return;
678
+ }
679
+ comment.snapshotId = snapshot().id;
680
+ comment.needsRevalidation = false;
681
+ saveDraft();
682
+ render();
683
+ }
684
+
685
+ /** Arm a comment so the next Comment click attaches it to a chosen line. */
686
+ function armComment(node) {
687
+ if (busy || submitting) return;
688
+ var index = Number(node.getAttribute('data-index'));
689
+ if (!Number.isInteger(index) || index < 0 || index >= comments.length) return;
690
+ if (!comments[index].needsRevalidation) return;
691
+ reattachIndex = reattachIndex === index ? -1 : index;
692
+ render();
693
+ }
694
+
695
+ function cancelReattach() {
696
+ reattachIndex = -1;
697
+ render();
698
+ }
699
+
700
+ function removeComment(node) {
701
+ var index = Number(node.getAttribute('data-index'));
702
+ if (!Number.isInteger(index) || index < 0 || index >= comments.length) return;
703
+ comments.splice(index, 1);
704
+ if (reattachIndex === index) reattachIndex = -1;
705
+ else if (reattachIndex > index) reattachIndex -= 1;
706
+ saveDraft();
707
+ render();
708
+ }
709
+
710
+ function focusEditor(index) {
711
+ var input = document.querySelector('.editor[data-index="' + index + '"] .editor-input');
712
+ if (!input) return;
713
+ input.focus();
714
+ if (input.scrollIntoView) input.scrollIntoView({ block: 'nearest', inline: 'nearest' });
715
+ }
716
+
717
+ function editComment(node) {
718
+ var index = Number(node.getAttribute('data-index'));
719
+ if (!Number.isInteger(index) || index < 0 || index >= comments.length) return;
720
+ focusEditor(index);
721
+ }
722
+
723
+ function setEvent(value) {
724
+ if (value !== 'COMMENT' && value !== 'APPROVE' && value !== 'REQUEST_CHANGES') return;
725
+ event = value;
726
+ saveDraft();
727
+ updateEventNote();
728
+ updateActionState();
729
+ }
730
+
731
+ function syncEventInputs() {
732
+ for (var i = 0; i < el.eventInputs.length; i += 1) {
733
+ el.eventInputs[i].checked = el.eventInputs[i].value === event;
734
+ }
735
+ }
736
+
737
+ /* ----------------------------------------------------------------- gates -- */
738
+
739
+ /** The loaded snapshot carries lines we can attach comments to. */
740
+ function reviewable() {
741
+ var snap = snapshot();
742
+ if (!snap || snap.unavailableReason) return false;
743
+ return lines().length > 0;
744
+ }
745
+
746
+ /**
747
+ * A composer is worth showing only while this session could still submit.
748
+ * After a confirmed review the backend refuses everything but state and
749
+ * reconcile, so an empty disabled form would be misleading.
750
+ */
751
+ function composerReady() {
752
+ if (!reviewable() || state.status === 'submitted') return false;
753
+ return true;
754
+ }
755
+
756
+ function composeDisabled() {
757
+ if (!reviewable() || busy || submitting || submitUncertain) return true;
758
+ return state.status !== 'ready';
759
+ }
760
+
761
+ function validationHint() {
762
+ var snap = snapshot();
763
+ if (!snap) return 'Load a pull request first.';
764
+ if (busy) return 'A request is already running.';
765
+ if (submitting) return 'The review is being submitted.';
766
+ if (state.status === 'submitted') return 'This pull request already has a submitted review.';
767
+ if (state.status === 'submitting') return 'A submission is already in progress; refresh the state.';
768
+ if (state.status === 'unknown') return 'The last submission outcome is unknown. Check GitHub state before submitting again.';
769
+ if (submitUncertain) return 'The submission request did not complete, so GitHub may or may not have recorded this review. Check GitHub state before doing anything else.';
770
+ if (snap.unavailableReason) return 'This pull request has no reviewable revision loaded.';
771
+ if (lines().length === 0) return 'This pull request has no reviewable diff lines.';
772
+ var pending = unvalidatedCount();
773
+ if (pending > 0) {
774
+ return pending === 1
775
+ ? 'One line comment is waiting to be attached to the current revision. Attach or confirm it before submitting.'
776
+ : pending + ' line comments are waiting to be attached to the current revision. Attach or confirm each one before submitting.';
777
+ }
778
+ for (var i = 0; i < comments.length; i += 1) {
779
+ if (comments[i].body.trim() === '') return 'Every line comment needs text, or remove it.';
780
+ }
781
+ if (body.trim() === '' && comments.length === 0) return 'Write a summary or add a line comment to submit.';
782
+ if (previewPayload === null) return 'Check the review first; Submit unlocks once it is checked.';
783
+ if (previewSignature !== JSON.stringify(draftInput())) return 'The draft changed since it was checked. Check it again.';
784
+ return '';
785
+ }
786
+
787
+ /* --------------------------------------------------------------- render -- */
788
+
789
+ function statusMessage() {
790
+ if (submitting) return 'Submitting the review to GitHub…';
791
+ if (busy) return 'Contacting the local diffninja server…';
792
+ if (!state) return 'Loading the current state…';
793
+ if (submitUncertain) return 'The submission outcome is unknown. Check GitHub state.';
794
+ if (lastError !== '') return 'The last request failed. Your draft is kept.';
795
+ var status = state.status;
796
+ if (status === 'submitting') return 'A GitHub review submission is already in progress in this session.';
797
+ if (status === 'unknown') return 'The submission outcome is unknown. Check GitHub before submitting again.';
798
+ if (status === 'submitted') return 'The review is submitted. The receipt below links to it on GitHub.';
799
+ var snap = snapshot();
800
+ if (!snap) return typeof state.message === 'string' && state.message !== '' ? state.message : 'No pull request loaded yet.';
801
+ if (status !== 'ready') {
802
+ if (typeof state.message === 'string' && state.message !== '') return state.message;
803
+ return 'No reviewable revision is loaded for this pull request. Your draft, if any, is kept in this tab.';
804
+ }
805
+ return 'Ready: ' + snap.owner + '/' + snap.repo + '#' + snap.number + ' at ' + shortSha(snap.headSha) + '.';
806
+ }
807
+
808
+ function addFact(dl, term, value, mono) {
809
+ dl.appendChild(make('dt', null, term));
810
+ dl.appendChild(make('dd', mono ? 'mono' : null, value));
811
+ }
812
+
813
+ function addLinkFact(dl, term, url, text) {
814
+ dl.appendChild(make('dt', null, term));
815
+ var dd = make('dd', 'mono');
816
+ dd.appendChild(githubLink(url, text));
817
+ dl.appendChild(dd);
818
+ }
819
+
820
+ function renderIdentity() {
821
+ var identity = state && state.identity && typeof state.identity === 'object' ? state.identity : null;
822
+ show(el.identitySection, Boolean(identity));
823
+ el.identityBody.textContent = '';
824
+ if (!identity) return;
825
+ var dl = make('dl', 'facts');
826
+ addFact(dl, 'GitHub account', typeof identity.login === 'string' && identity.login !== '' ? identity.login : 'unknown');
827
+ addFact(dl, 'Account id', typeof identity.id === 'number' ? String(identity.id) : 'unknown');
828
+ el.identityBody.appendChild(dl);
829
+ el.identityBody.appendChild(make('p', 'note', 'A submitted review is posted as this account, through gh. GH_TOKEN or GITHUB_TOKEN in the server environment takes precedence over the gh login.'));
830
+ }
831
+
832
+ function reviewerLogin() {
833
+ var identity = state && state.identity && typeof state.identity === 'object' ? state.identity : null;
834
+ return identity && typeof identity.login === 'string' && identity.login !== '' ? identity.login : '';
835
+ }
836
+
837
+ function renderSnapshot() {
838
+ var snap = snapshot();
839
+ show(el.detailsSection, Boolean(snap));
840
+ show(el.lede, !snap);
841
+ show(el.loadSection, !snap || showLoad);
842
+ show(el.pageMeta, Boolean(snap));
843
+ el.pageMeta.textContent = '';
844
+ el.snapshotBody.textContent = '';
845
+ if (!snap) {
846
+ setText(el.pageTitle, 'Pull request review');
847
+ return;
848
+ }
849
+ setText(el.pageTitle, typeof snap.title === 'string' && snap.title ? snap.title : 'Untitled pull request');
850
+ var ref = (typeof snap.owner === 'string' ? snap.owner : 'unknown') + '/' + (typeof snap.repo === 'string' ? snap.repo : 'unknown') + '#' + String(snap.number);
851
+ el.pageMeta.appendChild(githubLink(snap.url, ref));
852
+ var stateWord = typeof snap.state === 'string' ? snap.state.toLowerCase() : 'unknown';
853
+ el.pageMeta.insertBefore(make('span', 'state-badge state-' + stateWord, stateWord.charAt(0).toUpperCase() + stateWord.slice(1)), el.pageMeta.firstChild);
854
+ el.pageMeta.appendChild(make('span', 'meta-item sha', shortSha(snap.headSha)));
855
+ var login = reviewerLogin();
856
+ if (login !== '') el.pageMeta.appendChild(make('span', 'meta-item', 'Reviewing as ' + login));
857
+ var description = make('details', '');
858
+ description.appendChild(make('summary', '', 'Description (the author\u2019s claims)'));
859
+ description.appendChild(make('pre', '', typeof snap.body === 'string' && snap.body ? snap.body : 'No description.'));
860
+ el.snapshotBody.appendChild(description);
861
+ var dl = make('dl', 'facts');
862
+ addLinkFact(dl, 'Pull request', snap.url, typeof snap.url === 'string' ? snap.url : 'unknown');
863
+ addFact(dl, 'GitHub state', typeof snap.state === 'string' ? snap.state : 'unknown');
864
+ addFact(dl, 'Base commit', typeof snap.baseSha === 'string' ? snap.baseSha : 'unknown', true);
865
+ addFact(dl, 'Head commit', typeof snap.headSha === 'string' ? snap.headSha : 'unknown', true);
866
+ addFact(dl, 'Snapshot id', typeof snap.id === 'string' ? snap.id : 'unknown', true);
867
+ addFact(dl, 'Diff lines', String(lines().length));
868
+ el.snapshotBody.appendChild(dl);
869
+ el.snapshotBody.appendChild(make('p', 'note', 'This page reviews exactly this revision. If the pull request changes, submitting is blocked until you refresh and recheck your line comments; your review is pinned to the head commit above.'));
870
+ if (typeof snap.unavailableReason === 'string' && snap.unavailableReason !== '') {
871
+ el.snapshotBody.appendChild(make('p', 'note', snap.unavailableReason));
872
+ }
873
+ }
874
+
875
+ function editorRow(index) {
876
+ var comment = comments[index];
877
+ editorSeq += 1;
878
+ var wrap = make('div', 'editor');
879
+ wrap.dataset.index = String(index);
880
+ var inputId = 'line-comment-' + String(editorSeq);
881
+ var label = make('label', 'sr-only', 'Line comment on ' + comment.path + ' line ' + comment.line + ' (' + sideLabel(comment.side) + ' side)');
882
+ label.setAttribute('for', inputId);
883
+ var field = make('div', 'editor-row');
884
+ var input = document.createElement('input');
885
+ input.type = 'text';
886
+ input.id = inputId;
887
+ input.className = 'editor-input';
888
+ input.dataset.index = String(index);
889
+ input.value = comment.body;
890
+ input.setAttribute('aria-describedby', inputId + '-help');
891
+ var remove = make('button', 'btn btn-quiet btn-sm', 'Remove');
892
+ remove.type = 'button';
893
+ remove.dataset.action = 'remove-comment';
894
+ remove.dataset.index = String(index);
895
+ remove.setAttribute('aria-label', 'Remove the comment on ' + comment.path + ' line ' + comment.line);
896
+ field.appendChild(input);
897
+ field.appendChild(remove);
898
+ wrap.appendChild(label);
899
+ wrap.appendChild(field);
900
+ var help = make('p', 'hint', comment.suggestedBy
901
+ ? 'Suggested by ' + comment.suggestedBy + ' and added by you: it is your comment now, so edit it as you like. Kept in this browser tab until it is submitted.'
902
+ : 'One line of plain text, written by you. Kept in this browser tab for this pull request until it is submitted.');
903
+ help.id = inputId + '-help';
904
+ wrap.appendChild(help);
905
+ return wrap;
906
+ }
907
+
908
+ /* ------------------------------------------------------------ analysis -- */
909
+
910
+ var STATUS_ORDER = ['attention', 'uncertain', 'low', 'passed'];
911
+
912
+ function currentAnalysis() {
913
+ var snap = snapshot();
914
+ if (!snap || !analysis || analysis.available !== true || analysis.snapshotId !== snap.id) return null;
915
+ return analysis;
916
+ }
917
+
918
+ function worstStatusFor(path) {
919
+ var current = currentAnalysis();
920
+ if (!current || !Array.isArray(current.hunks)) return '';
921
+ var best = STATUS_ORDER.length;
922
+ for (var i = 0; i < current.hunks.length; i += 1) {
923
+ var hunk = current.hunks[i];
924
+ if (!hunk || hunk.file !== path) continue;
925
+ var rank = STATUS_ORDER.indexOf(hunk.status);
926
+ if (rank >= 0 && rank < best) best = rank;
927
+ }
928
+ return best < STATUS_ORDER.length ? STATUS_ORDER[best] : '';
929
+ }
930
+
931
+ function scheduleAnalysis(delay) {
932
+ if (analysisTimer !== null) clearTimeout(analysisTimer);
933
+ analysisTimer = setTimeout(function () { analysisTimer = null; loadAnalysis(); }, delay);
934
+ }
935
+
936
+ /** Fetch the analysis for the loaded revision; keep polling while answers or the agent's order are outstanding. */
937
+ function loadAnalysis() {
938
+ var snap = snapshot();
939
+ if (!snap || analysisLoading) return;
940
+ analysisLoading = true;
941
+ analysisFor = snap.id;
942
+ api('GET', '/api/analysis', null).then(function (data) {
943
+ analysis = data && typeof data === 'object' ? data : null;
944
+ }, function () {
945
+ analysis = { available: false, reason: 'The local analysis could not be read from the diffninja server.' };
946
+ }).then(function () {
947
+ analysisLoading = false;
948
+ var place = readingPlace();
949
+ renderAnalysis();
950
+ renderDiff();
951
+ keepReadingPlace(place);
952
+ queueStationMark();
953
+ var current = snapshot();
954
+ if (!current) return;
955
+ if (analysis && analysis.available === true && analysis.snapshotId !== current.id) { scheduleAnalysis(1000); return; }
956
+ var pendingAnswers = analysis && analysis.available === true && analysis.questions && analysis.questions.answered < analysis.questions.total;
957
+ var pendingOrder = analysis && analysis.available === true && !(analysis.order && analysis.order.source === 'agent');
958
+ var pendingSuggestions = analysis && analysis.available === true && !analysis.suggestions;
959
+ if (pendingAnswers || pendingOrder || pendingSuggestions) scheduleAnalysis(10000);
960
+ });
961
+ }
962
+
963
+ function reportLink(url) {
964
+ if (typeof url !== 'string' || !/^http:\\/\\/127\\.0\\.0\\.1:\\d+\\/report\\/[a-f0-9]{64}$/.test(url)) return null;
965
+ var link = make('a', '', 'Open the full report (agenda, call flows, every hunk)');
966
+ link.href = url;
967
+ link.target = '_blank';
968
+ link.rel = 'noopener noreferrer';
969
+ return link;
970
+ }
971
+
972
+ function answeredVerdicts(hunk) {
973
+ var out = [];
974
+ var questions = Array.isArray(hunk.questions) ? hunk.questions : [];
975
+ for (var q = 0; q < questions.length; q += 1) {
976
+ var question = questions[q];
977
+ if (question && question.verdict && typeof question.verdict.label === 'string') out.push(question);
978
+ }
979
+ return out;
980
+ }
981
+
982
+ var STATUS_LABEL = { attention: 'Attention', uncertain: 'Uncertain', low: 'Low', passed: 'Passed' };
983
+
984
+ /** A status as a small tinted label; the word is the text, the tint only repeats it. */
985
+ function statusTag(status) {
986
+ var known = STATUS_ORDER.indexOf(status) >= 0 ? status : 'uncertain';
987
+ return make('span', 'status-tag status-' + known, STATUS_LABEL[known]);
988
+ }
989
+
990
+ /** A path with its directory muted, so the file name reads first. */
991
+ function pathNode(path, className) {
992
+ var node = make('span', 'path ' + (className || ''));
993
+ var cut = String(path).lastIndexOf('/');
994
+ if (cut >= 0) node.appendChild(make('span', 'path-dir', String(path).slice(0, cut + 1)));
995
+ node.appendChild(make('span', 'path-base', cut >= 0 ? String(path).slice(cut + 1) : String(path)));
996
+ return node;
997
+ }
998
+
999
+ function sizeNode(added, removed) {
1000
+ var size = make('span', 'size');
1001
+ size.appendChild(make('span', 'plus', '+' + String(added)));
1002
+ size.appendChild(make('span', 'minus', '−' + String(removed)));
1003
+ return size;
1004
+ }
1005
+
1006
+ function gotoButton(path, line, side, label, className) {
1007
+ var go = make('button', className || 'btn btn-quiet', label);
1008
+ go.type = 'button';
1009
+ go.dataset.action = 'goto';
1010
+ go.dataset.path = String(path);
1011
+ go.dataset.line = String(line);
1012
+ go.dataset.side = side === 'LEFT' ? 'LEFT' : 'RIGHT';
1013
+ return go;
1014
+ }
1015
+
1016
+ /** One station of the rail: where the change is and whether it asks for attention; what to look at lives beside the code. */
1017
+ function renderHunkEntry(hunk, rank) {
1018
+ var entry = make('li', 'order-item');
1019
+ entry.dataset.action = 'goto-stop';
1020
+ entry.dataset.rank = String(rank);
1021
+ entry.dataset.path = String(hunk.file);
1022
+ entry.dataset.line = String(hunk.line);
1023
+ entry.dataset.side = hunk.side === 'LEFT' ? 'LEFT' : 'RIGHT';
1024
+ entry.title = String(hunk.file) + ':' + String(hunk.line);
1025
+ entry.appendChild(make('span', 'order-rank', String(rank)));
1026
+ var main = make('div', 'order-main');
1027
+ var where = pathNode(hunk.file, 'order-path');
1028
+ where.appendChild(make('span', 'path-line', ':' + String(hunk.line)));
1029
+ main.appendChild(where);
1030
+ var meta = make('div', 'order-meta');
1031
+ // Only a status that asks for something is shown; low and passed stay quiet.
1032
+ if (hunk.status === 'attention' || hunk.status === 'uncertain') meta.appendChild(statusTag(hunk.status));
1033
+ meta.appendChild(sizeNode(hunk.added, hunk.removed));
1034
+ main.appendChild(meta);
1035
+ var jump = make('a', 'sr-only', 'Go to change ' + rank + ', ' + hunk.file + ' line ' + hunk.line);
1036
+ jump.href = '#diff-section';
1037
+ jump.dataset.action = 'goto-stop';
1038
+ jump.dataset.rank = entry.dataset.rank;
1039
+ main.appendChild(jump);
1040
+ entry.appendChild(main);
1041
+ return entry;
1042
+ }
1043
+
1044
+ /**
1045
+ * What the agent says about one change and where to look, shown above its
1046
+ * code. A rank badge marks it when no stop header already carries the number.
1047
+ */
1048
+ function stopWhy(hunk, rank) {
1049
+ var verdicts = answeredVerdicts(hunk);
1050
+ var facts = Array.isArray(hunk.facts) ? hunk.facts : [];
1051
+ var note = typeof hunk.note === 'string' ? hunk.note : '';
1052
+ if (rank === 0 && verdicts.length === 0 && facts.length === 0 && note === '') return null;
1053
+ var why = make('div', 'stop-why' + (rank > 0 ? ' has-rank' : ''));
1054
+ if (rank > 0) {
1055
+ var badge = make('span', 'why-rank', String(rank));
1056
+ badge.setAttribute('aria-label', 'Change ' + rank + ' in the reading order');
1057
+ why.appendChild(badge);
1058
+ }
1059
+ var main = make('div', 'why-main');
1060
+ if (verdicts.length > 0) {
1061
+ var said = make('div', 'tag-row');
1062
+ said.appendChild(make('span', 'sr-only', 'Your agent says: '));
1063
+ for (var v = 0; v < verdicts.length; v += 1) {
1064
+ var tag = make('span', 'tag tone-' + String(verdicts[v].verdict.tone), String(verdicts[v].verdict.label));
1065
+ tag.title = String(verdicts[v].text);
1066
+ said.appendChild(tag);
1067
+ }
1068
+ main.appendChild(said);
1069
+ }
1070
+ if (facts.length > 0) {
1071
+ var tags = make('div', 'tag-row');
1072
+ tags.appendChild(make('span', 'tag-lead', 'Look at'));
1073
+ for (var f = 0; f < facts.length; f += 1) {
1074
+ var fact = facts[f];
1075
+ var chip;
1076
+ if (fact.at && typeof fact.at.line === 'number') {
1077
+ chip = gotoButton(hunk.file, fact.at.line, fact.at.side, '', 'fact');
1078
+ chip.appendChild(make('span', 'fact-name', String(fact.label)));
1079
+ chip.appendChild(make('span', 'fact-line', 'L' + String(fact.at.line)));
1080
+ chip.setAttribute('aria-label', String(fact.label) + ' on line ' + fact.at.line + ': ' + String(fact.text));
1081
+ } else {
1082
+ chip = make('span', 'fact');
1083
+ chip.appendChild(make('span', 'fact-name', String(fact.label)));
1084
+ }
1085
+ chip.title = String(fact.text);
1086
+ tags.appendChild(chip);
1087
+ }
1088
+ main.appendChild(tags);
1089
+ }
1090
+ if (note !== '') main.appendChild(make('p', 'why-note', note));
1091
+ why.appendChild(main);
1092
+ return why;
1093
+ }
1094
+
1095
+ function renderAnalysisDetails(current) {
1096
+ el.detailsAnalysis.textContent = '';
1097
+ if (!current) return;
1098
+ el.detailsAnalysis.appendChild(make('h3', 'subhead', 'Local analysis'));
1099
+ var counts = current.counts || {};
1100
+ var parts = [];
1101
+ for (var c = 0; c < STATUS_ORDER.length; c += 1) parts.push(String(counts[STATUS_ORDER[c]] || 0) + ' ' + STATUS_ORDER[c]);
1102
+ el.detailsAnalysis.appendChild(make('p', '', 'Hunks by status: ' + parts.join(', ') + '. Statuses come from lexical facts on this machine; no model ran in diffninja.'));
1103
+ if (current.scope && typeof current.scope.note === 'string') el.detailsAnalysis.appendChild(make('p', 'note', current.scope.note));
1104
+ var link = reportLink(current.reportUrl);
1105
+ if (link) { var linkLine = make('p', ''); linkLine.appendChild(link); el.detailsAnalysis.appendChild(linkLine); }
1106
+ if (Array.isArray(current.agenda) && current.agenda.length > 0) {
1107
+ var agenda = make('ul', 'agenda-list');
1108
+ for (var a = 0; a < current.agenda.length; a += 1) {
1109
+ var li = make('li', '');
1110
+ li.appendChild(make('strong', '', String(current.agenda[a].title)));
1111
+ li.appendChild(make('span', 'note', ' ' + String(current.agenda[a].reason)));
1112
+ agenda.appendChild(li);
1113
+ }
1114
+ el.detailsAnalysis.appendChild(agenda);
1115
+ }
1116
+ }
1117
+
1118
+ function renderAnalysis() {
1119
+ var snap = snapshot();
1120
+ show(el.analysisSection, Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== ''));
1121
+ el.analysisBody.textContent = '';
1122
+ el.analysisActions.textContent = '';
1123
+ setText(el.analysisSub, '');
1124
+ renderAnalysisDetails(currentAnalysis());
1125
+ if (!snap) return;
1126
+ if (!analysis || analysisFor !== snap.id) {
1127
+ el.analysisBody.appendChild(make('p', 'note', 'Reading the changes\u2026'));
1128
+ if (!analysisLoading) loadAnalysis();
1129
+ return;
1130
+ }
1131
+ if (analysis.available !== true) {
1132
+ el.analysisBody.appendChild(make('p', 'note', typeof analysis.reason === 'string' ? analysis.reason : 'No analysis is available for this revision. Read the diff below.'));
1133
+ return;
1134
+ }
1135
+ if (analysis.snapshotId !== snap.id) {
1136
+ el.analysisBody.appendChild(make('p', 'note', 'The pull request changed; reading the new revision\u2026'));
1137
+ return;
1138
+ }
1139
+ var byAgent = analysis.order && analysis.order.source === 'agent';
1140
+ var total = analysis.questions ? analysis.questions.total : 0;
1141
+ var answered = analysis.questions ? analysis.questions.answered : 0;
1142
+ setText(el.analysisSub, (byAgent
1143
+ ? 'Most important first, as ' + String(analysis.order.orderedBy) + ' recommends. Tags are its answers.'
1144
+ : 'Waiting for your agent\u2019s order; this is diffninja\u2019s until it arrives.')
1145
+ + (total > 0 && answered < total ? ' ' + answered + ' of ' + total + ' answers in.' : ''));
1146
+ var flowFiles = Array.isArray(analysis.callFlowFiles) ? analysis.callFlowFiles : [];
1147
+ if (flowFiles.length > 0) {
1148
+ el.analysisActions.appendChild(flowButton('', flowFiles.length === 1 ? 'Call flow' : 'Call flows (' + flowFiles.length + ' files)', 'btn btn-sm'));
1149
+ } else if (analysis.scope && analysis.scope.source === 'patch' && typeof analysis.scope.note === 'string') {
1150
+ el.analysisBody.appendChild(make('p', 'inline-note', 'No call-flow diagrams: ' + analysis.scope.note.replace(/^Patch-only: /, '')));
1151
+ }
1152
+ if (!el.flowDrawer.hidden && flowSnapshot !== analysis.snapshotId) closeFlow();
1153
+ var list = make('ol', 'order-list');
1154
+ var hunks = Array.isArray(analysis.hunks) ? analysis.hunks : [];
1155
+ for (var h = 0; h < hunks.length; h += 1) list.appendChild(renderHunkEntry(hunks[h], h + 1));
1156
+ el.analysisBody.appendChild(list);
1157
+ }
1158
+
1159
+ function gotoLine(node) {
1160
+ var path = node.getAttribute('data-path');
1161
+ var line = node.getAttribute('data-line');
1162
+ var side = node.getAttribute('data-side');
1163
+ var rows = el.diffBody.querySelectorAll('.diff-row');
1164
+ var target = null;
1165
+ for (var i = 0; i < rows.length; i += 1) {
1166
+ var row = rows[i];
1167
+ if (row.getAttribute('data-path') !== path) continue;
1168
+ if (row.getAttribute('data-line') === line && row.getAttribute('data-side') === side) { target = row; break; }
1169
+ if (target === null && row.getAttribute('data-line') === line) target = row;
1170
+ }
1171
+ if (target === null) return;
1172
+ var file = target.closest('details.file-block');
1173
+ if (file && !file.open) { file.open = true; closedFiles[path] = false; }
1174
+ // Land in the top quarter, above the line the reading-order spy measures from.
1175
+ window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.25 });
1176
+ target.classList.add('is-target');
1177
+ setTimeout(function () { target.classList.remove('is-target'); }, 2000);
1178
+ var action = target.querySelector('button');
1179
+ if (action) action.focus({ preventScroll: true });
1180
+ }
1181
+
1182
+ /* -------------------------------------------------------- you are here -- */
1183
+
1184
+ var spyQueued = false;
1185
+
1186
+ /** The diff row a station points at, or null when that line is not shown. */
1187
+ function stationRow(station) {
1188
+ var selector = '.diff-row[data-path="' + CSS.escape(station.dataset.path || '') + '"][data-line="' + CSS.escape(station.dataset.line || '') + '"][data-side="' + CSS.escape(station.dataset.side || '') + '"]';
1189
+ return el.diffBody.querySelector(selector);
1190
+ }
1191
+
1192
+ /** The rank of the last stop that has reached the window's reading line, or 0 above the first and once past the last. */
1193
+ function currentStopRank() {
1194
+ var sections = el.diffBody.querySelectorAll('.stop');
1195
+ // Near the top, so a short stop that was jumped to is the current one, not the next.
1196
+ var line = Math.min(window.innerHeight * 0.33, 160);
1197
+ var at = null;
1198
+ for (var i = 0; i < sections.length; i += 1) {
1199
+ if (sections[i].getBoundingClientRect().top > line) break;
1200
+ at = sections[i];
1201
+ }
1202
+ if (!at || (at === sections[sections.length - 1] && at.getBoundingClientRect().bottom <= line)) return 0;
1203
+ return Number(at.dataset.rank);
1204
+ }
1205
+
1206
+ /**
1207
+ * By file, the station whose hunk the reader is looking at: the last hunk
1208
+ * whose first line has scrolled past the top third of the window. Stations
1209
+ * are in reading order, not file order, so every one is measured.
1210
+ */
1211
+ function currentFileRank(stations) {
1212
+ var line = window.innerHeight * 0.33;
1213
+ var rank = 0;
1214
+ var bestTop = -Infinity;
1215
+ for (var i = 0; i < stations.length; i += 1) {
1216
+ var row = stationRow(stations[i]);
1217
+ if (!row || row.offsetParent === null) continue;
1218
+ var top = row.getBoundingClientRect().top;
1219
+ if (top <= line && top > bestTop) { rank = Number(stations[i].dataset.rank); bestTop = top; }
1220
+ }
1221
+ return rank;
1222
+ }
1223
+
1224
+ /** The stop a rank is read in: its own, or the one whose hunk already holds its line. */
1225
+ function stopOf(rank) {
1226
+ for (var i = 0; i < stopsNow.length; i += 1) {
1227
+ for (var h = 0; h < stopsNow[i].hunks.length; h += 1) {
1228
+ if (stopsNow[i].hunks[h].rank === rank) return stopsNow[i];
1229
+ }
1230
+ }
1231
+ return null;
1232
+ }
1233
+
1234
+ /**
1235
+ * Mark where the reader is on the rail and what they have read. In the
1236
+ * reading order the stops run 1, 2, 3 down the page, so the rail's list
1237
+ * follows each new one; by file the stations of one file sit far apart in the
1238
+ * reading order, so the list stays where the reader put it.
1239
+ */
1240
+ function markCurrentStation() {
1241
+ spyQueued = false;
1242
+ fitRail();
1243
+ var snap = snapshot();
1244
+ if (snap && seenFor !== snap.id) { seenFor = snap.id; seenRanks = Object.create(null); }
1245
+ var stations = el.analysisBody.querySelectorAll('.order-item');
1246
+ var guided = el.diffBody.querySelector('.stop') !== null;
1247
+ currentRank = guided ? currentStopRank() : currentFileRank(stations);
1248
+ // Every change read in the current stop is current, and read.
1249
+ var here = Object.create(null);
1250
+ var stop = guided ? stopOf(currentRank) : null;
1251
+ if (stop) {
1252
+ for (var h = 0; h < stop.hunks.length; h += 1) here[stop.hunks[h].rank] = true;
1253
+ } else if (currentRank > 0) here[currentRank] = true;
1254
+ for (var seen in here) seenRanks[seen] = true;
1255
+ var current = null;
1256
+ for (var j = 0; j < stations.length; j += 1) {
1257
+ var rank = Number(stations[j].dataset.rank);
1258
+ var on = here[rank] === true;
1259
+ if (rank === currentRank) current = stations[j];
1260
+ stations[j].classList.toggle('is-current', on);
1261
+ stations[j].classList.toggle('is-seen', seenRanks[rank] === true);
1262
+ if (on) stations[j].setAttribute('aria-current', 'step');
1263
+ else stations[j].removeAttribute('aria-current');
1264
+ }
1265
+ show(el.railProgress, stations.length > 0);
1266
+ setText(el.railAt, currentRank > 0 ? 'Change ' + currentRank + ' of ' + stations.length : stations.length + (stations.length === 1 ? ' change' : ' changes') + ' to read');
1267
+ // Only a new current change moves the list, so a list the reader scrolled stays put while they read on.
1268
+ if (guided && current && currentRank !== followedRank) {
1269
+ followedRank = currentRank;
1270
+ var list = el.analysisBody;
1271
+ var box = current.getBoundingClientRect();
1272
+ var frame = list.getBoundingClientRect();
1273
+ if (box.top < frame.top) list.scrollTop -= frame.top - box.top + 8;
1274
+ else if (box.bottom > frame.bottom) list.scrollTop += box.bottom - frame.bottom + 8;
1275
+ }
1276
+ }
1277
+
1278
+ /** Keep the whole rail, its finish button included, inside the window before it pins below the masthead. */
1279
+ function fitRail() {
1280
+ var rail = el.analysisSection;
1281
+ if (rail.hidden) return;
1282
+ if (window.getComputedStyle(rail).position !== 'sticky') { rail.style.maxHeight = ''; return; }
1283
+ var top = Math.max(16, rail.getBoundingClientRect().top);
1284
+ rail.style.maxHeight = Math.max(240, window.innerHeight - top - 16) + 'px';
1285
+ }
1286
+
1287
+ function queueStationMark() {
1288
+ if (spyQueued) return;
1289
+ spyQueued = true;
1290
+ window.requestAnimationFrame(markCurrentStation);
1291
+ }
1292
+
1293
+ /** Bring a change into view: its stop in the reading order, or its first line by file. */
1294
+ function gotoStop(rank) {
1295
+ var stop = stopOf(rank);
1296
+ var section = stop ? byId('stop-' + stop.rank) : null;
1297
+ if (section) {
1298
+ window.scrollTo({ top: section.getBoundingClientRect().top + window.scrollY - 12 });
1299
+ currentRank = stop.rank;
1300
+ var head = section.querySelector('.stop-head');
1301
+ if (head) head.focus({ preventScroll: true });
1302
+ queueStationMark();
1303
+ return;
1304
+ }
1305
+ var entry = el.analysisBody.querySelector('.order-item[data-rank="' + rank + '"]');
1306
+ if (!entry) return;
1307
+ gotoLine(entry);
1308
+ currentRank = rank;
1309
+ queueStationMark();
1310
+ }
1311
+
1312
+ /** j and k step through the changes in reading order, from wherever the reader is. */
1313
+ function onStepKey(event_) {
1314
+ if (event_.defaultPrevented || event_.metaKey || event_.ctrlKey || event_.altKey) return;
1315
+ if (event_.key !== 'j' && event_.key !== 'k') return;
1316
+ var target = event_.target;
1317
+ if (target && target.closest && target.closest('input, textarea, select, [contenteditable]')) return;
1318
+ if (!el.flowDrawer.hidden) return;
1319
+ var ranks = [];
1320
+ if (el.diffBody.querySelector('.stop') !== null) {
1321
+ for (var i = 0; i < stopsNow.length; i += 1) ranks.push(stopsNow[i].rank);
1322
+ } else {
1323
+ var stations = el.analysisBody.querySelectorAll('.order-item');
1324
+ for (var s = 0; s < stations.length; s += 1) if (stationRow(stations[s])) ranks.push(Number(stations[s].dataset.rank));
1325
+ }
1326
+ var next = 0;
1327
+ for (var r = 0; r < ranks.length; r += 1) {
1328
+ if (event_.key === 'j' && ranks[r] > currentRank) { next = ranks[r]; break; }
1329
+ if (event_.key === 'k' && ranks[r] < currentRank) next = ranks[r];
1330
+ }
1331
+ if (next === 0) return;
1332
+ event_.preventDefault();
1333
+ gotoStop(next);
1334
+ }
1335
+
1336
+ function readView() {
1337
+ try { return window.localStorage.getItem(VIEW_KEY) === 'file' ? 'file' : 'guided'; } catch (error) { return 'guided'; }
1338
+ }
1339
+
1340
+ /** Switch the diff's layout and land on the change the reader was at. */
1341
+ function setView(next) {
1342
+ if ((next !== 'guided' && next !== 'file') || next === view) return;
1343
+ var rank = currentRank;
1344
+ view = next;
1345
+ try { window.localStorage.setItem(VIEW_KEY, next); } catch (error) { /* the choice lasts this page only */ }
1346
+ renderDiff();
1347
+ if (rank > 0) gotoStop(rank);
1348
+ else if (el.diffSection.getBoundingClientRect().top < 0) el.diffSection.scrollIntoView({ block: 'start' });
1349
+ queueStationMark();
1350
+ }
1351
+
1352
+ /** Where the reader is in the reading order, to keep them there when the stops are rebuilt in a new order. */
1353
+ function readingPlace() {
1354
+ var section = currentRank > 0 && stopOf(currentRank) ? byId('stop-' + stopOf(currentRank).rank) : null;
1355
+ return section ? { hunk: section.dataset.hunk, top: section.getBoundingClientRect().top } : null;
1356
+ }
1357
+
1358
+ function keepReadingPlace(place) {
1359
+ if (!place) return;
1360
+ var sections = el.diffBody.querySelectorAll('.stop');
1361
+ for (var i = 0; i < sections.length; i += 1) {
1362
+ if (sections[i].dataset.hunk !== place.hunk) continue;
1363
+ var drift = sections[i].getBoundingClientRect().top - place.top;
1364
+ if (drift !== 0) window.scrollBy(0, drift);
1365
+ return;
1366
+ }
1367
+ }
1368
+
1369
+ /* ----------------------------------------------------------- highlight -- */
1370
+
1371
+ var C_KEYWORDS = 'abstract as async await break case catch chan class const continue crate debugger declare default defer delete do else enum export extends false final finally fn for from func function go if impl implements import in instanceof interface is let loop map match mod mut namespace new nil null of package private protected pub public readonly ref return select self static struct super switch this throw trait true try type typeof undefined unsafe use var void where while with yield';
1372
+ var HASH_KEYWORDS = 'and as assert async await begin break case class continue def del do elif else elsif end ensure esac except export fi finally for from function global if import in is lambda local module next nil None nonlocal not or pass raise require rescue return self then True False unless until when while with yield';
1373
+ var SQL_KEYWORDS = 'add all alter and as asc begin between by case commit create delete desc distinct drop else end exists false from group having if in index inner insert into is join key left like limit not null offset on or order outer primary references returning right rollback select set table then true union unique update using values when where with';
1374
+ var LANGUAGES = {
1375
+ c: { line: ['//'], block: true, quotes: '"\\'\`', words: C_KEYWORDS },
1376
+ hash: { line: ['#'], block: false, quotes: '"\\'', words: HASH_KEYWORDS },
1377
+ sql: { line: ['--'], block: true, quotes: '\\'"', words: SQL_KEYWORDS, caseless: true },
1378
+ json: { line: [], block: false, quotes: '"', words: 'true false null' },
1379
+ css: { line: [], block: true, quotes: '"\\'', words: 'important' }
1380
+ };
1381
+ var EXTENSIONS = {
1382
+ c: 'js jsx ts tsx mjs cjs mts cts java kt kts scala swift go rs c h cc cpp hpp cs php dart groovy gradle',
1383
+ hash: 'py rb sh bash zsh fish yml yaml toml pl r ex exs cfg conf ini ps1 psm1 dockerfile makefile',
1384
+ sql: 'sql',
1385
+ json: 'json jsonc',
1386
+ css: 'css scss less'
1387
+ };
1388
+ var languageByExtension = {};
1389
+ Object.keys(EXTENSIONS).forEach(function (name) {
1390
+ EXTENSIONS[name].split(' ').forEach(function (ext) { languageByExtension[ext] = name; });
1391
+ });
1392
+
1393
+ function languageOf(path) {
1394
+ var base = String(path).split('/').pop().toLowerCase();
1395
+ if (base === 'dockerfile' || base === 'makefile') return LANGUAGES.hash;
1396
+ var dot = base.lastIndexOf('.');
1397
+ var name = dot >= 0 ? languageByExtension[base.slice(dot + 1)] : undefined;
1398
+ return name ? LANGUAGES[name] : null;
1399
+ }
1400
+
1401
+ function wordSet(language) {
1402
+ if (!language.set) {
1403
+ language.set = Object.create(null);
1404
+ language.words.split(' ').forEach(function (word) { language.set[language.caseless ? word.toLowerCase() : word] = true; });
1405
+ }
1406
+ return language.set;
1407
+ }
1408
+
1409
+ /**
1410
+ * Split one line into [className, text] tokens. \`state.block\` carries an open
1411
+ * block comment and \`state.quote\` an open template string into the next line
1412
+ * of the same file. Text is only ever placed with textContent.
1413
+ */
1414
+ function tokenize(text, language, state) {
1415
+ var out = [];
1416
+ var i = 0;
1417
+ var plain = '';
1418
+ function flush() { if (plain !== '') { out.push(['', plain]); plain = ''; } }
1419
+ function push(cls, value) { flush(); out.push([cls, value]); }
1420
+ var words = wordSet(language);
1421
+ while (i < text.length) {
1422
+ if (state.block) {
1423
+ var close = text.indexOf('*/', i);
1424
+ if (close < 0) { push('tok-c', text.slice(i)); return out; }
1425
+ push('tok-c', text.slice(i, close + 2)); i = close + 2; state.block = false; continue;
1426
+ }
1427
+ if (state.quote) {
1428
+ var q = state.quote;
1429
+ var j = i;
1430
+ while (j < text.length && text[j] !== q) j += text[j] === '\\\\' ? 2 : 1;
1431
+ if (j >= text.length) { push('tok-s', text.slice(i)); return out; }
1432
+ push('tok-s', text.slice(i, j + 1)); i = j + 1; state.quote = ''; continue;
1433
+ }
1434
+ var ch = text[i];
1435
+ var lineComment = false;
1436
+ for (var l = 0; l < language.line.length; l += 1) {
1437
+ if (text.startsWith(language.line[l], i)) { lineComment = true; break; }
1438
+ }
1439
+ if (lineComment) { push('tok-c', text.slice(i)); return out; }
1440
+ if (language.block && text.startsWith('/*', i)) { state.block = true; continue; }
1441
+ if (language.quotes.indexOf(ch) >= 0) {
1442
+ var k = i + 1;
1443
+ while (k < text.length && text[k] !== ch) k += text[k] === '\\\\' ? 2 : 1;
1444
+ if (k >= text.length) {
1445
+ if (ch === '\`') { push('tok-s', text.slice(i)); state.quote = '\`'; return out; }
1446
+ push('tok-s', text.slice(i)); return out;
1447
+ }
1448
+ var literal = text.slice(i, k + 1);
1449
+ var rest = text.slice(k + 1);
1450
+ push(language === LANGUAGES.json && /^\\s*:/.test(rest) ? 'tok-p' : 'tok-s', literal);
1451
+ i = k + 1; continue;
1452
+ }
1453
+ if (/[0-9]/.test(ch) && !/[A-Za-z0-9_$]/.test(text[i - 1] || '')) {
1454
+ var num = /^(0x[0-9a-fA-F_]+|[0-9][0-9_]*(\\.[0-9_]+)?([eE][+-]?[0-9]+)?[a-zA-Z]*)/.exec(text.slice(i));
1455
+ push('tok-n', num[0]); i += num[0].length; continue;
1456
+ }
1457
+ if (/[A-Za-z_$@]/.test(ch)) {
1458
+ var word = /^[A-Za-z_$@][A-Za-z0-9_$]*/.exec(text.slice(i))[0];
1459
+ var after = text.slice(i + word.length);
1460
+ var key = language.caseless ? word.toLowerCase() : word;
1461
+ if (words[key]) push('tok-k', word);
1462
+ else if (/^\\s*\\(/.test(after)) push('tok-f', word);
1463
+ else if (/^[A-Z][a-z0-9]/.test(word) && language !== LANGUAGES.sql) push('tok-t', word);
1464
+ else plain += word;
1465
+ i += word.length; continue;
1466
+ }
1467
+ plain += ch; i += 1;
1468
+ }
1469
+ flush();
1470
+ return out;
1471
+ }
1472
+
1473
+ function codeNode(text, language, state) {
1474
+ var code = make('code', 'diff-code');
1475
+ if (!language || text.length > 2000) { code.textContent = text; return code; }
1476
+ var tokens = tokenize(text, language, state);
1477
+ for (var t = 0; t < tokens.length; t += 1) {
1478
+ if (tokens[t][0] === '') code.appendChild(document.createTextNode(tokens[t][1]));
1479
+ else code.appendChild(make('span', tokens[t][0], tokens[t][1]));
1480
+ }
1481
+ return code;
1482
+ }
1483
+
1484
+ /* ---------------------------------------------------------------- diff -- */
1485
+
1486
+ /* ------------------------------------------------------------ call flow -- */
1487
+
1488
+ function hasCallFlow(path) {
1489
+ var current = currentAnalysis();
1490
+ return Boolean(current && Array.isArray(current.callFlowFiles) && current.callFlowFiles.indexOf(path) >= 0);
1491
+ }
1492
+
1493
+ function flowButton(path, label, className) {
1494
+ var button = make('button', className || 'btn btn-quiet', label);
1495
+ button.type = 'button';
1496
+ button.dataset.action = 'open-flow';
1497
+ if (path !== '') button.dataset.path = path;
1498
+ button.setAttribute('aria-label', path === '' ? 'Open the call flows of every changed file' : 'Open the call flow of ' + path);
1499
+ return button;
1500
+ }
1501
+
1502
+ /** Show the call flow of one file, or of every file for an empty path, in the drawer beside the diff. */
1503
+ function openFlow(path, opener) {
1504
+ var current = currentAnalysis();
1505
+ if (!current) return;
1506
+ var src = '/flow?snapshot=' + encodeURIComponent(current.snapshotId) + (path === '' ? '' : '&file=' + encodeURIComponent(path));
1507
+ if (el.flowFrame.getAttribute('src') !== src) el.flowFrame.setAttribute('src', src);
1508
+ flowSnapshot = current.snapshotId;
1509
+ setText(el.flowTitle, path === '' ? 'Call flows' : 'Call flow: ' + path);
1510
+ el.flowFrame.title = path === '' ? 'Call flows of every changed file' : 'Call flow of ' + path;
1511
+ if (el.flowDrawer.hidden) flowReturn = opener || document.activeElement;
1512
+ show(el.flowDrawer, true);
1513
+ document.body.classList.add('flow-open');
1514
+ el.flowClose.focus();
1515
+ }
1516
+
1517
+ function closeFlow() {
1518
+ if (el.flowDrawer.hidden) return;
1519
+ show(el.flowDrawer, false);
1520
+ document.body.classList.remove('flow-open', 'flow-full');
1521
+ // The opener may have been re-rendered meanwhile: return to its replacement.
1522
+ var back = flowReturn && flowReturn.isConnected ? flowReturn : null;
1523
+ if (!back && flowReturn && flowReturn.dataset && flowReturn.dataset.action === 'open-flow') {
1524
+ back = document.querySelector(flowReturn.dataset.path
1525
+ ? '[data-action="open-flow"][data-path="' + CSS.escape(flowReturn.dataset.path) + '"]'
1526
+ : '[data-action="open-flow"]:not([data-path])');
1527
+ }
1528
+ if (back) back.focus();
1529
+ flowReturn = null;
1530
+ }
1531
+
1532
+ /** The diagram in the drawer opened or closed: the drawer takes the whole window while it is open. */
1533
+ function onFlowMessage(event_) {
1534
+ if (event_.origin !== window.location.origin || event_.source !== el.flowFrame.contentWindow) return;
1535
+ var data = event_.data;
1536
+ if (!data || data.type !== 'diffninja-diagram') return;
1537
+ document.body.classList.toggle('flow-full', data.open === true);
1538
+ }
1539
+
1540
+ function onFlowKey(event_) {
1541
+ // The diagram inside the drawer handles its own Escape first.
1542
+ if (event_.defaultPrevented) return;
1543
+ if (event_.key === 'Escape' && !el.flowDrawer.hidden) { event_.preventDefault(); closeFlow(); }
1544
+ }
1545
+
1546
+ /* --------------------------------------------------------- suggestions -- */
1547
+
1548
+ function anchorKeyOf(anchor) { return anchor.path + '|' + anchor.side + ':' + anchor.line; }
1549
+
1550
+ function suggestionKey(snapId, suggestion) { return snapId + '|' + anchorKeyOf(suggestion) + '|' + suggestion.body; }
1551
+
1552
+ function suggestedBy() {
1553
+ var current = currentAnalysis();
1554
+ return current && current.suggestions && typeof current.suggestions.suggestedBy === 'string' ? current.suggestions.suggestedBy : 'your agent';
1555
+ }
1556
+
1557
+ /**
1558
+ * The agent's suggested comments on the loaded revision that the reviewer has
1559
+ * neither added nor dismissed. A suggestion is never part of the review until
1560
+ * a human adds it; one naming a line this revision lacks is not offered.
1561
+ */
1562
+ function openSuggestions() {
1563
+ var current = currentAnalysis();
1564
+ if (!current || !current.suggestions || !Array.isArray(current.suggestions.comments)) return [];
1565
+ var out = [];
1566
+ var list = current.suggestions.comments;
1567
+ for (var i = 0; i < list.length; i += 1) {
1568
+ var s = list[i];
1569
+ var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
1570
+ && (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== '';
1571
+ if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
1572
+ out.push(s);
1573
+ }
1574
+ return out;
1575
+ }
1576
+
1577
+ function suggestionAt(node) {
1578
+ var key = (node.getAttribute('data-path') || '') + '|' + node.getAttribute('data-side') + ':' + node.getAttribute('data-line');
1579
+ var open = openSuggestions();
1580
+ for (var i = 0; i < open.length; i += 1) {
1581
+ if (anchorKeyOf(open[i]) === key) return open[i];
1582
+ }
1583
+ return null;
1584
+ }
1585
+
1586
+ /** Put a suggestion into the reviewer's draft: a new comment on its line, or appended to the comment already there. */
1587
+ function adoptSuggestion(suggestion) {
1588
+ var snap = snapshot();
1589
+ var current = currentAnalysis();
1590
+ if (!snap || !current) return;
1591
+ settled[suggestionKey(current.snapshotId, suggestion)] = true;
1592
+ var at = commentIndexAt(suggestion);
1593
+ if (at >= 0) {
1594
+ var existing = comments[at].body.trim();
1595
+ comments[at].body = existing === '' ? suggestion.body : existing + ' ' + suggestion.body;
1596
+ return;
1597
+ }
1598
+ commentSeq += 1;
1599
+ comments.push({
1600
+ id: 'c' + commentSeq,
1601
+ path: suggestion.path,
1602
+ line: suggestion.line,
1603
+ side: suggestion.side,
1604
+ body: suggestion.body,
1605
+ snapshotId: snap.id,
1606
+ needsRevalidation: false,
1607
+ suggestedBy: suggestedBy()
1608
+ });
1609
+ }
1610
+
1611
+ function addSuggestion(node) {
1612
+ if (composeDisabled()) return;
1613
+ var suggestion = suggestionAt(node);
1614
+ if (!suggestion) return;
1615
+ adoptSuggestion(suggestion);
1616
+ saveDraft();
1617
+ render();
1618
+ }
1619
+
1620
+ function addAllSuggestions() {
1621
+ if (composeDisabled()) return;
1622
+ var open = openSuggestions();
1623
+ for (var i = 0; i < open.length; i += 1) adoptSuggestion(open[i]);
1624
+ saveDraft();
1625
+ render();
1626
+ }
1627
+
1628
+ function dismissSuggestion(node) {
1629
+ var suggestion = suggestionAt(node);
1630
+ var current = currentAnalysis();
1631
+ if (!suggestion || !current) return;
1632
+ settled[suggestionKey(current.snapshotId, suggestion)] = true;
1633
+ saveDraft();
1634
+ render();
1635
+ }
1636
+
1637
+ function suggestionRow(suggestion, disabled) {
1638
+ var wrap = make('div', 'suggestion');
1639
+ var head = make('p', 'suggestion-by');
1640
+ head.appendChild(make('span', 'agent-dot', ''));
1641
+ head.appendChild(make('strong', '', suggestedBy()));
1642
+ head.appendChild(document.createTextNode(' suggests'));
1643
+ wrap.appendChild(head);
1644
+ wrap.appendChild(make('p', 'suggestion-body', suggestion.body));
1645
+ var actions = make('div', 'suggestion-actions');
1646
+ var add = make('button', 'btn btn-primary btn-sm', 'Add to review');
1647
+ add.type = 'button';
1648
+ add.dataset.action = 'add-suggestion';
1649
+ add.dataset.path = suggestion.path;
1650
+ add.dataset.line = String(suggestion.line);
1651
+ add.dataset.side = suggestion.side;
1652
+ add.disabled = disabled;
1653
+ add.setAttribute('aria-label', 'Add the suggested comment on line ' + suggestion.line + ' of ' + suggestion.path + ' to your review');
1654
+ var dismiss = make('button', 'btn btn-quiet btn-sm', 'Dismiss');
1655
+ dismiss.type = 'button';
1656
+ dismiss.dataset.action = 'dismiss-suggestion';
1657
+ dismiss.dataset.path = suggestion.path;
1658
+ dismiss.dataset.line = String(suggestion.line);
1659
+ dismiss.dataset.side = suggestion.side;
1660
+ dismiss.setAttribute('aria-label', 'Dismiss the suggested comment on line ' + suggestion.line + ' of ' + suggestion.path);
1661
+ actions.appendChild(add);
1662
+ actions.appendChild(dismiss);
1663
+ wrap.appendChild(actions);
1664
+ return wrap;
1665
+ }
1666
+
1667
+ /** The bar above the diff: add every open suggestion at once, or where the added ones went. */
1668
+ function suggestionBar(open, disabled) {
1669
+ var adopted = 0;
1670
+ for (var i = 0; i < comments.length; i += 1) {
1671
+ if (comments[i].suggestedBy && !comments[i].needsRevalidation) adopted += 1;
1672
+ }
1673
+ if (open.length === 0 && adopted === 0) return null;
1674
+ var bar = make('div', 'suggest-bar');
1675
+ if (open.length > 0) {
1676
+ var said = make('p', '');
1677
+ said.appendChild(make('span', 'agent-dot', ''));
1678
+ said.appendChild(make('strong', '', suggestedBy()));
1679
+ said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment' : ' comments')
1680
+ + ' on the lines below. Nothing is posted until you submit.'));
1681
+ bar.appendChild(said);
1682
+ var all = make('button', 'btn btn-primary btn-sm', open.length === 1 ? 'Add it to review' : 'Add all ' + open.length);
1683
+ all.type = 'button';
1684
+ all.dataset.action = 'add-all-suggestions';
1685
+ all.disabled = disabled;
1686
+ bar.appendChild(all);
1687
+ } else {
1688
+ bar.appendChild(make('p', '', adopted === 1
1689
+ ? '1 suggested comment is in your draft. Read it over, then check and submit below.'
1690
+ : adopted + ' suggested comments are in your draft. Read them over, then check and submit below.'));
1691
+ var go = make('a', 'bar-link', 'Review and submit \u2193');
1692
+ go.href = '#compose-heading';
1693
+ bar.appendChild(go);
1694
+ }
1695
+ return bar;
1696
+ }
1697
+
1698
+ function readingRank() {
1699
+ var rank = Object.create(null);
1700
+ var current = currentAnalysis();
1701
+ var hunks = current && Array.isArray(current.hunks) ? current.hunks : [];
1702
+ for (var h = 0; h < hunks.length; h += 1) {
1703
+ if (hunks[h] && !(hunks[h].file in rank)) rank[hunks[h].file] = h;
1704
+ }
1705
+ return rank;
1706
+ }
1707
+
1708
+ /**
1709
+ * Split one file's diff lines into its hunks: a new-side line that does not
1710
+ * follow the previous one starts another, together with any removed lines
1711
+ * right before it.
1712
+ */
1713
+ function segmentsOf(path, fileLines) {
1714
+ var segments = [];
1715
+ var current = [];
1716
+ var lastNew = 0;
1717
+ for (var i = 0; i < fileLines.length; i += 1) {
1718
+ var line = fileLines[i];
1719
+ if (line.side === 'RIGHT') {
1720
+ if (lastNew > 0 && line.line > lastNew + 1) {
1721
+ var cut = current.length;
1722
+ while (cut > 0 && current[cut - 1].kind === 'delete') cut -= 1;
1723
+ if (cut > 0) { segments.push({ path: path, lines: current.slice(0, cut), stop: null }); current = current.slice(cut); }
1724
+ }
1725
+ lastNew = line.line;
1726
+ }
1727
+ current.push(line);
1728
+ }
1729
+ if (current.length > 0) segments.push({ path: path, lines: current, stop: null });
1730
+ return segments;
1731
+ }
1732
+
1733
+ /**
1734
+ * The stops of the reading order: each ranked hunk with the diff segment its
1735
+ * first line is in. A hunk whose line shares a segment with a higher-ranked
1736
+ * one is read in that stop; one whose line is not in the diff has no stop.
1737
+ */
1738
+ function readingStops(groups) {
1739
+ var current = currentAnalysis();
1740
+ var hunks = current && Array.isArray(current.hunks) ? current.hunks : [];
1741
+ var where = Object.create(null);
1742
+ for (var g = 0; g < groups.length; g += 1) {
1743
+ for (var s = 0; s < groups[g].segments.length; s += 1) {
1744
+ var segment = groups[g].segments[s];
1745
+ for (var l = 0; l < segment.lines.length; l += 1) {
1746
+ var key = anchorKeyOf(segment.lines[l]);
1747
+ if (!(key in where)) where[key] = segment;
1748
+ }
1749
+ }
1750
+ }
1751
+ var stops = [];
1752
+ for (var h = 0; h < hunks.length; h += 1) {
1753
+ var hunk = hunks[h];
1754
+ if (!hunk) continue;
1755
+ var at = where[anchorKeyOf({ path: String(hunk.file), side: hunk.side === 'LEFT' ? 'LEFT' : 'RIGHT', line: hunk.line })];
1756
+ if (!at) continue;
1757
+ if (at.stop) { at.stop.hunks.push({ hunk: hunk, rank: h + 1 }); continue; }
1758
+ at.stop = { rank: h + 1, path: at.path, segment: at, hunks: [{ hunk: hunk, rank: h + 1 }] };
1759
+ stops.push(at.stop);
1760
+ }
1761
+ return stops;
1762
+ }
1763
+
1764
+ function diffSubText(guided, hasStops) {
1765
+ if (state && state.receipt && typeof state.receipt === 'object') return 'This review is posted. Read the diff here; replies and new reviews happen on GitHub.';
1766
+ var how = 'Hover a line and press + to comment. Comments stay in this tab until you submit.';
1767
+ if (!hasStops) return how;
1768
+ return (guided ? 'Each change in the order to read it. ' : 'File by file. ') + how;
1769
+ }
1770
+
1771
+ function renderDiff() {
1772
+ var snap = snapshot();
1773
+ show(el.diffSection, Boolean(snap));
1774
+ el.diffBody.textContent = '';
1775
+ stopsNow = [];
1776
+ show(el.viewSwitch, false);
1777
+ setText(el.diffSub, diffSubText(false, false));
1778
+ if (!snap) return;
1779
+ if (typeof snap.unavailableReason === 'string' && snap.unavailableReason !== '') {
1780
+ el.diffBody.appendChild(make('p', 'empty', snap.unavailableReason));
1781
+ return;
1782
+ }
1783
+ var list = lines();
1784
+ if (list.length === 0) {
1785
+ el.diffBody.appendChild(make('p', 'empty', 'No reviewable diff lines were returned for this revision.'));
1786
+ return;
1787
+ }
1788
+ var groups = [];
1789
+ var index = Object.create(null);
1790
+ for (var i = 0; i < list.length; i += 1) {
1791
+ var line = list[i];
1792
+ var group = index[line.path];
1793
+ if (!group) { group = { path: line.path, lines: [], order: groups.length }; index[line.path] = group; groups.push(group); }
1794
+ group.lines.push(line);
1795
+ }
1796
+ var rank = readingRank();
1797
+ groups.sort(function (a, b) {
1798
+ var ra = a.path in rank ? rank[a.path] : Infinity;
1799
+ var rb = b.path in rank ? rank[b.path] : Infinity;
1800
+ return ra === rb ? a.order - b.order : ra - rb;
1801
+ });
1802
+ for (var s = 0; s < groups.length; s += 1) groups[s].segments = segmentsOf(groups[s].path, groups[s].lines);
1803
+ stopsNow = readingStops(groups);
1804
+ var guided = stopsNow.length > 0 && view === 'guided';
1805
+ show(el.viewSwitch, stopsNow.length > 0);
1806
+ var switches = el.viewSwitch.querySelectorAll('button');
1807
+ for (var w = 0; w < switches.length; w += 1) switches[w].setAttribute('aria-pressed', String(switches[w].dataset.view === (guided ? 'guided' : 'file')));
1808
+ setText(el.diffSub, diffSubText(guided, stopsNow.length > 0));
1809
+ var disabled = composeDisabled();
1810
+ var open = openSuggestions();
1811
+ var bar = suggestionBar(open, disabled);
1812
+ if (bar) el.diffBody.appendChild(bar);
1813
+ var suggestions = Object.create(null);
1814
+ for (var o = 0; o < open.length; o += 1) suggestions[anchorKeyOf(open[o])] = open[o];
1815
+ if (!guided) {
1816
+ for (var g = 0; g < groups.length; g += 1) el.diffBody.appendChild(renderFileBlock(groups[g], groups[g].segments, disabled, suggestions, true));
1817
+ return;
1818
+ }
1819
+ for (var k = 0; k < stopsNow.length; k += 1) {
1820
+ el.diffBody.appendChild(renderStop(stopsNow[k], k > 0 ? stopsNow[k - 1].path : '', disabled, suggestions));
1821
+ }
1822
+ var rest = [];
1823
+ for (var r = 0; r < groups.length; r += 1) {
1824
+ var unranked = groups[r].segments.filter(function (segment) { return !segment.stop; });
1825
+ if (unranked.length > 0) rest.push({ group: groups[r], segments: unranked });
1826
+ }
1827
+ if (rest.length === 0) return;
1828
+ var head = make('div', 'rest-head');
1829
+ head.appendChild(make('h3', '', 'Other changes'));
1830
+ head.appendChild(make('p', '', 'Not in the reading order. File by file.'));
1831
+ el.diffBody.appendChild(head);
1832
+ for (var t = 0; t < rest.length; t += 1) el.diffBody.appendChild(renderFileBlock(rest[t].group, rest[t].segments, disabled, suggestions, false));
1833
+ }
1834
+
1835
+ function sizeOf(segments) {
1836
+ var added = 0;
1837
+ var removed = 0;
1838
+ for (var s = 0; s < segments.length; s += 1) {
1839
+ for (var i = 0; i < segments[s].lines.length; i += 1) {
1840
+ if (segments[s].lines[i].kind === 'add') added += 1;
1841
+ else if (segments[s].lines[i].kind === 'delete') removed += 1;
1842
+ }
1843
+ }
1844
+ return sizeNode(added, removed);
1845
+ }
1846
+
1847
+ function gapNode(count) {
1848
+ var gap = make('div', 'diff-gap');
1849
+ gap.appendChild(make('span', 'diff-gap-mark', '⋯'));
1850
+ gap.appendChild(make('span', 'diff-gap-text', count + ' unchanged ' + (count === 1 ? 'line' : 'lines')));
1851
+ return gap;
1852
+ }
1853
+
1854
+ /** One change in the reading order: its number and place, what to look at, then its code. */
1855
+ function renderStop(stop, previousPath, disabled, suggestions) {
1856
+ var lead = stop.hunks[0].hunk;
1857
+ var section = make('section', 'stop');
1858
+ section.id = 'stop-' + stop.rank;
1859
+ section.dataset.rank = String(stop.rank);
1860
+ section.dataset.hunk = String(lead.id);
1861
+ section.setAttribute('aria-label', 'Change ' + stop.rank + ', ' + stop.path);
1862
+ var head = make('div', 'stop-head');
1863
+ head.tabIndex = -1;
1864
+ head.appendChild(make('span', 'stop-rank', String(stop.rank)));
1865
+ // The directory repeats nothing new when the change before was in the same file.
1866
+ var where = pathNode(stop.path, 'file-path' + (stop.path === previousPath ? ' is-same-file' : ''));
1867
+ where.appendChild(make('span', 'path-line', ':' + String(lead.line)));
1868
+ head.appendChild(where);
1869
+ head.appendChild(sizeOf([stop.segment]));
1870
+ if (lead.status === 'attention' || lead.status === 'uncertain') head.appendChild(statusTag(lead.status));
1871
+ var tools = make('span', 'file-tools');
1872
+ if (hasCallFlow(stop.path)) tools.appendChild(flowButton(stop.path, 'Call flow'));
1873
+ head.appendChild(tools);
1874
+ section.appendChild(head);
1875
+ for (var h = 0; h < stop.hunks.length; h += 1) {
1876
+ var why = stopWhy(stop.hunks[h].hunk, h === 0 ? 0 : stop.hunks[h].rank);
1877
+ if (why) section.appendChild(why);
1878
+ }
1879
+ var rows = make('div', 'diff-rows');
1880
+ renderRows(rows, stop.segment.lines, languageOf(stop.path), disabled, suggestions);
1881
+ section.appendChild(rows);
1882
+ return section;
1883
+ }
1884
+
1885
+ /** A file's diff, or the given hunks of it; with ranks on, each ranked hunk opens with its number and what to look at. */
1886
+ function renderFileBlock(group, segments, disabled, suggestions, ranks) {
1887
+ var block = make('details', 'file-block');
1888
+ block.dataset.path = group.path;
1889
+ block.open = closedFiles[group.path] !== true;
1890
+ block.addEventListener('toggle', function () { closedFiles[group.path] = !block.open; });
1891
+ var head = make('summary', 'file-head');
1892
+ head.appendChild(pathNode(group.path, 'file-path'));
1893
+ head.appendChild(sizeOf(segments));
1894
+ var worst = worstStatusFor(group.path);
1895
+ if (worst === 'attention' || worst === 'uncertain') head.appendChild(statusTag(worst));
1896
+ var tools = make('span', 'file-tools');
1897
+ if (hasCallFlow(group.path)) tools.appendChild(flowButton(group.path, 'Call flow'));
1898
+ head.appendChild(tools);
1899
+ block.appendChild(head);
1900
+ var rows = make('div', 'diff-rows');
1901
+ var language = languageOf(group.path);
1902
+ var lastNew = 0;
1903
+ for (var s = 0; s < segments.length; s += 1) {
1904
+ var segment = segments[s];
1905
+ var firstNew = 0;
1906
+ for (var f = 0; f < segment.lines.length && firstNew === 0; f += 1) if (segment.lines[f].side === 'RIGHT') firstNew = segment.lines[f].line;
1907
+ if (lastNew > 0 && firstNew > lastNew + 1) rows.appendChild(gapNode(firstNew - lastNew - 1));
1908
+ if (ranks && segment.stop) {
1909
+ for (var h = 0; h < segment.stop.hunks.length; h += 1) rows.appendChild(stopWhy(segment.stop.hunks[h].hunk, segment.stop.hunks[h].rank));
1910
+ }
1911
+ renderRows(rows, segment.lines, language, disabled, suggestions);
1912
+ for (var b = segment.lines.length - 1; b >= 0; b -= 1) if (segment.lines[b].side === 'RIGHT') { lastNew = segment.lines[b].line; break; }
1913
+ }
1914
+ block.appendChild(rows);
1915
+ return block;
1916
+ }
1917
+
1918
+ /** Diff rows for consecutive lines, each with its + button, its draft comment and the agent's suggestion. */
1919
+ function renderRows(rows, segmentLines, language, disabled, suggestions) {
1920
+ var state = { block: false, quote: '' };
1921
+ var widest = Number(rows.style.getPropertyValue('--ln-digits')) || 1;
1922
+ for (var n = 0; n < segmentLines.length; n += 1) widest = Math.max(widest, String(segmentLines[n].line).length);
1923
+ rows.style.setProperty('--ln-digits', String(widest));
1924
+ for (var i = 0; i < segmentLines.length; i += 1) {
1925
+ var line = segmentLines[i];
1926
+ var row = make('div', 'diff-row kind-' + String(line.kind));
1927
+ row.dataset.path = line.path;
1928
+ row.dataset.line = String(line.line);
1929
+ row.dataset.side = line.side;
1930
+ var attached = commentIndexAt(line);
1931
+ var arming = reattachIndex >= 0;
1932
+ var gutter = make('span', 'diff-gutter');
1933
+ var action = make('button', 'diff-action' + (arming ? ' is-armed' : '') + (attached >= 0 ? ' has-comment' : ''), arming ? 'Attach' : '+');
1934
+ action.type = 'button';
1935
+ action.dataset.action = 'comment';
1936
+ action.dataset.path = line.path;
1937
+ action.dataset.line = String(line.line);
1938
+ action.dataset.side = line.side;
1939
+ action.disabled = disabled;
1940
+ action.setAttribute('aria-label', arming
1941
+ ? 'Attach the comment awaiting revalidation to line ' + line.line + ' of ' + line.path + ' (' + sideLabel(line.side) + ' side)'
1942
+ : (attached >= 0 ? 'Edit the comment on line ' : 'Comment on line ') + line.line + ' of ' + line.path + ' (' + sideLabel(line.side) + ' side)');
1943
+ gutter.appendChild(action);
1944
+ gutter.appendChild(make('span', 'diff-ln', line.line));
1945
+ row.appendChild(gutter);
1946
+ row.appendChild(make('span', 'diff-mark', line.kind === 'add' ? '+' : line.kind === 'delete' ? '−' : ' '));
1947
+ row.appendChild(codeNode(line.text, language, state));
1948
+ rows.appendChild(row);
1949
+ if (attached >= 0) rows.appendChild(editorRow(attached));
1950
+ var suggestion = suggestions[anchorKeyOf(line)];
1951
+ if (suggestion) rows.appendChild(suggestionRow(suggestion, disabled));
1952
+ }
1953
+ }
1954
+
1955
+ function renderDraftList(disabled) {
1956
+ el.draftList.textContent = '';
1957
+ var anchored = [];
1958
+ for (var i = 0; i < comments.length; i += 1) {
1959
+ if (!comments[i].needsRevalidation) anchored.push(i);
1960
+ }
1961
+ setText(el.draftCount, comments.length === 0 ? 'No line comments yet.' : comments.length + ' line comment' + (comments.length === 1 ? '' : 's') + ' in your draft.');
1962
+ setText(el.railDraft, comments.length === 0 ? 'No comments yet' : comments.length + (comments.length === 1 ? ' comment' : ' comments') + ' in your draft');
1963
+ if (anchored.length === 0) {
1964
+ el.draftList.appendChild(make('p', 'empty', comments.length === 0
1965
+ ? 'Hover a line in the diff and press + to comment on it.'
1966
+ : 'No line comment is attached to the current revision yet. Every comment above needs revalidation.'));
1967
+ return;
1968
+ }
1969
+ var list = make('ul', 'draft-list');
1970
+ for (var a = 0; a < anchored.length; a += 1) {
1971
+ var index = anchored[a];
1972
+ var comment = comments[index];
1973
+ var item = make('li', 'draft-item');
1974
+ item.dataset.index = String(index);
1975
+ var head = make('div', 'draft-head');
1976
+ var where = pathNode(comment.path, 'draft-where');
1977
+ where.appendChild(make('span', 'path-line', ':' + comment.line + (comment.side === 'LEFT' ? ' (old)' : '')));
1978
+ head.appendChild(where);
1979
+ if (comment.suggestedBy) head.appendChild(make('span', 'tag tone-quiet', 'from ' + comment.suggestedBy));
1980
+ var edit = make('button', 'btn btn-quiet btn-sm', 'Edit');
1981
+ edit.type = 'button';
1982
+ edit.dataset.action = 'edit-comment';
1983
+ edit.dataset.index = String(index);
1984
+ edit.disabled = disabled;
1985
+ edit.setAttribute('aria-label', 'Edit the comment on ' + comment.path + ' line ' + comment.line);
1986
+ var remove = make('button', 'btn btn-quiet btn-sm', 'Remove');
1987
+ remove.type = 'button';
1988
+ remove.dataset.action = 'remove-comment';
1989
+ remove.dataset.index = String(index);
1990
+ remove.disabled = disabled;
1991
+ remove.setAttribute('aria-label', 'Remove the comment on ' + comment.path + ' line ' + comment.line);
1992
+ head.appendChild(edit);
1993
+ head.appendChild(remove);
1994
+ item.appendChild(head);
1995
+ var text = make('p', 'draft-body', comment.body === '' ? '(empty)' : comment.body);
1996
+ text.dataset.role = 'body';
1997
+ item.appendChild(text);
1998
+ list.appendChild(item);
1999
+ }
2000
+ el.draftList.appendChild(list);
2001
+ }
2002
+
2003
+ /**
2004
+ * Comments carried over from another revision. Their text is shown with the
2005
+ * coordinate they were written against, even when the loaded diff has no such
2006
+ * line, and each one waits for the human to attach or confirm it. No comment
2007
+ * is dropped, and the loaded code for a still-existing coordinate is shown
2008
+ * next to the Confirm action so the choice is made against real text.
2009
+ */
2010
+ function renderRevalidateList() {
2011
+ var pending = [];
2012
+ for (var i = 0; i < comments.length; i += 1) {
2013
+ if (comments[i].needsRevalidation) pending.push(i);
2014
+ }
2015
+ if (pending.length === 0) reattachIndex = -1;
2016
+ else if (reattachIndex >= 0 && !comments[reattachIndex].needsRevalidation) reattachIndex = -1;
2017
+ show(el.revalidateSection, pending.length > 0);
2018
+ el.revalidateList.textContent = '';
2019
+ setText(el.revalidateNote, '');
2020
+ if (pending.length === 0) return;
2021
+ var locked = busy || submitting;
2022
+ setText(el.revalidateNote, pending.length === 1
2023
+ ? '1 line comment comes from an earlier revision of this pull request. The text is kept, but a line number is not proof of the same code, so preview and submit stay blocked until it is attached to a line below or confirmed against the code shown here.'
2024
+ : pending.length + ' line comments come from an earlier revision of this pull request. The text is kept, but a line number is not proof of the same code, so preview and submit stay blocked until each one is attached to a line below or confirmed against the code shown here.');
2025
+ var list = make('ul', 'draft-list');
2026
+ for (var p = 0; p < pending.length; p += 1) {
2027
+ var index = pending[p];
2028
+ var comment = comments[index];
2029
+ var line = currentLine(comment);
2030
+ var armed = reattachIndex === index;
2031
+ editorSeq += 1;
2032
+ var inputId = 'revalidate-comment-' + String(editorSeq);
2033
+ var hintId = inputId + '-hint';
2034
+ var item = make('li', 'editor revalidate-item' + (armed ? ' is-armed' : ''));
2035
+ item.dataset.index = String(index);
2036
+ var label = make('label', 'sr-only', 'Comment text awaiting revalidation, written for ' + comment.path + ' line ' + comment.line);
2037
+ label.setAttribute('for', inputId);
2038
+ item.appendChild(label);
2039
+ var where = make('p', 'hint revalidate-where', 'Written for ' + comment.path + ':' + comment.line + ' (' + sideLabel(comment.side) + ' side) in an earlier revision.');
2040
+ where.id = hintId;
2041
+ item.appendChild(where);
2042
+ var input = document.createElement('input');
2043
+ input.type = 'text';
2044
+ input.id = inputId;
2045
+ input.className = 'editor-input';
2046
+ input.dataset.index = String(index);
2047
+ input.value = comment.body;
2048
+ input.disabled = locked;
2049
+ input.setAttribute('aria-describedby', hintId);
2050
+ var field = make('div', 'editor-row');
2051
+ field.appendChild(input);
2052
+ item.appendChild(field);
2053
+ var actions = make('div', 'revalidate-actions');
2054
+ if (line) {
2055
+ item.appendChild(make('code', 'revalidate-code', line.text === '' ? '(blank line)' : line.text));
2056
+ var confirm = make('button', 'link-button', 'Confirm this line');
2057
+ confirm.type = 'button';
2058
+ confirm.dataset.action = 'confirm-comment';
2059
+ confirm.dataset.index = String(index);
2060
+ confirm.disabled = locked;
2061
+ confirm.setAttribute('aria-label', 'Confirm the comment against ' + comment.path + ' line ' + comment.line + ' in the current revision');
2062
+ actions.appendChild(confirm);
2063
+ } else {
2064
+ item.appendChild(make('p', 'hint', 'That line is not part of the loaded revision, so it cannot be confirmed. Attach the comment to a line in the diff instead.'));
2065
+ }
2066
+ var arm = make('button', 'link-button', armed ? 'Cancel attach' : 'Attach to a line');
2067
+ arm.type = 'button';
2068
+ arm.dataset.action = armed ? 'cancel-reattach' : 'reattach-comment';
2069
+ arm.dataset.index = String(index);
2070
+ arm.disabled = locked;
2071
+ arm.setAttribute('aria-label', armed
2072
+ ? 'Cancel attaching the comment for ' + comment.path + ' line ' + comment.line
2073
+ : 'Attach the comment for ' + comment.path + ' line ' + comment.line + ' to a line in the diff');
2074
+ actions.appendChild(arm);
2075
+ var remove = make('button', 'link-button', 'Remove');
2076
+ remove.type = 'button';
2077
+ remove.dataset.action = 'remove-comment';
2078
+ remove.dataset.index = String(index);
2079
+ remove.disabled = locked;
2080
+ remove.setAttribute('aria-label', 'Remove the comment written for ' + comment.path + ' line ' + comment.line);
2081
+ actions.appendChild(remove);
2082
+ item.appendChild(actions);
2083
+ if (armed) {
2084
+ item.appendChild(make('p', 'hint', 'Choose a line below: every Comment button now reads Attach.'));
2085
+ }
2086
+ list.appendChild(item);
2087
+ }
2088
+ el.revalidateList.appendChild(list);
2089
+ }
2090
+
2091
+ function updateEventNote() {
2092
+ if (event === 'COMMENT') { show(el.eventNote, false); return; }
2093
+ show(el.eventNote, true);
2094
+ setText(el.eventNote, event === 'APPROVE'
2095
+ ? 'Approve submits a formal approving review to GitHub under the signed-in account shown above.'
2096
+ : 'Request changes submits a formal review that blocks merging on GitHub under the signed-in account shown above.');
2097
+ }
2098
+
2099
+ function renderCompose() {
2100
+ if (!composerReady()) {
2101
+ show(el.composeSection, false);
2102
+ return;
2103
+ }
2104
+ show(el.composeSection, true);
2105
+ var disabled = composeDisabled();
2106
+ if (el.reviewBody.value !== body) el.reviewBody.value = body;
2107
+ el.reviewBody.disabled = disabled;
2108
+ for (var i = 0; i < el.eventInputs.length; i += 1) el.eventInputs[i].disabled = disabled;
2109
+ syncEventInputs();
2110
+ updateEventNote();
2111
+ show(el.anchorNotice, anchorNotice !== '');
2112
+ if (anchorNotice !== '') setText(el.anchorNotice, anchorNotice);
2113
+ renderRevalidateList();
2114
+ renderDraftList(disabled);
2115
+ }
2116
+
2117
+ var EVENT_NAMES = { COMMENT: 'Comment', APPROVE: 'Approve', REQUEST_CHANGES: 'Request changes' };
2118
+
2119
+ /** One line saying what the checked request will post. */
2120
+ function checkedSummary(payload) {
2121
+ var count = payload && Array.isArray(payload.comments) ? payload.comments.length : 0;
2122
+ var name = payload && EVENT_NAMES[payload.event] ? EVENT_NAMES[payload.event] : 'Review';
2123
+ return '\u2713 ' + name + (count > 0 ? ' with ' + count + (count === 1 ? ' line comment' : ' line comments') : '')
2124
+ + (payload && typeof payload.commit_id === 'string' ? ' on ' + shortSha(payload.commit_id) : '') + '.';
2125
+ }
2126
+
2127
+ function updateActionState() {
2128
+ var hint = validationHint();
2129
+ var previewing = busyAction === 'preview';
2130
+ var posting = busyAction === 'submit' && submitting;
2131
+ el.submitButton.disabled = hint !== '' || posting;
2132
+ setText(el.submitButton, posting ? 'Submitting\u2026' : 'Submit review');
2133
+ el.submitButton.classList.toggle('is-loading', posting);
2134
+ el.submitButton.setAttribute('aria-busy', posting ? 'true' : 'false');
2135
+ setText(el.previewButton, previewing ? 'Checking\u2026' : 'Check the review');
2136
+ el.previewButton.classList.toggle('is-loading', previewing);
2137
+ el.previewButton.setAttribute('aria-busy', previewing ? 'true' : 'false');
2138
+ if (previewing) el.previewButton.disabled = true;
2139
+ var failed = lastError !== '' && !busy && !submitting;
2140
+ setText(el.submitHint, posting
2141
+ ? 'Posting your review to GitHub. This takes a few seconds; keep this tab open.'
2142
+ : failed && placeAnchor === el.submitButton
2143
+ ? 'Not submitted: ' + lastError
2144
+ : hint === '' ? 'Ready. Submit posts this review to GitHub; it cannot be undone from here.' : hint);
2145
+ el.submitHint.classList.toggle('is-error', failed && placeAnchor === el.submitButton);
2146
+ var stale = previewPayload !== null && previewSignature !== JSON.stringify(draftInput());
2147
+ setText(el.previewState, previewPayload === null
2148
+ ? ''
2149
+ : stale
2150
+ ? 'The draft changed since it was checked.'
2151
+ : checkedSummary(previewPayload));
2152
+ el.previewState.className = stale ? 'status is-stale' : previewPayload !== null ? 'status is-ok' : 'status';
2153
+ if (previewing) setText(el.previewState, 'Checking the draft against the pull request\u2026');
2154
+ else if (failed && placeAnchor === el.previewButton) {
2155
+ setText(el.previewState, 'Check failed: ' + lastError);
2156
+ el.previewState.className = 'status is-error';
2157
+ }
2158
+ }
2159
+
2160
+ function renderPreview() {
2161
+ if (!composerReady()) {
2162
+ show(el.previewSection, false);
2163
+ return;
2164
+ }
2165
+ show(el.previewSection, true);
2166
+ el.previewButton.disabled = composeDisabled() || unvalidatedCount() > 0;
2167
+ setText(el.previewJson, previewPayload === null ? 'Nothing checked yet.' : JSON.stringify(previewPayload, null, 2));
2168
+ }
2169
+
2170
+ function renderReceipt() {
2171
+ var receipt = state && state.receipt && typeof state.receipt === 'object' ? state.receipt : null;
2172
+ show(el.receiptSection, Boolean(receipt));
2173
+ el.receiptBody.textContent = '';
2174
+ // Once posted, the rail's footer points at the receipt, not at a draft that no longer exists.
2175
+ setText(el.railFinish, receipt ? 'See the receipt' : 'Finish review');
2176
+ el.railFinish.href = receipt ? '#receipt-heading' : '#compose-heading';
2177
+ if (!receipt) return;
2178
+ setText(el.railDraft, 'Review posted to GitHub');
2179
+ var dl = make('dl', 'facts');
2180
+ addFact(dl, 'Review id', typeof receipt.id === 'number' ? String(receipt.id) : 'unknown');
2181
+ addFact(dl, 'GitHub state', typeof receipt.state === 'string' ? receipt.state : 'unknown');
2182
+ addFact(dl, 'Reviewed commit', typeof receipt.commitId === 'string' ? receipt.commitId : 'unknown', true);
2183
+ addLinkFact(dl, 'On GitHub', receipt.url, typeof receipt.url === 'string' ? receipt.url : 'unknown');
2184
+ el.receiptBody.appendChild(dl);
2185
+ el.receiptBody.appendChild(make('p', 'note', 'GitHub recorded this review. This session has posted its one review: further loads, previews and submissions are refused, and Check GitHub state re-reads the recorded outcome. Start diffninja again to review another revision.'));
2186
+ }
2187
+
2188
+ function renderMessage() {
2189
+ var status = state ? state.status : '';
2190
+ var message = state && typeof state.message === 'string' ? state.message : '';
2191
+ // On a ready snapshot the message is informational — a fork head, for
2192
+ // instance — so it never replaces the readiness line. Everywhere else the
2193
+ // status line carries it, and a failure's own text does too.
2194
+ var informational = status === 'ready' && message !== '' && lastError === '';
2195
+ show(el.messageNote, informational);
2196
+ setText(el.messageNote, informational ? message : '');
2197
+ }
2198
+
2199
+ /**
2200
+ * Re-render without moving what the reviewer is looking at: the control they
2201
+ * last pressed stays where it was on screen, even when a status line or an
2202
+ * error box appears above it.
2203
+ */
2204
+ function render() {
2205
+ var anchor = placeAnchor && placeAnchor.isConnected && placeAnchor.offsetParent !== null ? placeAnchor : null;
2206
+ var before = anchor ? anchor.getBoundingClientRect().top : 0;
2207
+ renderAll();
2208
+ queueStationMark();
2209
+ if (revealReceipt && !el.receiptSection.hidden) {
2210
+ revealReceipt = false;
2211
+ el.receiptSection.scrollIntoView({ block: 'nearest' });
2212
+ return;
2213
+ }
2214
+ if (!anchor || !anchor.isConnected || anchor.offsetParent === null) return;
2215
+ var drift = anchor.getBoundingClientRect().top - before;
2216
+ if (drift !== 0) window.scrollBy(0, drift);
2217
+ }
2218
+
2219
+ function renderAll() {
2220
+ var snap = snapshot();
2221
+ var status = state ? state.status : '';
2222
+ var blocked = status === 'unknown' || status === 'submitting';
2223
+ setText(el.statusLine, statusMessage());
2224
+ show(el.statusLine, !(snap && status === 'ready' && !busy && !submitting && lastError === '' && !submitUncertain));
2225
+ document.body.classList.toggle('is-busy', busy || submitting);
2226
+ if (submitUncertain) {
2227
+ // The write's fate is unknown, so this box says only what is true and
2228
+ // points at the one action that can resolve it.
2229
+ setText(el.errorText, 'The submission request to the local diffninja server did not complete, so it is unknown whether GitHub recorded this review. Do not submit again. Use Check GitHub state to re-read what GitHub has; it settles the outcome.'
2230
+ + (serverError && lastError !== '' ? ' Last transport error: ' + lastError : ''));
2231
+ } else if (lastError !== '') setText(el.errorText, lastError);
2232
+ else if (status === 'unknown') setText(el.errorText, 'The last submission outcome is unknown: GitHub may or may not have recorded the review. Check the pull request on GitHub before submitting anything again.');
2233
+ else if (status === 'submitting') setText(el.errorText, 'A review submission is already in progress in this session. Check GitHub state to see how it ended.');
2234
+ else setText(el.errorText, 'The request failed.');
2235
+ show(el.errorBox, lastError !== '' || blocked || submitUncertain);
2236
+ show(el.refreshButton, serverError || blocked || submitUncertain);
2237
+ el.refreshButton.disabled = busy;
2238
+ el.loadButton.disabled = busy;
2239
+ el.prUrl.disabled = busy;
2240
+ show(el.emptyNote, !snap && !busy);
2241
+ renderIdentity();
2242
+ renderSnapshot();
2243
+ renderMessage();
2244
+ renderAnalysis();
2245
+ renderDiff();
2246
+ renderCompose();
2247
+ renderPreview();
2248
+ renderReceipt();
2249
+ updateActionState();
2250
+ }
2251
+
2252
+ /* ------------------------------------------------------------ listeners -- */
2253
+
2254
+ function onLoad(event_) {
2255
+ event_.preventDefault();
2256
+ if (busy) return;
2257
+ showLoad = false;
2258
+ var url = el.prUrl.value.trim();
2259
+ if (url === '') {
2260
+ lastError = 'Enter the pull request URL first.';
2261
+ serverError = false;
2262
+ render();
2263
+ return;
2264
+ }
2265
+ if (url.indexOf('https://github.com/') !== 0) {
2266
+ lastError = 'Enter a full https://github.com pull request URL.';
2267
+ serverError = false;
2268
+ render();
2269
+ return;
2270
+ }
2271
+ loadPullRequest(url);
2272
+ }
2273
+
2274
+ function onClick(event_) {
2275
+ var target = event_.target;
2276
+ if (!target || !target.closest) return;
2277
+ var node = target.closest('[data-action]');
2278
+ if (!node) return;
2279
+ var action = node.getAttribute('data-action');
2280
+ placeAnchor = action === 'preview' || action === 'submit' || action === 'refresh' ? node : null;
2281
+ if (action === 'comment') { event_.preventDefault(); addComment(node); return; }
2282
+ if (action === 'open-flow') { event_.preventDefault(); openFlow(node.getAttribute('data-path') || '', node); return; }
2283
+ if (action === 'close-flow') { event_.preventDefault(); closeFlow(); return; }
2284
+ if (action === 'add-suggestion') { event_.preventDefault(); addSuggestion(node); return; }
2285
+ if (action === 'add-all-suggestions') { event_.preventDefault(); addAllSuggestions(); return; }
2286
+ if (action === 'dismiss-suggestion') { event_.preventDefault(); dismissSuggestion(node); return; }
2287
+ if (action === 'remove-comment') { event_.preventDefault(); removeComment(node); return; }
2288
+ if (action === 'edit-comment') { event_.preventDefault(); editComment(node); return; }
2289
+ if (action === 'confirm-comment') { event_.preventDefault(); confirmComment(node); return; }
2290
+ if (action === 'reattach-comment') { event_.preventDefault(); armComment(node); return; }
2291
+ if (action === 'cancel-reattach') { event_.preventDefault(); cancelReattach(); return; }
2292
+ if (action === 'preview') { event_.preventDefault(); previewReview(); return; }
2293
+ if (action === 'submit') { event_.preventDefault(); submitReview(); return; }
2294
+ if (action === 'refresh') { event_.preventDefault(); refreshState(); return; }
2295
+ if (action === 'goto') { event_.preventDefault(); gotoLine(node); return; }
2296
+ if (action === 'goto-stop') { event_.preventDefault(); gotoStop(Number(node.getAttribute('data-rank'))); return; }
2297
+ if (action === 'view') { event_.preventDefault(); setView(node.getAttribute('data-view') || ''); return; }
2298
+ if (action === 'show-load') { event_.preventDefault(); showLoad = true; render(); el.prUrl.focus(); return; }
2299
+ }
2300
+
2301
+ function onChange(event_) {
2302
+ var target = event_.target;
2303
+ if (target && target.name === 'event') setEvent(target.value);
2304
+ }
2305
+
2306
+ function onInput(event_) {
2307
+ var target = event_.target;
2308
+ if (!target) return;
2309
+ if (target === el.reviewBody) {
2310
+ body = target.value;
2311
+ saveDraft();
2312
+ updateActionState();
2313
+ return;
2314
+ }
2315
+ if (target.classList && target.classList.contains('editor-input')) {
2316
+ var index = Number(target.getAttribute('data-index'));
2317
+ if (!Number.isInteger(index) || index < 0 || index >= comments.length) return;
2318
+ comments[index].body = target.value;
2319
+ saveDraft();
2320
+ var mirror = document.querySelector('.draft-item[data-index="' + index + '"] [data-role="body"]');
2321
+ if (mirror) setText(mirror, target.value === '' ? '(empty)' : target.value);
2322
+ updateActionState();
2323
+ }
2324
+ }
2325
+
2326
+ function initialise() {
2327
+ el.loadForm = byId('load-form');
2328
+ el.prUrl = byId('pr-url');
2329
+ el.loadButton = byId('load-button');
2330
+ el.statusLine = byId('status-line');
2331
+ el.emptyNote = byId('empty-note');
2332
+ el.errorBox = byId('error-box');
2333
+ el.errorText = byId('error-text');
2334
+ el.refreshButton = byId('refresh-button');
2335
+ el.pageTitle = byId('page-title');
2336
+ el.pageMeta = byId('page-meta');
2337
+ el.lede = byId('lede');
2338
+ el.loadSection = byId('load-section');
2339
+ el.detailsSection = byId('details-section');
2340
+ el.detailsAnalysis = byId('details-analysis');
2341
+ el.identitySection = byId('identity-section');
2342
+ el.identityBody = byId('identity-body');
2343
+ el.snapshotSection = byId('snapshot-section');
2344
+ el.snapshotBody = byId('snapshot-body');
2345
+ el.messageNote = byId('message-note');
2346
+ el.analysisSection = byId('analysis-section');
2347
+ el.analysisBody = byId('analysis-body');
2348
+ el.analysisSub = byId('analysis-sub');
2349
+ el.railDraft = byId('rail-draft');
2350
+ el.railFinish = byId('rail-finish');
2351
+ el.railProgress = byId('rail-progress');
2352
+ el.railAt = byId('rail-at');
2353
+ el.viewSwitch = byId('view-switch');
2354
+ view = readView();
2355
+ el.analysisActions = byId('analysis-actions');
2356
+ el.diffSub = byId('diff-sub');
2357
+ el.diffSection = byId('diff-section');
2358
+ el.diffBody = byId('diff-body');
2359
+ el.composeSection = byId('compose-section');
2360
+ el.draftCount = byId('draft-count');
2361
+ el.draftList = byId('draft-list');
2362
+ el.anchorNotice = byId('anchor-notice');
2363
+ el.revalidateSection = byId('revalidate-section');
2364
+ el.revalidateNote = byId('revalidate-note');
2365
+ el.revalidateList = byId('revalidate-list');
2366
+ el.eventNote = byId('event-note');
2367
+ el.reviewBody = byId('review-body');
2368
+ el.previewSection = byId('preview-section');
2369
+ el.previewButton = byId('preview-button');
2370
+ el.previewState = byId('preview-state');
2371
+ el.previewJson = byId('preview-json');
2372
+ el.submitButton = byId('submit-button');
2373
+ el.submitHint = byId('submit-hint');
2374
+ el.receiptSection = byId('receipt-section');
2375
+ el.receiptBody = byId('receipt-body');
2376
+ el.eventInputs = document.querySelectorAll('input[name="event"]');
2377
+ el.loadForm.addEventListener('submit', onLoad);
2378
+ el.flowDrawer = byId('flow-drawer');
2379
+ el.flowTitle = byId('flow-title');
2380
+ el.flowClose = byId('flow-close');
2381
+ el.flowFrame = byId('flow-frame');
2382
+ // Escape closes the drawer from inside the diagram too: it is served from this origin.
2383
+ el.flowFrame.addEventListener('load', function () {
2384
+ try { el.flowFrame.contentDocument.addEventListener('keydown', onFlowKey); } catch (error) { /* not ours to reach */ }
2385
+ });
2386
+ document.addEventListener('keydown', onFlowKey);
2387
+ document.addEventListener('keydown', onStepKey);
2388
+ window.addEventListener('message', onFlowMessage);
2389
+ window.addEventListener('scroll', queueStationMark, { passive: true });
2390
+ window.addEventListener('resize', queueStationMark);
2391
+ document.addEventListener('click', onClick);
2392
+ document.addEventListener('change', onChange);
2393
+ document.addEventListener('input', onInput);
2394
+ run(function () {
2395
+ return api('GET', '/api/state', null).then(function (data) { setState(data); });
2396
+ });
2397
+ }
2398
+
2399
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialise);
2400
+ else initialise();
2401
+ }());
2402
+ `;
2403
+ }
2404
+ const STYLES = `
2405
+ ${PALETTE_STYLES}
2406
+ *, *::before, *::after { box-sizing: border-box; }
2407
+ /* Class rules below set display, which would otherwise beat the UA's hidden
2408
+ rule and leave every collapsed panel visible. */
2409
+ [hidden] { display: none !important; }
2410
+ /* File headers stay pinned to the top while the diff scrolls. Anything scrolled
2411
+ into view (a jump, a focused control, a reattached comment) must land below
2412
+ one, or a click meant for it hits the header and folds the file. */
2413
+ html { -webkit-text-size-adjust: 100%; max-width: 100%; scroll-padding-top: 56px; }
2414
+ body {
2415
+ margin: 0; background: var(--bg); color: var(--ink);
2416
+ font: 400 14px/1.5 var(--sans); max-width: 100%; overflow-wrap: break-word;
2417
+ -webkit-font-smoothing: antialiased;
2418
+ }
2419
+ .wrap {
2420
+ max-width: 1480px; margin: 0 auto; padding: 0 24px 64px;
2421
+ display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 28px; align-items: start;
2422
+ }
2423
+ .wrap:has(> #analysis-section:not([hidden])) { grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); }
2424
+ .wrap > * { grid-column: -2; min-width: 0; }
2425
+ .wrap > .masthead { grid-column: 1 / -1; }
2426
+ h1, h2, h3, h4 { margin: 0; line-height: 1.3; }
2427
+ h1 { font-size: 28px; line-height: 1.2; font-weight: 650; letter-spacing: -0.02em; overflow-wrap: anywhere; max-width: 36em; }
2428
+ h2 { font-size: 14px; font-weight: 600; }
2429
+ h3.subhead { font-size: 12px; font-weight: 600; color: var(--ink-soft); margin-top: 4px; }
2430
+ p { margin: 0; }
2431
+ a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
2432
+ a:hover { text-decoration: underline; }
2433
+ .mono, code, .payload { font-family: var(--mono); }
2434
+ .note, .hint, .count, .status { font-size: 12px; color: var(--ink-soft); }
2435
+ .lede { font-size: 14px; color: var(--ink-soft); }
2436
+ .status { overflow-wrap: anywhere; }
2437
+ .status.is-stale { color: var(--warn); }
2438
+ .status.is-ok { color: var(--ok); }
2439
+ .is-error { color: var(--alarm) !important; }
2440
+ .sr-only {
2441
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
2442
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
2443
+ }
2444
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 4px; }
2445
+ :focus:not(:focus-visible) { outline: none; }
2446
+
2447
+ /* ---------------------------------------------------------------- header */
2448
+ .masthead { display: flex; flex-direction: column; gap: 10px; padding: 18px 0 22px; margin-bottom: 20px; border-bottom: 1px solid var(--line); }
2449
+ .brand { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; letter-spacing: -0.01em; color: var(--ink); }
2450
+ ${BRAND_MARK_STYLES}
2451
+ .brand .brand-mark { width: 20px; height: 20px; }
2452
+ .page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--ink-soft); }
2453
+ .page-meta a { font-weight: 600; }
2454
+ .meta-item.sha { font: 12px var(--mono); padding: 2px 7px; border-radius: 6px; background: var(--neutral-soft); color: var(--ink); }
2455
+ .state-badge {
2456
+ display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 999px;
2457
+ font-size: 12px; font-weight: 600; color: #fff; background: var(--ink-faint);
2458
+ }
2459
+ .state-badge.state-open { background: #2f8f4e; }
2460
+ .state-badge.state-merged { background: #8250df; }
2461
+ .state-badge.state-closed { background: var(--del-ink); }
2462
+ #message-note { font-size: 12px; color: var(--ink-faint); }
2463
+
2464
+ /* ----------------------------------------------------------------- cards */
2465
+ .panel {
2466
+ border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel);
2467
+ padding: 16px; margin: 0 0 20px; box-shadow: var(--shadow);
2468
+ display: flex; flex-direction: column; gap: 12px; min-width: 0;
2469
+ }
2470
+ .panel.card { padding: 0; gap: 0; overflow: hidden; }
2471
+ /* The diff is the page's main surface: file blocks are its only frames. */
2472
+ #diff-section { background: none; border: 0; box-shadow: none; overflow: visible; }
2473
+ #diff-section > .card-head { background: none; border: 0; padding: 0 0 12px; }
2474
+ #diff-section > .card-head h2 { font-size: 16px; font-weight: 650; }
2475
+ #diff-section > .card-body { padding: 0; }
2476
+ .card-head {
2477
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px;
2478
+ padding: 12px 16px; background: var(--panel-head); border-bottom: 1px solid var(--line);
2479
+ }
2480
+ .card-titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 320px; }
2481
+ .card-sub { font-size: 12px; color: var(--ink-soft); }
2482
+ .card-sub:empty { display: none; }
2483
+ .card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
2484
+ .card-actions:empty { display: none; }
2485
+ .card-body { padding: 16px; min-width: 0; }
2486
+ .card-body.stack { display: flex; flex-direction: column; gap: 14px; }
2487
+
2488
+ /* --------------------------------------------------------------- buttons */
2489
+ button { font: inherit; color: inherit; cursor: pointer; max-width: 100%; }
2490
+ button:disabled { cursor: not-allowed; }
2491
+ button:not([class]), .btn {
2492
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
2493
+ height: 32px; padding: 0 12px; border-radius: var(--radius);
2494
+ font-size: 13px; font-weight: 500; line-height: 1; white-space: nowrap;
2495
+ color: var(--ink); background: var(--btn-bg); border: 1px solid var(--line);
2496
+ transition: background 0.1s, border-color 0.1s;
2497
+ }
2498
+ button:not([class]):hover:not(:disabled), .btn:hover:not(:disabled) { background: var(--btn-hover); border-color: var(--line-strong); }
2499
+ button:not([class]):disabled, .btn:disabled { color: var(--ink-faint); background: var(--btn-bg); opacity: 0.7; }
2500
+ .btn-primary { color: var(--primary-ink); background: var(--primary); border-color: rgba(31, 35, 40, 0.15); }
2501
+ .btn-primary:hover:not(:disabled) { background: var(--primary-hover); border-color: rgba(31, 35, 40, 0.15); }
2502
+ .btn-primary:disabled { color: rgba(255, 255, 255, 0.8); background: var(--primary); opacity: 0.45; }
2503
+ .btn-quiet { background: transparent; border-color: transparent; color: var(--ink-soft); }
2504
+ .btn-quiet:hover:not(:disabled) { background: var(--hover); border-color: transparent; color: var(--ink); }
2505
+ .btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
2506
+ .link-button {
2507
+ border: 0; background: none; color: var(--accent); padding: 0; font-size: 12px; cursor: pointer;
2508
+ }
2509
+ .link-button:hover { text-decoration: underline; }
2510
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; }
2511
+ body.is-busy button { cursor: progress; }
2512
+ .btn.is-loading::before, button.is-loading::before {
2513
+ content: ""; width: 12px; height: 12px; flex: 0 0 auto; border-radius: 50%;
2514
+ border: 2px solid currentColor; border-right-color: transparent; animation: spin 0.8s linear infinite;
2515
+ }
2516
+ @keyframes spin { to { transform: rotate(360deg); } }
2517
+ @media (prefers-reduced-motion: reduce) { .btn.is-loading::before, button.is-loading::before { animation-duration: 2.4s; } }
2518
+
2519
+ /* ------------------------------------------------------------ tags, paths */
2520
+ .path { font-family: var(--mono); font-size: 12.5px; min-width: 0; overflow-wrap: anywhere; }
2521
+ .path-dir { color: var(--ink-soft); }
2522
+ .path-base { color: var(--ink); font-weight: 600; }
2523
+ .path-line { color: var(--ink-faint); }
2524
+ .size { display: inline-flex; gap: 6px; font: 12px var(--mono); white-space: nowrap; }
2525
+ .size .plus { color: var(--add-ink); }
2526
+ .size .minus { color: var(--del-ink); }
2527
+ .status-tag {
2528
+ display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 999px;
2529
+ font-size: 11.5px; font-weight: 600; white-space: nowrap;
2530
+ color: var(--ink-soft); background: var(--neutral-soft);
2531
+ }
2532
+ .status-tag.status-attention { color: var(--alarm); background: var(--alarm-bg); }
2533
+ .status-tag.status-uncertain { color: var(--warn); background: var(--warn-soft); }
2534
+ .tag-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
2535
+ .tag {
2536
+ display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px;
2537
+ border-radius: 999px; border: 1px solid var(--line); font-size: 12px; color: var(--ink); white-space: nowrap;
2538
+ }
2539
+ .tag::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ink-faint); }
2540
+ .tag.tone-ok::before { background: var(--ok); }
2541
+ .tag.tone-watch { border-color: var(--warn); color: var(--warn); }
2542
+ .tag.tone-watch::before { background: var(--warn); }
2543
+ .tag.tone-unsure { color: var(--ink-soft); border-style: dashed; }
2544
+ .tag.tone-unsure::before { background: transparent; border: 1px solid var(--ink-faint); }
2545
+ .tag-lead { font-size: 12px; color: var(--ink-faint); margin-left: 6px; }
2546
+ .tag + .tag-lead { margin-left: 10px; }
2547
+ .tag-row > .tag-lead:first-child { margin-left: 0; }
2548
+ .fact {
2549
+ display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px;
2550
+ border-radius: 6px; border: 1px solid transparent; background: var(--neutral-soft);
2551
+ font-size: 12px; color: var(--ink); white-space: nowrap;
2552
+ }
2553
+ button.fact:hover { border-color: var(--accent); background: var(--accent-soft); }
2554
+ .fact-line { font: 11px var(--mono); color: var(--ink-soft); }
2555
+ .agent-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #8250df; margin-right: 6px; vertical-align: 1px; }
2556
+
2557
+ /* ------------------------------------------------ the reading-order rail */
2558
+ .rail {
2559
+ grid-column: 1; grid-row: 2 / span 40; position: sticky; top: 16px;
2560
+ display: flex; flex-direction: column; max-height: calc(100vh - 32px); min-width: 0;
2561
+ background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
2562
+ }
2563
+ .rail-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 4px; }
2564
+ .rail-head h2 { font-size: 16px; font-weight: 650; letter-spacing: -0.01em; }
2565
+ .rail-sub { font-size: 12px; color: var(--ink-soft); }
2566
+ .rail-sub:empty { display: none; }
2567
+ .rail-actions { display: flex; gap: 6px; margin-top: 6px; }
2568
+ .rail-actions:empty { display: none; }
2569
+ .rail-body { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; padding: 6px 0; }
2570
+ .rail-body > .note, .rail-body > .inline-note { padding: 10px 16px; }
2571
+ .rail-foot {
2572
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
2573
+ padding: 10px 12px 10px 16px; border-top: 1px solid var(--line-soft); background: var(--panel-head);
2574
+ border-radius: 0 0 var(--radius) var(--radius);
2575
+ }
2576
+ .rail-draft { font-size: 12px; color: var(--ink-soft); }
2577
+ .rail-foot .btn { text-decoration: none; }
2578
+ .order-list { list-style: none; margin: 0; padding: 0; }
2579
+ /* Stations sit on one continuous line: the route through the pull request. */
2580
+ .order-item {
2581
+ position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 10px;
2582
+ padding: 6px 14px 6px 12px; cursor: pointer;
2583
+ }
2584
+ .order-item::before {
2585
+ content: ""; position: absolute; left: 24px; top: 0; bottom: 0; width: 2px; background: var(--route);
2586
+ }
2587
+ .order-item:first-child::before { top: 16px; }
2588
+ .order-item:last-child::before { bottom: calc(100% - 16px); }
2589
+ /* The route fills in behind the reader: a read station and the line after it turn accent. */
2590
+ .order-item.is-seen::before { background: var(--accent); opacity: 0.55; }
2591
+ .order-item:hover { background: var(--hover); }
2592
+ .order-item:focus-within { outline: 2px solid var(--accent); outline-offset: -2px; }
2593
+ .order-rank {
2594
+ position: relative; z-index: 1; width: 26px; height: 26px; border-radius: 50%;
2595
+ display: grid; place-items: center; font-size: 12px; font-weight: 650; font-variant-numeric: tabular-nums;
2596
+ color: var(--ink-soft); background: var(--panel); border: 2px solid var(--route);
2597
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
2598
+ }
2599
+ .order-item.is-seen .order-rank { color: var(--accent); border-color: var(--accent); }
2600
+ .order-item.is-current { background: var(--accent-soft); }
2601
+ .order-item.is-current .order-rank { color: var(--primary-ink); background: var(--accent); border-color: var(--accent); }
2602
+ .order-main { min-width: 0; padding-top: 1px; display: flex; flex-direction: column; gap: 3px; }
2603
+ .order-path { display: flex; min-width: 0; font-size: 12.5px; white-space: nowrap; }
2604
+ .order-path .path-base { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
2605
+ .order-path .path-line { flex: 0 0 auto; }
2606
+ .order-path .path-dir { display: none; }
2607
+ .order-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
2608
+ .rail-progress { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
2609
+ .rail-progress[hidden] { display: none; }
2610
+ .rail-keys { color: var(--ink-faint); white-space: nowrap; }
2611
+ kbd {
2612
+ display: inline-block; min-width: 18px; padding: 0 4px; font: 11px/16px var(--mono); text-align: center;
2613
+ color: var(--ink-soft); background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px;
2614
+ }
2615
+ .inline-note { font-size: 12px; color: var(--ink-soft); }
2616
+ .btn-xs { height: 22px; padding: 0 6px; font-size: 12px; margin-left: -6px; color: var(--accent); }
2617
+
2618
+ /* ------------------------------------------------------------------ diff */
2619
+ .suggest-bar {
2620
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px;
2621
+ padding: 10px 12px; margin: 0 0 16px; border-radius: var(--radius);
2622
+ background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
2623
+ font-size: 13px;
2624
+ }
2625
+ .suggest-bar p { flex: 1 1 280px; }
2626
+ .bar-link { font-size: 13px; font-weight: 500; }
2627
+ .file-block, .stop { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 16px; min-width: 0; }
2628
+ .file-block:last-child, .stop:last-child { margin-bottom: 0; }
2629
+ /* ------------------------------------------- a change in the reading order */
2630
+ .stop-head {
2631
+ position: sticky; top: 0; z-index: 2;
2632
+ display: flex; flex-wrap: nowrap; gap: 4px 12px; align-items: center;
2633
+ padding: 6px 8px 6px 10px; min-height: 42px; background: var(--panel-head);
2634
+ border-bottom: 1px solid var(--line); border-radius: var(--radius) var(--radius) 0 0;
2635
+ }
2636
+ .stop-head:focus { outline: none; }
2637
+ .stop-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2638
+ .stop-rank, .why-rank {
2639
+ flex: 0 0 auto; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
2640
+ font: 650 12px var(--sans); font-variant-numeric: tabular-nums; color: var(--primary-ink); background: var(--accent);
2641
+ }
2642
+ .why-rank { width: 22px; height: 22px; font-size: 11.5px; }
2643
+ .is-same-file .path-dir { display: none; }
2644
+ .stop-why { display: flex; gap: 10px; align-items: flex-start; padding: 8px 12px 10px; background: var(--panel); border-bottom: 1px solid var(--line-soft); font-family: var(--sans); }
2645
+ .stop-why .tag-row:first-child { margin-top: 0; }
2646
+ .why-main { min-width: 0; flex: 1 1 auto; }
2647
+ .why-note { font-size: 12px; color: var(--ink-soft); margin-top: 4px; }
2648
+ .why-main > .why-note:first-child { margin-top: 0; }
2649
+ /* By file, a ranked hunk opens with its number, between the code around it. */
2650
+ .diff-rows > .stop-why { border-top: 1px solid var(--line-soft); }
2651
+ .diff-rows > .stop-why:first-child { border-top: 0; }
2652
+ .stop-why.has-rank { padding-left: 16px; }
2653
+ .stop-why.has-rank .why-main:empty { display: none; }
2654
+ .rest-head { margin: 28px 0 12px; }
2655
+ .rest-head h3 { font-size: 14px; font-weight: 650; }
2656
+ .rest-head p { font-size: 12px; color: var(--ink-soft); margin-top: 2px; }
2657
+ .view-switch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); flex: 0 0 auto; }
2658
+ .view-switch[hidden] { display: none; }
2659
+ .view-switch button {
2660
+ height: 26px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent;
2661
+ font-size: 12px; font-weight: 600; color: var(--ink-soft);
2662
+ }
2663
+ .view-switch button:hover { color: var(--ink); background: var(--hover); }
2664
+ .view-switch button[aria-pressed="true"] { color: var(--ink); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); }
2665
+ .view-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2666
+ .file-head {
2667
+ position: sticky; top: 0; z-index: 2;
2668
+ display: flex; flex-wrap: nowrap; gap: 4px 12px; align-items: center;
2669
+ padding: 6px 8px 6px 12px; min-height: 42px; background: var(--panel-head);
2670
+ border-radius: var(--radius) var(--radius) 0 0; cursor: pointer; list-style: none;
2671
+ }
2672
+ .file-block:not([open]) > .file-head { border-radius: var(--radius); }
2673
+ .file-head::-webkit-details-marker { display: none; }
2674
+ .file-head::before {
2675
+ content: ""; width: 6px; height: 6px; margin: 0 2px 0 2px;
2676
+ border-right: 1.5px solid var(--ink-soft); border-bottom: 1.5px solid var(--ink-soft);
2677
+ transform: rotate(-45deg); transition: transform 0.12s;
2678
+ }
2679
+ .file-block[open] > .file-head { border-bottom: 1px solid var(--line); }
2680
+ .file-block[open] > .file-head::before { transform: rotate(45deg); }
2681
+ .file-tools { margin-left: auto; display: flex; gap: 4px; flex: 0 0 auto; }
2682
+ /* A long path gives way first: its directory truncates, the file name stays whole. */
2683
+ .file-head .file-path, .stop-head .file-path { display: flex; min-width: 0; flex: 0 1 auto; overflow: hidden; }
2684
+ .file-head .path-dir, .stop-head .path-dir { flex: 0 1000 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2685
+ .file-head .path-base, .stop-head .path-base { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2686
+ .stop-head .path-line { flex: 0 0 auto; white-space: nowrap; }
2687
+ .file-head .size, .file-head .status-tag, .stop-head .size, .stop-head .status-tag { flex: 0 0 auto; }
2688
+ .diff-rows { min-width: 0; overflow: hidden; border-radius: 0 0 var(--radius) var(--radius); }
2689
+ .diff-row { display: flex; align-items: stretch; font: 12px/20px var(--mono); min-height: 20px; }
2690
+ /* The number column fits the widest line number of its block, set by the page as --ln-digits. */
2691
+ .diff-rows { --gutter: calc(36px + var(--ln-digits, 3) * 1ch); }
2692
+ .diff-gutter { position: relative; flex: 0 0 auto; width: var(--gutter); background: var(--panel); }
2693
+ .diff-ln { display: block; padding: 0 10px 0 26px; text-align: right; white-space: nowrap; color: var(--ink-faint); user-select: none; }
2694
+ .diff-action {
2695
+ position: absolute; left: 4px; top: 0; width: 20px; height: 20px; padding: 0;
2696
+ border: 0; border-radius: 6px; background: var(--accent); color: #fff;
2697
+ font: 600 14px/20px var(--sans); cursor: pointer; opacity: 0; transform: scale(0.9);
2698
+ transition: opacity 0.08s, transform 0.08s;
2699
+ }
2700
+ .diff-row:hover .diff-action:not(:disabled), .diff-action:focus-visible { opacity: 1; transform: scale(1); }
2701
+ .diff-action.has-comment { opacity: 1; transform: scale(1); background: var(--warn); }
2702
+ .diff-action.is-armed { opacity: 1; transform: scale(1); width: auto; padding: 0 6px; font-size: 11px; color: #fff; }
2703
+ .diff-mark { flex: 0 0 auto; width: 20px; text-align: center; color: var(--ink-faint); user-select: none; }
2704
+ .diff-code { display: block; flex: 1 1 auto; min-width: 0; padding: 0 12px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); }
2705
+ .kind-add { background: var(--add-bg); }
2706
+ .kind-delete { background: var(--del-bg); }
2707
+ .kind-add .diff-gutter { background: var(--add-gutter); }
2708
+ .kind-delete .diff-gutter { background: var(--del-gutter); }
2709
+ .kind-add .diff-ln, .kind-delete .diff-ln { color: var(--ink-soft); }
2710
+ .kind-add .diff-mark { color: var(--add-ink); }
2711
+ .kind-delete .diff-mark { color: var(--del-ink); }
2712
+ .diff-row.is-target { box-shadow: inset 3px 0 0 var(--accent); background: var(--accent-soft); }
2713
+ .diff-gap {
2714
+ display: flex; align-items: center; gap: 10px; min-height: 28px;
2715
+ background: var(--gap-bg); color: var(--ink-soft); font: 12px var(--mono);
2716
+ }
2717
+ .diff-gap-mark { width: var(--gutter); text-align: center; color: var(--accent); }
2718
+ .tok-k { color: var(--syn-keyword); }
2719
+ .tok-s { color: var(--syn-string); }
2720
+ .tok-c { color: var(--syn-comment); font-style: italic; }
2721
+ .tok-n { color: var(--syn-number); }
2722
+ .tok-t { color: var(--syn-type); }
2723
+ .tok-f { color: var(--syn-func); }
2724
+ .tok-p { color: var(--syn-number); }
2725
+
2726
+ /* ------------------------------------------------ inline comments on lines */
2727
+ .editor, .suggestion {
2728
+ margin: 8px 12px 10px 60px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius);
2729
+ background: var(--panel); font-family: var(--sans); display: flex; flex-direction: column; gap: 8px; min-width: 0;
2730
+ }
2731
+ .editor { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-soft); }
2732
+ .editor-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2733
+ .editor-input { flex: 1 1 240px; min-width: 0; }
2734
+ .suggestion-by { font-size: 12px; color: var(--ink-soft); }
2735
+ .suggestion-by strong { color: var(--ink); font-weight: 600; }
2736
+ .suggestion-body { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
2737
+ .suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
2738
+
2739
+ /* ---------------------------------------------------------------- forms */
2740
+ .field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
2741
+ label { font-weight: 600; font-size: 13px; }
2742
+ input[type="text"], input[type="url"], textarea {
2743
+ font: 14px/1.5 var(--sans); color: var(--ink); background: var(--bg);
2744
+ border: 1px solid var(--line); border-radius: var(--radius);
2745
+ padding: 6px 10px; width: 100%; max-width: 100%; min-width: 0;
2746
+ transition: border-color 0.1s, box-shadow 0.1s;
2747
+ }
2748
+ input[type="text"]:focus, input[type="url"]:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
2749
+ textarea { resize: vertical; min-height: 88px; }
2750
+ form { display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
2751
+ .event-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
2752
+ .event-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; }
2753
+ .event-option {
2754
+ display: grid; grid-template-columns: auto 1fr; column-gap: 8px; align-items: center;
2755
+ padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; font-weight: 400;
2756
+ }
2757
+ .event-option:hover { background: var(--hover); }
2758
+ .event-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
2759
+ .event-option input { grid-row: span 2; margin: 0; accent-color: var(--accent); }
2760
+ .event-name { font-weight: 600; font-size: 13px; }
2761
+ .event-help { grid-column: 2; font-size: 12px; color: var(--ink-soft); }
2762
+
2763
+ /* --------------------------------------------------------- draft comments */
2764
+ .draft-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); }
2765
+ .draft-item { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--line-soft); min-width: 0; }
2766
+ .draft-item:first-child { border-top: 0; }
2767
+ .draft-head { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
2768
+ .draft-head .btn-quiet:first-of-type { margin-left: auto; }
2769
+ .draft-body { font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
2770
+ .empty {
2771
+ border: 1px dashed var(--line); border-radius: var(--radius); padding: 14px 16px;
2772
+ color: var(--ink-soft); font-size: 13px; text-align: center;
2773
+ }
2774
+ #empty-note { margin: 0 0 16px; }
2775
+
2776
+ /* ----------------------------------------------------------------- submit */
2777
+ .submit-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
2778
+ .submit-row .status { flex: 1 1 200px; }
2779
+ .payload-details > summary { cursor: pointer; font-size: 12px; color: var(--ink-soft); width: fit-content; }
2780
+ .payload-details[open] > summary { margin-bottom: 8px; }
2781
+ .payload-details .hint { margin-top: 8px; }
2782
+ .payload {
2783
+ background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius);
2784
+ padding: 12px; margin: 0; max-height: 40vh; max-width: 100%; overflow: auto; font-size: 12px; white-space: pre;
2785
+ }
2786
+
2787
+ /* ------------------------------------------------------ alerts and states */
2788
+ .error {
2789
+ border: 1px solid var(--alarm); background: var(--alarm-bg); border-radius: var(--radius);
2790
+ padding: 12px 16px; margin: 0 0 16px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;
2791
+ }
2792
+ .error-title { font-weight: 600; font-size: 13px; }
2793
+ .error-text { font-size: 13px; overflow-wrap: anywhere; }
2794
+ #status-line:empty { display: none; }
2795
+ #status-line { margin: 0 0 12px; }
2796
+ .revalidate-section {
2797
+ border: 1px solid var(--warn); background: var(--warn-soft); border-radius: var(--radius);
2798
+ padding: 12px; display: flex; flex-direction: column; gap: 10px; min-width: 0;
2799
+ }
2800
+ .revalidate-item { position: static; width: auto; margin: 0; }
2801
+ /* The shared editor row sizes its input by flex-basis; inside this column
2802
+ container that basis would become a 240px height. */
2803
+ .revalidate-item .editor-input { flex: 0 0 auto; }
2804
+ .revalidate-item.is-armed { outline: 2px solid var(--accent); outline-offset: 2px; }
2805
+ .revalidate-where { overflow-wrap: anywhere; }
2806
+ .revalidate-code {
2807
+ display: block; white-space: pre; overflow-x: auto; max-width: 100%;
2808
+ background: var(--sunken); border: 1px solid var(--line); border-radius: 4px; padding: 4px 8px; font-size: 12px;
2809
+ }
2810
+ .revalidate-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2811
+
2812
+ /* ---------------------------------------------------------------- details */
2813
+ .details-panel > summary { cursor: pointer; font-weight: 600; font-size: 13px; color: var(--ink-soft); }
2814
+ .details-panel[open] > summary { margin-bottom: 8px; color: var(--ink); }
2815
+ .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 18px; margin: 0; }
2816
+ .facts dt { font-size: 12px; color: var(--ink-soft); }
2817
+ .facts dd { margin: 2px 0 0; font-size: 13px; overflow-wrap: anywhere; }
2818
+ .facts dd.mono { font-size: 12px; }
2819
+ .agenda-list { margin: 6px 0; padding-left: 20px; font-size: 13px; }
2820
+ .foot { padding-top: 4px; }
2821
+
2822
+ /* -------------------------------------------------------- call-flow drawer */
2823
+ .flow-drawer {
2824
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 20;
2825
+ width: min(760px, 60vw); display: flex; flex-direction: column;
2826
+ background: var(--bg); border-left: 1px solid var(--line);
2827
+ box-shadow: -8px 0 24px rgba(1, 4, 9, 0.2);
2828
+ }
2829
+ .flow-bar {
2830
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
2831
+ padding: 10px 12px 10px 16px; background: var(--panel-head); border-bottom: 1px solid var(--line);
2832
+ }
2833
+ .flow-title { font: 600 13px var(--sans); overflow-wrap: anywhere; }
2834
+ .flow-frame { flex: 1 1 auto; width: 100%; border: 0; background: var(--bg); }
2835
+ /* Beside the diff only when both still have room; otherwise it lies over the page. */
2836
+ @media (min-width: 1760px) { body.flow-open .wrap { margin-right: min(760px, 44vw); } }
2837
+ @media (max-width: 1099px) { .flow-drawer { width: 100%; } }
2838
+ body.flow-full .flow-drawer { width: 100%; box-shadow: none; border-left: 0; }
2839
+ body.flow-full .flow-bar { display: none; }
2840
+
2841
+ @media (max-width: 960px) {
2842
+ .wrap:has(> #analysis-section:not([hidden])) { grid-template-columns: minmax(0, 1fr); }
2843
+ .rail { grid-column: 1; grid-row: auto; position: static; max-height: 60vh; margin-bottom: 20px; }
2844
+ }
2845
+ @media (max-width: 700px) {
2846
+ .wrap { padding: 16px 12px 48px; }
2847
+ h1 { font-size: 20px; }
2848
+ .panel { padding: 12px; }
2849
+ .card-head, .card-body { padding: 12px; }
2850
+ .facts { grid-template-columns: 1fr; }
2851
+ .editor, .suggestion { margin-left: 8px; margin-right: 8px; }
2852
+ }
2853
+ `;