diffninja 0.3.1 → 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.d.ts +3 -1
- package/dist/review/call-flow-html.js +13 -11
- 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 +342 -35
- 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.d.ts +3 -2
- package/dist/review/html.js +19 -18
- 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 -59
- 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/process-html.d.ts +1 -5
- package/dist/review/process-html.js +3 -12
- 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 -5
- package/dist/review/types.js +2 -1
- 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);
|
|
@@ -1843,7 +2101,10 @@ function script(csrf) {
|
|
|
1843
2101
|
for (var i = 0; i < list.length; i += 1) {
|
|
1844
2102
|
var s = list[i];
|
|
1845
2103
|
var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
|
|
1846
|
-
&& (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
|
|
2104
|
+
&& (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
|
|
2105
|
+
&& typeof s.scenario === 'string' && s.scenario !== ''
|
|
2106
|
+
&& (s.evidence === 'ran' || s.evidence === 'traced')
|
|
2107
|
+
&& typeof s.unlessTrue === 'string' && s.unlessTrue !== '';
|
|
1847
2108
|
if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
|
|
1848
2109
|
out.push(s);
|
|
1849
2110
|
}
|
|
@@ -1910,14 +2171,24 @@ function script(csrf) {
|
|
|
1910
2171
|
render();
|
|
1911
2172
|
}
|
|
1912
2173
|
|
|
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. */
|
|
1913
2175
|
function suggestionRow(suggestion, disabled) {
|
|
1914
2176
|
var wrap = make('div', 'suggestion');
|
|
1915
2177
|
var head = make('p', 'suggestion-by');
|
|
1916
2178
|
head.appendChild(make('span', 'agent-dot', ''));
|
|
1917
2179
|
head.appendChild(make('strong', '', suggestedBy()));
|
|
1918
2180
|
head.appendChild(document.createTextNode(' suggests'));
|
|
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);
|
|
1919
2184
|
wrap.appendChild(head);
|
|
1920
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);
|
|
1921
2192
|
var actions = make('div', 'suggestion-actions');
|
|
1922
2193
|
var add = make('button', 'btn btn-primary btn-sm', 'Add to review');
|
|
1923
2194
|
add.type = 'button';
|
|
@@ -1952,8 +2223,8 @@ function script(csrf) {
|
|
|
1952
2223
|
var said = make('p', '');
|
|
1953
2224
|
said.appendChild(make('span', 'agent-dot', ''));
|
|
1954
2225
|
said.appendChild(make('strong', '', suggestedBy()));
|
|
1955
|
-
said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment' : ' comments')
|
|
1956
|
-
+ ' 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.'));
|
|
1957
2228
|
bar.appendChild(said);
|
|
1958
2229
|
var all = make('button', 'btn btn-primary btn-sm', open.length === 1 ? 'Add it to review' : 'Add all ' + open.length);
|
|
1959
2230
|
all.type = 'button';
|
|
@@ -2569,6 +2840,7 @@ function script(csrf) {
|
|
|
2569
2840
|
if (action === 'submit') { event_.preventDefault(); submitReview(); return; }
|
|
2570
2841
|
if (action === 'refresh') { event_.preventDefault(); refreshState(); return; }
|
|
2571
2842
|
if (action === 'goto') { event_.preventDefault(); gotoLine(node); return; }
|
|
2843
|
+
if (action === 'toggle-viewed') { event_.preventDefault(); toggleViewed(node.getAttribute('data-hunk') || ''); return; }
|
|
2572
2844
|
if (action === 'goto-stop') { event_.preventDefault(); gotoStop(Number(node.getAttribute('data-rank'))); return; }
|
|
2573
2845
|
if (action === 'view') { event_.preventDefault(); setView(node.getAttribute('data-view') || ''); return; }
|
|
2574
2846
|
if (action === 'show-load') { event_.preventDefault(); showLoad = true; render(); el.prUrl.focus(); return; }
|
|
@@ -2610,6 +2882,7 @@ function script(csrf) {
|
|
|
2610
2882
|
el.refreshButton = byId('refresh-button');
|
|
2611
2883
|
el.pageTitle = byId('page-title');
|
|
2612
2884
|
el.pageMeta = byId('page-meta');
|
|
2885
|
+
el.updateNotice = byId('update-notice');
|
|
2613
2886
|
el.prGoal = byId('pr-goal');
|
|
2614
2887
|
el.prGoalText = byId('pr-goal-text');
|
|
2615
2888
|
el.prGoalBy = byId('pr-goal-by');
|
|
@@ -2634,6 +2907,9 @@ function script(csrf) {
|
|
|
2634
2907
|
el.railFinish = byId('rail-finish');
|
|
2635
2908
|
el.railProgress = byId('rail-progress');
|
|
2636
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');
|
|
2637
2913
|
el.viewSwitch = byId('view-switch');
|
|
2638
2914
|
view = readView();
|
|
2639
2915
|
el.analysisActions = byId('analysis-actions');
|
|
@@ -2736,6 +3012,7 @@ ${BRAND_MARK_STYLES}
|
|
|
2736
3012
|
.page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--ink-soft); }
|
|
2737
3013
|
.page-meta a { font-weight: 600; }
|
|
2738
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; }
|
|
2739
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); }
|
|
2740
3017
|
.pr-goal.is-empty { border-left-color: var(--line-strong); background: var(--sunken); }
|
|
2741
3018
|
.pr-goal-heading { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); }
|
|
@@ -2918,7 +3195,7 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2918
3195
|
.order-list { list-style: none; margin: 0; padding: 0; }
|
|
2919
3196
|
/* Stations sit on one continuous line: the route through the pull request. */
|
|
2920
3197
|
.order-item {
|
|
2921
|
-
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;
|
|
2922
3199
|
padding: 6px 14px 6px 12px; cursor: pointer;
|
|
2923
3200
|
}
|
|
2924
3201
|
.order-item::before {
|
|
@@ -2926,8 +3203,8 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2926
3203
|
}
|
|
2927
3204
|
.order-item:first-child::before { top: 16px; }
|
|
2928
3205
|
.order-item:last-child::before { bottom: calc(100% - 16px); }
|
|
2929
|
-
/* The route fills in behind the reader: a
|
|
2930
|
-
.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; }
|
|
2931
3208
|
.order-item:hover { background: var(--hover); }
|
|
2932
3209
|
.order-item:focus-within { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2933
3210
|
.order-rank {
|
|
@@ -2936,18 +3213,38 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
|
|
|
2936
3213
|
color: var(--ink-soft); background: var(--panel); border: 2px solid var(--route);
|
|
2937
3214
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
2938
3215
|
}
|
|
2939
|
-
.order-item.is-
|
|
3216
|
+
.order-item.is-viewed .order-rank { color: var(--accent); border-color: var(--accent); }
|
|
2940
3217
|
.order-item.is-current { background: var(--accent-soft); }
|
|
2941
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; }
|
|
2942
3235
|
.order-main { min-width: 0; padding-top: 1px; display: flex; flex-direction: column; gap: 3px; }
|
|
2943
3236
|
.order-path { display: flex; min-width: 0; font-size: 12.5px; white-space: nowrap; }
|
|
2944
3237
|
.order-path .path-base { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
2945
3238
|
.order-path .path-line { flex: 0 0 auto; }
|
|
2946
3239
|
.order-path .path-dir { display: none; }
|
|
2947
3240
|
.order-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
|
2948
|
-
.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; }
|
|
2949
3242
|
.rail-progress[hidden] { display: none; }
|
|
2950
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; }
|
|
2951
3248
|
kbd {
|
|
2952
3249
|
display: inline-block; min-width: 18px; padding: 0 4px; font: 11px/16px var(--mono); text-align: center;
|
|
2953
3250
|
color: var(--ink-soft); background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px;
|
|
@@ -3041,6 +3338,7 @@ kbd {
|
|
|
3041
3338
|
.diff-action.has-comment { opacity: 1; transform: scale(1); background: var(--warn); }
|
|
3042
3339
|
.diff-action.is-armed { opacity: 1; transform: scale(1); width: auto; padding: 0 6px; font-size: 11px; color: #fff; }
|
|
3043
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); }
|
|
3044
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); }
|
|
3045
3343
|
.kind-add { background: var(--add-bg); }
|
|
3046
3344
|
.kind-delete { background: var(--del-bg); }
|
|
@@ -3071,9 +3369,18 @@ kbd {
|
|
|
3071
3369
|
.editor { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-soft); }
|
|
3072
3370
|
.editor-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
3073
3371
|
.editor-input { flex: 1 1 240px; min-width: 0; }
|
|
3074
|
-
.suggestion-by { font-size: 12px; color: var(--ink-soft); }
|
|
3372
|
+
.suggestion-by { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-soft); }
|
|
3075
3373
|
.suggestion-by strong { color: var(--ink); font-weight: 600; }
|
|
3374
|
+
.blocks-badge {
|
|
3375
|
+
margin-left: 6px; padding: 1px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
|
|
3376
|
+
color: var(--alarm); background: var(--alarm-bg);
|
|
3377
|
+
}
|
|
3076
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); }
|
|
3077
3384
|
.suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
3078
3385
|
|
|
3079
3386
|
/* ---------------------------------------------------------------- forms */
|