@giamme/forge 0.0.0-stage → 0.9.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 +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -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 +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -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 +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -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 +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/* forge-ui files heatmap: which tasks planned, touched (attempt by attempt), drifted into or conflicted on which
|
|
2
|
+
files of a run, as a matrix of files (grouped under their directories) by tasks (grouped by wave), with the
|
|
3
|
+
overlaps, drift, conflicts and deferrals listed under it. Reads GET /api/runs/<key>/files (api.md, Files).
|
|
4
|
+
window.ForgeFiles = { matrix(data, run, opts), describe(row, column, cell), normPrefs(prefs), render(host, data, ctx) };
|
|
5
|
+
under node:test the same object, where only render needs a DOM. SVG drawn with classes only (style.css colours
|
|
6
|
+
it: the page CSP refuses style attributes). A solo run has one column, `solo`; the files its repo had already
|
|
7
|
+
changed when it started (existing.diff) are `existing` cells, drawn like planned ones but dashed, and the
|
|
8
|
+
planned switch shows or hides both. */
|
|
9
|
+
(function (root, factory) {
|
|
10
|
+
'use strict';
|
|
11
|
+
const api = factory();
|
|
12
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
13
|
+
else root.ForgeFiles = api;
|
|
14
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const SORTS = ['path', 'heat', 'tasks'];
|
|
18
|
+
const KINDS = ['planned', 'touched', 'drift', 'conflict'];
|
|
19
|
+
const ROW_CAP = 600, COLLAPSED_CAP = 1000;
|
|
20
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
21
|
+
const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
22
|
+
const clean = p => typeof p === 'string' ? p.replace(/\/+$/, '') : '';
|
|
23
|
+
const paths = v => list(v).map(clean).filter(Boolean);
|
|
24
|
+
const dirOf = p => p.slice(0, Math.max(0, p.lastIndexOf('/')));
|
|
25
|
+
const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
26
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
27
|
+
const blank = () => ({ planned: false, via: null, existing: false, touched: 0, attempts: [], current: false, drift: false, conflict: false, overlap: false, with: [], n: 0 });
|
|
28
|
+
|
|
29
|
+
function normPrefs(p) {
|
|
30
|
+
const o = p && typeof p === 'object' ? p : {}, s = o.show && typeof o.show === 'object' ? o.show : {};
|
|
31
|
+
const show = {};
|
|
32
|
+
for (const k of KINDS) show[k] = s[k] !== false;
|
|
33
|
+
return { sort: SORTS.includes(o.sort) ? o.sort : 'path', show,
|
|
34
|
+
collapsed: [...new Set(list(o.collapsed).filter(x => typeof x === 'string' && x))].slice(-COLLAPSED_CAP) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Columns: the run's tasks (titles, tasks.tsv order) and the files endpoint's, by wave (no wave last).
|
|
38
|
+
function columnsOf(data, run) {
|
|
39
|
+
const cols = new Map();
|
|
40
|
+
const put = (t, i, fromRun) => {
|
|
41
|
+
if (!t || typeof t !== 'object' || t.id == null) return;
|
|
42
|
+
const id = String(t.id);
|
|
43
|
+
if (!cols.has(id)) cols.set(id, { id, wave: null, status: null, title: '', planned_all: false, order: (fromRun ? 0 : 1e6) + i });
|
|
44
|
+
const c = cols.get(id);
|
|
45
|
+
if (c.wave === null) c.wave = num(t.wave);
|
|
46
|
+
if (!c.status && t.status) c.status = String(t.status);
|
|
47
|
+
if (!c.title && fromRun && t.title) c.title = String(t.title);
|
|
48
|
+
if (!fromRun) c.planned_all = c.planned_all || !!t.planned_all || list(t.planned).includes('.');
|
|
49
|
+
};
|
|
50
|
+
list(run && run.tasks).forEach((t, i) => put(t, i, true));
|
|
51
|
+
list(data && data.tasks).forEach((t, i) => put(t, i, false));
|
|
52
|
+
return [...cols.values()].sort((a, b) => (a.wave ?? Infinity) - (b.wave ?? Infinity) || a.order - b.order)
|
|
53
|
+
.map(c => ({ id: c.id, wave: c.wave, status: c.status || 'PENDING', title: c.title, planned_all: c.planned_all }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Two tasks of one wave that both planned or touched a path overlap (forge-schedule keeps those apart).
|
|
57
|
+
function markOverlaps(cells, waveOf) {
|
|
58
|
+
const byWave = new Map();
|
|
59
|
+
for (const [id, c] of cells) {
|
|
60
|
+
const w = waveOf.get(id);
|
|
61
|
+
if (w == null || !(c.planned || c.touched || c.drift || c.conflict)) continue;
|
|
62
|
+
if (!byWave.has(w)) byWave.set(w, []);
|
|
63
|
+
byWave.get(w).push(id);
|
|
64
|
+
}
|
|
65
|
+
for (const ids of byWave.values()) {
|
|
66
|
+
if (ids.length < 2) continue;
|
|
67
|
+
for (const id of ids) { const c = cells.get(id); c.overlap = true; for (const x of ids) if (x !== id && !c.with.includes(x)) c.with.push(x); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mask(c, show) {
|
|
72
|
+
const m = { planned: show.planned && c.planned, via: null, existing: show.planned && c.existing,
|
|
73
|
+
touched: show.touched ? c.touched : 0, attempts: show.touched ? c.attempts : [], current: show.touched && c.current,
|
|
74
|
+
drift: show.drift && c.drift, conflict: show.conflict && c.conflict, overlap: c.overlap, with: c.with };
|
|
75
|
+
if (m.planned) m.via = c.via;
|
|
76
|
+
return m.planned || m.existing || m.touched || m.drift || m.conflict ? m : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* The matrix of files × tasks. opts: {filter, sort, collapsed, show} (see normPrefs). Cells are sparse: a row's
|
|
80
|
+
`cells` holds only the tasks with something to show there. `show` switches a kind of mark off: cells lose it
|
|
81
|
+
and files left with nothing are dropped. waves[].from/to are column indices, `to` exclusive (like slice). */
|
|
82
|
+
function matrix(data, run, opts) {
|
|
83
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
84
|
+
const sort = SORTS.includes(o.sort) ? o.sort : 'path';
|
|
85
|
+
const show = {};
|
|
86
|
+
for (const k of KINDS) show[k] = !(o.show && o.show[k] === false);
|
|
87
|
+
const needle = typeof o.filter === 'string' ? o.filter.trim().toLowerCase() : '';
|
|
88
|
+
const collapsed = new Set(o.collapsed instanceof Set ? o.collapsed : list(o.collapsed));
|
|
89
|
+
const columns = columnsOf(data, run), waveOf = new Map(columns.map(c => [c.id, c.wave]));
|
|
90
|
+
const waves = [];
|
|
91
|
+
columns.forEach((c, i) => {
|
|
92
|
+
const w = waves[waves.length - 1];
|
|
93
|
+
if (w && w.wave === c.wave) w.to = i + 1; else waves.push({ wave: c.wave, from: i, to: i + 1 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const T = new Map(), scopes = new Map(), alls = [], where = new Map(), near = new Map();
|
|
97
|
+
const plan = (scope, id) => {
|
|
98
|
+
scope = clean(scope);
|
|
99
|
+
if (!scope || scope === '.') return;
|
|
100
|
+
if (!scopes.has(scope)) scopes.set(scope, new Set());
|
|
101
|
+
scopes.get(scope).add(id);
|
|
102
|
+
};
|
|
103
|
+
const addPath = p => { if (!where.has(p)) where.set(p, new Set()); return where.get(p); };
|
|
104
|
+
for (const t of list(data && data.tasks)) {
|
|
105
|
+
if (!t || typeof t !== 'object' || t.id == null || T.has(String(t.id))) continue;
|
|
106
|
+
const f = { id: String(t.id), tries: new Map(), current: new Set(paths(t.current)), drift: new Set(paths(t.drift)),
|
|
107
|
+
conflict: new Set(paths(t.conflicts)), existing: new Set(paths(t.existing)) };
|
|
108
|
+
for (const s of paths(t.planned)) plan(s, f.id);
|
|
109
|
+
if (t.planned_all || list(t.planned).includes('.')) alls.push(f.id);
|
|
110
|
+
for (const a of list(t.attempts)) {
|
|
111
|
+
for (const p of new Set(paths(a && a.files))) {
|
|
112
|
+
if (!f.tries.has(p)) f.tries.set(p, []);
|
|
113
|
+
f.tries.get(p).push(num(a.attempt));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
T.set(f.id, f);
|
|
117
|
+
}
|
|
118
|
+
for (const f of list(data && data.files)) {
|
|
119
|
+
const p = clean(f && f.path);
|
|
120
|
+
if (!p) continue;
|
|
121
|
+
const by = addPath(p);
|
|
122
|
+
for (const id of list(f.planned_by)) by.add(String(id));
|
|
123
|
+
}
|
|
124
|
+
for (const f of T.values()) {
|
|
125
|
+
for (const set of [f.tries.keys(), f.current, f.drift, f.conflict, f.existing]) {
|
|
126
|
+
for (const p of set) {
|
|
127
|
+
addPath(p);
|
|
128
|
+
if (!near.has(p)) near.set(p, new Set());
|
|
129
|
+
near.get(p).add(f.id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const plannedDirs = [];
|
|
134
|
+
for (const d of list(data && data.planned_dirs)) {
|
|
135
|
+
const p = clean(d && d.path);
|
|
136
|
+
if (!p || p === '.') continue;
|
|
137
|
+
for (const id of list(d.planned_by)) plan(p, String(id));
|
|
138
|
+
plannedDirs.push(p);
|
|
139
|
+
}
|
|
140
|
+
// the tasks whose plan covers a path (the path itself, a dir above it, or `.`), with the entry that does
|
|
141
|
+
const plannersOf = p => {
|
|
142
|
+
const out = new Map();
|
|
143
|
+
for (let a = p; a; a = dirOf(a)) for (const id of scopes.get(a) || []) if (!out.has(id)) out.set(id, a);
|
|
144
|
+
for (const id of alls) if (!out.has(id)) out.set(id, '.');
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const counts = { files: where.size, shown: 0, hidden: 0, filtered: 0, dirs: 0, tasks: columns.length,
|
|
149
|
+
planned: 0, existing: 0, touched: 0, drift: 0, conflict: 0, overlap: 0 };
|
|
150
|
+
const nodes = new Map(), top = { path: '', name: '', dirs: [], leaves: [] };
|
|
151
|
+
nodes.set('', top);
|
|
152
|
+
const dirAt = p => {
|
|
153
|
+
if (nodes.has(p)) return nodes.get(p);
|
|
154
|
+
const parent = dirAt(dirOf(p)), node = { path: p, name: p.slice(p.lastIndexOf('/') + 1) || p, dirs: [], leaves: [] };
|
|
155
|
+
parent.dirs.push(node); nodes.set(p, node);
|
|
156
|
+
return node;
|
|
157
|
+
};
|
|
158
|
+
let max = 0;
|
|
159
|
+
for (const [p, apiBy] of where) {
|
|
160
|
+
const planners = plannersOf(p);
|
|
161
|
+
for (const id of apiBy) if (!planners.has(id)) planners.set(id, null);
|
|
162
|
+
const raw = new Map();
|
|
163
|
+
for (const id of new Set([...planners.keys(), ...(near.get(p) || [])])) {
|
|
164
|
+
if (!waveOf.has(id)) continue;
|
|
165
|
+
const t = T.get(id), tries = t && t.tries.get(p) || [], current = !!t && t.current.has(p);
|
|
166
|
+
raw.set(id, { planned: planners.has(id), via: planners.get(id) ?? null, existing: !!t && t.existing.has(p),
|
|
167
|
+
touched: tries.length || (current ? 1 : 0), attempts: tries.slice().sort((x, y) => (x ?? Infinity) - (y ?? Infinity)),
|
|
168
|
+
current, drift: !!t && t.drift.has(p), conflict: !!t && t.conflict.has(p), overlap: false, with: [] });
|
|
169
|
+
}
|
|
170
|
+
markOverlaps(raw, waveOf);
|
|
171
|
+
const cells = Object.create(null);
|
|
172
|
+
let heat = 0, ntasks = 0;
|
|
173
|
+
for (const [id, c] of raw) {
|
|
174
|
+
const m = mask(c, show);
|
|
175
|
+
if (m) { cells[id] = m; heat += m.touched; ntasks++; }
|
|
176
|
+
}
|
|
177
|
+
if (!ntasks) { counts.hidden++; continue; }
|
|
178
|
+
if (needle && !p.toLowerCase().includes(needle)) { counts.filtered++; continue; }
|
|
179
|
+
counts.shown++;
|
|
180
|
+
for (const id in cells) {
|
|
181
|
+
const c = cells[id];
|
|
182
|
+
max = Math.max(max, c.touched);
|
|
183
|
+
for (const k of ['planned', 'existing', 'drift', 'conflict', 'overlap']) if (c[k]) counts[k]++;
|
|
184
|
+
if (c.touched) counts.touched++;
|
|
185
|
+
}
|
|
186
|
+
dirAt(dirOf(p)).leaves.push({ kind: 'file', path: p, name: p.slice(p.lastIndexOf('/') + 1) || p, cells, heat, ntasks });
|
|
187
|
+
}
|
|
188
|
+
if (show.planned) for (const p of plannedDirs) if (!needle || p.toLowerCase().includes(needle)) dirAt(p);
|
|
189
|
+
|
|
190
|
+
// dirs aggregate what is under them (max heat, any flag) plus their own plan; empty ones drop out
|
|
191
|
+
(function agg(node) {
|
|
192
|
+
const cells = new Map(), kids = [];
|
|
193
|
+
const into = id => cells.get(id) || cells.set(id, blank()).get(id);
|
|
194
|
+
const merge = (a, c, n) => {
|
|
195
|
+
a.planned = a.planned || c.planned; a.existing = a.existing || c.existing; a.touched = Math.max(a.touched, c.touched);
|
|
196
|
+
a.current = a.current || c.current; a.drift = a.drift || c.drift; a.conflict = a.conflict || c.conflict; a.n += n;
|
|
197
|
+
if (c.overlap) { a.overlap = true; for (const x of c.with) if (!a.with.includes(x)) a.with.push(x); }
|
|
198
|
+
};
|
|
199
|
+
node.files = 0; node.heat = 0; node.ntasks = 0;
|
|
200
|
+
for (const d of node.dirs) {
|
|
201
|
+
agg(d);
|
|
202
|
+
if (!d.files && !d.cells.size) continue;
|
|
203
|
+
kids.push(d); node.files += d.files; node.heat = Math.max(node.heat, d.heat); node.ntasks = Math.max(node.ntasks, d.ntasks);
|
|
204
|
+
for (const [id, c] of d.cells) merge(into(id), c, c.n);
|
|
205
|
+
}
|
|
206
|
+
for (const f of node.leaves) {
|
|
207
|
+
kids.push(f); node.files++; node.heat = Math.max(node.heat, f.heat); node.ntasks = Math.max(node.ntasks, f.ntasks);
|
|
208
|
+
for (const id in f.cells) merge(into(id), f.cells[id], 1);
|
|
209
|
+
}
|
|
210
|
+
if (node.path && show.planned) {
|
|
211
|
+
const own = new Map();
|
|
212
|
+
for (const [id, via] of plannersOf(node.path)) {
|
|
213
|
+
if (!waveOf.has(id)) continue;
|
|
214
|
+
const c = into(id);
|
|
215
|
+
c.planned = true; c.via = via; own.set(id, c);
|
|
216
|
+
}
|
|
217
|
+
markOverlaps(own, waveOf);
|
|
218
|
+
}
|
|
219
|
+
node.cells = cells; node.kids = kids;
|
|
220
|
+
})(top);
|
|
221
|
+
|
|
222
|
+
const rows = [], dirs = [];
|
|
223
|
+
const keyOf = sort === 'heat' ? r => r.heat : sort === 'tasks' ? r => r.ntasks : () => 0;
|
|
224
|
+
const order = (a, b) => keyOf(b) - keyOf(a) || (a.kind === b.kind ? 0 : a.kind === 'file' ? 1 : -1) || byName(a, b);
|
|
225
|
+
const dict = m => { const o = Object.create(null); for (const [id, c] of m) o[id] = c; return o; };
|
|
226
|
+
(function walk(node, depth, open) {
|
|
227
|
+
for (const k of node.kids.slice().sort(order)) {
|
|
228
|
+
if (k.kind === 'file') {
|
|
229
|
+
if (open) rows.push({ kind: 'file', path: k.path, name: k.name, depth, dir: node.path, collapsed: false, files: 1, cells: k.cells, heat: k.heat, ntasks: k.ntasks });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// a chain of dirs that hold nothing but one dir each is one row (src/main/java)
|
|
233
|
+
let d = k, name = k.name;
|
|
234
|
+
while (d.kids.length === 1 && d.kids[0].kind !== 'file') { d = d.kids[0]; name += '/' + d.name; }
|
|
235
|
+
const shut = collapsed.has(d.path);
|
|
236
|
+
dirs.push(d.path);
|
|
237
|
+
if (open) rows.push({ kind: 'dir', path: d.path, name, depth, dir: node.path, collapsed: shut, files: d.files, cells: dict(d.cells), heat: d.heat, ntasks: d.ntasks });
|
|
238
|
+
walk(d, depth + 1, open && !shut);
|
|
239
|
+
}
|
|
240
|
+
})(top, 0, true);
|
|
241
|
+
counts.dirs = dirs.length;
|
|
242
|
+
return { columns, waves, rows, dirs, max, counts };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The words for one cell, e.g. "reader touched ui/reader.py in attempts 1, 2 and its current diff · planned".
|
|
246
|
+
function describe(row, col, cell) {
|
|
247
|
+
const id = col && col.id != null ? String(col.id) : '?', c = cell || blank(), bits = [];
|
|
248
|
+
const planned = () => c.via === '.' ? 'planned (the whole repo)' : c.via && c.via !== row.path ? `planned as ${c.via}/` : 'planned';
|
|
249
|
+
let head;
|
|
250
|
+
if (row.kind === 'dir') {
|
|
251
|
+
head = `${id}: ${c.n || 0} of ${plural(row.files || 0, 'file')} under ${row.path}/`;
|
|
252
|
+
if (c.touched) bits.push(`touched in up to ${plural(c.touched, 'attempt')}`);
|
|
253
|
+
if (c.planned) bits.push(c.via ? planned() : 'planned files under it');
|
|
254
|
+
if (c.existing) bits.push('files already changed before the run');
|
|
255
|
+
} else if (c.touched) {
|
|
256
|
+
const tries = list(c.attempts);
|
|
257
|
+
head = `${id} touched ${row.path} ` + (tries.length ? `in attempt${tries.length > 1 ? 's' : ''} ${tries.map(n => n ?? '?').join(', ')}${c.current ? ' and its current diff' : ''}`
|
|
258
|
+
: 'in its current diff (not reviewed yet)');
|
|
259
|
+
if (c.planned) bits.push(planned());
|
|
260
|
+
} else if (c.planned) {
|
|
261
|
+
head = `${id} planned ${row.path}`;
|
|
262
|
+
if (c.via && c.via !== row.path) bits.push(c.via === '.' ? 'the whole repo (.)' : `as ${c.via}/`);
|
|
263
|
+
bits.push('not touched by its diffs');
|
|
264
|
+
} else if (c.existing) {
|
|
265
|
+
head = `${id}: ${row.path} already had uncommitted changes when the run started (existing.diff)`;
|
|
266
|
+
} else head = `${id} · ${row.path}`;
|
|
267
|
+
if (c.existing && row.kind !== 'dir' && (c.touched || c.planned)) bits.push('already changed before the run (existing.diff)');
|
|
268
|
+
if (c.drift) bits.push('drift: changed without being declared');
|
|
269
|
+
if (c.conflict) bits.push('conflicted on merge');
|
|
270
|
+
if (c.overlap) bits.push(`same wave${col && col.wave != null ? ' (' + col.wave + ')' : ''} as ${list(c.with).join(', ') || 'another task'}`);
|
|
271
|
+
return [head, ...bits].join(' · ');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ---------- the view (page only) ---------- */
|
|
275
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
276
|
+
const CELL = 20, ROW = 20, BOX = 14, IND = 14, CH = 7.25, HCH = 6.65, TILT = 55, LABEL_MAX = 380;
|
|
277
|
+
const HELP = {
|
|
278
|
+
planned: 'Outlined: the task planned this file (its files column names it, a directory above it, or . for the whole repo)',
|
|
279
|
+
touched: 'Filled: the task\'s diff changed this file; darker means more of its reviewed attempts did',
|
|
280
|
+
drift: 'Hatched: the task\'s diff changed this file without declaring it (drift.txt)',
|
|
281
|
+
conflict: 'Red border: this file conflicted when the task was merged (conflict.files)',
|
|
282
|
+
};
|
|
283
|
+
const views = new Map(); // run key -> {filter, pop}: what a re-render keeps for the session
|
|
284
|
+
const hosts = new WeakMap(); // host -> the view drawn in it: {sig, prefs, root, setCtx, handle}
|
|
285
|
+
let uid = 0, openPop = null, watching = false, memo = null, handled = null;
|
|
286
|
+
|
|
287
|
+
function el(tag, text, cls) {
|
|
288
|
+
const n = document.createElement(tag);
|
|
289
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
290
|
+
if (cls) n.className = cls;
|
|
291
|
+
return n;
|
|
292
|
+
}
|
|
293
|
+
function svg(name, attrs, parent) {
|
|
294
|
+
const e = document.createElementNS(NS, name);
|
|
295
|
+
for (const k in attrs || {}) e.setAttribute(k, attrs[k]);
|
|
296
|
+
if (parent) parent.append(e);
|
|
297
|
+
return e;
|
|
298
|
+
}
|
|
299
|
+
const stc = s => 'st-' + String(s || 'pending').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
300
|
+
const selTask = sel => !sel ? null : sel.kind === 'task' ? sel.id ?? null : sel.kind === 'execution' ? sel.task ?? null : null;
|
|
301
|
+
const fmt = n => Number(n).toLocaleString('en');
|
|
302
|
+
const clip = (s, n) => s.length <= n ? s : s.slice(0, Math.ceil((n - 1) / 2)) + '…' + s.slice(s.length - Math.floor((n - 1) / 2));
|
|
303
|
+
const shown = (e, on) => e.setAttribute('visibility', on ? 'visible' : 'hidden');
|
|
304
|
+
|
|
305
|
+
// The prefs this view works from: ctx.prefs, unless it is what we were handed last time or what we saved since
|
|
306
|
+
// (then ours): a page that hands back stale prefs does not undo the viewer's changes.
|
|
307
|
+
const json = v => { try { return JSON.stringify(v ?? null); } catch (_) { return ''; } };
|
|
308
|
+
function prefsOf(ctx) {
|
|
309
|
+
const seen = json(ctx.prefs);
|
|
310
|
+
if (!memo || (seen !== memo.seen && seen !== memo.saved)) memo = { seen, saved: null, prefs: normPrefs(ctx.prefs) };
|
|
311
|
+
return memo.prefs;
|
|
312
|
+
}
|
|
313
|
+
// What a render draws from, but the selection: the same again keeps the DOM (a live refresh, a new selection).
|
|
314
|
+
const sigOf = (data, run) => json(data) + json(list(run && run.tasks).map(t => t && [t.id, t.status, t.title, t.wave]));
|
|
315
|
+
|
|
316
|
+
// One listener for the page: a click outside the cells and the diff link closes the link.
|
|
317
|
+
function watch() {
|
|
318
|
+
if (watching) return;
|
|
319
|
+
watching = true;
|
|
320
|
+
document.addEventListener('click', e => {
|
|
321
|
+
const t = e.target;
|
|
322
|
+
if (openPop && e !== handled && !(t && t.closest && t.closest('.fm-pop'))) { const close = openPop; openPop = null; close(); }
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function mark(g, bx, by, c, max, ids) {
|
|
327
|
+
if (c.touched) svg('rect', { class: 'fm-touch', x: bx + 2, y: by + 2, width: BOX - 4, height: BOX - 4, rx: 1.5, 'fill-opacity': (0.3 + 0.7 * Math.min(1, c.touched / (max || 1))).toFixed(2) }, g);
|
|
328
|
+
if (c.drift) svg('rect', { class: 'fm-drift', x: bx, y: by, width: BOX, height: BOX, rx: 2, fill: `url(#${ids.hatch})` }, g);
|
|
329
|
+
if (c.planned || c.existing) svg('rect', { class: c.planned ? 'fm-plan' : 'fm-exist', fill: 'transparent', x: bx + 0.5, y: by + 0.5, width: BOX - 1, height: BOX - 1, rx: 2 }, g);
|
|
330
|
+
if (c.conflict) svg('rect', { class: 'fm-conflict', fill: 'none', x: bx - 1.5, y: by - 1.5, width: BOX + 3, height: BOX + 3, rx: 3 }, g);
|
|
331
|
+
if (c.overlap) svg('circle', { class: 'fm-overlap', cx: bx + BOX - 0.5, cy: by + 0.5, r: 2.8 }, g);
|
|
332
|
+
}
|
|
333
|
+
function swatch(c, ids) {
|
|
334
|
+
const s = svg('svg', { class: 'fm-swatch', width: 18, height: 18, viewBox: '0 0 18 18', 'aria-hidden': 'true', focusable: 'false' });
|
|
335
|
+
mark(s, 2, 2, Object.assign(blank(), c), c.max || 1, ids);
|
|
336
|
+
return s;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* render(host, data, ctx): ctx = {run, sel, onSelectTask(id), diffHref(taskId, path), prefs, savePrefs(prefs)}.
|
|
340
|
+
Returns {select(sel)}, which moves the highlighted column without rebuilding. */
|
|
341
|
+
function render(host, data, ctx) {
|
|
342
|
+
ctx = ctx || {};
|
|
343
|
+
watch();
|
|
344
|
+
const run = ctx.run && typeof ctx.run === 'object' ? ctx.run : null;
|
|
345
|
+
const sig = sigOf(data, run), was = hosts.get(host);
|
|
346
|
+
if (was && was.sig === sig && was.prefs === prefsOf(ctx) && was.root.parentNode === host) {
|
|
347
|
+
was.setCtx(ctx); was.handle.select(ctx.sel);
|
|
348
|
+
return was.handle;
|
|
349
|
+
}
|
|
350
|
+
const key = String(data && data.run || run && run.key || '');
|
|
351
|
+
const V = views.get(key) || { filter: '', pop: null };
|
|
352
|
+
views.delete(key); views.set(key, V);
|
|
353
|
+
if (views.size > 40) views.delete(views.keys().next().value);
|
|
354
|
+
const prefs = prefsOf(ctx);
|
|
355
|
+
const save = () => { const copy = JSON.parse(JSON.stringify(prefs)); memo.saved = json(copy); if (ctx.savePrefs) ctx.savePrefs(copy); };
|
|
356
|
+
// what the last render of this host had: its scroll position and focus
|
|
357
|
+
const before = host.querySelector('.fm-scroll'), active = document.activeElement;
|
|
358
|
+
const keep = { scroll: before ? [before.scrollLeft, before.scrollTop] : null, focus: null, caret: null };
|
|
359
|
+
if (active && host.contains(active)) {
|
|
360
|
+
keep.focus = active.getAttribute('data-fm-focus');
|
|
361
|
+
if (keep.focus === 'filter') keep.caret = [active.selectionStart, active.selectionEnd];
|
|
362
|
+
}
|
|
363
|
+
let sel = selTask(ctx.sel), live = null;
|
|
364
|
+
host.replaceChildren();
|
|
365
|
+
const root = el('div', undefined, 'fm');
|
|
366
|
+
host.append(root);
|
|
367
|
+
const handle = { select(s) { sel = selTask(s); if (live) live.setSel(sel); } };
|
|
368
|
+
hosts.set(host, { sig, prefs, root, handle, setCtx: c => { ctx = c; } });
|
|
369
|
+
if (!data || typeof data !== 'object') {
|
|
370
|
+
root.append(el('p', 'No file information for this run yet.', 'fm-empty'));
|
|
371
|
+
return handle;
|
|
372
|
+
}
|
|
373
|
+
const id = ++uid, ids = { hatch: `fm-hatch-${id}`, zebra: `fm-zebra-${id}` };
|
|
374
|
+
const defs = svg('svg', { class: 'fm-defs', width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
|
|
375
|
+
const d = svg('defs', {}, defs);
|
|
376
|
+
const hatch = svg('pattern', { id: ids.hatch, patternUnits: 'userSpaceOnUse', width: 4, height: 4, patternTransform: 'rotate(45)' }, d);
|
|
377
|
+
svg('rect', { class: 'fm-hatch-bg', width: 4, height: 4, fill: 'none' }, hatch);
|
|
378
|
+
svg('line', { class: 'fm-hatch-line', x1: 1, y1: 0, x2: 1, y2: 4 }, hatch);
|
|
379
|
+
const zebra = svg('pattern', { id: ids.zebra, patternUnits: 'userSpaceOnUse', width: 8, height: ROW * 2 }, d);
|
|
380
|
+
svg('rect', { class: 'fm-zebra', x: 0, y: ROW, width: 8, height: ROW }, zebra);
|
|
381
|
+
root.append(defs);
|
|
382
|
+
|
|
383
|
+
/* ---------- the toolbar: filter, sort, switches (which are the legend), counts ---------- */
|
|
384
|
+
const bar = el('div', undefined, 'filters fm-bar');
|
|
385
|
+
const fl = el('label', undefined, 'filter');
|
|
386
|
+
const input = el('input', undefined, 'fm-filter');
|
|
387
|
+
input.type = 'search'; input.placeholder = 'Filter paths…'; input.autocomplete = 'off'; input.spellcheck = false; input.value = V.filter;
|
|
388
|
+
input.setAttribute('data-fm-focus', 'filter');
|
|
389
|
+
fl.append(el('span', 'Path'), input);
|
|
390
|
+
const sl = el('label', undefined, 'filter');
|
|
391
|
+
const sortSel = el('select', undefined, 'fm-sort');
|
|
392
|
+
for (const [v, t] of [['path', 'path'], ['heat', 'most touched'], ['tasks', 'most tasks']]) { const o = el('option', t); o.value = v; sortSel.append(o); }
|
|
393
|
+
sortSel.value = prefs.sort; sortSel.setAttribute('data-fm-focus', 'sort');
|
|
394
|
+
sl.append(el('span', 'Sort'), sortSel);
|
|
395
|
+
bar.append(fl, sl);
|
|
396
|
+
const toggles = el('div', undefined, 'fm-toggles');
|
|
397
|
+
toggles.setAttribute('role', 'group'); toggles.setAttribute('aria-label', 'Show');
|
|
398
|
+
const solo = list(data.tasks).length === 1 && data.tasks[0] && Array.isArray(data.tasks[0].existing);
|
|
399
|
+
for (const k of KINDS) {
|
|
400
|
+
const lab = el('label', undefined, 'filter check fm-toggle'), cb = el('input'), existing = solo && k === 'planned';
|
|
401
|
+
cb.type = 'checkbox'; cb.checked = prefs.show[k]; cb.setAttribute('data-fm-focus', 'show-' + k);
|
|
402
|
+
cb.addEventListener('change', () => { prefs.show[k] = cb.checked; save(); draw(); });
|
|
403
|
+
lab.title = existing ? 'Dashed: the repo already had uncommitted changes to this file when the run started (existing.diff)' : HELP[k];
|
|
404
|
+
lab.append(cb, swatch({ planned: k === 'planned' && !existing, existing, touched: k === 'touched' ? 1 : 0, drift: k === 'drift', conflict: k === 'conflict' }, ids), el('span', existing ? 'existing' : k));
|
|
405
|
+
toggles.append(lab);
|
|
406
|
+
}
|
|
407
|
+
const tools = el('div', undefined, 'fm-tools');
|
|
408
|
+
const btn = (text, focus, onClick) => { const b = el('button', text); b.type = 'button'; b.setAttribute('data-fm-focus', focus); b.addEventListener('click', onClick); return b; };
|
|
409
|
+
tools.append(btn('Collapse all', 'collapse', () => {
|
|
410
|
+
const M = live && live.M;
|
|
411
|
+
if (!M) return;
|
|
412
|
+
prefs.collapsed = [...new Set(prefs.collapsed.concat(M.dirs))].slice(-COLLAPSED_CAP); save(); draw();
|
|
413
|
+
}), btn('Expand all', 'expand', () => {
|
|
414
|
+
const M = live && live.M, mine = new Set(M ? M.dirs : []);
|
|
415
|
+
prefs.collapsed = prefs.collapsed.filter(p => !mine.has(p)); save(); draw();
|
|
416
|
+
}));
|
|
417
|
+
bar.append(toggles, tools);
|
|
418
|
+
const legend = el('div', undefined, 'fm-legend');
|
|
419
|
+
const key1 = el('span', undefined, 'fm-key');
|
|
420
|
+
key1.append(swatch({ touched: 1, overlap: true }, ids), el('span', 'another task of the same wave planned or touched it too'));
|
|
421
|
+
const key2 = el('span', undefined, 'fm-key');
|
|
422
|
+
const ramp = svg('svg', { class: 'fm-ramp', width: 58, height: 18, viewBox: '0 0 58 18', 'aria-hidden': 'true', focusable: 'false' });
|
|
423
|
+
for (let i = 0; i < 4; i++) mark(ramp, 2 + i * 14, 2, { touched: i + 1 }, 4, ids);
|
|
424
|
+
const rampMax = el('span', 'more attempts');
|
|
425
|
+
key2.append(el('span', 'touched in 1 attempt'), ramp, rampMax);
|
|
426
|
+
const key3 = el('span', undefined, 'fm-key');
|
|
427
|
+
const stMark = svg('svg', { class: 'fm-swatch', width: 12, height: 18, viewBox: '0 0 12 18', 'aria-hidden': 'true', focusable: 'false' });
|
|
428
|
+
svg('rect', { class: 'fm-st st-running', x: 2, y: 5, width: 8, height: 8, rx: 2 }, stMark);
|
|
429
|
+
key3.append(stMark, el('span', 'the task\'s status'));
|
|
430
|
+
legend.append(key1, key2, key3);
|
|
431
|
+
if (list(data.tasks).some(t => t && list(t.existing).length)) {
|
|
432
|
+
const key4 = el('span', undefined, 'fm-key');
|
|
433
|
+
key4.append(swatch({ existing: true }, ids), el('span', 'already changed in the repo when the run started (existing.diff)'));
|
|
434
|
+
legend.append(key4);
|
|
435
|
+
}
|
|
436
|
+
const count = el('p', '', 'fm-count');
|
|
437
|
+
count.setAttribute('aria-live', 'polite');
|
|
438
|
+
root.append(bar, legend, count);
|
|
439
|
+
if (data.truncated) root.append(el('p', 'The server cut this run short: it lists at most 2,000 files, 300 tasks and 5,000 overlaps.', 'warn fm-note'));
|
|
440
|
+
const body = el('div', undefined, 'fm-body');
|
|
441
|
+
root.append(body, lists());
|
|
442
|
+
|
|
443
|
+
let timer = 0;
|
|
444
|
+
input.addEventListener('input', () => { V.filter = input.value; clearTimeout(timer); timer = setTimeout(draw, 120); });
|
|
445
|
+
input.addEventListener('search', () => { clearTimeout(timer); V.filter = input.value; draw(); });
|
|
446
|
+
sortSel.addEventListener('change', () => { prefs.sort = sortSel.value; save(); draw(); });
|
|
447
|
+
if (keep.focus) {
|
|
448
|
+
const t = [...bar.querySelectorAll('[data-fm-focus]')].find(e => e.getAttribute('data-fm-focus') === keep.focus);
|
|
449
|
+
if (t) { t.focus({ preventScroll: true }); if (keep.caret) try { t.setSelectionRange(keep.caret[0], keep.caret[1]); } catch (_) { /* not a text input */ } }
|
|
450
|
+
}
|
|
451
|
+
draw(keep.focus, keep.scroll);
|
|
452
|
+
return handle;
|
|
453
|
+
|
|
454
|
+
/* ---------- the matrix: four panes so the header and the paths stay put while it scrolls ---------- */
|
|
455
|
+
function draw(want, scrollTo) {
|
|
456
|
+
const old = body.querySelector('.fm-scroll');
|
|
457
|
+
if (!scrollTo && old) scrollTo = [old.scrollLeft, old.scrollTop];
|
|
458
|
+
if (want === undefined && document.activeElement && body.contains(document.activeElement)) want = document.activeElement.getAttribute('data-fm-focus');
|
|
459
|
+
const M = matrix(data, run, { filter: V.filter, sort: prefs.sort, collapsed: prefs.collapsed, show: prefs.show });
|
|
460
|
+
live = { M, setSel() {} };
|
|
461
|
+
rampMax.textContent = M.max > 1 ? `… ${M.max} attempts` : '… more attempts';
|
|
462
|
+
body.replaceChildren();
|
|
463
|
+
const c0 = M.counts;
|
|
464
|
+
count.textContent = `${fmt(c0.files)} ${c0.files === 1 ? 'file' : 'files'} × ${fmt(c0.tasks)} ${c0.tasks === 1 ? 'task' : 'tasks'}` +
|
|
465
|
+
(c0.shown !== c0.files ? ` · ${fmt(c0.shown)} shown` : '') + (c0.dirs ? ` · ${fmt(c0.dirs)} ${c0.dirs === 1 ? 'directory' : 'directories'}` : '');
|
|
466
|
+
if (!M.columns.length) { body.append(el('p', 'This run has no tasks yet.', 'fm-empty')); return; }
|
|
467
|
+
if (!M.rows.length) {
|
|
468
|
+
body.append(el('p', !c0.files ? 'No files yet: no task planned a file and no diff touched one.' :
|
|
469
|
+
c0.filtered && V.filter.trim() ? `No file path contains “${V.filter.trim()}”.` : 'Nothing to show with these switches: turn a kind of mark back on.', 'fm-empty'));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const cols = M.columns, rows = M.rows.slice(0, ROW_CAP), nc = cols.length, nr = rows.length;
|
|
473
|
+
const colIx = new Map(cols.map((c, i) => [c.id, i])), rowIx = new Map(rows.map((r, i) => [r.path, i]));
|
|
474
|
+
const longest = Math.min(24, cols.reduce((m, c) => Math.max(m, c.id.length), 3)) * HCH;
|
|
475
|
+
const rad = TILT * Math.PI / 180, WAVE_H = 24, HH = WAVE_H + Math.ceil(longest * Math.sin(rad)) + 28;
|
|
476
|
+
const CW = Math.max(nc * CELL + Math.ceil(longest * Math.cos(rad)) + 12, 200), BH = nr * ROW + 4;
|
|
477
|
+
// the paths stay put while the cells scroll, so on a narrow screen they get at most 45% of it
|
|
478
|
+
const avail = body.clientWidth, labelMax = avail ? Math.max(160, Math.min(LABEL_MAX, Math.floor(avail * 0.45))) : LABEL_MAX;
|
|
479
|
+
const counts = rows.map(r => r.kind === 'dir' ? String(r.files) : '');
|
|
480
|
+
const names = rows.map((r, i) => {
|
|
481
|
+
const room = labelMax - 30 - r.depth * IND - (counts[i].length + 1) * CH;
|
|
482
|
+
return clip(r.kind === 'dir' ? r.name + '/' : r.name, Math.max(8, Math.floor(room / CH)));
|
|
483
|
+
});
|
|
484
|
+
const LW = Math.min(labelMax, Math.max(Math.min(200, labelMax), Math.ceil(rows.reduce((m, r, i) => Math.max(m, 30 + r.depth * IND + (names[i].length + counts[i].length + 1) * CH), 0))));
|
|
485
|
+
|
|
486
|
+
const scroll = el('div', undefined, 'fm-scroll'), sheet = el('div', undefined, 'fm-sheet');
|
|
487
|
+
const headRow = el('div', undefined, 'fm-headrow'), bodyRow = el('div', undefined, 'fm-bodyrow');
|
|
488
|
+
const corner = svg('svg', { class: 'fm-corner', width: LW, height: HH, 'aria-hidden': 'true', focusable: 'false' });
|
|
489
|
+
const head = svg('svg', { class: 'fm-head', width: CW, height: HH, role: 'group', 'aria-label': 'Tasks by wave: select one' });
|
|
490
|
+
const labels = svg('svg', { class: 'fm-labels', width: LW, height: BH, role: 'group', 'aria-label': 'Files and directories' });
|
|
491
|
+
const grid = svg('svg', { class: 'fm-grid', width: CW, height: BH, role: 'group', 'aria-label': 'Cells: arrow keys move between them, Enter selects the task and offers its diff' });
|
|
492
|
+
headRow.append(corner, head); bodyRow.append(labels, grid); sheet.append(headRow, bodyRow); scroll.append(sheet); body.append(scroll);
|
|
493
|
+
|
|
494
|
+
svg('text', { class: 'fm-axis', x: 12, y: HH - 9 }, corner).textContent = 'files ↓';
|
|
495
|
+
svg('text', { class: 'fm-axis', x: LW - 10, y: HH - 9, 'text-anchor': 'end' }, corner).textContent = 'tasks →';
|
|
496
|
+
// the header: wave bands and labels, then one button per task
|
|
497
|
+
M.waves.forEach((w, i) => {
|
|
498
|
+
const x = w.from * CELL, wd = (w.to - w.from) * CELL, full = w.wave == null ? 'no wave' : `wave ${w.wave}`;
|
|
499
|
+
const g = svg('g', { class: 'fm-wave' }, head);
|
|
500
|
+
if (i % 2) svg('rect', { class: 'fm-waveband', x, y: 0, width: wd, height: HH }, g);
|
|
501
|
+
svg('text', { x: x + 3, y: 14 }, g).textContent = wd >= full.length * 6.02 + 3 ? full : w.wave == null ? '–' : `w${w.wave}`;
|
|
502
|
+
svg('line', { x1: x + 2, x2: x + wd - 2, y1: 19.5, y2: 19.5 }, g);
|
|
503
|
+
svg('title', {}, g).textContent = `${full}: ${cols.slice(w.from, w.to).map(c => c.id).join(', ')}`;
|
|
504
|
+
});
|
|
505
|
+
const selH = svg('rect', { class: 'fm-colsel', x: 0, y: WAVE_H, width: CELL, height: HH - WAVE_H, visibility: 'hidden' }, head);
|
|
506
|
+
const hovH = svg('rect', { class: 'fm-hover', x: 0, y: WAVE_H, width: CELL, height: HH - WAVE_H, visibility: 'hidden' }, head);
|
|
507
|
+
const heads = cols.map((c, i) => {
|
|
508
|
+
const cx = i * CELL + CELL / 2, ty = HH - 19;
|
|
509
|
+
const g = svg('g', { class: `fm-colhead ${stc(c.status)}`, tabindex: 0, role: 'button', 'data-c': i, 'data-fm-focus': 'col:' + c.id,
|
|
510
|
+
'aria-label': `${c.id}, ${String(c.status).toLowerCase()}${c.wave == null ? '' : ', wave ' + c.wave}: select this task` }, head);
|
|
511
|
+
svg('title', {}, g).textContent = `${c.id}${c.title ? ' — ' + c.title : ''} · ${c.status}${c.wave == null ? '' : ' · wave ' + c.wave}${c.planned_all ? ' · planned the whole repo (.)' : ''}`;
|
|
512
|
+
svg('rect', { class: 'fm-hit', fill: 'transparent', x: i * CELL + 1, y: WAVE_H + 1, width: CELL - 2, height: HH - WAVE_H - 2, rx: 3 }, g);
|
|
513
|
+
svg('text', { x: cx + 3, y: ty, transform: `rotate(-${TILT} ${cx + 3} ${ty})` }, g).textContent = clip(c.id, 24);
|
|
514
|
+
svg('rect', { class: 'fm-st', x: cx - 4, y: HH - 13, width: 8, height: 8, rx: 2 }, g);
|
|
515
|
+
return g;
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// the paths
|
|
519
|
+
svg('rect', { width: LW, height: BH, fill: `url(#${ids.zebra})` }, labels);
|
|
520
|
+
svg('rect', { width: CW, height: BH, fill: `url(#${ids.zebra})` }, grid);
|
|
521
|
+
rows.forEach((r, i) => {
|
|
522
|
+
if (r.kind !== 'dir') return;
|
|
523
|
+
svg('rect', { class: 'fm-dirband', x: 0, y: i * ROW, width: LW, height: ROW }, labels);
|
|
524
|
+
svg('rect', { class: 'fm-dirband', x: 0, y: i * ROW, width: CW, height: ROW }, grid);
|
|
525
|
+
});
|
|
526
|
+
const hovL = svg('rect', { class: 'fm-hover', x: 0, y: 0, width: LW, height: ROW, visibility: 'hidden' }, labels);
|
|
527
|
+
const dirEls = [];
|
|
528
|
+
rows.forEach((r, i) => {
|
|
529
|
+
const x = 10 + r.depth * IND, y = i * ROW, dir = r.kind === 'dir';
|
|
530
|
+
if (!dir) {
|
|
531
|
+
const g = svg('g', { class: 'fm-file' }, labels);
|
|
532
|
+
svg('title', {}, g).textContent = r.path;
|
|
533
|
+
svg('text', { class: 'fm-label', x: x + 14, y: y + 14 }, g).textContent = names[i];
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const g = svg('g', { class: 'fm-dirrow', tabindex: dirEls.length ? -1 : 0, role: 'button', 'aria-expanded': String(!r.collapsed), 'data-r': i, 'data-fm-focus': 'dir:' + r.path }, labels);
|
|
537
|
+
dirEls.push(g);
|
|
538
|
+
svg('title', {}, g).textContent = `${r.path}/ · ${plural(r.files, 'file')}${r.collapsed ? ' (collapsed: click to open)' : ''}`;
|
|
539
|
+
svg('rect', { class: 'fm-hit', fill: 'transparent', x: 0, y, width: LW, height: ROW }, g);
|
|
540
|
+
svg('path', { class: 'fm-tri', d: r.collapsed ? `M${x + 2} ${y + 6}l6 4l-6 4z` : `M${x} ${y + 7}l8 0l-4 6z` }, g);
|
|
541
|
+
svg('text', { class: 'fm-label', x: x + 14, y: y + 14 }, g).textContent = names[i];
|
|
542
|
+
svg('text', { class: 'fm-num', x: x + 14 + (names[i].length + 1) * CH, y: y + 14 }, g).textContent = counts[i];
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// the cells
|
|
546
|
+
for (let i = 1; i < M.waves.length; i++) {
|
|
547
|
+
const x = M.waves[i].from * CELL;
|
|
548
|
+
svg('line', { class: 'fm-wavesep', x1: x, x2: x, y1: WAVE_H, y2: HH }, head);
|
|
549
|
+
svg('line', { class: 'fm-wavesep', x1: x, x2: x, y1: 0, y2: BH }, grid);
|
|
550
|
+
}
|
|
551
|
+
const selC = svg('rect', { class: 'fm-colsel', x: 0, y: 0, width: CELL, height: BH, visibility: 'hidden' }, grid);
|
|
552
|
+
const hovC = svg('rect', { class: 'fm-hover', x: 0, y: 0, width: CELL, height: BH, visibility: 'hidden' }, grid);
|
|
553
|
+
const hovR = svg('rect', { class: 'fm-hover', x: 0, y: 0, width: CW, height: ROW, visibility: 'hidden' }, grid);
|
|
554
|
+
const at = new Map(), lanes = [];
|
|
555
|
+
rows.forEach((r, ri) => {
|
|
556
|
+
const lane = [];
|
|
557
|
+
for (const tid in r.cells) { const ci = colIx.get(tid); if (ci !== undefined) lane.push(ci); }
|
|
558
|
+
lane.sort((a, b) => a - b);
|
|
559
|
+
for (const ci of lane) {
|
|
560
|
+
const g = svg('g', { class: 'fm-cell' }, grid);
|
|
561
|
+
svg('title', {}, g).textContent = describe(r, cols[ci], r.cells[cols[ci].id]);
|
|
562
|
+
mark(g, ci * CELL + (CELL - BOX) / 2, ri * ROW + (ROW - BOX) / 2, r.cells[cols[ci].id], M.max, ids);
|
|
563
|
+
at.set(ri * nc + ci, g);
|
|
564
|
+
}
|
|
565
|
+
lanes.push(lane);
|
|
566
|
+
});
|
|
567
|
+
if (M.rows.length > ROW_CAP) body.append(el('p', `Showing the first ${fmt(ROW_CAP)} of ${fmt(M.rows.length)} rows: filter by path or collapse directories to see the rest.`, 'warn fm-note'));
|
|
568
|
+
|
|
569
|
+
/* ---------- selection, hover, the diff link and the keyboard ---------- */
|
|
570
|
+
const setSel = tid => {
|
|
571
|
+
const c = tid == null ? undefined : colIx.get(tid);
|
|
572
|
+
for (const e of [selH, selC]) { if (c !== undefined) e.setAttribute('x', c * CELL); shown(e, c !== undefined); }
|
|
573
|
+
heads.forEach((g, i) => { g.classList.toggle('fm-sel', i === c); if (i === c) g.setAttribute('aria-current', 'true'); else g.removeAttribute('aria-current'); });
|
|
574
|
+
};
|
|
575
|
+
setSel(sel);
|
|
576
|
+
live = { M, setSel };
|
|
577
|
+
const hover = (r, c) => {
|
|
578
|
+
for (const e of [hovL, hovR]) { if (r != null) e.setAttribute('y', r * ROW); shown(e, r != null); }
|
|
579
|
+
for (const e of [hovH, hovC]) { if (c != null) e.setAttribute('x', c * CELL); shown(e, c != null); }
|
|
580
|
+
};
|
|
581
|
+
const spot = (pane, e) => {
|
|
582
|
+
const b = pane.getBoundingClientRect(), r = Math.floor((e.clientY - b.top) / ROW), c = Math.floor((e.clientX - b.left) / CELL);
|
|
583
|
+
return [r >= 0 && r < nr ? r : null, c >= 0 && c < nc ? c : null];
|
|
584
|
+
};
|
|
585
|
+
grid.addEventListener('pointermove', e => hover(...spot(grid, e)));
|
|
586
|
+
labels.addEventListener('pointermove', e => hover(spot(labels, e)[0], null));
|
|
587
|
+
head.addEventListener('pointermove', e => {
|
|
588
|
+
const g = e.target.closest && e.target.closest('.fm-colhead');
|
|
589
|
+
hover(null, g ? +g.getAttribute('data-c') : spot(head, e)[1]);
|
|
590
|
+
});
|
|
591
|
+
for (const p of [grid, labels, head]) p.addEventListener('pointerleave', () => hover(null, null));
|
|
592
|
+
|
|
593
|
+
let pop = null;
|
|
594
|
+
const closePop = () => { V.pop = null; if (pop) pop.remove(); pop = null; };
|
|
595
|
+
const drawPop = () => {
|
|
596
|
+
if (pop) pop.remove();
|
|
597
|
+
pop = null;
|
|
598
|
+
const r = V.pop ? rowIx.get(V.pop.path) : undefined, c = V.pop ? colIx.get(V.pop.task) : undefined;
|
|
599
|
+
const href = r !== undefined && c !== undefined && ctx.diffHref ? ctx.diffHref(V.pop.task, V.pop.path) : null;
|
|
600
|
+
if (!href) return;
|
|
601
|
+
const text = 'Diff for this file', w = Math.ceil(text.length * 6.6) + 16, h = 20;
|
|
602
|
+
let x = (c + 1) * CELL + 3;
|
|
603
|
+
if (x + w > CW - 2) x = Math.max(2, c * CELL - w - 3);
|
|
604
|
+
const y = Math.max(0, Math.min(r * ROW, BH - h - 1));
|
|
605
|
+
pop = svg('a', { class: 'fm-pop', href, 'aria-label': `Diff of ${V.pop.task} at ${V.pop.path}` }, grid);
|
|
606
|
+
svg('rect', { x, y, width: w, height: h, rx: 4 }, pop);
|
|
607
|
+
svg('text', { x: x + 8, y: y + 14 }, pop).textContent = text;
|
|
608
|
+
openPop = closePop;
|
|
609
|
+
};
|
|
610
|
+
drawPop();
|
|
611
|
+
|
|
612
|
+
// one cell at a time is in the tab order (roving tabindex); a single ring marks it while it has keyboard focus
|
|
613
|
+
let cur = null, curAt = null;
|
|
614
|
+
const ring = svg('rect', { class: 'fm-ring', fill: 'none', x: 0, y: 0, width: CELL - 2, height: ROW - 2, rx: 3, visibility: 'hidden' }, grid);
|
|
615
|
+
const rove = (r, c, focus) => {
|
|
616
|
+
const g = at.get(r * nc + c);
|
|
617
|
+
if (!g) return;
|
|
618
|
+
if (cur && cur !== g) for (const a of ['tabindex', 'role', 'data-fm-focus']) cur.removeAttribute(a);
|
|
619
|
+
cur = g; curAt = [r, c];
|
|
620
|
+
g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button');
|
|
621
|
+
g.setAttribute('data-fm-focus', `cell:${cols[c].id}\n${rows[r].path}`);
|
|
622
|
+
if (focus) g.focus();
|
|
623
|
+
};
|
|
624
|
+
const first = colIx.get(sel), fr = first === undefined ? -1 : lanes.findIndex(l => l.includes(first));
|
|
625
|
+
if (fr >= 0) rove(fr, first);
|
|
626
|
+
else { const r0 = lanes.findIndex(l => l.length); if (r0 >= 0) rove(r0, lanes[r0][0]); }
|
|
627
|
+
const activate = (r, c) => {
|
|
628
|
+
const row = rows[r], col = cols[c], cell = row && col && row.cells[col.id];
|
|
629
|
+
if (!cell) return;
|
|
630
|
+
rove(r, c);
|
|
631
|
+
V.pop = row.kind === 'file' && (cell.touched || cell.drift || cell.conflict) ? { task: col.id, path: row.path } : null;
|
|
632
|
+
drawPop();
|
|
633
|
+
if (ctx.onSelectTask) ctx.onSelectTask(col.id);
|
|
634
|
+
};
|
|
635
|
+
const near = (lane, c) => lane.reduce((b, x) => b === null || Math.abs(x - c) < Math.abs(b - c) ? x : b, null);
|
|
636
|
+
const step = (r, c, key) => {
|
|
637
|
+
const lane = lanes[r];
|
|
638
|
+
if (key === 'ArrowRight') { const x = lane.find(v => v > c); return x === undefined ? null : [r, x]; }
|
|
639
|
+
if (key === 'ArrowLeft') { const x = lane.filter(v => v < c).pop(); return x === undefined ? null : [r, x]; }
|
|
640
|
+
if (key === 'Home') return [r, lane[0]];
|
|
641
|
+
if (key === 'End') return [r, lane[lane.length - 1]];
|
|
642
|
+
const dr = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : 0;
|
|
643
|
+
if (!dr) return null;
|
|
644
|
+
for (let i = r + dr; i >= 0 && i < nr; i += dr) if (lanes[i].includes(c)) return [i, c];
|
|
645
|
+
for (let i = r + dr; i >= 0 && i < nr; i += dr) if (lanes[i].length) return [i, near(lanes[i], c)];
|
|
646
|
+
return null;
|
|
647
|
+
};
|
|
648
|
+
// a click focuses the cell it hit (so the arrow keys go on from there), never the pane itself
|
|
649
|
+
grid.addEventListener('mousedown', e => { if (!(e.target.closest && e.target.closest('.fm-pop'))) e.preventDefault(); });
|
|
650
|
+
grid.addEventListener('click', e => {
|
|
651
|
+
if (e.target.closest && e.target.closest('.fm-pop')) return;
|
|
652
|
+
const [r, c] = spot(grid, e);
|
|
653
|
+
if (r === null || c === null || !at.has(r * nc + c)) return;
|
|
654
|
+
handled = e;
|
|
655
|
+
activate(r, c);
|
|
656
|
+
if (cur && cur.isConnected) cur.focus({ preventScroll: true });
|
|
657
|
+
});
|
|
658
|
+
grid.addEventListener('keydown', e => {
|
|
659
|
+
if (!cur || e.target !== cur) { if (e.key === 'Escape') closePop(); return; }
|
|
660
|
+
const [r, c] = curAt;
|
|
661
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(r, c); return; }
|
|
662
|
+
if (e.key === 'Escape') { closePop(); return; }
|
|
663
|
+
const next = step(r, c, e.key);
|
|
664
|
+
if (next) { e.preventDefault(); rove(next[0], next[1], true); hover(next[0], next[1]); }
|
|
665
|
+
});
|
|
666
|
+
// focus listeners sit on the HTML row: Chromium makes an SVG element with one focusable itself
|
|
667
|
+
bodyRow.addEventListener('focusin', e => {
|
|
668
|
+
if (e.target !== cur || !curAt) return;
|
|
669
|
+
ring.setAttribute('x', curAt[1] * CELL + 1); ring.setAttribute('y', curAt[0] * ROW + 1);
|
|
670
|
+
shown(ring, cur.matches(':focus-visible'));
|
|
671
|
+
grid.append(ring);
|
|
672
|
+
});
|
|
673
|
+
bodyRow.addEventListener('focusout', e => { if (grid.contains(e.target)) { hover(null, null); shown(ring, false); } });
|
|
674
|
+
const pick = g => { if (g && ctx.onSelectTask) ctx.onSelectTask(cols[+g.getAttribute('data-c')].id); };
|
|
675
|
+
head.addEventListener('click', e => pick(e.target.closest && e.target.closest('.fm-colhead')));
|
|
676
|
+
head.addEventListener('keydown', e => {
|
|
677
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
678
|
+
e.preventDefault(); pick(e.target.closest && e.target.closest('.fm-colhead'));
|
|
679
|
+
});
|
|
680
|
+
const toggle = path => {
|
|
681
|
+
const set = new Set(prefs.collapsed);
|
|
682
|
+
if (set.has(path)) set.delete(path); else set.add(path);
|
|
683
|
+
prefs.collapsed = [...set].slice(-COLLAPSED_CAP); save(); draw('dir:' + path);
|
|
684
|
+
};
|
|
685
|
+
const dirOfEvent = e => { const g = e.target.closest && e.target.closest('.fm-dirrow'); return g ? rows[+g.getAttribute('data-r')] : null; };
|
|
686
|
+
labels.addEventListener('click', e => { const r = dirOfEvent(e); if (r) toggle(r.path); });
|
|
687
|
+
// the directories are one stop in the tab order too: up and down move between them
|
|
688
|
+
let dirCur = dirEls[0] || null;
|
|
689
|
+
const roveDir = (g, focus) => {
|
|
690
|
+
if (dirCur && dirCur !== g) dirCur.setAttribute('tabindex', '-1');
|
|
691
|
+
dirCur = g; g.setAttribute('tabindex', '0');
|
|
692
|
+
if (focus) g.focus();
|
|
693
|
+
};
|
|
694
|
+
bodyRow.addEventListener('focusin', e => { const g = labels.contains(e.target) && e.target.closest('.fm-dirrow'); if (g) roveDir(g); });
|
|
695
|
+
labels.addEventListener('keydown', e => {
|
|
696
|
+
const r = dirOfEvent(e);
|
|
697
|
+
if (!r) return;
|
|
698
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
699
|
+
const next = dirEls[dirEls.indexOf(e.target.closest('.fm-dirrow')) + (e.key === 'ArrowDown' ? 1 : -1)];
|
|
700
|
+
if (next) { e.preventDefault(); roveDir(next, true); }
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (e.key === 'Enter' || e.key === ' ' || (e.key === 'ArrowLeft' && !r.collapsed) || (e.key === 'ArrowRight' && r.collapsed)) { e.preventDefault(); toggle(r.path); }
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
if (scrollTo) { scroll.scrollLeft = scrollTo[0]; scroll.scrollTop = scrollTo[1]; }
|
|
707
|
+
if (typeof want === 'string') {
|
|
708
|
+
let target = null;
|
|
709
|
+
if (want.startsWith('col:')) target = heads[colIx.get(want.slice(4))] || null;
|
|
710
|
+
else if (want.startsWith('dir:')) { const r = rowIx.get(want.slice(4)); target = r === undefined ? null : labels.querySelector(`[data-r="${r}"]`); if (target) roveDir(target); }
|
|
711
|
+
else if (want.startsWith('cell:')) {
|
|
712
|
+
const nl = want.indexOf('\n'), r = rowIx.get(want.slice(nl + 1)), c = colIx.get(want.slice(5, nl));
|
|
713
|
+
if (r !== undefined && c !== undefined && at.has(r * nc + c)) { rove(r, c); target = cur; }
|
|
714
|
+
}
|
|
715
|
+
if (target) target.focus({ preventScroll: true });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* ---------- under the matrix: overlaps, drift, conflicts, deferrals, as text ---------- */
|
|
720
|
+
function lists() {
|
|
721
|
+
const box = el('div', undefined, 'fm-lists');
|
|
722
|
+
const titles = new Map(list(run && run.tasks).filter(t => t && t.id != null).map(t => [String(t.id), t.title || '']));
|
|
723
|
+
const waveOf = new Map(list(data.tasks).filter(t => t && t.id != null).map(t => [String(t.id), num(t.wave)]));
|
|
724
|
+
let n = 0;
|
|
725
|
+
const task = id => {
|
|
726
|
+
const b = el('button', id, 'linkish fm-task');
|
|
727
|
+
b.type = 'button'; b.title = titles.get(String(id)) || `Select ${id}`; b.setAttribute('data-fm-focus', 'list:' + n++);
|
|
728
|
+
b.addEventListener('click', () => { if (ctx.onSelectTask) ctx.onSelectTask(String(id)); });
|
|
729
|
+
return b;
|
|
730
|
+
};
|
|
731
|
+
const some = (ps, k = 6) => ps.slice(0, k).join(', ') + (ps.length > k ? ` +${ps.length - k} more` : '');
|
|
732
|
+
const items = (rows, ul = el('ul')) => {
|
|
733
|
+
for (const parts of rows.slice(0, 200)) { const li = el('li'); li.append(...parts); ul.append(li); }
|
|
734
|
+
if (rows.length > 200) ul.append(el('li', `… and ${fmt(rows.length - 200)} more`, 'muted'));
|
|
735
|
+
return ul;
|
|
736
|
+
};
|
|
737
|
+
// rows past `fold` (when there are more than a few) go in a closed <details> titled `more`
|
|
738
|
+
const section = (title, help, rows, fold = rows.length, more = '') => {
|
|
739
|
+
const s = el('section', undefined, 'fm-list'), h = el('h3', rows.length ? `${title} (${fmt(rows.length)})` : title);
|
|
740
|
+
h.title = help;
|
|
741
|
+
s.append(h);
|
|
742
|
+
if (!rows.length) s.append(el('p', 'None.', 'muted'));
|
|
743
|
+
else if (rows.length - fold <= 4) s.append(items(rows));
|
|
744
|
+
else {
|
|
745
|
+
if (fold) s.append(items(rows.slice(0, fold)));
|
|
746
|
+
const det = el('details', undefined, 'fm-more');
|
|
747
|
+
det.append(el('summary', `${fmt(rows.length - fold)} ${more}`), items(rows.slice(fold)));
|
|
748
|
+
s.append(det);
|
|
749
|
+
}
|
|
750
|
+
box.append(s);
|
|
751
|
+
};
|
|
752
|
+
if (!solo) {
|
|
753
|
+
const ov = list(data.overlaps).filter(o => o && o.a != null && o.b != null);
|
|
754
|
+
ov.sort((x, y) => (y.same_wave ? 1 : 0) - (x.same_wave ? 1 : 0));
|
|
755
|
+
const same = ov.filter(o => o.same_wave).length;
|
|
756
|
+
section('Overlaps', 'Pairs of tasks whose planned files overlap (equal, a directory above the other, or .) or that changed a file in common. In one wave, forge should have kept them apart.',
|
|
757
|
+
ov.map(o => {
|
|
758
|
+
const wa = waveOf.get(String(o.a)), wb = waveOf.get(String(o.b));
|
|
759
|
+
const bits = [o.planned ? 'plans overlap' : '', list(o.touched).length ? `both touched ${some(list(o.touched).map(String), 2)}` : ''].filter(Boolean);
|
|
760
|
+
return [task(o.a), ' ↔ ', task(o.b), ' · ', o.same_wave ? el('span', `same wave${wa != null ? ' ' + wa : ''}`, 'warn') :
|
|
761
|
+
el('span', wa != null && wb != null ? `waves ${wa} and ${wb}` : 'different waves', 'muted'), bits.length ? ' · ' + bits.join(' · ') : ''];
|
|
762
|
+
}), same, 'in different waves');
|
|
763
|
+
const per = (field, title, help) => section(title, help, list(data.tasks).filter(t => t && t.id != null && paths(t[field]).length)
|
|
764
|
+
.map(t => [task(t.id), ': ' + some(paths(t[field]))]), 8, 'more tasks');
|
|
765
|
+
per('drift', 'Drift', 'Files a task\'s diff changed without declaring them (tasks/<id>/drift.txt).');
|
|
766
|
+
per('conflicts', 'Merge conflicts', 'Files that conflicted when the task was merged (tasks/<id>/conflict.files).');
|
|
767
|
+
section('Deferrals', 'Tasks the wave planner moved to a later wave (deferrals.txt).', list(data.deferrals).filter(x => typeof x === 'string' && x).map(x => [x]), 8, 'more');
|
|
768
|
+
}
|
|
769
|
+
const warns = list(data.warnings).filter(x => typeof x === 'string' && x);
|
|
770
|
+
if (warns.length) section('Warnings', 'Files or lines the reader skipped as malformed.', warns.map(x => [x]), 3, 'more');
|
|
771
|
+
return box;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return { SORTS, KINDS, ROW_CAP, normPrefs, matrix, describe, render };
|
|
776
|
+
});
|