@giamme/forge 0.8.0 → 0.10.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/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* forge-ui finding beacons: every review finding as a small mark over the stage. Its shape is its severity
|
|
2
|
+
(P0 spike, P1 diamond, P2 cone, P3 pin, no severity a ring), drawn like the blocks: a hairline in its --sev-*
|
|
3
|
+
colour over a translucent fill of the same, on a short post, its foot on the block it is about; a confirmed
|
|
4
|
+
finding is full size and fuller, a plausible one smaller and paler. Seen from straight above (the top-down
|
|
5
|
+
projection) the marks lie flat, so each keeps its silhouette. scene3d.js says where each one stands (an anchor
|
|
6
|
+
that follows its block as it lifts, opens or travels); this module draws them, one instanced mesh per shape
|
|
7
|
+
with the engine's hairline outline, and picks them. Marks stand still, so an idle stage draws no frames; one
|
|
8
|
+
that arrives (a new finding on a poll, one a replay reaches, one moving onto an opened task) drops in. */
|
|
9
|
+
export const SHAPES = ['P0', 'P1', 'P2', 'P3', 'none'];
|
|
10
|
+
const MAX = 512; // marks per shape
|
|
11
|
+
const SIZE = 18; // plan units per unit of the shapes below: a mark stands ~40 tall
|
|
12
|
+
const MIN_PX = 28; // a mark stands at least this tall on screen while the plate is near
|
|
13
|
+
const FAR_PPU = .5; // screen px per plan unit below which that floor shrinks with the plate, to half
|
|
14
|
+
const TALL = 2.2; // a mark's height in shape units (post and head)
|
|
15
|
+
|
|
16
|
+
export function createBeacons(E, { reduce = false } = {}) {
|
|
17
|
+
const { THREE, W, TS } = E;
|
|
18
|
+
const POST = .3; // the post's height; every head sits on it
|
|
19
|
+
const post = () => new THREE.CylinderGeometry(.035, .035, POST, 6).translate(0, POST / 2, 0);
|
|
20
|
+
const geo = {
|
|
21
|
+
P0: mergeTwo(THREE, new THREE.ConeGeometry(.42, 1.6, 4).translate(0, POST + .8, 0), post()),
|
|
22
|
+
P1: mergeTwo(THREE, new THREE.OctahedronGeometry(.62).scale(1, 1.3, 1).translate(0, POST + .8, 0), post()),
|
|
23
|
+
P2: mergeTwo(THREE, new THREE.ConeGeometry(.62, 1.2, 16).rotateX(Math.PI).translate(0, POST + .6, 0), post()),
|
|
24
|
+
P3: mergeTwo(THREE, new THREE.SphereGeometry(.5, 18, 12).translate(0, POST + .48, 0), post()),
|
|
25
|
+
none: mergeTwo(THREE, new THREE.TorusGeometry(.5, .12, 8, 24).translate(0, POST + .56, 0), post()),
|
|
26
|
+
};
|
|
27
|
+
const group = new THREE.Group(); group.name = 'beacons';
|
|
28
|
+
const meshes = {}, outlines = {};
|
|
29
|
+
for (const k of SHAPES) {
|
|
30
|
+
const m = new THREE.InstancedMesh(geo[k], new THREE.MeshBasicMaterial({ toneMapped: false, transparent: true, depthWrite: false }), MAX);
|
|
31
|
+
m.count = 0; m.frustumCulled = false; m.userData = { beacons: k }; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
32
|
+
m.setColorAt(0, new THREE.Color()); // allocates the per-instance colours
|
|
33
|
+
const o = new THREE.InstancedMesh(geo[k], E.outlineInstanced(), MAX);
|
|
34
|
+
o.material.side = THREE.BackSide; o.count = 0; o.frustumCulled = false; o.instanceMatrix = m.instanceMatrix; o.raycast = () => {};
|
|
35
|
+
group.add(m, o); meshes[k] = m; outlines[k] = o;
|
|
36
|
+
}
|
|
37
|
+
let unit = SIZE; // plan units per shape unit this frame (marks grow when far away)
|
|
38
|
+
let items = [], byShape = Object.fromEntries(SHAPES.map(k => [k, []])), seen = null, visible = true, hot = null, flat = false;
|
|
39
|
+
const FLAT = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); // the head toward the top of the screen
|
|
40
|
+
const M4 = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), P = new THREE.Vector3(), C = new THREE.Color();
|
|
41
|
+
const ez = t => 1 - Math.pow(1 - t, 3);
|
|
42
|
+
|
|
43
|
+
// items: [{key, sev: 'P0'..'P3'|null, plausible, anchor() → [x, y, z, scale?] in plan units | null (hidden), data}]
|
|
44
|
+
function set(next) {
|
|
45
|
+
const now = performance.now(), first = seen === null;
|
|
46
|
+
seen = seen || new Set();
|
|
47
|
+
items = next.map(it => ({ ...it, shape: SHAPES.includes(it.sev) ? it.sev : 'none', born: first || seen.has(it.key) || reduce ? -Infinity : now,
|
|
48
|
+
phase: (hash(it.key) % 628) / 100 }));
|
|
49
|
+
for (const it of items) seen.add(it.key);
|
|
50
|
+
byShape = Object.fromEntries(SHAPES.map(k => [k, []]));
|
|
51
|
+
for (const it of items) if (byShape[it.shape].length < MAX) byShape[it.shape].push(it);
|
|
52
|
+
paint();
|
|
53
|
+
}
|
|
54
|
+
// the fill is the severity's colour seen through: fuller when confirmed, paler when plausible; the hairline is the colour itself
|
|
55
|
+
function paint() {
|
|
56
|
+
const bg = E.BG_COL, light = E.theme === 'light';
|
|
57
|
+
for (const k of SHAPES) {
|
|
58
|
+
const m = meshes[k], list = byShape[k];
|
|
59
|
+
list.forEach((it, i) => {
|
|
60
|
+
C.copy(E.sevColor(it.sev || 'none')).lerp(bg, it.plausible ? .7 : .45);
|
|
61
|
+
if (hot === it.key) C.lerp(E.INKHI, .3);
|
|
62
|
+
m.setColorAt(i, C);
|
|
63
|
+
});
|
|
64
|
+
if (m.instanceColor) m.instanceColor.needsUpdate = true;
|
|
65
|
+
m.material.opacity = light ? .82 : .75;
|
|
66
|
+
outlines[k].material.uniforms.color.value.copy(E.sevColor(k));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// every frame: each mark where its anchor is; one that just arrived falls in. Returns whether anything moves.
|
|
70
|
+
function tick(now) {
|
|
71
|
+
if (!visible) { for (const k of SHAPES) meshes[k].count = outlines[k].count = 0; return false; }
|
|
72
|
+
let moving = false;
|
|
73
|
+
const ppu = Math.max(1e-6, E.pxPerUnit()), least = MIN_PX * Math.min(1, Math.max(.5, ppu / FAR_PPU)) / (ppu * TALL);
|
|
74
|
+
unit = Math.max(SIZE, least);
|
|
75
|
+
for (const k of SHAPES) {
|
|
76
|
+
const m = meshes[k], list = byShape[k];
|
|
77
|
+
let n = 0;
|
|
78
|
+
for (const it of list) {
|
|
79
|
+
const a = it.anchor();
|
|
80
|
+
if (!a) { it.hidden = true; continue; }
|
|
81
|
+
if (it.hidden && !reduce) it.born = now; // shown again: it arrives
|
|
82
|
+
it.hidden = false;
|
|
83
|
+
const age = (now - it.born) / 650, drop = age < 1 ? 1 - ez(Math.max(0, age)) : 0;
|
|
84
|
+
if (drop > 0) moving = true;
|
|
85
|
+
const sc = unit * (a[3] || 1) * (it.plausible ? .8 : 1) * (hot === it.key ? 1.2 : 1);
|
|
86
|
+
const lie = flat ? .7 * sc : 0; // lying flat, it floats its own thickness over the roof
|
|
87
|
+
P.copy(W(a[0], a[1], a[2] + drop * 60 + lie));
|
|
88
|
+
it.head = flat ? [a[0], a[1] - 1.15 * sc, a[2] + lie] : [a[0], a[1], a[2] + drop * 60 + 1.15 * sc]; // where its head is drawn, in plan units
|
|
89
|
+
Q.setFromAxisAngle(UP, it.phase); if (flat) Q.premultiply(FLAT);
|
|
90
|
+
S.setScalar(sc * TS);
|
|
91
|
+
M4.compose(P, Q, S); m.setMatrixAt(n, M4); it.slot = n; n++;
|
|
92
|
+
}
|
|
93
|
+
m.count = outlines[k].count = n; m.instanceMatrix.needsUpdate = true;
|
|
94
|
+
m.boundingSphere = null; // three.js keeps the first one it computes: picking needs today's
|
|
95
|
+
}
|
|
96
|
+
return moving;
|
|
97
|
+
}
|
|
98
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
99
|
+
// the item under the pointer, or null
|
|
100
|
+
function pick(cx, cy) {
|
|
101
|
+
if (!visible) return null;
|
|
102
|
+
const h = E.pickHit(cx, cy, SHAPES.map(k => meshes[k]).filter(m => m.count));
|
|
103
|
+
if (!h || h.instanceId == null) return null;
|
|
104
|
+
const k = h.object.userData.beacons;
|
|
105
|
+
return byShape[k].find(it => it.slot === h.instanceId && it.anchor()) || null;
|
|
106
|
+
}
|
|
107
|
+
function setHot(key) { if (key !== hot) { hot = key; paint(); } }
|
|
108
|
+
E.themeHooks.add(() => paint());
|
|
109
|
+
return {
|
|
110
|
+
group, set, tick, pick, setHot, repaint: paint,
|
|
111
|
+
get count() { return items.length; },
|
|
112
|
+
get unit() { return unit; }, // what scene3d spaces marks by
|
|
113
|
+
get items() { return items; },
|
|
114
|
+
setVisible(v) { visible = !!v; group.visible = visible; },
|
|
115
|
+
setFlat(v) { flat = !!v; }, // the top-down projection
|
|
116
|
+
get flat() { return flat; },
|
|
117
|
+
dispose() { for (const k of SHAPES) { meshes[k].dispose(); outlines[k].dispose(); geo[k].dispose(); } },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hash(s) { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; }
|
|
122
|
+
// two geometries as one (positions and normals), so the pin is one instance; one without an index (the
|
|
123
|
+
// octahedron) draws its vertices in order
|
|
124
|
+
function mergeTwo(THREE, a, b) {
|
|
125
|
+
const out = new THREE.BufferGeometry(), na = a.attributes.position.count, nb = b.attributes.position.count;
|
|
126
|
+
const cat = (x, y, n) => { const r = new Float32Array(x.length + y.length); r.set(x); r.set(y, x.length); return new THREE.BufferAttribute(r, n); };
|
|
127
|
+
out.setAttribute('position', cat(a.attributes.position.array, b.attributes.position.array, 3));
|
|
128
|
+
out.setAttribute('normal', cat(a.attributes.normal.array, b.attributes.normal.array, 3));
|
|
129
|
+
const order = (g, n) => g.index ? Array.from(g.index.array) : Array.from({ length: n }, (_, i) => i);
|
|
130
|
+
const ia = order(a, na), ib = order(b, nb).map(i => i + na);
|
|
131
|
+
out.setIndex(ia.concat(ib));
|
|
132
|
+
a.dispose(); b.dispose();
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/* forge-ui open findings: the findings a run (or every run of its repo) left open, to dismiss or to file as GitHub issues
|
|
2
|
+
(window.ForgeFindings, and a CommonJS module under node:test). Route #/runs/<key>/findings.
|
|
3
|
+
The pure helpers (status, filters, selection rules, request URLs and bodies, merging the online `filed` data) are
|
|
4
|
+
tested in ui/tests/js/findingsview.test.js; render(host, ctx) is thin DOM over them. Finding and issue texts are
|
|
5
|
+
untrusted: they only ever enter the page as text.
|
|
6
|
+
ctx = { key, run? (the cached run, for the title), back (href of the run), tablePrefs?(id), saveTablePrefs?(id, prefs),
|
|
7
|
+
onCount?(openRows, scope) }. The write calls are ForgeUI's; without controls the page shows no write control. */
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
'use strict';
|
|
10
|
+
const api = factory(root);
|
|
11
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
12
|
+
else root.ForgeFindings = api;
|
|
13
|
+
})(typeof self !== 'undefined' ? self : this, function (root) {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const MAX_FPS = 25, MAX_LABELS = 5, MAX_LABEL = 50, MAX_REASON = 500;
|
|
17
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
18
|
+
const SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
|
|
19
|
+
const ORIGINS = ['tolerated', 'review', 'plausible'];
|
|
20
|
+
const STATUSES = ['open', 'filed', 'fixed', 'dismissed'];
|
|
21
|
+
|
|
22
|
+
/* ---------- pure ---------- */
|
|
23
|
+
// open | filed (its issue is OPEN, or its state is not known yet) | fixed (CLOSED) | dismissed. A dismissal wins, so
|
|
24
|
+
// that an Undismiss reaches every dismissed row.
|
|
25
|
+
function statusOf(f) {
|
|
26
|
+
if (f && f.dismissed) return 'dismissed';
|
|
27
|
+
if (f && f.filed) return String(f.filed.state || '').toUpperCase() === 'CLOSED' ? 'fixed' : 'filed';
|
|
28
|
+
return 'open';
|
|
29
|
+
}
|
|
30
|
+
// filter = { status: 'open' (default) | 'all' | one of STATUSES, severity: 'all' | P0..P3, origin: 'all' | ORIGINS }
|
|
31
|
+
function filterRows(rows, filter) {
|
|
32
|
+
const f = filter || {}, status = f.status || 'open', severity = f.severity || 'all', origin = f.origin || 'all';
|
|
33
|
+
// `dismissed` (optional): false hides dismissed rows whatever the status; true adds them to the default 'open' view.
|
|
34
|
+
return list(rows).filter(r => {
|
|
35
|
+
const st = statusOf(r);
|
|
36
|
+
if (f.dismissed === false && st === 'dismissed') return false;
|
|
37
|
+
const byStatus = status === 'all' || st === status || (f.dismissed === true && status === 'open' && st === 'dismissed');
|
|
38
|
+
return byStatus && (severity === 'all' || r.severity === severity) && (origin === 'all' || r.origin === origin);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// The online listing knows which findings have an issue: its `filed` goes into the offline rows by fingerprint.
|
|
42
|
+
// A row only the online listing has is added; a row only the offline listing has stays as it was.
|
|
43
|
+
function mergeFiled(offline, online) {
|
|
44
|
+
const by = new Map(list(online).map(r => [r.fp, r]));
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const rows = list(offline).map(r => {
|
|
47
|
+
seen.add(r.fp);
|
|
48
|
+
const o = by.get(r.fp);
|
|
49
|
+
return o ? { ...r, filed: o.filed || null } : r;
|
|
50
|
+
});
|
|
51
|
+
for (const r of list(online)) if (!seen.has(r.fp)) rows.push(r);
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
// Which rows an action takes: filing and dismissing take open rows, Undismiss takes dismissed rows.
|
|
55
|
+
function selectable(action, row) {
|
|
56
|
+
const status = statusOf(row);
|
|
57
|
+
return action === 'undismiss' ? status === 'dismissed' : (action === 'file' || action === 'dismiss') && status === 'open';
|
|
58
|
+
}
|
|
59
|
+
// What `selected` (a Set of fingerprints) means for one action: the fingerprints it takes, and whether the button
|
|
60
|
+
// is enabled (1 to 25) with the note to show when it is not.
|
|
61
|
+
function selection(action, rows, selected) {
|
|
62
|
+
const fps = list(rows).filter(r => selected.has(r.fp) && selectable(action, r)).map(r => r.fp);
|
|
63
|
+
const n = fps.length;
|
|
64
|
+
const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
|
|
65
|
+
return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
|
|
66
|
+
}
|
|
67
|
+
const bit = v => v ? '1' : '0';
|
|
68
|
+
// The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
|
|
69
|
+
function issuesUrl(key, o) {
|
|
70
|
+
const q = [`scope=${o.scope === 'repo' ? 'repo' : 'run'}`, `offline=${bit(o.offline)}`, `plausible=${bit(o.plausible)}`];
|
|
71
|
+
if (o.dismissed) q.push('dismissed=1');
|
|
72
|
+
if (list(o.only).length) q.push('only=' + o.only.join(','));
|
|
73
|
+
return `/api/runs/${encodeURIComponent(key)}/issues?${q.join('&')}`;
|
|
74
|
+
}
|
|
75
|
+
const issuesPath = key => `/api/runs/${encodeURIComponent(key)}/issues`;
|
|
76
|
+
const dismissPath = key => issuesPath(key) + '/dismiss';
|
|
77
|
+
// "a, b" → {labels, error}: at most 5, each 1 to 50 characters, no control character, not starting with "-".
|
|
78
|
+
function parseLabels(text) {
|
|
79
|
+
const labels = String(text || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
80
|
+
if (labels.length > MAX_LABELS) return { labels: [], error: `at most ${MAX_LABELS} labels` };
|
|
81
|
+
for (const l of labels) {
|
|
82
|
+
if (l.length > MAX_LABEL) return { labels: [], error: `a label is at most ${MAX_LABEL} characters` };
|
|
83
|
+
if (l.startsWith('-')) return { labels: [], error: 'a label cannot start with "-"' };
|
|
84
|
+
if (/[\u0000-\u001f\u007f]/.test(l)) return { labels: [], error: 'a label cannot hold control characters' };
|
|
85
|
+
}
|
|
86
|
+
return { labels, error: null };
|
|
87
|
+
}
|
|
88
|
+
function fileBody(scope, fps, includePlausible, labels) {
|
|
89
|
+
return { scope: scope === 'repo' ? 'repo' : 'run', fingerprints: list(fps).slice(), include_plausible: !!includePlausible, labels: list(labels).slice() };
|
|
90
|
+
}
|
|
91
|
+
// `reason` goes with a dismissal only; Undismiss has none.
|
|
92
|
+
function dismissBody(fps, reason, undo) {
|
|
93
|
+
const body = { fingerprints: list(fps).slice() };
|
|
94
|
+
if (undo) { body.undo = true; return body; }
|
|
95
|
+
const r = String(reason || '').trim();
|
|
96
|
+
if (r) body.reason = r.slice(0, MAX_REASON);
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
// The POST's `created` as lines: {kind: 'created' | 'skipped' | 'note', text, url?}
|
|
100
|
+
function resultLines(created) {
|
|
101
|
+
return list(created).map(c => {
|
|
102
|
+
if (c && c.url) return { kind: 'created', text: c.title || c.url, url: c.url };
|
|
103
|
+
if (c && c.skipped) return { kind: 'skipped', text: `${c.title || 'finding'}: skipped, ${c.skipped}`, url: c.issue || null };
|
|
104
|
+
return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// An untrusted URL becomes a link only when it is a plain https address.
|
|
108
|
+
const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
|
|
109
|
+
|
|
110
|
+
/* ---------- DOM ---------- */
|
|
111
|
+
function render(host, ctx) {
|
|
112
|
+
const UI = root.ForgeUI;
|
|
113
|
+
const el = (tag, text, cls) => UI.el(tag, text, cls);
|
|
114
|
+
const s = { scope: 'run', plausible: false, dismissed: false, filter: { status: 'open', severity: 'all', origin: 'all' },
|
|
115
|
+
rows: [], selected: new Set(), seq: 0, writes: false, busy: false };
|
|
116
|
+
const key = ctx.key;
|
|
117
|
+
|
|
118
|
+
const head = el('div', undefined, 'fnd-head');
|
|
119
|
+
const title = el('h1', 'Open findings', 'module-title');
|
|
120
|
+
const back = el('a', 'Back to the run', 'fnd-back'); back.href = ctx.back || '#/';
|
|
121
|
+
head.append(title, back);
|
|
122
|
+
const lede = el('p', undefined, 'muted fnd-lede');
|
|
123
|
+
const scopeBar = el('div', undefined, 'seg fnd-scope'); scopeBar.setAttribute('role', 'group'); scopeBar.setAttribute('aria-label', 'Scope');
|
|
124
|
+
const scopeKeys = {};
|
|
125
|
+
for (const [v, label] of [['run', 'This run'], ['repo', 'All runs of this repo']]) {
|
|
126
|
+
const b = el('button', label); b.type = 'button';
|
|
127
|
+
b.addEventListener('click', () => { if (s.scope === v) return; s.scope = v; s.selected.clear(); load(); });
|
|
128
|
+
scopeKeys[v] = b; scopeBar.append(b);
|
|
129
|
+
}
|
|
130
|
+
const lamp = el('p', undefined, 'fnd-lamp'); lamp.setAttribute('role', 'status');
|
|
131
|
+
const notes = el('div', undefined, 'fnd-notes muted');
|
|
132
|
+
|
|
133
|
+
// filters
|
|
134
|
+
const filters = el('div', undefined, 'fnd-filters');
|
|
135
|
+
const select = (label, options, get, set) => {
|
|
136
|
+
const l = el('label', undefined, 'fnd-field'), sel = el('select');
|
|
137
|
+
for (const [v, t] of options) { const o = el('option', t); o.value = v; sel.append(o); }
|
|
138
|
+
sel.value = get();
|
|
139
|
+
sel.addEventListener('change', () => { set(sel.value); drawTable(); });
|
|
140
|
+
l.append(el('span', label, 'plate'), sel);
|
|
141
|
+
return l;
|
|
142
|
+
};
|
|
143
|
+
const toggle = (label, get, set) => {
|
|
144
|
+
const l = el('label', undefined, 'fnd-toggle'), c = el('input');
|
|
145
|
+
c.type = 'checkbox'; c.checked = get();
|
|
146
|
+
c.addEventListener('change', () => { set(c.checked); load(); });
|
|
147
|
+
l.append(c, el('span', label));
|
|
148
|
+
return l;
|
|
149
|
+
};
|
|
150
|
+
filters.append(
|
|
151
|
+
select('Status', [['open', 'open'], ['all', 'all'], ...STATUSES.slice(1).map(x => [x, x])], () => s.filter.status, v => { s.filter.status = v; }),
|
|
152
|
+
select('Severity', [['all', 'all'], ...SEVERITIES.map(x => [x, x])], () => s.filter.severity, v => { s.filter.severity = v; }),
|
|
153
|
+
select('Origin', [['all', 'all'], ...ORIGINS.map(x => [x, x])], () => s.filter.origin, v => { s.filter.origin = v; }),
|
|
154
|
+
toggle('Include plausible', () => s.plausible, v => { s.plausible = v; }),
|
|
155
|
+
toggle('Show dismissed', () => s.dismissed, v => { s.dismissed = v; }));
|
|
156
|
+
|
|
157
|
+
// actions (only with controls)
|
|
158
|
+
const actions = el('div', undefined, 'fnd-actions'); actions.hidden = true;
|
|
159
|
+
actions.setAttribute('role', 'group'); actions.setAttribute('aria-label', 'Actions on the selected findings');
|
|
160
|
+
const key_ = (text, cls, fn) => { const b = el('button', text, cls); b.type = 'button'; b.addEventListener('click', fn); return b; };
|
|
161
|
+
const fileKey = key_('File issues', 'primary', () => openFile());
|
|
162
|
+
const dismissKey = key_('Dismiss', undefined, () => openDismiss());
|
|
163
|
+
const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
|
|
164
|
+
const allKey = key_('Select shown open', undefined, () => {
|
|
165
|
+
for (const r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
|
|
166
|
+
drawTable(); syncActions();
|
|
167
|
+
});
|
|
168
|
+
const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
|
|
169
|
+
const actionNote = el('span', undefined, 'fnd-note muted'); actionNote.setAttribute('aria-live', 'polite');
|
|
170
|
+
actions.append(allKey, noneKey, fileKey, dismissKey, undismissKey, actionNote);
|
|
171
|
+
const reasonHost = el('div', undefined, 'fnd-reason');
|
|
172
|
+
const outcomeHost = el('div', undefined, 'fnd-outcome');
|
|
173
|
+
const tableHost = el('div', undefined, 'fnd-table');
|
|
174
|
+
const readOnly = el('p', 'read-only: findings can be read here, not filed or dismissed.', 'muted fnd-readonly'); readOnly.hidden = true;
|
|
175
|
+
|
|
176
|
+
host.replaceChildren(head, lede, scopeBar, lamp, notes, filters, readOnly, actions, reasonHost, outcomeHost, tableHost);
|
|
177
|
+
let posting = false; // a filing POST is running: the dialog stays open until its results are shown
|
|
178
|
+
const dlg = el('dialog', undefined, 'fnd-dialog'); dlg.setAttribute('aria-label', 'File issues on GitHub');
|
|
179
|
+
host.append(dlg);
|
|
180
|
+
|
|
181
|
+
const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
|
|
182
|
+
function syncScope() {
|
|
183
|
+
for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
|
|
184
|
+
lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
|
|
185
|
+
: 'Findings every run of this repository left unfixed.';
|
|
186
|
+
}
|
|
187
|
+
function syncActions() {
|
|
188
|
+
actions.hidden = !s.writes;
|
|
189
|
+
if (!s.writes) return;
|
|
190
|
+
const f = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
|
|
191
|
+
fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
|
|
192
|
+
fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
|
|
193
|
+
undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
|
|
194
|
+
actionNote.textContent = f.note || d.note || u.note || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
|
|
195
|
+
}
|
|
196
|
+
function setLamp(cls, text) {
|
|
197
|
+
lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// cells
|
|
201
|
+
const statusCell = r => {
|
|
202
|
+
const st = statusOf(r), box = el('span', undefined, 'fnd-status fnd-' + st);
|
|
203
|
+
box.append(el('i', undefined, 'lamp ' + (st === 'filed' ? 'fnd-amber' : st === 'fixed' ? 'st-pass' : 'lamp-off')));
|
|
204
|
+
if (st === 'dismissed') { box.title = r.dismissed.reason ? String(r.dismissed.reason) : 'dismissed'; box.append(el('span', 'dismissed')); return box; }
|
|
205
|
+
if (st === 'open') { box.append(el('span', 'open')); return box; }
|
|
206
|
+
const url = safeUrl(r.filed && r.filed.url), word = st === 'filed' ? 'filed' : 'fixed';
|
|
207
|
+
if (url) { const a = el('a', word); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.title = 'the GitHub issue (' + String(r.filed.state || 'state unknown').toLowerCase() + ')'; box.append(a); }
|
|
208
|
+
else box.append(el('span', word));
|
|
209
|
+
return box;
|
|
210
|
+
};
|
|
211
|
+
const selectCell = r => {
|
|
212
|
+
const c = el('input'); c.type = 'checkbox';
|
|
213
|
+
c.setAttribute('aria-label', 'Select finding: ' + String(r.summary || r.fp).slice(0, 80));
|
|
214
|
+
c.checked = s.selected.has(r.fp);
|
|
215
|
+
c.disabled = !selectable('file', r) && !selectable('undismiss', r);
|
|
216
|
+
c.addEventListener('change', () => { if (c.checked) s.selected.add(r.fp); else s.selected.delete(r.fp); syncActions(); });
|
|
217
|
+
return c;
|
|
218
|
+
};
|
|
219
|
+
const summaryCell = r => {
|
|
220
|
+
const box = el('div', undefined, 'fnd-summary'), line = el('div', undefined, 'fnd-summary-line');
|
|
221
|
+
const more = el('button', undefined, 'fnd-more'); more.type = 'button';
|
|
222
|
+
more.setAttribute('aria-expanded', 'false'); more.setAttribute('aria-label', 'Show the full text');
|
|
223
|
+
more.append(el('span', '▸', 'fnd-caret'));
|
|
224
|
+
const detail = el('div', undefined, 'fnd-detail'); detail.hidden = true;
|
|
225
|
+
detail.append(el('pre', r.text || r.summary || '', 'fnd-text'));
|
|
226
|
+
const meta = el('dl', undefined, 'fnd-meta');
|
|
227
|
+
for (const [k, v] of [['Reviewer', r.reviewer], ['Commit', r.commit], ['Fingerprint', r.fp], ['Plan', r.plan_dir || r.plan],
|
|
228
|
+
['Dismissed', r.dismissed ? `${r.dismissed.at || ''} ${r.dismissed.reason || ''}`.trim() : null]]) {
|
|
229
|
+
if (v) meta.append(el('dt', k), el('dd', v));
|
|
230
|
+
}
|
|
231
|
+
detail.append(meta);
|
|
232
|
+
more.addEventListener('click', () => {
|
|
233
|
+
const open = detail.hidden; detail.hidden = !open;
|
|
234
|
+
more.setAttribute('aria-expanded', String(open)); more.setAttribute('aria-label', open ? 'Hide the full text' : 'Show the full text');
|
|
235
|
+
more.firstChild.textContent = open ? '▾' : '▸';
|
|
236
|
+
});
|
|
237
|
+
line.append(more, el('span', r.summary || r.text || '—'));
|
|
238
|
+
box.append(line, detail);
|
|
239
|
+
return box;
|
|
240
|
+
};
|
|
241
|
+
const sevCell = r => el('span', r.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(r.severity) ? r.severity : 'none'));
|
|
242
|
+
const columns = () => {
|
|
243
|
+
const cols = [];
|
|
244
|
+
if (s.writes) cols.push({ id: 'select', label: 'Select', sortable: false, hideable: false, cell: selectCell });
|
|
245
|
+
cols.push(
|
|
246
|
+
{ id: 'severity', label: 'Severity', value: r => SEVERITIES.indexOf(r.severity), cell: sevCell },
|
|
247
|
+
{ id: 'confidence', label: 'Confidence', value: r => r.confidence, cell: r => r.confidence || '—' },
|
|
248
|
+
{ id: 'origin', label: 'Origin', value: r => r.origin, cell: r => r.origin || '—' },
|
|
249
|
+
{ id: 'task', label: 'Task', value: r => r.task, mono: true, cell: r => r.task || 'whole run' });
|
|
250
|
+
if (s.scope === 'repo') cols.push({ id: 'plan', label: 'Plan', value: r => r.plan, mono: true, cell: r => r.plan || '—' });
|
|
251
|
+
cols.push(
|
|
252
|
+
{ id: 'file', label: 'File', value: r => r.file, mono: true, cell: r => r.file || '—' },
|
|
253
|
+
{ id: 'summary', label: 'Summary', value: r => r.summary, cell: summaryCell },
|
|
254
|
+
{ id: 'status', label: 'Status', value: r => STATUSES.indexOf(statusOf(r)), cell: statusCell });
|
|
255
|
+
return cols;
|
|
256
|
+
};
|
|
257
|
+
function drawTable() {
|
|
258
|
+
const rows = shown();
|
|
259
|
+
if (!rows.length) {
|
|
260
|
+
const total = s.rows.length;
|
|
261
|
+
tableHost.replaceChildren(el('p', total ? `No findings match these filters (${total} hidden).` : 'No findings. Nothing here needs fixing.', 'muted fnd-empty'));
|
|
262
|
+
syncActions();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const id = 'findings-' + (s.scope === 'repo' ? 'repo' : 'run') + (s.writes ? '-w' : '');
|
|
266
|
+
tableHost.replaceChildren();
|
|
267
|
+
root.ForgeTable.render(tableHost, { id, label: 'Open findings', columns: columns(), rows, className: 'fnd-grid',
|
|
268
|
+
prefs: ctx.tablePrefs ? ctx.tablePrefs(id) : null, savePrefs: p => ctx.saveTablePrefs && ctx.saveTablePrefs(id, p) });
|
|
269
|
+
syncActions();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// loading: offline first (fast), then online for `filed`
|
|
273
|
+
const detailOf = res => res.detail || res.error || 'no answer';
|
|
274
|
+
async function load() {
|
|
275
|
+
const mine = ++s.seq;
|
|
276
|
+
syncScope();
|
|
277
|
+
setLamp('lamp-route', 'reading the findings…');
|
|
278
|
+
const q = { scope: s.scope, plausible: s.plausible, dismissed: s.dismissed };
|
|
279
|
+
const off = await UI.get(issuesUrl(key, { ...q, offline: true }));
|
|
280
|
+
if (mine !== s.seq) return;
|
|
281
|
+
if (!off.ok) {
|
|
282
|
+
s.rows = []; drawTable();
|
|
283
|
+
setLamp('st-fail', 'could not read the findings'); outcomeHost.replaceChildren(); UI.outcome(outcomeHost, off);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
s.rows = list(off.data.findings);
|
|
287
|
+
notes.replaceChildren(...list(off.data.notes).map(n => el('p', String(n))));
|
|
288
|
+
prune(); drawTable(); report();
|
|
289
|
+
setLamp('st-running', 'checking GitHub…');
|
|
290
|
+
const on = await UI.get(issuesUrl(key, { ...q, offline: false }));
|
|
291
|
+
if (mine !== s.seq) return;
|
|
292
|
+
if (!on.ok) { setLamp('st-fail', 'GitHub status unknown: ' + detailOf(on)); return; }
|
|
293
|
+
s.rows = mergeFiled(s.rows, list(on.data.findings));
|
|
294
|
+
notes.replaceChildren(...list(on.data.notes).map(n => el('p', String(n))));
|
|
295
|
+
prune(); drawTable(); report();
|
|
296
|
+
setLamp('st-pass', 'GitHub status checked');
|
|
297
|
+
}
|
|
298
|
+
function prune() {
|
|
299
|
+
const fps = new Set(s.rows.map(r => r.fp));
|
|
300
|
+
for (const fp of Array.from(s.selected)) if (!fps.has(fp)) s.selected.delete(fp);
|
|
301
|
+
}
|
|
302
|
+
function report() { if (ctx.onCount) ctx.onCount(s.rows.filter(r => statusOf(r) === 'open').length, s.scope); }
|
|
303
|
+
|
|
304
|
+
// dismiss: an optional reason, then the call
|
|
305
|
+
async function openDismiss() {
|
|
306
|
+
reasonHost.replaceChildren();
|
|
307
|
+
const sel = selection('dismiss', s.rows, s.selected);
|
|
308
|
+
if (!sel.enabled) return;
|
|
309
|
+
const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
|
|
310
|
+
input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
|
|
311
|
+
l.append(el('span', `Reason for dismissing ${sel.count}`, 'plate'), input);
|
|
312
|
+
const go = key_('Dismiss', 'ui-danger', () => { const reason = input.value; reasonHost.replaceChildren(); doDismiss(false, reason); });
|
|
313
|
+
const cancel = key_('Cancel', undefined, () => { reasonHost.replaceChildren(); dismissKey.focus(); });
|
|
314
|
+
input.addEventListener('keydown', e => {
|
|
315
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel.click(); }
|
|
316
|
+
else if (e.key === 'Enter') { e.preventDefault(); go.click(); }
|
|
317
|
+
});
|
|
318
|
+
reasonHost.append(l, go, cancel);
|
|
319
|
+
input.focus();
|
|
320
|
+
}
|
|
321
|
+
async function doDismiss(undo, reason) {
|
|
322
|
+
const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
|
|
323
|
+
if (!sel.enabled || s.busy) return;
|
|
324
|
+
s.busy = true; syncActions(); outcomeHost.replaceChildren();
|
|
325
|
+
const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
|
|
326
|
+
s.busy = false;
|
|
327
|
+
UI.outcome(outcomeHost, res, undo ? `restored ${sel.count}` : `dismissed ${sel.count}`);
|
|
328
|
+
if (res.ok) { for (const fp of sel.fps) s.selected.delete(fp); await load(); } else syncActions();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// filing: drafts for exactly the selection, a preview, then the POST
|
|
332
|
+
async function openFile() {
|
|
333
|
+
const sel = selection('file', s.rows, s.selected);
|
|
334
|
+
if (!sel.enabled || s.busy) return;
|
|
335
|
+
s.busy = true; syncActions(); outcomeHost.replaceChildren();
|
|
336
|
+
setLamp('st-running', 'preparing the issue drafts…');
|
|
337
|
+
const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
|
|
338
|
+
s.busy = false; syncActions();
|
|
339
|
+
if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
|
|
340
|
+
setLamp('st-pass', 'drafts ready');
|
|
341
|
+
preview(sel.fps, list(res.data.drafts));
|
|
342
|
+
}
|
|
343
|
+
function preview(fps, drafts) {
|
|
344
|
+
dlg.replaceChildren();
|
|
345
|
+
dlg.append(el('h2', 'File issues on GitHub'));
|
|
346
|
+
const warn = el('p', undefined, 'fnd-warn');
|
|
347
|
+
warn.append(el('i', undefined, 'lamp st-running'),
|
|
348
|
+
el('span', `This creates ${drafts.length} issue${drafts.length === 1 ? '' : 's'} on this run's GitHub repository, from your gh login, and cannot be undone from here.`));
|
|
349
|
+
dlg.append(warn);
|
|
350
|
+
if (!drafts.length) dlg.append(el('p', 'forge has no draft for this selection (its findings may be filed already).', 'muted'));
|
|
351
|
+
for (const d of drafts) {
|
|
352
|
+
const art = el('article', undefined, 'fnd-draft');
|
|
353
|
+
const h = el('h3', undefined, 'fnd-draft-title');
|
|
354
|
+
h.append(el('span', d.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(d.severity) ? d.severity : 'none')), el('span', d.title || 'untitled'));
|
|
355
|
+
art.append(h);
|
|
356
|
+
if (d.same_title) {
|
|
357
|
+
const sameUrl = safeUrl(d.same_title);
|
|
358
|
+
const p = el('p', undefined, 'fnd-same'); p.append(el('i', undefined, 'lamp fnd-amber'), el('span', 'An open issue with this title exists already: '));
|
|
359
|
+
if (sameUrl) { const a = el('a', sameUrl); a.href = sameUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', String(d.same_title)));
|
|
360
|
+
p.append(el('span', '. forge skips it.'));
|
|
361
|
+
art.append(p);
|
|
362
|
+
}
|
|
363
|
+
art.append(el('pre', d.body || '', 'fnd-body'));
|
|
364
|
+
dlg.append(art);
|
|
365
|
+
}
|
|
366
|
+
const lab = el('label', undefined, 'fnd-field'), labels = el('input');
|
|
367
|
+
labels.type = 'text'; labels.placeholder = 'bug, forge (optional, comma separated)';
|
|
368
|
+
lab.append(el('span', 'Labels', 'plate'), labels);
|
|
369
|
+
const labelNote = el('p', undefined, 'warn fnd-label-note'); labelNote.hidden = true;
|
|
370
|
+
const results = el('div', undefined, 'fnd-results');
|
|
371
|
+
const go = key_('File on GitHub', 'primary', async () => {
|
|
372
|
+
const parsed = parseLabels(labels.value);
|
|
373
|
+
labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
|
|
374
|
+
if (parsed.error) return;
|
|
375
|
+
go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
|
|
376
|
+
const res = await UI.post(issuesPath(key), fileBody(s.scope, fps, s.plausible, parsed.labels));
|
|
377
|
+
posting = false;
|
|
378
|
+
if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
|
|
379
|
+
const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
|
|
380
|
+
for (const ln of lines) {
|
|
381
|
+
const li = el('li', undefined, 'fnd-created-' + ln.kind), u = safeUrl(ln.url);
|
|
382
|
+
li.append(el('i', undefined, 'lamp ' + (ln.kind === 'created' ? 'st-pass' : 'fnd-amber')));
|
|
383
|
+
if (u) { const a = el('a', ln.text); a.href = u; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', ln.text));
|
|
384
|
+
ul.append(li);
|
|
385
|
+
}
|
|
386
|
+
results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
|
|
387
|
+
go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
|
|
388
|
+
for (const fp of fps) s.selected.delete(fp);
|
|
389
|
+
load();
|
|
390
|
+
});
|
|
391
|
+
const cancel = key_('Cancel', undefined, () => dlg.close());
|
|
392
|
+
go.disabled = !drafts.length;
|
|
393
|
+
const row = el('div', undefined, 'fnd-dialog-actions'); row.append(cancel, go);
|
|
394
|
+
dlg.append(lab, labelNote, results, row);
|
|
395
|
+
dlg.showModal(); (drafts.length ? go : cancel).focus();
|
|
396
|
+
}
|
|
397
|
+
dlg.addEventListener('click', e => { if (e.target === dlg && !posting) dlg.close(); });
|
|
398
|
+
dlg.addEventListener('cancel', e => { if (posting) e.preventDefault(); });
|
|
399
|
+
dlg.addEventListener('close', () => { fileKey.focus(); });
|
|
400
|
+
|
|
401
|
+
syncScope();
|
|
402
|
+
UI.controlsEnabled().then(on => {
|
|
403
|
+
s.writes = !!on; readOnly.hidden = on; syncActions();
|
|
404
|
+
if (s.rows.length) drawTable();
|
|
405
|
+
});
|
|
406
|
+
load();
|
|
407
|
+
return { reload: load };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
|
|
411
|
+
parseLabels, resultLines, safeUrl, MAX_FPS };
|
|
412
|
+
});
|
|
@@ -240,9 +240,9 @@
|
|
|
240
240
|
|
|
241
241
|
if (t.verdict === 'PASS' || t.verdict === 'FAIL') {
|
|
242
242
|
var pass = t.verdict === 'PASS';
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
// a drawn tick or cross, in the verdict's colour
|
|
244
|
+
el('path', { 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), d: pass ? 'M' + (NODE_W - 20) + ' 12l4 4 7-8' : 'M' + (NODE_W - 19) + ' 8l8 8m0-8l-8 8',
|
|
245
|
+
fill: 'none', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
function select() { if (typeof options.onSelect === 'function') options.onSelect(t.id); }
|