@giamme/forge 0.10.0 → 0.11.1
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 +43 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +13 -1
- package/docs/commands.md +11 -5
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +345 -17
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge_dataset/__init__.py +7 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +15 -10
- package/ui/README.md +5 -4
- package/ui/docs/api.md +88 -23
- package/ui/forge_ui/control.py +26 -5
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/findingsview.js +65 -14
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +52 -10
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +7 -3
|
@@ -89,12 +89,13 @@ export async function createStage(host, cb = {}) {
|
|
|
89
89
|
el.col = col || E.statusColor(el.status);
|
|
90
90
|
el.pulse = el.status === 'RUNNING';
|
|
91
91
|
}
|
|
92
|
-
const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN']);
|
|
92
|
+
const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN', 'OUTDATED']); // unlit: no glow and no tint
|
|
93
93
|
function baseTargets(el) {
|
|
94
94
|
const T = el.tgt, s = el.status, light = E.theme === 'light';
|
|
95
95
|
T.lit = PLAIN.has(s) ? 0 : .32;
|
|
96
96
|
T.tint = PLAIN.has(s) ? 0 : light ? .34 : .1; // a pale panel shows a lit section by its colour: no glow to carry it
|
|
97
97
|
T.vis = 1; T.op = 1; T.opB = 1;
|
|
98
|
+
// Not started yet. OUTDATED is a finished review that is no longer current, so it stays solid.
|
|
98
99
|
if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
|
|
99
100
|
// parts are small: their status has to read from the colour of the whole piece
|
|
100
101
|
if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
|
|
@@ -166,7 +167,8 @@ export async function createStage(host, cb = {}) {
|
|
|
166
167
|
const parts = [r.kind === 'solo' ? (Object.keys(c)[0] || 'solo') : `${merged}/${total} done`];
|
|
167
168
|
if (running) parts.push(`${running} running`);
|
|
168
169
|
if (bad) parts.push(`${bad} failed`);
|
|
169
|
-
|
|
170
|
+
const reviewStatus = RM.runReviewStatus(r);
|
|
171
|
+
if (reviewStatus) parts.push(`review ${reviewStatus}`);
|
|
170
172
|
else if (r.verification_status) parts.push(`verify ${r.verification_status}`);
|
|
171
173
|
return parts.join(' · ');
|
|
172
174
|
}
|
|
@@ -191,7 +193,7 @@ export async function createStage(host, cb = {}) {
|
|
|
191
193
|
cx.fillText(E.ellipsis(`${RM.basename(r.repo)} · ${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, Math.floor((w - 30) / (7 * E.CW))), 2 * s, 44 * s);
|
|
192
194
|
}, 'cover', { x: x + 12, y: y + 8, z: h });
|
|
193
195
|
leds(el, Math.min(60, m.tasks.length), x + 17, y + d - 14, h + .16, { step: 8, s: 2.4, perRow: Math.max(4, Math.floor((w - 30) / 8)) });
|
|
194
|
-
setLeds(el, m.tasks.map(t => t
|
|
196
|
+
setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
|
|
195
197
|
display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
|
|
196
198
|
halo(el);
|
|
197
199
|
}
|
|
@@ -206,17 +208,28 @@ export async function createStage(host, cb = {}) {
|
|
|
206
208
|
const el = EL.get('r:' + m.id); if (!el) continue;
|
|
207
209
|
el.data.run = m.run; el.data.module = m;
|
|
208
210
|
setStatus(el, STATE_STATUS[m.state]); halo(el);
|
|
209
|
-
setLeds(el, m.tasks.map(t => t
|
|
210
|
-
const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.verification_status]);
|
|
211
|
+
setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
|
|
212
|
+
const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.final_review_outdated, m.run.verification_status]);
|
|
211
213
|
if (sig !== el.sig) { el.sig = sig; redraw(el); }
|
|
212
214
|
}
|
|
213
215
|
retarget();
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
/* ---------- plan: waves, tasks, the finish line ---------- */
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
// During a replay the recorded status is what it was then; acceptance shows only once that status is MERGED.
|
|
220
|
+
function shownTask(t) {
|
|
221
|
+
if (t && st.replay !== null && st.replayState) {
|
|
222
|
+
const s = st.replayState.tasks[t.id];
|
|
223
|
+
return Object.assign({}, t, { status: s ? s.status : 'PENDING' });
|
|
224
|
+
}
|
|
225
|
+
return t;
|
|
226
|
+
}
|
|
227
|
+
function taskStatus(t) { return RM.taskStatus(shownTask(t)); }
|
|
228
|
+
function attemptLamps(t) {
|
|
229
|
+
const shown = shownTask(t);
|
|
230
|
+
const rows = list(t.attempts).map(a => RM.attemptStatus(shown, a.attempt, a.status));
|
|
231
|
+
if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
|
|
232
|
+
return rows;
|
|
220
233
|
}
|
|
221
234
|
function taskLine(el) {
|
|
222
235
|
const t = el.data.task, s = el.status;
|
|
@@ -256,6 +269,7 @@ export async function createStage(host, cb = {}) {
|
|
|
256
269
|
const tries = Math.max(1, list(t.attempts).length);
|
|
257
270
|
leds(el, Math.min(20, tries), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
|
|
258
271
|
el.ledCount = tries;
|
|
272
|
+
setLeds(el, attemptLamps(t).slice(-el.ledCount));
|
|
259
273
|
display(el, taskLine, { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
|
|
260
274
|
// the Clef ring: how likely the first try was to pass, in the colour of what happened
|
|
261
275
|
const p0 = t.clef && t.clef.first_pass && num(t.clef.first_pass.p);
|
|
@@ -276,9 +290,12 @@ export async function createStage(host, cb = {}) {
|
|
|
276
290
|
const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
|
|
277
291
|
if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
|
|
278
292
|
if (id === 'f:verification') return { title: 'VERIFICATION', sub: F.verification.command || 'no command recorded', status: F.verification.status, line: F.verification.runs ? `${F.verification.runs} run${F.verification.runs > 1 ? 's' : ''}` : 'not run yet' };
|
|
293
|
+
const status = finishStatus(id, { status: F.final.status });
|
|
294
|
+
const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
|
|
279
295
|
const f = F.final.findings;
|
|
280
|
-
|
|
281
|
-
|
|
296
|
+
const line = status === 'OUTDATED' ? RM.reviewSinceLine(src)
|
|
297
|
+
: F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : status;
|
|
298
|
+
return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status, findings: f, line };
|
|
282
299
|
}
|
|
283
300
|
function buildFinish(m, p, g) {
|
|
284
301
|
const [x, y, w, d, h] = p;
|
|
@@ -308,7 +325,7 @@ export async function createStage(host, cb = {}) {
|
|
|
308
325
|
}
|
|
309
326
|
function finishLeds(el) {
|
|
310
327
|
if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
|
|
311
|
-
else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED'
|
|
328
|
+
else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
|
|
312
329
|
else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
|
|
313
330
|
}
|
|
314
331
|
function buildPlan(g) {
|
|
@@ -954,7 +971,7 @@ export async function createStage(host, cb = {}) {
|
|
|
954
971
|
if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
|
|
955
972
|
if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * (E.theme === 'light' ? 1.45 : 1.1) + .08 * c.sel) * ob * gf * c.vis; }
|
|
956
973
|
if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
|
|
957
|
-
if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
|
|
974
|
+
if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS', 'ACCEPTED'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
|
|
958
975
|
if (el.inner) {
|
|
959
976
|
const p = pr ? pr.p : 0, vis = p > .002;
|
|
960
977
|
if (!vis && el.inner.visible) { E.release(el.inner); }
|
|
@@ -1239,6 +1256,7 @@ export async function createStage(host, cb = {}) {
|
|
|
1239
1256
|
const lamps = { title: 'Lamps', rows: [
|
|
1240
1257
|
{ sw: { lamp: 'st-merged' }, label: 'merged · passed · accepted' }, { sw: { lamp: 'st-running' }, label: 'running · interrupted' },
|
|
1241
1258
|
{ sw: { lamp: 'st-fail' }, label: 'failed · conflict · infra · timeout' }, { sw: { lamp: 'st-pending' }, label: 'pending, not started' },
|
|
1259
|
+
{ sw: { lamp: 'st-outdated' }, label: 'outdated review' },
|
|
1242
1260
|
{ sw: { route: true }, label: st.view === 'atlas' ? 'chosen run' : 'chosen task' + (L.route ? ' and its route' : '') }] };
|
|
1243
1261
|
if (st.view === 'atlas') {
|
|
1244
1262
|
out.push({ title: 'Map', rows: [], note: 'Each block is a run, each district a repository. The lamps on a block are its tasks; the block stands taller the more tasks it has.' }, lamps);
|
|
@@ -1318,8 +1336,7 @@ export async function createStage(host, cb = {}) {
|
|
|
1318
1336
|
if (el.kind === 'task') {
|
|
1319
1337
|
const t = byId.get(el.data.task.id); if (!t) continue;
|
|
1320
1338
|
el.data.task = t; setStatus(el, taskStatus(t)); halo(el);
|
|
1321
|
-
const rows =
|
|
1322
|
-
if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
|
|
1339
|
+
const rows = attemptLamps(t);
|
|
1323
1340
|
if (rows.length > el.ledCount) { /* more tries than lamps: the newest show */ }
|
|
1324
1341
|
setLeds(el, rows.slice(-el.ledCount));
|
|
1325
1342
|
const sig = JSON.stringify([t.status, t.findings, t.gate, list(t.shadow).map(s => s.agreement), t.dwarf, t.accepted_by, el.status]);
|
|
@@ -1385,7 +1402,7 @@ export async function createStage(host, cb = {}) {
|
|
|
1385
1402
|
E.invalidate(100); // marks a replay reaches drop in
|
|
1386
1403
|
st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
|
|
1387
1404
|
for (const el of EL.values()) {
|
|
1388
|
-
if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
|
|
1405
|
+
if (el.kind === 'task') { setStatus(el, taskStatus(el.data.task)); setLeds(el, attemptLamps(el.data.task).slice(-el.ledCount)); }
|
|
1389
1406
|
else if (el.kind === 'finish') setStatus(el, finishStatus(el.id, finishInfo(el.id)));
|
|
1390
1407
|
else if (el.data && el.data.item) setStatus(el, st.replay !== null ? replayItemStatus(el.data.item) : el.data.item.status);
|
|
1391
1408
|
else continue;
|
|
@@ -85,13 +85,25 @@
|
|
|
85
85
|
return { editor: { base: fresh, draft: e.draft }, dirty: true, moved: norm(fresh) !== norm(e.base) };
|
|
86
86
|
}
|
|
87
87
|
const edited = (editor, draft) => ({ base: editor.base, draft: text(draft) });
|
|
88
|
+
// The editor after typing in a textarea drawn from the file text `from`. When a read moved `base` on since the textarea
|
|
89
|
+
// was drawn (a held body is not redrawn), the typing sits on the old text, so the save would overwrite the new file.
|
|
90
|
+
function typed(editor, draft, from) {
|
|
91
|
+
const e = edited(editor, draft), dirty = isDirty(e);
|
|
92
|
+
return { editor: e, dirty, moved: dirty && norm(from) !== norm(e.base) };
|
|
93
|
+
}
|
|
88
94
|
const shown = editor => editor.draft !== null ? editor.draft : editor.base;
|
|
95
|
+
// What a failed GET /api/config does: with a good read of this run on the page, it stays and a note names the
|
|
96
|
+
// failure; with none yet, the message replaces the page. Status 0 is no answer at all.
|
|
97
|
+
function readFailure(res, hasData) {
|
|
98
|
+
const message = (res && res.status !== 0 && (res.detail || res.error)) || 'could not reach forge-ui';
|
|
99
|
+
return { keep: !!hasData, message };
|
|
100
|
+
}
|
|
89
101
|
|
|
90
102
|
/* ---------- DOM ---------- */
|
|
91
103
|
function render(host, ctx) {
|
|
92
104
|
const UI = root.ForgeUI, c = ctx || {};
|
|
93
105
|
const el = (tag, t, cls) => UI.el(tag, t, cls);
|
|
94
|
-
const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false };
|
|
106
|
+
const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false, asking: 0, asks: 0, pickerLate: false };
|
|
95
107
|
const key_ = (label, cls, onclick) => { const b = el('button', label, cls); b.type = 'button'; if (onclick) b.addEventListener('click', onclick); return b; };
|
|
96
108
|
|
|
97
109
|
const head = el('header', undefined, 'set-head');
|
|
@@ -101,10 +113,17 @@
|
|
|
101
113
|
const body = el('div', undefined, 'set-body');
|
|
102
114
|
const note = el('p', 'This page is read-only: this server has no write token, or has controls off.', 'muted set-readonly');
|
|
103
115
|
note.hidden = true;
|
|
104
|
-
|
|
116
|
+
// A failed read over a good one: the page stays, and this says the shown values may be old. Outside the body, so a
|
|
117
|
+
// held body (an open confirm) still gets it.
|
|
118
|
+
const stale = el('p', undefined, 'warn set-problem set-stale'); stale.setAttribute('role', 'status');
|
|
119
|
+
stale.hidden = true;
|
|
120
|
+
host.replaceChildren(head, picker, note, stale, body);
|
|
121
|
+
const staleNote = msg => { stale.hidden = !msg; stale.textContent = msg ? `The last read of the configuration failed: ${msg}. This shows the read before it; the next refresh tries again.` : ''; };
|
|
105
122
|
|
|
106
123
|
/* the repository picker */
|
|
107
124
|
function drawPicker() {
|
|
125
|
+
// The switch confirm lives in the picker: while it is open the rebuild waits, and runs when it is answered.
|
|
126
|
+
if (s.asking) { s.pickerLate = true; return; }
|
|
108
127
|
const runs = c.runs ? c.runs() : [];
|
|
109
128
|
const groups = groupRuns(runs);
|
|
110
129
|
const before = s.run, usable = withRepo(runs);
|
|
@@ -138,7 +157,14 @@
|
|
|
138
157
|
}
|
|
139
158
|
sel.addEventListener('change', async () => {
|
|
140
159
|
const unsaved = FILES.filter(n => s.editors[n] && isDirty(s.editors[n]));
|
|
141
|
-
if (unsaved.length
|
|
160
|
+
if (unsaved.length) {
|
|
161
|
+
const ask = s.asking = ++s.asks;
|
|
162
|
+
const yes = await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true });
|
|
163
|
+
if (s.asking !== ask) return; // a later change asked again, and that confirm decides
|
|
164
|
+
const late = s.pickerLate;
|
|
165
|
+
s.asking = 0; s.pickerLate = false;
|
|
166
|
+
if (!yes) { sel.value = s.gone ? '' : s.run; if (late) drawPicker(); return; }
|
|
167
|
+
}
|
|
142
168
|
pick(sel.value);
|
|
143
169
|
});
|
|
144
170
|
const lab = el('label', undefined, 'set-field'); lab.htmlFor = 'set-run';
|
|
@@ -151,11 +177,13 @@
|
|
|
151
177
|
}
|
|
152
178
|
// A new run: nothing of the old one stays, and confirms of the old body (now detached) no longer hold a redraw.
|
|
153
179
|
function resetRun() {
|
|
180
|
+
staleNote(null);
|
|
154
181
|
s.data = null; s.dataText = null; s.error = null; s.editors = {}; s.moved = {}; s.loading = s.run != null;
|
|
155
182
|
s.gen++; s.hold = 0; s.redraw = false;
|
|
156
183
|
}
|
|
157
184
|
function pick(key) {
|
|
158
185
|
s.run = key; resetRun();
|
|
186
|
+
s.asking = 0; s.pickerLate = false; // a switch confirm still open goes with the old picker
|
|
159
187
|
s.runsSig = null; drawPicker(); drawBody();
|
|
160
188
|
if (c.onPick) c.onPick(key);
|
|
161
189
|
load();
|
|
@@ -171,12 +199,12 @@
|
|
|
171
199
|
if (seq !== s.seq || run !== s.run) return; // a later pick or load answered
|
|
172
200
|
s.loading = false;
|
|
173
201
|
if (!res.ok) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
202
|
+
const failed = readFailure(res, s.data);
|
|
203
|
+
if (failed.keep) { staleNote(failed.message); return; } // the last good page, its editors and their unsaved text stay
|
|
176
204
|
if (s.hold > 0) return; // a confirm or a write is open: its strip must not be torn down
|
|
177
|
-
s.error =
|
|
205
|
+
s.error = failed.message; s.data = null; s.dataText = null; drawBody(); return;
|
|
178
206
|
}
|
|
179
|
-
s.error = null;
|
|
207
|
+
s.error = null; staleNote(null);
|
|
180
208
|
const files = object(object(res.data.repo).files);
|
|
181
209
|
for (const name of FILES) {
|
|
182
210
|
const next = dirtyState(s.editors[name], object(files[name]).content);
|
|
@@ -305,7 +333,9 @@
|
|
|
305
333
|
const top = el('div', undefined, 'set-file-head'); top.append(h, status);
|
|
306
334
|
art.append(top, el('p', blurb, 'muted set-file-blurb'));
|
|
307
335
|
if (file.note) art.append(el('p', text(file.note), 'warn set-file-note'));
|
|
308
|
-
|
|
336
|
+
const movedNote = el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note');
|
|
337
|
+
movedNote.hidden = !(isDirty(ed) && s.moved[name]); // sync() keeps it current as the text is typed
|
|
338
|
+
art.append(movedNote);
|
|
309
339
|
if (!s.writes) {
|
|
310
340
|
status.replaceChildren(el('i', undefined, 'lamp ' + (file.exists ? 'st-pass' : '')), el('span', file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
311
341
|
art.append(unreadable ? el('p', file.exists ? 'The content is not shown.' : 'Not set.', 'muted') : el('pre', text(file.content), 'set-pre'));
|
|
@@ -316,6 +346,7 @@
|
|
|
316
346
|
ta.dataset.editor = name; ta.rows = Math.max(3, Math.min(14, shown(ed).split('\n').length + 1));
|
|
317
347
|
ta.spellcheck = false; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocomplete', 'off');
|
|
318
348
|
ta.value = shown(ed);
|
|
349
|
+
let from = ed.base; // the file text this textarea was drawn from
|
|
319
350
|
if (unreadable && file.exists) { ta.disabled = true; ta.placeholder = 'The content is not shown, so it cannot be edited here.'; }
|
|
320
351
|
const problem = el('p', undefined, 'warn set-problem'); problem.hidden = true; problem.id = id + '-problem';
|
|
321
352
|
const result = el('div', undefined, 'set-result');
|
|
@@ -328,10 +359,15 @@
|
|
|
328
359
|
if (bad) { ta.setAttribute('aria-invalid', 'true'); ta.setAttribute('aria-describedby', problem.id); }
|
|
329
360
|
else { ta.removeAttribute('aria-invalid'); ta.removeAttribute('aria-describedby'); }
|
|
330
361
|
save.disabled = !dirtyNow || !!bad;
|
|
362
|
+
movedNote.hidden = !(dirtyNow && s.moved[name]);
|
|
331
363
|
status.replaceChildren(el('i', undefined, 'lamp ' + (dirtyNow ? 'set-amber' : file.exists ? 'st-pass' : '')),
|
|
332
364
|
el('span', dirtyNow ? 'unsaved edits' : file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
333
365
|
};
|
|
334
|
-
ta.addEventListener('input', () => {
|
|
366
|
+
ta.addEventListener('input', () => {
|
|
367
|
+
const next = typed(s.editors[name], ta.value, from);
|
|
368
|
+
s.editors[name] = next.editor; s.moved[name] = next.moved || (next.dirty && !!s.moved[name]);
|
|
369
|
+
sync();
|
|
370
|
+
});
|
|
335
371
|
remove.disabled = !file.exists;
|
|
336
372
|
save.addEventListener('click', async () => {
|
|
337
373
|
const cur = s.editors[name], bad = valueProblem(name, cur.draft);
|
|
@@ -342,7 +378,7 @@
|
|
|
342
378
|
if (!yes) { ta.readOnly = false; remove.disabled = !file.exists; release(); sync(); save.focus({ preventScroll: true }); return; }
|
|
343
379
|
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), cur.draft));
|
|
344
380
|
UI.outcome(result, res, `Saved ${target}.`);
|
|
345
|
-
if (res.ok && gen === s.gen) s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n');
|
|
381
|
+
if (res.ok && gen === s.gen) { s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n'); s.moved[name] = false; from = s.editors[name].base; }
|
|
346
382
|
ta.readOnly = false; remove.disabled = !file.exists; release(); sync();
|
|
347
383
|
if (res.ok && gen === s.gen) await reload();
|
|
348
384
|
});
|
|
@@ -353,7 +389,7 @@
|
|
|
353
389
|
if (!yes) { ta.readOnly = false; release(); sync(); remove.disabled = !file.exists; remove.focus({ preventScroll: true }); return; }
|
|
354
390
|
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), null, true));
|
|
355
391
|
UI.outcome(result, res, `Removed ${target}.`);
|
|
356
|
-
if (res.ok && gen === s.gen) s.editors[name] = editorOf('');
|
|
392
|
+
if (res.ok && gen === s.gen) { s.editors[name] = editorOf(''); s.moved[name] = false; from = ''; }
|
|
357
393
|
ta.readOnly = false; release();
|
|
358
394
|
if (res.ok && gen === s.gen) await reload(); else if (!res.ok) { remove.disabled = !file.exists; sync(); }
|
|
359
395
|
});
|
|
@@ -395,5 +431,5 @@
|
|
|
395
431
|
}
|
|
396
432
|
|
|
397
433
|
return { render, configBody, repoKey, targetPath, subsetOk, valueProblem, pickDefault, groupRuns, optionLabel,
|
|
398
|
-
editorOf, isDirty, dirtyState, edited, shown, FILES, VALUE_MAX };
|
|
434
|
+
editorOf, isDirty, dirtyState, edited, typed, shown, readFailure, FILES, VALUE_MAX };
|
|
399
435
|
});
|
|
@@ -91,13 +91,14 @@
|
|
|
91
91
|
:root[data-theme=dark] { color-scheme: dark; }
|
|
92
92
|
|
|
93
93
|
/* status: the lamp behind each forge state. Five lamps, one meaning each (--st; --st-lit on the 3D plate);
|
|
94
|
-
a hollow lamp is that lamp's family but not its plain case: passed without a merge, stopped, failed outside the code
|
|
94
|
+
a hollow lamp is that lamp's family but not its plain case: passed without a merge, stopped, failed outside the code,
|
|
95
|
+
an outdated review */
|
|
95
96
|
.st-merged, .st-pass, .st-accepted, .st-nochanges { --st: var(--lamp-green); --st-lit: var(--lit-green); }
|
|
96
97
|
.st-running, .st-interrupted { --st: var(--lamp-amber); --st-lit: var(--lit-amber); }
|
|
97
|
-
.st-pending, .st-none, .st-unknown, .st-invalidated { --st: var(--lamp-off); --st-lit: var(--lamp-off); }
|
|
98
|
+
.st-pending, .st-none, .st-unknown, .st-invalidated, .st-outdated { --st: var(--lamp-off); --st-lit: var(--lamp-off); }
|
|
98
99
|
.st-fail, .st-error, .st-conflict, .st-infra, .st-timeout, .st-blocked { --st: var(--lamp-red); --st-lit: var(--lit-red); }
|
|
99
100
|
[class^="st-"], [class*=" st-"] { --st-fill: var(--st); --st-ring: var(--lamp-edge); }
|
|
100
|
-
.st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked { --st-fill: transparent; --st-ring: var(--st); }
|
|
101
|
+
.st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked, .st-outdated { --st-fill: transparent; --st-ring: var(--st); }
|
|
101
102
|
/* review severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin) on a ramp of its own, never a status lamp */
|
|
102
103
|
.sev-P0 { --sev: var(--sev-p0); --sev-shape: polygon(50% 0, 100% 100%, 0 100%); }
|
|
103
104
|
.sev-P1 { --sev: var(--sev-p1); --sev-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
|
@@ -152,6 +153,7 @@ kbd { font-family: var(--mono); font-size: var(--t-xs); padding: 0 5px; border:
|
|
|
152
153
|
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 7px 0 6px; border: 1px solid var(--rule); border-radius: var(--r-small); background: var(--key); color: var(--ink); white-space: nowrap;
|
|
153
154
|
font-family: var(--font-plate); font-weight: 700; font-size: .857rem; letter-spacing: .05em; text-transform: uppercase; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
|
154
155
|
.chip::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--st-fill, var(--lamp-off)); box-shadow: inset 0 0 0 1.5px var(--st-ring, transparent); flex: none; }
|
|
156
|
+
a.chip, a.chip:hover { color: inherit; text-decoration: none; }
|
|
155
157
|
.chips { display: flex; gap: var(--s-1); flex-wrap: wrap; }
|
|
156
158
|
.sev-chip { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: var(--t-xs); white-space: nowrap; }
|
|
157
159
|
.sev-chip::before { content: ''; width: 9px; height: 9px; clip-path: var(--sev-shape, circle(30%)); background: var(--sev, var(--muted)); flex: none; }
|
|
@@ -683,6 +685,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
|
|
|
683
685
|
.fg-bar { fill: var(--st, var(--lamp-off)); }
|
|
684
686
|
.fg-verdict { stroke: var(--lamp-red); }
|
|
685
687
|
.fg-verdict.fg-pass { stroke: var(--st, var(--lamp-green)); }
|
|
688
|
+
.fg-node.st-accepted .fg-box { fill: transparent; }
|
|
686
689
|
.fg-empty { color: var(--muted); padding: var(--s-3) 0; }
|
|
687
690
|
.gantt, .strip { display: block; font-family: var(--mono); }
|
|
688
691
|
.gt-lane { fill: var(--module-2); }
|
|
@@ -693,6 +696,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
|
|
|
693
696
|
.gt-tick-label { fill: var(--muted); font-size: 10px; }
|
|
694
697
|
.gt-item { cursor: pointer; }
|
|
695
698
|
.gt-box { fill: var(--st, var(--lamp-off)); fill-opacity: .2; stroke: var(--st, var(--lamp-off)); stroke-width: 1.2; }
|
|
699
|
+
.gt-item.st-accepted .gt-box { fill: transparent; }
|
|
696
700
|
.gt-item:hover .gt-box, .gt-item:focus .gt-box { fill-opacity: .35; }
|
|
697
701
|
.gt-item:focus { outline: none; }
|
|
698
702
|
.gt-item.gt-sel .gt-box { stroke: var(--route); stroke-width: 2.5; }
|