diffninja 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -11
- package/dist/executables.d.ts +18 -0
- package/dist/executables.js +32 -0
- package/dist/git.d.ts +26 -1
- package/dist/git.js +56 -4
- package/dist/languages/child-env.d.ts +11 -0
- package/dist/languages/child-env.js +60 -0
- package/dist/languages/grammar-lock.d.ts +569 -0
- package/dist/languages/grammar-lock.js +574 -0
- package/dist/languages/grammars.d.ts +69 -9
- package/dist/languages/grammars.js +186 -119
- package/dist/review/call-flow-html.js +1 -0
- package/dist/review/change-facts.d.ts +21 -1
- package/dist/review/change-facts.js +271 -49
- package/dist/review/cli.js +13 -1
- package/dist/review/connected-analysis.d.ts +4 -1
- package/dist/review/connected-analysis.js +4 -2
- package/dist/review/connected-html.d.ts +15 -4
- package/dist/review/connected-html.js +338 -43
- package/dist/review/connected.js +47 -14
- package/dist/review/escape-html.d.ts +5 -1
- package/dist/review/escape-html.js +7 -2
- package/dist/review/explanation.d.ts +4 -0
- package/dist/review/explanation.js +6 -1
- package/dist/review/github.d.ts +56 -0
- package/dist/review/github.js +234 -33
- package/dist/review/grammars-command.d.ts +12 -0
- package/dist/review/grammars-command.js +60 -0
- package/dist/review/hidden-characters.d.ts +31 -0
- package/dist/review/hidden-characters.js +113 -0
- package/dist/review/history.js +7 -3
- package/dist/review/html.js +11 -3
- package/dist/review/input.js +5 -2
- package/dist/review/intent.d.ts +7 -0
- package/dist/review/intent.js +25 -3
- package/dist/review/markdown.js +11 -0
- package/dist/review/mcp-cli.js +4 -1
- package/dist/review/mcp.d.ts +7 -1
- package/dist/review/mcp.js +145 -56
- package/dist/review/pipeline.d.ts +2 -1
- package/dist/review/pipeline.js +4 -3
- package/dist/review/pr-input.d.ts +7 -0
- package/dist/review/pr-input.js +25 -3
- package/dist/review/questions.js +11 -3
- package/dist/review/reference-check.d.ts +5 -1
- package/dist/review/reference-check.js +40 -16
- package/dist/review/report-pages.d.ts +24 -9
- package/dist/review/report-pages.js +111 -28
- package/dist/review/result-budget.d.ts +28 -0
- package/dist/review/result-budget.js +136 -0
- package/dist/review/service.js +26 -2
- package/dist/review/setup.d.ts +1 -1
- package/dist/review/setup.js +9 -4
- package/dist/review/types.d.ts +19 -9
- package/dist/review/types.js +2 -2
- package/dist/review/update-check.d.ts +35 -0
- package/dist/review/update-check.js +76 -0
- package/dist/run.js +11 -5
- package/npm-shrinkwrap.json +3483 -0
- package/package.json +3 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BRAND_MARK, BRAND_MARK_STYLES } from "./brand.js";
|
|
2
2
|
import { PALETTE_STYLES } from "./palette.js";
|
|
3
3
|
import { escapeHtml } from "./escape-html.js";
|
|
4
|
+
import { HIDDEN_CHARACTER_FALLBACK_SOURCE, HIDDEN_CHARACTER_SOURCE } from "./hidden-characters.js";
|
|
4
5
|
/**
|
|
5
6
|
* The one page the connected session serves.
|
|
6
7
|
*
|
|
@@ -8,17 +9,25 @@ import { escapeHtml } from "./escape-html.js";
|
|
|
8
9
|
* draft to `/api/preview`, and posts the same payload to `/api/submit`. Every
|
|
9
10
|
* string that comes from GitHub (paths, diff lines, the diff text itself, error
|
|
10
11
|
* messages) reaches the DOM through `textContent`; nothing is interpolated into
|
|
11
|
-
* markup or into the script. Style and script carry
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* markup or into the script. Style and script carry a per-response nonce, so the
|
|
13
|
+
* server can serve a `default-src 'none'` policy with no inline handlers and no
|
|
14
|
+
* inline styles; the session's CSRF token is a different secret, and the whole
|
|
15
|
+
* page lives under the session's secret path prefix.
|
|
14
16
|
*
|
|
15
17
|
* Drafts live in `sessionStorage`, keyed per pull request and stamped with the
|
|
16
18
|
* snapshot id. Restoring a draft revalidates every comment anchor against the
|
|
17
19
|
* snapshot on screen: a comment whose line no longer exists in the new revision
|
|
18
20
|
* is dropped, never silently carried over.
|
|
21
|
+
*
|
|
22
|
+
* A change is viewed only by the reader's click on its rail checkbox, never by
|
|
23
|
+
* scrolling. GitHub's Viewed mark is per file, so the page marks a file Viewed
|
|
24
|
+
* on GitHub (`POST api/viewed`) exactly when every hunk of it is viewed, and
|
|
25
|
+
* un-marks it when one is not. Progress inside a file stays in `sessionStorage`,
|
|
26
|
+
* keyed by snapshot id.
|
|
19
27
|
*/
|
|
20
|
-
export function renderConnectedPage(
|
|
21
|
-
const
|
|
28
|
+
export function renderConnectedPage(options) {
|
|
29
|
+
const { csrf, base } = options;
|
|
30
|
+
const nonce = escapeHtml(options.nonce);
|
|
22
31
|
return [
|
|
23
32
|
"<!doctype html>",
|
|
24
33
|
'<html lang="en">',
|
|
@@ -36,6 +45,7 @@ export function renderConnectedPage(csrf) {
|
|
|
36
45
|
`<p class="brand">${BRAND_MARK}<span>diffninja</span></p>`,
|
|
37
46
|
'<h1 id="page-title">Pull request review</h1>',
|
|
38
47
|
'<p id="page-meta" class="page-meta" hidden></p>',
|
|
48
|
+
'<p id="update-notice" class="update-notice" role="status" hidden></p>',
|
|
39
49
|
'<section id="pr-goal" class="pr-goal" aria-labelledby="pr-goal-heading" hidden>',
|
|
40
50
|
'<h2 id="pr-goal-heading" class="pr-goal-heading">Goal</h2>',
|
|
41
51
|
'<p id="pr-goal-text" class="pr-goal-text"></p>',
|
|
@@ -83,7 +93,9 @@ export function renderConnectedPage(csrf) {
|
|
|
83
93
|
'<header class="rail-head">',
|
|
84
94
|
'<h2 id="analysis-heading">Reading order</h2>',
|
|
85
95
|
'<p id="analysis-sub" class="rail-sub"></p>',
|
|
86
|
-
'<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>',
|
|
96
|
+
'<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><span id="rail-viewed-count" class="rail-viewed-count"></span></p>',
|
|
97
|
+
'<p id="rail-sync-note" class="rail-note" hidden></p>',
|
|
98
|
+
'<p id="rail-viewed-error" class="rail-note is-error" role="alert" hidden></p>',
|
|
87
99
|
'<div id="analysis-actions" class="rail-actions"></div>',
|
|
88
100
|
"</header>",
|
|
89
101
|
'<div id="analysis-body" class="rail-body"></div>',
|
|
@@ -177,13 +189,13 @@ export function renderConnectedPage(csrf) {
|
|
|
177
189
|
"</div>",
|
|
178
190
|
'<iframe id="flow-frame" class="flow-frame" title="Call flow"></iframe>',
|
|
179
191
|
"</aside>",
|
|
180
|
-
`<script nonce="${nonce}">${script(csrf)}</script>`,
|
|
192
|
+
`<script nonce="${nonce}">${script(csrf, base)}</script>`,
|
|
181
193
|
"</body>",
|
|
182
194
|
"</html>",
|
|
183
195
|
"",
|
|
184
196
|
].join("\n");
|
|
185
197
|
}
|
|
186
|
-
function script(csrf) {
|
|
198
|
+
function script(csrf, base) {
|
|
187
199
|
// The nonce doubles as a JS string literal; `\u003c` keeps a hostile value
|
|
188
200
|
// from closing the surrounding script element.
|
|
189
201
|
return `
|
|
@@ -191,6 +203,8 @@ function script(csrf) {
|
|
|
191
203
|
'use strict';
|
|
192
204
|
|
|
193
205
|
var CSRF = ${JSON.stringify(csrf).replaceAll("<", "\\u003c")};
|
|
206
|
+
// Every request goes under the session's secret prefix; without it the server answers 404.
|
|
207
|
+
var BASE = ${JSON.stringify(base).replaceAll("<", "\\u003c")};
|
|
194
208
|
var DRAFT_PREFIX = 'diffninja.connected.draft.v1';
|
|
195
209
|
|
|
196
210
|
var state = null;
|
|
@@ -227,21 +241,40 @@ function script(csrf) {
|
|
|
227
241
|
var stopsNow = [];
|
|
228
242
|
var currentRank = 0;
|
|
229
243
|
var followedRank = 0;
|
|
230
|
-
var
|
|
231
|
-
var
|
|
244
|
+
var DRAFT_VIEWED_PREFIX = 'diffninja.connected.viewed.v1';
|
|
245
|
+
var viewedHunks = Object.create(null);
|
|
246
|
+
var viewedFor = '';
|
|
247
|
+
var viewedDirty = true;
|
|
248
|
+
var viewedError = '';
|
|
249
|
+
var fileSync = Object.create(null);
|
|
232
250
|
var el = {};
|
|
233
251
|
|
|
234
252
|
function byId(id) { return document.getElementById(id); }
|
|
235
253
|
|
|
254
|
+
// Bidirectional and other invisible control characters would let untrusted text reorder or hide
|
|
255
|
+
// what the reviewer reads; every string from the pull request is shown with visible markers instead.
|
|
256
|
+
var HIDDEN;
|
|
257
|
+
try {
|
|
258
|
+
HIDDEN = new RegExp(${JSON.stringify(HIDDEN_CHARACTER_SOURCE).replaceAll("<", "\\u003c")}, 'gu');
|
|
259
|
+
} catch (unsupported) {
|
|
260
|
+
// A browser that cannot compile the lookbehind marks every such character rather than lose the page.
|
|
261
|
+
HIDDEN = new RegExp(${JSON.stringify(HIDDEN_CHARACTER_FALLBACK_SOURCE)}, 'gu');
|
|
262
|
+
}
|
|
263
|
+
function visible(value) {
|
|
264
|
+
return String(value).replace(HIDDEN, function (character) {
|
|
265
|
+
return '\u27E6U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '\u27E7';
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
236
269
|
function make(tag, className, textValue) {
|
|
237
270
|
var node = document.createElement(tag);
|
|
238
271
|
if (className) node.className = className;
|
|
239
|
-
if (textValue !== undefined && textValue !== null) node.textContent =
|
|
272
|
+
if (textValue !== undefined && textValue !== null) node.textContent = visible(textValue);
|
|
240
273
|
return node;
|
|
241
274
|
}
|
|
242
275
|
|
|
243
276
|
function setText(node, value) {
|
|
244
|
-
node.textContent = value === undefined || value === null ? '' :
|
|
277
|
+
node.textContent = value === undefined || value === null ? '' : visible(value);
|
|
245
278
|
}
|
|
246
279
|
|
|
247
280
|
function show(node, visible) { node.hidden = !visible; }
|
|
@@ -321,6 +354,7 @@ function script(csrf) {
|
|
|
321
354
|
return;
|
|
322
355
|
}
|
|
323
356
|
state = next;
|
|
357
|
+
viewedDirty = true;
|
|
324
358
|
var snap = snapshot();
|
|
325
359
|
if (snap) restoreDraft(snap);
|
|
326
360
|
}
|
|
@@ -490,7 +524,7 @@ function script(csrf) {
|
|
|
490
524
|
init.headers['X-Diffninja-CSRF'] = CSRF;
|
|
491
525
|
init.body = JSON.stringify(payload === undefined ? {} : payload);
|
|
492
526
|
}
|
|
493
|
-
return fetch(path, init).then(function (response) {
|
|
527
|
+
return fetch(BASE + (path.charAt(0) === '/' ? path.slice(1) : path), init).then(function (response) {
|
|
494
528
|
return response.text().then(function (raw) {
|
|
495
529
|
var data = null;
|
|
496
530
|
if (raw) {
|
|
@@ -942,7 +976,7 @@ function script(csrf) {
|
|
|
942
976
|
if (!node || typeof node !== 'object') continue;
|
|
943
977
|
var type = typeof node.t === 'string' ? node.t : '';
|
|
944
978
|
if (type === 'text') {
|
|
945
|
-
if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
|
|
979
|
+
if (typeof node.v === 'string') host.appendChild(document.createTextNode(visible(node.v)));
|
|
946
980
|
continue;
|
|
947
981
|
}
|
|
948
982
|
if (type === 'code') {
|
|
@@ -973,7 +1007,7 @@ function script(csrf) {
|
|
|
973
1007
|
continue;
|
|
974
1008
|
}
|
|
975
1009
|
// Anything this page does not know is shown as its own text.
|
|
976
|
-
if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
|
|
1010
|
+
if (typeof node.v === 'string') host.appendChild(document.createTextNode(visible(node.v)));
|
|
977
1011
|
}
|
|
978
1012
|
}
|
|
979
1013
|
|
|
@@ -1104,6 +1138,19 @@ function script(csrf) {
|
|
|
1104
1138
|
* nothing rather than a stale goal — and when none was recorded the block
|
|
1105
1139
|
* says so instead of describing the pull request itself.
|
|
1106
1140
|
*/
|
|
1141
|
+
/** A newer diffninja exists: one line with the command, from the analysis the page already polls. */
|
|
1142
|
+
function renderUpdate() {
|
|
1143
|
+
var current = currentAnalysis();
|
|
1144
|
+
var u = current && current.update && typeof current.update === 'object' ? current.update : null;
|
|
1145
|
+
var valid = u && typeof u.latest === 'string' && typeof u.current === 'string' && typeof u.command === 'string';
|
|
1146
|
+
el.updateNotice.textContent = '';
|
|
1147
|
+
show(el.updateNotice, Boolean(valid));
|
|
1148
|
+
if (!valid) return;
|
|
1149
|
+
el.updateNotice.appendChild(document.createTextNode('diffninja ' + u.latest + ' is available (you have ' + u.current + '). Update by running '));
|
|
1150
|
+
el.updateNotice.appendChild(make('code', '', u.command));
|
|
1151
|
+
el.updateNotice.appendChild(document.createTextNode(' in a terminal, then restart your agent.'));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1107
1154
|
function renderGoal() {
|
|
1108
1155
|
var snap = snapshot();
|
|
1109
1156
|
// A revision that cannot be reviewed gets no goal block: there is nothing to aim the reading at.
|
|
@@ -1146,7 +1193,7 @@ function script(csrf) {
|
|
|
1146
1193
|
var by = typeof explanation.explainedBy === 'string' && explanation.explainedBy.trim() !== '' ? explanation.explainedBy.trim() : 'your review agent';
|
|
1147
1194
|
var processes = Array.isArray(explanation.processes) ? explanation.processes.length : 0;
|
|
1148
1195
|
setText(el.prHowBy, 'Explained by ' + by + ' from the code: ' + processes + (processes === 1 ? ' process' : ' processes') + ' and ' + (explanation.rules || 0) + ((explanation.rules || 0) === 1 ? ' business rule' : ' business rules') + '. Its reading, not a verdict; the diff below is the ground truth.');
|
|
1149
|
-
var src = '
|
|
1196
|
+
var src = BASE + 'flow?snapshot=' + encodeURIComponent(current.snapshotId) + '&view=business';
|
|
1150
1197
|
if (el.prHowFrame.getAttribute('src') !== src) el.prHowFrame.setAttribute('src', src);
|
|
1151
1198
|
}
|
|
1152
1199
|
|
|
@@ -1268,6 +1315,7 @@ function script(csrf) {
|
|
|
1268
1315
|
var entry = make('li', 'order-item');
|
|
1269
1316
|
entry.dataset.action = 'goto-stop';
|
|
1270
1317
|
entry.dataset.rank = String(rank);
|
|
1318
|
+
entry.dataset.hunk = String(hunk.id);
|
|
1271
1319
|
entry.dataset.path = String(hunk.file);
|
|
1272
1320
|
entry.dataset.line = String(hunk.line);
|
|
1273
1321
|
entry.dataset.side = hunk.side === 'LEFT' ? 'LEFT' : 'RIGHT';
|
|
@@ -1287,6 +1335,7 @@ function script(csrf) {
|
|
|
1287
1335
|
jump.dataset.rank = entry.dataset.rank;
|
|
1288
1336
|
main.appendChild(jump);
|
|
1289
1337
|
entry.appendChild(main);
|
|
1338
|
+
entry.appendChild(viewedBox(hunk, rank));
|
|
1290
1339
|
return entry;
|
|
1291
1340
|
}
|
|
1292
1341
|
|
|
@@ -1382,11 +1431,13 @@ function script(csrf) {
|
|
|
1382
1431
|
var snap = snapshot();
|
|
1383
1432
|
// The goal rides on the analysis, so it re-renders wherever the analysis does — every poll included.
|
|
1384
1433
|
renderGoal();
|
|
1434
|
+
renderUpdate();
|
|
1385
1435
|
renderHow();
|
|
1386
1436
|
show(el.analysisSection, Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== ''));
|
|
1387
1437
|
el.analysisBody.textContent = '';
|
|
1388
1438
|
el.analysisActions.textContent = '';
|
|
1389
1439
|
setText(el.analysisSub, '');
|
|
1440
|
+
clearViewedNotes();
|
|
1390
1441
|
renderAnalysisDetails(currentAnalysis());
|
|
1391
1442
|
if (!snap) return;
|
|
1392
1443
|
if (!analysis || analysisFor !== snap.id) {
|
|
@@ -1416,10 +1467,12 @@ function script(csrf) {
|
|
|
1416
1467
|
el.analysisBody.appendChild(make('p', 'inline-note', 'No call-flow diagrams: ' + analysis.scope.note.replace(/^Patch-only: /, '')));
|
|
1417
1468
|
}
|
|
1418
1469
|
if (!el.flowDrawer.hidden && flowSnapshot !== analysis.snapshotId) closeFlow();
|
|
1470
|
+
if (viewedDirty || viewedFor !== snap.id) adoptViewed();
|
|
1419
1471
|
var list = make('ol', 'order-list');
|
|
1420
1472
|
var hunks = Array.isArray(analysis.hunks) ? analysis.hunks : [];
|
|
1421
1473
|
for (var h = 0; h < hunks.length; h += 1) list.appendChild(renderHunkEntry(hunks[h], h + 1));
|
|
1422
1474
|
el.analysisBody.appendChild(list);
|
|
1475
|
+
paintViewed();
|
|
1423
1476
|
}
|
|
1424
1477
|
|
|
1425
1478
|
function gotoLine(node) {
|
|
@@ -1445,6 +1498,211 @@ function script(csrf) {
|
|
|
1445
1498
|
if (action) action.focus({ preventScroll: true });
|
|
1446
1499
|
}
|
|
1447
1500
|
|
|
1501
|
+
/* ------------------------------------------------------------- viewed -- */
|
|
1502
|
+
|
|
1503
|
+
function viewedStoreKey(snap) { return DRAFT_VIEWED_PREFIX + '|' + snap.id; }
|
|
1504
|
+
|
|
1505
|
+
/** The tab's own viewed flags for one snapshot, hunk id to true; none when storage is unusable. */
|
|
1506
|
+
function readViewedStore(snap) {
|
|
1507
|
+
var flags = Object.create(null);
|
|
1508
|
+
var store = storage();
|
|
1509
|
+
if (!store) return flags;
|
|
1510
|
+
try {
|
|
1511
|
+
var stored = JSON.parse(store.getItem(viewedStoreKey(snap)) || '[]');
|
|
1512
|
+
if (Array.isArray(stored)) {
|
|
1513
|
+
for (var i = 0; i < stored.length; i += 1) {
|
|
1514
|
+
if (typeof stored[i] === 'string') flags[stored[i]] = true;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
/* Unreadable or blocked: this tab starts with none marked. */
|
|
1519
|
+
}
|
|
1520
|
+
return flags;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function saveViewedStore() {
|
|
1524
|
+
var snap = snapshot();
|
|
1525
|
+
var store = storage();
|
|
1526
|
+
if (!snap || !store || viewedFor !== snap.id) return;
|
|
1527
|
+
try {
|
|
1528
|
+
store.setItem(viewedStoreKey(snap), JSON.stringify(Object.keys(viewedHunks)));
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
/* Storage disabled or full: the marks stay in memory. */
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function analysisHunks() {
|
|
1535
|
+
var current = currentAnalysis();
|
|
1536
|
+
return current && Array.isArray(current.hunks) ? current.hunks : [];
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function hunksOfFile(path) {
|
|
1540
|
+
return analysisHunks().filter(function (hunk) { return hunk.file === path; });
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** GitHub's Viewed is per file: a file counts as viewed exactly when it has hunks and every one of them is viewed. */
|
|
1544
|
+
function fileViewed(path) {
|
|
1545
|
+
var mine = hunksOfFile(path);
|
|
1546
|
+
return mine.length > 0 && mine.every(function (hunk) { return viewedHunks[hunk.id] === true; });
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** Whether the server could read GitHub's marks, so a click may be mirrored there. */
|
|
1550
|
+
function viewedSynced() {
|
|
1551
|
+
return Boolean(state && state.viewed && state.viewed.available === true);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Bring the flags in line with what the server last knew of GitHub. GitHub wins
|
|
1556
|
+
* per file: a file it calls viewed shows every hunk viewed, and a file it does
|
|
1557
|
+
* not call viewed cannot show all of them. Progress inside a file is the tab's
|
|
1558
|
+
* own and is kept. A file with a request out is left to that request.
|
|
1559
|
+
*/
|
|
1560
|
+
function adoptViewed() {
|
|
1561
|
+
var snap = snapshot();
|
|
1562
|
+
if (!snap) return;
|
|
1563
|
+
if (viewedFor !== snap.id) {
|
|
1564
|
+
viewedFor = snap.id;
|
|
1565
|
+
viewedHunks = readViewedStore(snap);
|
|
1566
|
+
fileSync = Object.create(null);
|
|
1567
|
+
}
|
|
1568
|
+
var hunks = analysisHunks();
|
|
1569
|
+
var known = Object.create(null);
|
|
1570
|
+
for (var h = 0; h < hunks.length; h += 1) known[hunks[h].id] = true;
|
|
1571
|
+
for (var id in viewedHunks) {
|
|
1572
|
+
if (known[id] !== true) delete viewedHunks[id];
|
|
1573
|
+
}
|
|
1574
|
+
var files = viewedSynced() && Array.isArray(state.viewed.files) ? state.viewed.files : [];
|
|
1575
|
+
var synced = Object.create(null);
|
|
1576
|
+
for (var f = 0; f < files.length; f += 1) {
|
|
1577
|
+
var entry = files[f];
|
|
1578
|
+
if (!entry || typeof entry.path !== 'string') continue;
|
|
1579
|
+
synced[entry.path] = true;
|
|
1580
|
+
var record = fileSync[entry.path] || (fileSync[entry.path] = { confirmed: false, sending: false, last: '' });
|
|
1581
|
+
if (record.sending) continue;
|
|
1582
|
+
record.confirmed = entry.viewed === true;
|
|
1583
|
+
var mine = hunksOfFile(entry.path);
|
|
1584
|
+
if (record.confirmed) {
|
|
1585
|
+
for (var a = 0; a < mine.length; a += 1) viewedHunks[mine[a].id] = true;
|
|
1586
|
+
} else if (fileViewed(entry.path)) {
|
|
1587
|
+
for (var b = 0; b < mine.length; b += 1) delete viewedHunks[mine[b].id];
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
for (var path in fileSync) {
|
|
1591
|
+
if (synced[path] !== true) delete fileSync[path];
|
|
1592
|
+
}
|
|
1593
|
+
viewedDirty = false;
|
|
1594
|
+
saveViewedStore();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** The rail's checkbox for one change: a real button, so it takes focus, Space and Enter. */
|
|
1598
|
+
function viewedBox(hunk, rank) {
|
|
1599
|
+
var box = make('button', 'viewed-box');
|
|
1600
|
+
box.type = 'button';
|
|
1601
|
+
box.dataset.action = 'toggle-viewed';
|
|
1602
|
+
box.dataset.hunk = String(hunk.id);
|
|
1603
|
+
box.setAttribute('role', 'checkbox');
|
|
1604
|
+
box.setAttribute('aria-checked', viewedHunks[hunk.id] === true ? 'true' : 'false');
|
|
1605
|
+
box.setAttribute('aria-label', visible('Mark change ' + rank + ' of ' + hunk.file + ' as viewed'));
|
|
1606
|
+
var mirrored = viewedSynced() && fileSync[hunk.file] !== undefined;
|
|
1607
|
+
box.title = visible(mirrored ? 'Marks the file viewed on GitHub once every change in it is viewed' : 'Kept in this tab only');
|
|
1608
|
+
return box;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** Repaint the flags in place, so a focused checkbox keeps focus. */
|
|
1612
|
+
function paintViewed() {
|
|
1613
|
+
var stations = el.analysisBody.querySelectorAll('.order-item');
|
|
1614
|
+
var count = 0;
|
|
1615
|
+
for (var i = 0; i < stations.length; i += 1) {
|
|
1616
|
+
var station = stations[i];
|
|
1617
|
+
var on = viewedHunks[station.dataset.hunk] === true;
|
|
1618
|
+
if (on) count += 1;
|
|
1619
|
+
station.classList.toggle('is-viewed', on);
|
|
1620
|
+
var box = station.querySelector('.viewed-box');
|
|
1621
|
+
if (!box) continue;
|
|
1622
|
+
box.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
1623
|
+
var record = fileSync[station.dataset.path];
|
|
1624
|
+
box.classList.toggle('is-pending', Boolean(record && record.sending));
|
|
1625
|
+
}
|
|
1626
|
+
setText(el.railViewedCount, stations.length > 0 ? count + ' of ' + stations.length + ' viewed' : '');
|
|
1627
|
+
var note = '';
|
|
1628
|
+
if (stations.length > 0 && !viewedSynced()) {
|
|
1629
|
+
var reason = state && state.viewed && typeof state.viewed.reason === 'string' && state.viewed.reason !== '' ? state.viewed.reason : 'GitHub did not offer viewed marks';
|
|
1630
|
+
note = 'not synced with GitHub: ' + reason;
|
|
1631
|
+
}
|
|
1632
|
+
setText(el.railSyncNote, note);
|
|
1633
|
+
show(el.railSyncNote, note !== '');
|
|
1634
|
+
setText(el.railViewedError, viewedError);
|
|
1635
|
+
show(el.railViewedError, viewedError !== '');
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function clearViewedNotes() {
|
|
1639
|
+
setText(el.railViewedCount, '');
|
|
1640
|
+
setText(el.railSyncNote, '');
|
|
1641
|
+
show(el.railSyncNote, false);
|
|
1642
|
+
setText(el.railViewedError, '');
|
|
1643
|
+
show(el.railViewedError, false);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** A click on a change's checkbox: flip it here, then mirror the file's new state to GitHub when the file's state changed. */
|
|
1647
|
+
function toggleViewed(hunkId) {
|
|
1648
|
+
var snap = snapshot();
|
|
1649
|
+
var hunk = null;
|
|
1650
|
+
var hunks = analysisHunks();
|
|
1651
|
+
for (var i = 0; i < hunks.length; i += 1) {
|
|
1652
|
+
if (hunks[i].id === hunkId) { hunk = hunks[i]; break; }
|
|
1653
|
+
}
|
|
1654
|
+
if (!snap || !hunk || viewedFor !== snap.id) return;
|
|
1655
|
+
if (viewedHunks[hunkId] === true) delete viewedHunks[hunkId];
|
|
1656
|
+
else viewedHunks[hunkId] = true;
|
|
1657
|
+
viewedError = '';
|
|
1658
|
+
saveViewedStore();
|
|
1659
|
+
var record = fileSync[hunk.file];
|
|
1660
|
+
if (record) record.last = hunkId;
|
|
1661
|
+
syncFile(hunk.file);
|
|
1662
|
+
paintViewed();
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Make GitHub's mark of one file match the page. One request per file is out at
|
|
1667
|
+
* a time; a click that lands meanwhile is sent when the answer comes back, so
|
|
1668
|
+
* the file ends in the state of the last click. A request goes out only when
|
|
1669
|
+
* the file's state differs from what GitHub last confirmed.
|
|
1670
|
+
*/
|
|
1671
|
+
function syncFile(path) {
|
|
1672
|
+
var snap = snapshot();
|
|
1673
|
+
var record = fileSync[path];
|
|
1674
|
+
if (!snap || !record || record.sending) return;
|
|
1675
|
+
var want = fileViewed(path);
|
|
1676
|
+
if (want === record.confirmed) return;
|
|
1677
|
+
var trigger = record.last;
|
|
1678
|
+
record.sending = true;
|
|
1679
|
+
api('POST', '/api/viewed', { snapshotId: snap.id, path: path, viewed: want }).then(function () {
|
|
1680
|
+
record.sending = false;
|
|
1681
|
+
if (viewedFor !== snap.id) return;
|
|
1682
|
+
record.confirmed = want;
|
|
1683
|
+
// The state the page holds must say what GitHub now says, or the next adoption would undo the click.
|
|
1684
|
+
var files = state && state.viewed && Array.isArray(state.viewed.files) ? state.viewed.files : [];
|
|
1685
|
+
for (var i = 0; i < files.length; i += 1) {
|
|
1686
|
+
if (files[i] && files[i].path === path) files[i].viewed = want;
|
|
1687
|
+
}
|
|
1688
|
+
}, function (error) {
|
|
1689
|
+
record.sending = false;
|
|
1690
|
+
if (viewedFor !== snap.id) return;
|
|
1691
|
+
// Put the click back: GitHub did not take it, so neither does the page.
|
|
1692
|
+
if (want) delete viewedHunks[trigger];
|
|
1693
|
+
else viewedHunks[trigger] = true;
|
|
1694
|
+
viewedError = error && error.message ? error.message : 'The Viewed mark could not be saved.';
|
|
1695
|
+
if (error && error.uncertain) state.viewed = { available: false, reason: 'diffninja did not answer the last Viewed mark' };
|
|
1696
|
+
else if (error && error.state && error.state.viewed && error.state.viewed.available === false) state.viewed = error.state.viewed;
|
|
1697
|
+
viewedDirty = true;
|
|
1698
|
+
}).then(function () {
|
|
1699
|
+
if (viewedDirty) adoptViewed();
|
|
1700
|
+
saveViewedStore();
|
|
1701
|
+
paintViewed();
|
|
1702
|
+
syncFile(path);
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1448
1706
|
/* -------------------------------------------------------- you are here -- */
|
|
1449
1707
|
|
|
1450
1708
|
var spyQueued = false;
|
|
@@ -1498,33 +1756,30 @@ function script(csrf) {
|
|
|
1498
1756
|
}
|
|
1499
1757
|
|
|
1500
1758
|
/**
|
|
1501
|
-
* Mark where the reader is on the rail
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1759
|
+
* Mark where the reader is on the rail. In the reading order the stops run 1,
|
|
1760
|
+
* 2, 3 down the page, so the rail's list follows each new one; by file the
|
|
1761
|
+
* stations of one file sit far apart in the reading order, so the list stays
|
|
1762
|
+
* where the reader put it. Position only: nothing here says a change was
|
|
1763
|
+
* viewed, because only the reader's click does.
|
|
1505
1764
|
*/
|
|
1506
1765
|
function markCurrentStation() {
|
|
1507
1766
|
spyQueued = false;
|
|
1508
1767
|
fitRail();
|
|
1509
|
-
var snap = snapshot();
|
|
1510
|
-
if (snap && seenFor !== snap.id) { seenFor = snap.id; seenRanks = Object.create(null); }
|
|
1511
1768
|
var stations = el.analysisBody.querySelectorAll('.order-item');
|
|
1512
1769
|
var guided = el.diffBody.querySelector('.stop') !== null;
|
|
1513
1770
|
currentRank = guided ? currentStopRank() : currentFileRank(stations);
|
|
1514
|
-
// Every change
|
|
1771
|
+
// Every change in the current stop is current.
|
|
1515
1772
|
var here = Object.create(null);
|
|
1516
1773
|
var stop = guided ? stopOf(currentRank) : null;
|
|
1517
1774
|
if (stop) {
|
|
1518
1775
|
for (var h = 0; h < stop.hunks.length; h += 1) here[stop.hunks[h].rank] = true;
|
|
1519
1776
|
} else if (currentRank > 0) here[currentRank] = true;
|
|
1520
|
-
for (var seen in here) seenRanks[seen] = true;
|
|
1521
1777
|
var current = null;
|
|
1522
1778
|
for (var j = 0; j < stations.length; j += 1) {
|
|
1523
1779
|
var rank = Number(stations[j].dataset.rank);
|
|
1524
1780
|
var on = here[rank] === true;
|
|
1525
1781
|
if (rank === currentRank) current = stations[j];
|
|
1526
1782
|
stations[j].classList.toggle('is-current', on);
|
|
1527
|
-
stations[j].classList.toggle('is-seen', seenRanks[rank] === true);
|
|
1528
1783
|
if (on) stations[j].setAttribute('aria-current', 'step');
|
|
1529
1784
|
else stations[j].removeAttribute('aria-current');
|
|
1530
1785
|
}
|
|
@@ -1738,6 +1993,9 @@ function script(csrf) {
|
|
|
1738
1993
|
|
|
1739
1994
|
function codeNode(text, language, state) {
|
|
1740
1995
|
var code = make('code', 'diff-code');
|
|
1996
|
+
var shown = visible(text);
|
|
1997
|
+
if (shown !== text) code.classList.add('diff-code-hidden');
|
|
1998
|
+
text = shown;
|
|
1741
1999
|
if (!language || text.length > 2000) { code.textContent = text; return code; }
|
|
1742
2000
|
var tokens = tokenize(text, language, state);
|
|
1743
2001
|
for (var t = 0; t < tokens.length; t += 1) {
|
|
@@ -1769,7 +2027,7 @@ function script(csrf) {
|
|
|
1769
2027
|
function openFlow(path, opener) {
|
|
1770
2028
|
var current = currentAnalysis();
|
|
1771
2029
|
if (!current) return;
|
|
1772
|
-
var src = '
|
|
2030
|
+
var src = BASE + 'flow?snapshot=' + encodeURIComponent(current.snapshotId) + (path === '' ? '' : '&file=' + encodeURIComponent(path));
|
|
1773
2031
|
if (el.flowFrame.getAttribute('src') !== src) el.flowFrame.setAttribute('src', src);
|
|
1774
2032
|
flowSnapshot = current.snapshotId;
|
|
1775
2033
|
var explained = Boolean(current.explanation);
|
|
@@ -1844,7 +2102,9 @@ function script(csrf) {
|
|
|
1844
2102
|
var s = list[i];
|
|
1845
2103
|
var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
|
|
1846
2104
|
&& (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
|
|
1847
|
-
&&
|
|
2105
|
+
&& typeof s.scenario === 'string' && s.scenario !== ''
|
|
2106
|
+
&& (s.evidence === 'ran' || s.evidence === 'traced')
|
|
2107
|
+
&& typeof s.unlessTrue === 'string' && s.unlessTrue !== '';
|
|
1848
2108
|
if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
|
|
1849
2109
|
out.push(s);
|
|
1850
2110
|
}
|
|
@@ -1911,19 +2171,24 @@ function script(csrf) {
|
|
|
1911
2171
|
render();
|
|
1912
2172
|
}
|
|
1913
2173
|
|
|
1914
|
-
|
|
1915
|
-
|
|
2174
|
+
/** A suggestion is a claim that the pull request should not merge until it is fixed, so it shows its proof under the comment. */
|
|
1916
2175
|
function suggestionRow(suggestion, disabled) {
|
|
1917
2176
|
var wrap = make('div', 'suggestion');
|
|
1918
2177
|
var head = make('p', 'suggestion-by');
|
|
1919
2178
|
head.appendChild(make('span', 'agent-dot', ''));
|
|
1920
2179
|
head.appendChild(make('strong', '', suggestedBy()));
|
|
1921
2180
|
head.appendChild(document.createTextNode(' suggests'));
|
|
1922
|
-
var
|
|
1923
|
-
|
|
1924
|
-
head.appendChild(
|
|
2181
|
+
var badge = make('span', 'blocks-badge', 'Blocks merge');
|
|
2182
|
+
badge.title = 'The agent says this must be fixed before the pull request merges';
|
|
2183
|
+
head.appendChild(badge);
|
|
1925
2184
|
wrap.appendChild(head);
|
|
1926
2185
|
wrap.appendChild(make('p', 'suggestion-body', suggestion.body));
|
|
2186
|
+
var proof = make('div', 'suggestion-proof');
|
|
2187
|
+
proof.appendChild(make('p', 'proof-title', 'Why it blocks'));
|
|
2188
|
+
proof.appendChild(make('p', 'proof-scenario', suggestion.scenario));
|
|
2189
|
+
proof.appendChild(make('p', 'proof-evidence', 'Checked: ' + (suggestion.evidence === 'ran' ? 'ran it' : 'traced the code')));
|
|
2190
|
+
proof.appendChild(make('p', 'proof-unless', 'Not a problem if: ' + suggestion.unlessTrue));
|
|
2191
|
+
wrap.appendChild(proof);
|
|
1927
2192
|
var actions = make('div', 'suggestion-actions');
|
|
1928
2193
|
var add = make('button', 'btn btn-primary btn-sm', 'Add to review');
|
|
1929
2194
|
add.type = 'button';
|
|
@@ -1958,8 +2223,8 @@ function script(csrf) {
|
|
|
1958
2223
|
var said = make('p', '');
|
|
1959
2224
|
said.appendChild(make('span', 'agent-dot', ''));
|
|
1960
2225
|
said.appendChild(make('strong', '', suggestedBy()));
|
|
1961
|
-
said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment' : ' comments')
|
|
1962
|
-
+ ' on the lines below. Nothing is posted until you submit.'));
|
|
2226
|
+
said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment that blocks' : ' comments that block')
|
|
2227
|
+
+ ' the merge on the lines below. Nothing is posted until you submit.'));
|
|
1963
2228
|
bar.appendChild(said);
|
|
1964
2229
|
var all = make('button', 'btn btn-primary btn-sm', open.length === 1 ? 'Add it to review' : 'Add all ' + open.length);
|
|
1965
2230
|
all.type = 'button';
|
|
@@ -2575,6 +2840,7 @@ function script(csrf) {
|
|
|
2575
2840
|
if (action === 'submit') { event_.preventDefault(); submitReview(); return; }
|
|
2576
2841
|
if (action === 'refresh') { event_.preventDefault(); refreshState(); return; }
|
|
2577
2842
|
if (action === 'goto') { event_.preventDefault(); gotoLine(node); return; }
|
|
2843
|
+
if (action === 'toggle-viewed') { event_.preventDefault(); toggleViewed(node.getAttribute('data-hunk') || ''); return; }
|
|
2578
2844
|
if (action === 'goto-stop') { event_.preventDefault(); gotoStop(Number(node.getAttribute('data-rank'))); return; }
|
|
2579
2845
|
if (action === 'view') { event_.preventDefault(); setView(node.getAttribute('data-view') || ''); return; }
|
|
2580
2846
|
if (action === 'show-load') { event_.preventDefault(); showLoad = true; render(); el.prUrl.focus(); return; }
|
|
@@ -2616,6 +2882,7 @@ function script(csrf) {
|
|
|
2616
2882
|
el.refreshButton = byId('refresh-button');
|
|
2617
2883
|
el.pageTitle = byId('page-title');
|
|
2618
2884
|
el.pageMeta = byId('page-meta');
|
|
2885
|
+
el.updateNotice = byId('update-notice');
|
|
2619
2886
|
el.prGoal = byId('pr-goal');
|
|
2620
2887
|
el.prGoalText = byId('pr-goal-text');
|
|
2621
2888
|
el.prGoalBy = byId('pr-goal-by');
|
|
@@ -2640,6 +2907,9 @@ function script(csrf) {
|
|
|
2640
2907
|
el.railFinish = byId('rail-finish');
|
|
2641
2908
|
el.railProgress = byId('rail-progress');
|
|
2642
2909
|
el.railAt = byId('rail-at');
|
|
2910
|
+
el.railViewedCount = byId('rail-viewed-count');
|
|
2911
|
+
el.railSyncNote = byId('rail-sync-note');
|
|
2912
|
+
el.railViewedError = byId('rail-viewed-error');
|
|
2643
2913
|
el.viewSwitch = byId('view-switch');
|
|
2644
2914
|
view = readView();
|
|
2645
2915
|
el.analysisActions = byId('analysis-actions');
|
|
@@ -2742,6 +3012,7 @@ ${BRAND_MARK_STYLES}
|
|
|
2742
3012
|
.page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--ink-soft); }
|
|
2743
3013
|
.page-meta a { font-weight: 600; }
|
|
2744
3014
|
/* The agent's stated goal, first thing under the masthead: one sentence, with its attribution. */
|
|
3015
|
+
.update-notice { padding: 8px 12px; border-left: 3px solid var(--warn); background: var(--warn-soft); border-radius: 4px; font-size: 14px; }
|
|
2745
3016
|
.pr-goal { margin-top: 6px; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: var(--radius); background: var(--panel); }
|
|
2746
3017
|
.pr-goal.is-empty { border-left-color: var(--line-strong); background: var(--sunken); }
|
|
2747
3018
|
.pr-goal-heading { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); }
|
|
@@ -2924,7 +3195,7 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2924
3195
|
.order-list { list-style: none; margin: 0; padding: 0; }
|
|
2925
3196
|
/* Stations sit on one continuous line: the route through the pull request. */
|
|
2926
3197
|
.order-item {
|
|
2927
|
-
position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 10px;
|
|
3198
|
+
position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; column-gap: 10px;
|
|
2928
3199
|
padding: 6px 14px 6px 12px; cursor: pointer;
|
|
2929
3200
|
}
|
|
2930
3201
|
.order-item::before {
|
|
@@ -2932,8 +3203,8 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2932
3203
|
}
|
|
2933
3204
|
.order-item:first-child::before { top: 16px; }
|
|
2934
3205
|
.order-item:last-child::before { bottom: calc(100% - 16px); }
|
|
2935
|
-
/* The route fills in behind the reader: a
|
|
2936
|
-
.order-item.is-
|
|
3206
|
+
/* The route fills in behind what the reader marked viewed: a viewed station and the line after it turn accent. */
|
|
3207
|
+
.order-item.is-viewed::before { background: var(--accent); opacity: 0.55; }
|
|
2937
3208
|
.order-item:hover { background: var(--hover); }
|
|
2938
3209
|
.order-item:focus-within { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2939
3210
|
.order-rank {
|
|
@@ -2942,18 +3213,38 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2942
3213
|
color: var(--ink-soft); background: var(--panel); border: 2px solid var(--route);
|
|
2943
3214
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
2944
3215
|
}
|
|
2945
|
-
.order-item.is-
|
|
3216
|
+
.order-item.is-viewed .order-rank { color: var(--accent); border-color: var(--accent); }
|
|
2946
3217
|
.order-item.is-current { background: var(--accent-soft); }
|
|
2947
3218
|
.order-item.is-current .order-rank { color: var(--primary-ink); background: var(--accent); border-color: var(--accent); }
|
|
3219
|
+
/* A 24px target around a 16px box, drawn in CSS so it needs no glyph and follows the theme. */
|
|
3220
|
+
.viewed-box {
|
|
3221
|
+
position: relative; z-index: 1; align-self: center; width: 24px; height: 24px; margin: 0; padding: 0;
|
|
3222
|
+
display: grid; place-items: center; background: transparent; border: 0; border-radius: 6px; cursor: pointer;
|
|
3223
|
+
}
|
|
3224
|
+
.viewed-box::before {
|
|
3225
|
+
content: ""; width: 16px; height: 16px; box-sizing: border-box; border-radius: 4px;
|
|
3226
|
+
border: 2px solid var(--ink-faint); background: var(--panel); transition: background 0.15s, border-color 0.15s;
|
|
3227
|
+
}
|
|
3228
|
+
.viewed-box:hover::before { border-color: var(--accent); }
|
|
3229
|
+
.viewed-box[aria-checked="true"]::before { background: var(--accent); border-color: var(--accent); }
|
|
3230
|
+
.viewed-box[aria-checked="true"]::after {
|
|
3231
|
+
content: ""; position: absolute; width: 4px; height: 8px; margin-top: -2px;
|
|
3232
|
+
border: solid var(--primary-ink); border-width: 0 2px 2px 0; transform: rotate(45deg);
|
|
3233
|
+
}
|
|
3234
|
+
.viewed-box.is-pending { opacity: 0.55; }
|
|
2948
3235
|
.order-main { min-width: 0; padding-top: 1px; display: flex; flex-direction: column; gap: 3px; }
|
|
2949
3236
|
.order-path { display: flex; min-width: 0; font-size: 12.5px; white-space: nowrap; }
|
|
2950
3237
|
.order-path .path-base { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
2951
3238
|
.order-path .path-line { flex: 0 0 auto; }
|
|
2952
3239
|
.order-path .path-dir { display: none; }
|
|
2953
3240
|
.order-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
|
2954
|
-
.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; }
|
|
3241
|
+
.rail-progress { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
2955
3242
|
.rail-progress[hidden] { display: none; }
|
|
2956
3243
|
.rail-keys { color: var(--ink-faint); white-space: nowrap; }
|
|
3244
|
+
.rail-viewed-count { order: 3; flex: 1 0 100%; color: var(--ink-soft); }
|
|
3245
|
+
.rail-viewed-count:empty { display: none; }
|
|
3246
|
+
.rail-note { margin-top: 4px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }
|
|
3247
|
+
.rail-note[hidden] { display: none; }
|
|
2957
3248
|
kbd {
|
|
2958
3249
|
display: inline-block; min-width: 18px; padding: 0 4px; font: 11px/16px var(--mono); text-align: center;
|
|
2959
3250
|
color: var(--ink-soft); background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px;
|
|
@@ -3047,6 +3338,7 @@ kbd {
|
|
|
3047
3338
|
.diff-action.has-comment { opacity: 1; transform: scale(1); background: var(--warn); }
|
|
3048
3339
|
.diff-action.is-armed { opacity: 1; transform: scale(1); width: auto; padding: 0 6px; font-size: 11px; color: #fff; }
|
|
3049
3340
|
.diff-mark { flex: 0 0 auto; width: 20px; text-align: center; color: var(--ink-faint); user-select: none; }
|
|
3341
|
+
.diff-code-hidden { outline: 1px solid var(--warn); outline-offset: -1px; background: var(--warn-soft); }
|
|
3050
3342
|
.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); }
|
|
3051
3343
|
.kind-add { background: var(--add-bg); }
|
|
3052
3344
|
.kind-delete { background: var(--del-bg); }
|
|
@@ -3079,13 +3371,16 @@ kbd {
|
|
|
3079
3371
|
.editor-input { flex: 1 1 240px; min-width: 0; }
|
|
3080
3372
|
.suggestion-by { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-soft); }
|
|
3081
3373
|
.suggestion-by strong { color: var(--ink); font-weight: 600; }
|
|
3082
|
-
.
|
|
3374
|
+
.blocks-badge {
|
|
3083
3375
|
margin-left: 6px; padding: 1px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
|
|
3084
|
-
color: var(--
|
|
3376
|
+
color: var(--alarm); background: var(--alarm-bg);
|
|
3085
3377
|
}
|
|
3086
|
-
.severity-critical { color: var(--alarm); background: var(--alarm-bg); }
|
|
3087
|
-
.severity-major { color: var(--warn); background: var(--warn-soft); }
|
|
3088
3378
|
.suggestion-body { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
3379
|
+
.suggestion-proof {
|
|
3380
|
+
display: flex; flex-direction: column; gap: 2px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line);
|
|
3381
|
+
font-size: 13px; line-height: 1.45; color: var(--ink-soft); overflow-wrap: anywhere;
|
|
3382
|
+
}
|
|
3383
|
+
.proof-title { font-weight: 600; color: var(--ink); }
|
|
3089
3384
|
.suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
3090
3385
|
|
|
3091
3386
|
/* ---------------------------------------------------------------- forms */
|