@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
|
@@ -107,7 +107,8 @@ window.ForgeSearch = (() => {
|
|
|
107
107
|
dlg.addEventListener('click', e => { if (e.target === dlg) close(); }); // the backdrop
|
|
108
108
|
document.addEventListener('keydown', e => {
|
|
109
109
|
if ((e.key === 'k' || e.key === 'K') && (e.ctrlKey || e.metaKey) && !e.altKey) { e.preventDefault(); if (dlg.open) close(); else open(); }
|
|
110
|
-
|
|
110
|
+
// a view that handles / itself (a conversation's search) keeps it
|
|
111
|
+
else if (e.key === '/' && !e.defaultPrevented && !e.ctrlKey && !e.metaKey && !e.altKey && !dlg.open && !typing(e.target)) { e.preventDefault(); open(); }
|
|
111
112
|
});
|
|
112
113
|
for (const b of document.querySelectorAll('[data-jump]')) {
|
|
113
114
|
b.addEventListener('click', open);
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/* forge-ui settings view (window.ForgeSettings, and a CommonJS module under node:test). Route #/settings: the model
|
|
2
|
+
registry (read-only), Jev, the three per-repo .forge files of one repository, the dataset and the FORGE_* environment.
|
|
3
|
+
The pure helpers (request bodies, value checks, the repository picker, the editors' unsaved-text state) are tested in
|
|
4
|
+
ui/tests/js/settings.test.js; render(host, ctx) is thin DOM over them. Every value from the server enters the page as text.
|
|
5
|
+
ctx = { key (the run whose repo to show, or null), runs() (the run list), onPick?(key) }. render returns { poll() }:
|
|
6
|
+
the page calls it on each refresh, and unsaved editor text survives it.
|
|
7
|
+
The calls are GET /api/config?run=<key> and POST /api/config, see ui/docs/api.md, "Controls (write endpoints)". A write
|
|
8
|
+
changes a file in the repository's working tree (or forge's own Jev config) and is only offered with controls on. */
|
|
9
|
+
(function (root, factory) {
|
|
10
|
+
'use strict';
|
|
11
|
+
const api = factory(root);
|
|
12
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
13
|
+
else root.ForgeSettings = api;
|
|
14
|
+
})(typeof self !== 'undefined' ? self : this, function (root) {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const FILES = ['verify', 'setup', 'verify-subset'];
|
|
18
|
+
const VALUE_MAX = 4096;
|
|
19
|
+
const POLL_MS = 20000; // the config rarely changes, and each read starts a CLI process
|
|
20
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
21
|
+
const object = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
22
|
+
const text = v => typeof v === 'string' ? v : '';
|
|
23
|
+
const basename = p => String(p || '').replace(/\/+$/, '').split('/').pop() || String(p || '');
|
|
24
|
+
|
|
25
|
+
/* ---------- pure ---------- */
|
|
26
|
+
// The POST body: a set carries `value`, an unset carries `unset: true`, never both. jev.enabled's value is the text
|
|
27
|
+
// 'true' or 'false', as the CLI takes it.
|
|
28
|
+
function configBody(run, key, value, unset) {
|
|
29
|
+
if (unset) return { run: String(run), key: String(key), unset: true };
|
|
30
|
+
return { run: String(run), key: String(key), value: String(value) };
|
|
31
|
+
}
|
|
32
|
+
const repoKey = name => 'repo.' + name;
|
|
33
|
+
// The file a save or remove touches, as the confirm names it.
|
|
34
|
+
const targetPath = (repo, name) => String(repo || '').replace(/\/+$/, '') + '/.forge/' + name;
|
|
35
|
+
|
|
36
|
+
const subsetOk = value => typeof value === 'string' && value.includes('{files}');
|
|
37
|
+
// Why the server would refuse this text for this file, or null: empty, too long, a control character other than
|
|
38
|
+
// newline and tab, a leading '-', or (verify-subset) no {files}.
|
|
39
|
+
function valueProblem(name, value) {
|
|
40
|
+
const v = text(value);
|
|
41
|
+
if (!v.trim()) return 'Empty: use Remove to delete the file.';
|
|
42
|
+
const chars = Array.from(v).length; // code points, as the server counts them
|
|
43
|
+
if (chars > VALUE_MAX) return `Too long: ${chars} of ${VALUE_MAX} characters.`;
|
|
44
|
+
if (/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(v)) return 'The text has an unpaired surrogate, which cannot be saved.';
|
|
45
|
+
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v)) return 'Control characters are not allowed (newline and tab are).';
|
|
46
|
+
if (v.startsWith('-')) return 'The text cannot start with "-".';
|
|
47
|
+
if (name === 'verify-subset' && !subsetOk(v)) return 'verify-subset must contain {files}: the per-file command needs the place for the file list.';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The runs a repository can be taken from (those that name one), newest first.
|
|
52
|
+
const withRepo = runs => list(runs).filter(r => r && r.key != null && typeof r.repo === 'string' && r.repo);
|
|
53
|
+
const newestFirst = (a, b) => (Number(b.updated) || 0) - (Number(a.updated) || 0);
|
|
54
|
+
// The run whose repository the page opens on: the current run when it is in the list, else the most recent one.
|
|
55
|
+
function pickDefault(runs, current) {
|
|
56
|
+
const usable = withRepo(runs);
|
|
57
|
+
if (current != null && usable.some(r => r.key === current)) return current;
|
|
58
|
+
const latest = usable.slice().sort(newestFirst)[0];
|
|
59
|
+
return latest ? latest.key : null;
|
|
60
|
+
}
|
|
61
|
+
// The choices grouped by repository: [{repo, name, runs}], the repository of the newest run first.
|
|
62
|
+
function groupRuns(runs) {
|
|
63
|
+
const groups = new Map();
|
|
64
|
+
for (const r of withRepo(runs).sort(newestFirst)) {
|
|
65
|
+
if (!groups.has(r.repo)) groups.set(r.repo, { repo: r.repo, name: basename(r.repo), runs: [] });
|
|
66
|
+
groups.get(r.repo).runs.push(r);
|
|
67
|
+
}
|
|
68
|
+
return [...groups.values()];
|
|
69
|
+
}
|
|
70
|
+
const optionLabel = r => {
|
|
71
|
+
const goal = text(r.goal).split('\n')[0].trim();
|
|
72
|
+
return [r.run_id || r.key, goal.length > 60 ? goal.slice(0, 59) + '…' : goal].filter(Boolean).join(' · ');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// An editor's unsaved text. `base` is the file as last read, `draft` the textarea (null: untouched). The CLI ends a saved
|
|
76
|
+
// file with a newline, so trailing newlines do not count as an edit.
|
|
77
|
+
const norm = s => text(s).replace(/\n+$/, '');
|
|
78
|
+
const editorOf = base => ({ base: text(base), draft: null });
|
|
79
|
+
const isDirty = e => !!e && e.draft !== null && norm(e.draft) !== norm(e.base);
|
|
80
|
+
// The editor after the file was read again: untouched or equal-to-disk text follows the disk; unsaved text stays, and
|
|
81
|
+
// `moved` says the file changed under it (the next save overwrites that).
|
|
82
|
+
function dirtyState(editor, content) {
|
|
83
|
+
const e = editor || editorOf(content), fresh = text(content);
|
|
84
|
+
if (!isDirty(e)) return { editor: editorOf(fresh), dirty: false, moved: false };
|
|
85
|
+
return { editor: { base: fresh, draft: e.draft }, dirty: true, moved: norm(fresh) !== norm(e.base) };
|
|
86
|
+
}
|
|
87
|
+
const edited = (editor, draft) => ({ base: editor.base, draft: text(draft) });
|
|
88
|
+
const shown = editor => editor.draft !== null ? editor.draft : editor.base;
|
|
89
|
+
|
|
90
|
+
/* ---------- DOM ---------- */
|
|
91
|
+
function render(host, ctx) {
|
|
92
|
+
const UI = root.ForgeUI, c = ctx || {};
|
|
93
|
+
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 };
|
|
95
|
+
const key_ = (label, cls, onclick) => { const b = el('button', label, cls); b.type = 'button'; if (onclick) b.addEventListener('click', onclick); return b; };
|
|
96
|
+
|
|
97
|
+
const head = el('header', undefined, 'set-head');
|
|
98
|
+
head.append(el('h1', 'Settings', 'module-title'),
|
|
99
|
+
el('p', 'What forge reads when it runs: its model registry and Jev switch, and the three files a repository can keep in .forge.', 'muted set-lede'));
|
|
100
|
+
const picker = el('div', undefined, 'set-picker');
|
|
101
|
+
const body = el('div', undefined, 'set-body');
|
|
102
|
+
const note = el('p', 'This page is read-only: this server has no write token, or has controls off.', 'muted set-readonly');
|
|
103
|
+
note.hidden = true;
|
|
104
|
+
host.replaceChildren(head, picker, note, body);
|
|
105
|
+
|
|
106
|
+
/* the repository picker */
|
|
107
|
+
function drawPicker() {
|
|
108
|
+
const runs = c.runs ? c.runs() : [];
|
|
109
|
+
const groups = groupRuns(runs);
|
|
110
|
+
const before = s.run, usable = withRepo(runs);
|
|
111
|
+
let kept = false;
|
|
112
|
+
s.gone = false;
|
|
113
|
+
if (s.run != null && !usable.some(r => r.key === s.run)) {
|
|
114
|
+
// The run left the list. Another run of the same repository carries on with the editors as they are; with unsaved
|
|
115
|
+
// text and no such run the page stays on it, flagged, rather than throwing the text away.
|
|
116
|
+
const sibling = usable.slice().sort(newestFirst).find(r => r.repo === s.repo);
|
|
117
|
+
if (sibling) { s.run = sibling.key; kept = true; }
|
|
118
|
+
else if (FILES.some(n => isDirty(s.editors[n]))) s.gone = true;
|
|
119
|
+
else s.run = null;
|
|
120
|
+
}
|
|
121
|
+
if (s.run == null) s.run = pickDefault(runs, c.key);
|
|
122
|
+
const now = usable.find(r => r.key === s.run);
|
|
123
|
+
if (now) s.repo = now.repo;
|
|
124
|
+
if (s.run !== before) {
|
|
125
|
+
if (!kept) resetRun();
|
|
126
|
+
if (s.ready) { if (!kept) drawBody(); load(); } // the default arrived, or the run went away
|
|
127
|
+
}
|
|
128
|
+
const sig = JSON.stringify([s.run, s.gone, groups.map(g => [g.repo, g.runs.map(r => [r.key, optionLabel(r)])])]);
|
|
129
|
+
if (sig === s.runsSig) return;
|
|
130
|
+
s.runsSig = sig;
|
|
131
|
+
if (!groups.length) { picker.replaceChildren(el('p', 'No run names a repository yet, so there is nothing to configure.', 'muted')); return; }
|
|
132
|
+
const sel = el('select'); sel.id = 'set-run';
|
|
133
|
+
if (s.gone) { const o = el('option', 'This run is no longer listed'); o.value = ''; o.selected = true; o.disabled = true; sel.append(o); }
|
|
134
|
+
for (const g of groups) {
|
|
135
|
+
const og = el('optgroup'); og.label = g.name + ' · ' + g.repo;
|
|
136
|
+
for (const r of g.runs) { const o = el('option', optionLabel(r)); o.value = r.key; o.selected = r.key === s.run; og.append(o); }
|
|
137
|
+
sel.append(og);
|
|
138
|
+
}
|
|
139
|
+
sel.addEventListener('change', async () => {
|
|
140
|
+
const unsaved = FILES.filter(n => s.editors[n] && isDirty(s.editors[n]));
|
|
141
|
+
if (unsaved.length && !(await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true }))) { sel.value = s.gone ? '' : s.run; return; }
|
|
142
|
+
pick(sel.value);
|
|
143
|
+
});
|
|
144
|
+
const lab = el('label', undefined, 'set-field'); lab.htmlFor = 'set-run';
|
|
145
|
+
lab.append(el('span', 'Repository of run', 'plate'));
|
|
146
|
+
const path = el('p', undefined, 'set-repo-path');
|
|
147
|
+
path.append(el('i', undefined, 'lamp lamp-route'), el('span', s.repo || '', 'mono'));
|
|
148
|
+
const row = el('div', undefined, 'set-picker-row'); row.append(lab, sel);
|
|
149
|
+
picker.replaceChildren(row, path);
|
|
150
|
+
if (s.gone) picker.append(el('p', 'The run is gone from the list. Your unsaved edits are kept; pick another run to continue, which discards them.', 'warn set-problem'));
|
|
151
|
+
}
|
|
152
|
+
// A new run: nothing of the old one stays, and confirms of the old body (now detached) no longer hold a redraw.
|
|
153
|
+
function resetRun() {
|
|
154
|
+
s.data = null; s.dataText = null; s.error = null; s.editors = {}; s.moved = {}; s.loading = s.run != null;
|
|
155
|
+
s.gen++; s.hold = 0; s.redraw = false;
|
|
156
|
+
}
|
|
157
|
+
function pick(key) {
|
|
158
|
+
s.run = key; resetRun();
|
|
159
|
+
s.runsSig = null; drawPicker(); drawBody();
|
|
160
|
+
if (c.onPick) c.onPick(key);
|
|
161
|
+
load();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* loading, and keeping unsaved text across it */
|
|
165
|
+
async function load() {
|
|
166
|
+
const run = s.run, seq = ++s.seq;
|
|
167
|
+
if (run == null) { s.loading = false; drawBody(); return; }
|
|
168
|
+
if (s.gone) return; // the run is not listed: nothing to read until another is picked
|
|
169
|
+
s.loadedAt = Date.now();
|
|
170
|
+
const res = await UI.get('/api/config?run=' + encodeURIComponent(run));
|
|
171
|
+
if (seq !== s.seq || run !== s.run) return; // a later pick or load answered
|
|
172
|
+
s.loading = false;
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const msg = res.status === 0 ? null : res.detail || res.error;
|
|
175
|
+
if (msg === null && s.data) return; // a poll that did not reach the server keeps the last good page
|
|
176
|
+
if (s.hold > 0) return; // a confirm or a write is open: its strip must not be torn down
|
|
177
|
+
s.error = msg || 'could not reach forge-ui'; s.data = null; s.dataText = null; drawBody(); return;
|
|
178
|
+
}
|
|
179
|
+
s.error = null;
|
|
180
|
+
const files = object(object(res.data.repo).files);
|
|
181
|
+
for (const name of FILES) {
|
|
182
|
+
const next = dirtyState(s.editors[name], object(files[name]).content);
|
|
183
|
+
s.editors[name] = next.editor; s.moved[name] = next.moved || (next.dirty && !!s.moved[name]);
|
|
184
|
+
}
|
|
185
|
+
const t = JSON.stringify(res.data);
|
|
186
|
+
if (t === s.dataText && !s.redraw) return;
|
|
187
|
+
s.data = res.data; s.dataText = t;
|
|
188
|
+
if (s.hold > 0) { s.redraw = true; return; } // a confirm or a write is open: draw after it
|
|
189
|
+
drawBody();
|
|
190
|
+
}
|
|
191
|
+
// Holds the redraw while a confirm or a write is open; the returned release is a no-op once the run changed.
|
|
192
|
+
function grab() {
|
|
193
|
+
const gen = s.gen; s.hold++;
|
|
194
|
+
return () => { if (gen !== s.gen) return; s.hold--; if (!s.hold && s.redraw) { s.redraw = false; drawBody(); } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function drawBody() {
|
|
198
|
+
const focus = document.activeElement && body.contains(document.activeElement) ? document.activeElement : null;
|
|
199
|
+
const at = focus && focus.dataset.editor ? { name: focus.dataset.editor, a: focus.selectionStart, b: focus.selectionEnd } : null;
|
|
200
|
+
if (s.loading) { body.replaceChildren(el('p', 'Reading the configuration…', 'muted')); return; }
|
|
201
|
+
if (s.error) { body.replaceChildren(el('p', 'Could not read the configuration: ' + s.error, 'warn')); return; }
|
|
202
|
+
if (!s.data) { body.replaceChildren(); return; }
|
|
203
|
+
body.replaceChildren(registrySection(), jevSection(), repoSection(), datasetSection(), envSection());
|
|
204
|
+
if (at) {
|
|
205
|
+
const t = body.querySelector(`textarea[data-editor="${at.name}"]`);
|
|
206
|
+
if (t) { t.focus({ preventScroll: true }); try { t.setSelectionRange(at.a, at.b); } catch (_) { /* not selectable */ } }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* sections */
|
|
211
|
+
function section(id, title, lede) {
|
|
212
|
+
const sec = el('section', undefined, 'set-section'); sec.id = 'set-' + id;
|
|
213
|
+
const h = el('h2', title, 'plate'); h.id = 'set-' + id + '-h'; sec.setAttribute('aria-labelledby', h.id);
|
|
214
|
+
sec.append(h);
|
|
215
|
+
if (lede) sec.append(el('p', lede, 'muted set-lede'));
|
|
216
|
+
return sec;
|
|
217
|
+
}
|
|
218
|
+
const pathLine = (label, value, cls) => {
|
|
219
|
+
const p = el('p', undefined, 'set-path');
|
|
220
|
+
p.append(el('span', label, 'set-path-label'), el('span', value, 'mono'));
|
|
221
|
+
if (cls) p.append(el('span', cls, 'tag'));
|
|
222
|
+
return p;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function registrySection() {
|
|
226
|
+
const reg = object(s.data.registry), sec = section('registry', 'Model registry');
|
|
227
|
+
sec.append(pathLine('file', text(reg.path), reg.overridden ? 'FORGE_REGISTRY' : null));
|
|
228
|
+
const why = el('p', undefined, 'set-why');
|
|
229
|
+
why.append(el('i', undefined, 'lamp'), el('span', text(reg.reason) || 'Read-only here.'));
|
|
230
|
+
sec.append(why);
|
|
231
|
+
const rows = list(reg.rows);
|
|
232
|
+
if (!rows.length) { sec.append(el('p', 'The registry has no rows.', 'muted')); return sec; }
|
|
233
|
+
const wrap = el('div', undefined, 'set-table-wrap'), table = el('table', undefined, 'set-table');
|
|
234
|
+
const cap = el('caption', 'Model registry rows', 'visually-hidden');
|
|
235
|
+
const thead = el('thead'), hr = el('tr');
|
|
236
|
+
for (const h of ['Alias', 'Harness', 'Model', 'Effort', 'Timeout']) { const th = el('th', h); th.scope = 'col'; hr.append(th); }
|
|
237
|
+
thead.append(hr);
|
|
238
|
+
const tbody = el('tbody');
|
|
239
|
+
for (const r of rows) {
|
|
240
|
+
const tr = el('tr');
|
|
241
|
+
const th = el('th', text(r.alias), 'set-alias'); th.scope = 'row';
|
|
242
|
+
tr.append(th, el('td', text(r.harness)), el('td', text(r.model), 'mono'), el('td', text(r.effort) || '—', 'mono'), el('td', text(r.timeout) || '—', 'mono'));
|
|
243
|
+
tbody.append(tr);
|
|
244
|
+
}
|
|
245
|
+
table.append(cap, thead, tbody); wrap.append(table); sec.append(wrap);
|
|
246
|
+
return sec;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function jevSection() {
|
|
250
|
+
const jev = object(s.data.jev), on = !!jev.enabled;
|
|
251
|
+
const sec = section('jev', 'Jev', 'Forge\'s own switch for its decision model. It lives in forge\'s config, so it applies to every repository and run, not only this one.');
|
|
252
|
+
sec.append(pathLine('config', text(jev.path), jev.exists ? null : 'absent'));
|
|
253
|
+
const row = el('div', undefined, 'set-switch-row');
|
|
254
|
+
const lamp = el('i', undefined, 'lamp ' + (on ? 'st-pass' : ''));
|
|
255
|
+
const state = el('span', on ? 'Jev is enabled' : 'Jev is disabled', 'set-state');
|
|
256
|
+
row.append(lamp, state);
|
|
257
|
+
const result = el('div', undefined, 'set-result');
|
|
258
|
+
if (s.writes) {
|
|
259
|
+
const sw = el('button', undefined, 'set-switch'); sw.type = 'button';
|
|
260
|
+
sw.setAttribute('role', 'switch'); sw.setAttribute('aria-checked', String(on));
|
|
261
|
+
sw.append(el('span', undefined, 'set-switch-knob'), el('span', on ? 'On' : 'Off', 'set-switch-label'));
|
|
262
|
+
sw.setAttribute('aria-label', 'Jev enabled');
|
|
263
|
+
sw.addEventListener('click', async () => {
|
|
264
|
+
const next = !on;
|
|
265
|
+
const gen = s.gen, release = grab();
|
|
266
|
+
sw.disabled = true;
|
|
267
|
+
const yes = await UI.confirmInline(result, `Turn Jev ${next ? 'on' : 'off'}? This changes forge's own config (${text(jev.path)}) for every repository.`);
|
|
268
|
+
if (!yes) { sw.disabled = false; release(); sw.focus({ preventScroll: true }); return; }
|
|
269
|
+
const res = await UI.post('/api/config', configBody(s.run, 'jev.enabled', next));
|
|
270
|
+
UI.outcome(result, res, `Jev is now ${next ? 'enabled' : 'disabled'}.`);
|
|
271
|
+
sw.disabled = false; release();
|
|
272
|
+
if (res.ok && gen === s.gen) await reload();
|
|
273
|
+
});
|
|
274
|
+
row.append(sw);
|
|
275
|
+
}
|
|
276
|
+
sec.append(row, result);
|
|
277
|
+
return sec;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function repoSection() {
|
|
281
|
+
const repo = object(s.data.repo), files = object(repo.files);
|
|
282
|
+
const sec = section('repo', 'This repository');
|
|
283
|
+
if (!repo.path) {
|
|
284
|
+
sec.append(el('p', 'forge found no repository for this run.', 'muted'));
|
|
285
|
+
return sec;
|
|
286
|
+
}
|
|
287
|
+
sec.append(pathLine('repository', text(repo.path)));
|
|
288
|
+
sec.append(el('p', 'These files sit in the repository\'s working tree. Saving writes into it; commit the file to share it.', 'muted set-lede'));
|
|
289
|
+
const what = {
|
|
290
|
+
verify: 'The command that verifies the combined result of a run.',
|
|
291
|
+
setup: 'A shell command forge runs to prepare a worktree (executable, or a file holding one command).',
|
|
292
|
+
'verify-subset': 'The per-file test command used by the review checks; it must contain {files}.',
|
|
293
|
+
};
|
|
294
|
+
for (const name of FILES) sec.append(fileEditor(name, object(files[name]), text(repo.path), what[name]));
|
|
295
|
+
return sec;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function fileEditor(name, file, repoPath, blurb) {
|
|
299
|
+
const target = targetPath(repoPath, name), id = 'set-f-' + name;
|
|
300
|
+
const ed = s.editors[name] || (s.editors[name] = editorOf(file.content));
|
|
301
|
+
const art = el('article', undefined, 'set-file'); art.setAttribute('aria-labelledby', id + '-h');
|
|
302
|
+
const unreadable = file.content === null || file.content === undefined;
|
|
303
|
+
const h = el('h3', '.forge/' + name, 'set-file-name'); h.id = id + '-h';
|
|
304
|
+
const status = el('p', undefined, 'set-file-status');
|
|
305
|
+
const top = el('div', undefined, 'set-file-head'); top.append(h, status);
|
|
306
|
+
art.append(top, el('p', blurb, 'muted set-file-blurb'));
|
|
307
|
+
if (file.note) art.append(el('p', text(file.note), 'warn set-file-note'));
|
|
308
|
+
if (isDirty(ed) && s.moved[name]) art.append(el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note'));
|
|
309
|
+
if (!s.writes) {
|
|
310
|
+
status.replaceChildren(el('i', undefined, 'lamp ' + (file.exists ? 'st-pass' : '')), el('span', file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
311
|
+
art.append(unreadable ? el('p', file.exists ? 'The content is not shown.' : 'Not set.', 'muted') : el('pre', text(file.content), 'set-pre'));
|
|
312
|
+
return art;
|
|
313
|
+
}
|
|
314
|
+
const lab = el('label', 'Contents of ' + target, 'visually-hidden'); lab.htmlFor = id;
|
|
315
|
+
const ta = el('textarea', undefined, 'set-editor mono'); ta.id = id;
|
|
316
|
+
ta.dataset.editor = name; ta.rows = Math.max(3, Math.min(14, shown(ed).split('\n').length + 1));
|
|
317
|
+
ta.spellcheck = false; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocomplete', 'off');
|
|
318
|
+
ta.value = shown(ed);
|
|
319
|
+
if (unreadable && file.exists) { ta.disabled = true; ta.placeholder = 'The content is not shown, so it cannot be edited here.'; }
|
|
320
|
+
const problem = el('p', undefined, 'warn set-problem'); problem.hidden = true; problem.id = id + '-problem';
|
|
321
|
+
const result = el('div', undefined, 'set-result');
|
|
322
|
+
const save = key_('Save', 'primary'), remove = key_('Remove');
|
|
323
|
+
remove.classList.add('set-remove');
|
|
324
|
+
save.setAttribute('aria-label', 'Save ' + name); remove.setAttribute('aria-label', 'Remove ' + name);
|
|
325
|
+
const sync = () => {
|
|
326
|
+
const cur = s.editors[name], dirtyNow = isDirty(cur), bad = dirtyNow ? valueProblem(name, cur.draft) : null;
|
|
327
|
+
problem.hidden = !bad; problem.textContent = bad || '';
|
|
328
|
+
if (bad) { ta.setAttribute('aria-invalid', 'true'); ta.setAttribute('aria-describedby', problem.id); }
|
|
329
|
+
else { ta.removeAttribute('aria-invalid'); ta.removeAttribute('aria-describedby'); }
|
|
330
|
+
save.disabled = !dirtyNow || !!bad;
|
|
331
|
+
status.replaceChildren(el('i', undefined, 'lamp ' + (dirtyNow ? 'set-amber' : file.exists ? 'st-pass' : '')),
|
|
332
|
+
el('span', dirtyNow ? 'unsaved edits' : file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
333
|
+
};
|
|
334
|
+
ta.addEventListener('input', () => { s.editors[name] = edited(s.editors[name], ta.value); sync(); });
|
|
335
|
+
remove.disabled = !file.exists;
|
|
336
|
+
save.addEventListener('click', async () => {
|
|
337
|
+
const cur = s.editors[name], bad = valueProblem(name, cur.draft);
|
|
338
|
+
if (bad) { sync(); return; }
|
|
339
|
+
const gen = s.gen, release = grab();
|
|
340
|
+
save.disabled = remove.disabled = ta.readOnly = true; // the text sent is the text that was confirmed
|
|
341
|
+
const yes = await UI.confirmInline(result, `Write ${target}? This changes a file in the repository's working tree.`);
|
|
342
|
+
if (!yes) { ta.readOnly = false; remove.disabled = !file.exists; release(); sync(); save.focus({ preventScroll: true }); return; }
|
|
343
|
+
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), cur.draft));
|
|
344
|
+
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');
|
|
346
|
+
ta.readOnly = false; remove.disabled = !file.exists; release(); sync();
|
|
347
|
+
if (res.ok && gen === s.gen) await reload();
|
|
348
|
+
});
|
|
349
|
+
remove.addEventListener('click', async () => {
|
|
350
|
+
const gen = s.gen, release = grab();
|
|
351
|
+
save.disabled = remove.disabled = ta.readOnly = true;
|
|
352
|
+
const yes = await UI.confirmInline(result, `Delete ${target}? This removes a file from the repository's working tree.`, { danger: true });
|
|
353
|
+
if (!yes) { ta.readOnly = false; release(); sync(); remove.disabled = !file.exists; remove.focus({ preventScroll: true }); return; }
|
|
354
|
+
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), null, true));
|
|
355
|
+
UI.outcome(result, res, `Removed ${target}.`);
|
|
356
|
+
if (res.ok && gen === s.gen) s.editors[name] = editorOf('');
|
|
357
|
+
ta.readOnly = false; release();
|
|
358
|
+
if (res.ok && gen === s.gen) await reload(); else if (!res.ok) { remove.disabled = !file.exists; sync(); }
|
|
359
|
+
});
|
|
360
|
+
const keys = el('div', undefined, 'set-keys'); keys.append(save, remove);
|
|
361
|
+
art.append(lab, ta, problem, keys, result);
|
|
362
|
+
sync();
|
|
363
|
+
return art;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function datasetSection() {
|
|
367
|
+
const d = object(s.data.dataset), sec = section('dataset', 'Dataset');
|
|
368
|
+
sec.append(pathLine('folder', text(d.path)));
|
|
369
|
+
const p = el('p', undefined, 'set-state-line');
|
|
370
|
+
p.append(el('i', undefined, 'lamp ' + (d.enabled ? 'st-pass' : '')), el('span', d.enabled ? 'Capture is on' : 'Capture is off'));
|
|
371
|
+
sec.append(p);
|
|
372
|
+
return sec;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function envSection() {
|
|
376
|
+
const env = object(s.data.env), names = Object.keys(env).sort(), sec = section('env', 'Environment', 'The FORGE_* variables of the forge-ui process. Names that hold a secret show only as <set>.');
|
|
377
|
+
if (!names.length) { sec.append(el('p', 'No FORGE_* variable is set.', 'muted')); return sec; }
|
|
378
|
+
const dl = el('dl', undefined, 'set-env');
|
|
379
|
+
for (const n of names) dl.append(el('dt', n, 'mono'), el('dd', text(env[n]) || '(empty)', 'mono'));
|
|
380
|
+
sec.append(dl);
|
|
381
|
+
return sec;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* start, and the page's refresh */
|
|
385
|
+
async function reload() { s.dataText = null; await load(); }
|
|
386
|
+
// The page's refresh: the picker follows the run list at once (and a run that just appeared loads), the config itself
|
|
387
|
+
// is read again at most every POLL_MS.
|
|
388
|
+
const poll = () => {
|
|
389
|
+
if (!s.ready) return Promise.resolve();
|
|
390
|
+
drawPicker();
|
|
391
|
+
return Date.now() - s.loadedAt >= POLL_MS ? load() : Promise.resolve();
|
|
392
|
+
};
|
|
393
|
+
UI.controlsEnabled().then(on => { s.writes = !!on; s.ready = true; note.hidden = on; const asked = s.seq; drawPicker(); if (s.seq === asked) load(); });
|
|
394
|
+
return { poll, pick };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { render, configBody, repoKey, targetPath, subsetOk, valueProblem, pickDefault, groupRuns, optionLabel,
|
|
398
|
+
editorOf, isDirty, dirtyState, edited, shown, FILES, VALUE_MAX };
|
|
399
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/* forge-ui shell: Immersive (modules float over a full-bleed stage) or Workbench (modules docked, resized by
|
|
2
|
+
splitters), which modules are open, and their sizes. Loaded at the top of <body> without defer, so the page's first
|
|
3
|
+
paint already has the user's layout; the controls are wired once the document is parsed.
|
|
4
|
+
State on <body>: data-shell="immersive|workbench", data-runs / data-insp / data-tasks="open|closed", and the sizes as
|
|
5
|
+
--runs-w, --insp-w, --tasks-h. Keys 1, 2, 3 toggle the runs, inspector and tasks modules (digits need no Shift on US
|
|
6
|
+
or Italian keyboards). On a first visit the inspector and the tasks start folded, so a run opens on its plate; choosing
|
|
7
|
+
something opens the inspector (app.js), and once a module is opened or folded that choice is kept.
|
|
8
|
+
Remembered in localStorage under forge-ui-shell.
|
|
9
|
+
window.ForgeShell = { state, toggle(panel, open?), setShell(name), head(panel, title, count?), splitter(panel), onChange(fn) } */
|
|
10
|
+
window.ForgeShell = (() => {
|
|
11
|
+
'use strict';
|
|
12
|
+
const KEY = 'forge-ui-shell', body = document.body;
|
|
13
|
+
const PANELS = { runs: { label: 'Runs', key: '1', icon: 'panel-left' }, insp: { label: 'Inspector', key: '2', icon: 'panel-right' }, tasks: { label: 'Tasks', key: '3', icon: 'panel-bottom' } };
|
|
14
|
+
const SIZE = { runs: ['--runs-w', 220, 560, 300], insp: ['--insp-w', 300, 760, 380], tasks: ['--tasks-h', 120, 900, 208] };
|
|
15
|
+
const read = () => { try { const v = JSON.parse(localStorage.getItem(KEY)); return v && typeof v === 'object' ? v : {}; } catch (_) { return {}; } };
|
|
16
|
+
const saved = read();
|
|
17
|
+
const narrow = window.matchMedia && window.matchMedia('(max-width: 760px)').matches;
|
|
18
|
+
const state = {
|
|
19
|
+
shell: saved.shell === 'workbench' ? 'workbench' : 'immersive',
|
|
20
|
+
runs: typeof saved.runs === 'boolean' ? saved.runs : !narrow, insp: saved.insp === true, tasks: saved.tasks === true,
|
|
21
|
+
size: { runs: num(saved.size && saved.size.runs, 'runs'), insp: num(saved.size && saved.size.insp, 'insp'), tasks: num(saved.size && saved.size.tasks, 'tasks') },
|
|
22
|
+
};
|
|
23
|
+
function num(v, panel) { const [, lo, hi, d] = SIZE[panel]; return typeof v === 'number' && Number.isFinite(v) ? Math.max(lo, Math.min(hi, Math.round(v))) : d; }
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
|
|
26
|
+
function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (_) { /* private mode: not remembered */ } }
|
|
27
|
+
function apply() {
|
|
28
|
+
body.dataset.shell = state.shell;
|
|
29
|
+
for (const p of Object.keys(PANELS)) body.dataset[p] = state[p] ? 'open' : 'closed';
|
|
30
|
+
for (const [p, [prop]] of Object.entries(SIZE)) body.style.setProperty(prop, state.size[p] + 'px');
|
|
31
|
+
for (const b of document.querySelectorAll('[data-panel-toggle]')) {
|
|
32
|
+
const p = b.dataset.panelToggle; b.setAttribute('aria-pressed', String(!!state[p]));
|
|
33
|
+
b.title = `${state[p] ? 'Hide' : 'Show'} the ${PANELS[p].label.toLowerCase()} (${PANELS[p].key})`;
|
|
34
|
+
}
|
|
35
|
+
for (const b of document.querySelectorAll('[data-shell-pick]')) b.setAttribute('aria-pressed', String(b.dataset.shellPick === state.shell));
|
|
36
|
+
for (const b of document.querySelectorAll('[data-theme-pick]')) b.setAttribute('aria-pressed', String(window.ForgeTheme && b.dataset.themePick === window.ForgeTheme.get()));
|
|
37
|
+
for (const s of document.querySelectorAll('.splitter[data-panel]')) s.setAttribute('aria-valuenow', String(state.size[s.dataset.panel]));
|
|
38
|
+
}
|
|
39
|
+
function changed() { apply(); save(); for (const f of listeners) try { f(state); } catch (_) { /* a listener's own problem */ } }
|
|
40
|
+
function toggle(panel, open) { if (!PANELS[panel]) return; state[panel] = open === undefined ? !state[panel] : !!open; changed(); }
|
|
41
|
+
function setShell(name) { state.shell = name === 'workbench' ? 'workbench' : 'immersive'; changed(); }
|
|
42
|
+
apply();
|
|
43
|
+
|
|
44
|
+
function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; }
|
|
45
|
+
function bank(label, cls) { const g = el('div', 'seg ' + cls); g.setAttribute('role', 'group'); g.setAttribute('aria-label', label); return g; }
|
|
46
|
+
// a module's head: its plate, an optional count, room for tools, and the key that folds it away
|
|
47
|
+
function head(panel, title, count) {
|
|
48
|
+
const h = el('div', 'module-head');
|
|
49
|
+
const plate = el('h2', 'module-title', title); h.append(plate);
|
|
50
|
+
if (count !== undefined) h.append(el('span', 'module-count', count));
|
|
51
|
+
const tools = el('span', 'module-tools');
|
|
52
|
+
tools.append(foldButton(panel));
|
|
53
|
+
h.append(tools);
|
|
54
|
+
return h;
|
|
55
|
+
}
|
|
56
|
+
function foldButton(panel) {
|
|
57
|
+
const b = el('button', 'icon-btn fold-btn'); b.type = 'button'; b.dataset.fold = panel;
|
|
58
|
+
const sync = () => { b.replaceChildren(icon(state[panel] ? (panel === 'tasks' ? 'chevron-down' : 'close') : panel === 'tasks' ? 'chevron-up' : PANELS[panel].icon));
|
|
59
|
+
b.setAttribute('aria-label', `${state[panel] ? 'Fold away' : 'Open'} the ${PANELS[panel].label.toLowerCase()} (${PANELS[panel].key})`); b.title = b.getAttribute('aria-label'); };
|
|
60
|
+
sync(); listeners.add(() => { if (b.isConnected) sync(); });
|
|
61
|
+
b.addEventListener('click', () => toggle(panel));
|
|
62
|
+
return b;
|
|
63
|
+
}
|
|
64
|
+
// a splitter on a module's inner edge: drag it, or focus it and use the arrow keys
|
|
65
|
+
function splitter(panel) {
|
|
66
|
+
const [prop, lo, hi] = SIZE[panel], rows = panel === 'tasks';
|
|
67
|
+
const s = el('div', 'splitter ' + (rows ? 'h' : 'v'));
|
|
68
|
+
s.dataset.panel = panel; s.tabIndex = 0;
|
|
69
|
+
s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', rows ? 'horizontal' : 'vertical');
|
|
70
|
+
s.setAttribute('aria-label', `Resize the ${PANELS[panel].label.toLowerCase()}`);
|
|
71
|
+
s.setAttribute('aria-valuemin', String(lo)); s.setAttribute('aria-valuemax', String(hi)); s.setAttribute('aria-valuenow', String(state.size[panel]));
|
|
72
|
+
const set = v => { state.size[panel] = num(v, panel); body.style.setProperty(prop, state.size[panel] + 'px'); s.setAttribute('aria-valuenow', String(state.size[panel])); };
|
|
73
|
+
let start = null;
|
|
74
|
+
s.addEventListener('pointerdown', e => {
|
|
75
|
+
if (e.button !== 0) return;
|
|
76
|
+
e.preventDefault(); s.setPointerCapture(e.pointerId); s.classList.add('dragging'); body.classList.add('resizing'); body.classList.toggle('rows', rows);
|
|
77
|
+
start = { x: e.clientX, y: e.clientY, v: state.size[panel] };
|
|
78
|
+
});
|
|
79
|
+
s.addEventListener('pointermove', e => {
|
|
80
|
+
if (!start) return;
|
|
81
|
+
const d = rows ? start.y - e.clientY : panel === 'insp' ? start.x - e.clientX : e.clientX - start.x;
|
|
82
|
+
set(start.v + d);
|
|
83
|
+
});
|
|
84
|
+
const end = () => { if (!start) return; start = null; s.classList.remove('dragging'); body.classList.remove('resizing', 'rows'); changed(); };
|
|
85
|
+
s.addEventListener('pointerup', end); s.addEventListener('pointercancel', end);
|
|
86
|
+
s.addEventListener('keydown', e => {
|
|
87
|
+
const grow = rows ? 'ArrowUp' : panel === 'insp' ? 'ArrowLeft' : 'ArrowRight', shrink = rows ? 'ArrowDown' : panel === 'insp' ? 'ArrowRight' : 'ArrowLeft';
|
|
88
|
+
const step = e.shiftKey ? 64 : 16;
|
|
89
|
+
if (e.key === grow) set(state.size[panel] + step); else if (e.key === shrink) set(state.size[panel] - step);
|
|
90
|
+
else if (e.key === 'Home') set(lo); else if (e.key === 'End') set(hi); else return;
|
|
91
|
+
e.preventDefault(); changed();
|
|
92
|
+
});
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function wire() {
|
|
97
|
+
const tools = document.querySelector('.desk-tools');
|
|
98
|
+
if (tools) {
|
|
99
|
+
const panels = bank('Modules', 'panel-bank');
|
|
100
|
+
for (const [p, d] of Object.entries(PANELS)) {
|
|
101
|
+
const b = el('button'); b.type = 'button'; b.dataset.panelToggle = p; b.dataset.key = d.key;
|
|
102
|
+
b.append(icon(d.icon), el('span', 'panel-label', d.label)); panels.append(b); // the label, or on a narrow desk the panel's picture
|
|
103
|
+
b.addEventListener('click', () => toggle(p));
|
|
104
|
+
}
|
|
105
|
+
const shells = bank('Layout', 'icons shell-switch');
|
|
106
|
+
for (const [name, label] of [['immersive', 'Immersive: modules float over the stage'], ['workbench', 'Workbench: modules docked beside the stage']]) {
|
|
107
|
+
const b = el('button'); b.type = 'button'; b.dataset.shellPick = name; b.setAttribute('aria-label', label); b.title = label;
|
|
108
|
+
b.append(icon(name)); b.addEventListener('click', () => setShell(name)); shells.append(b);
|
|
109
|
+
}
|
|
110
|
+
const themes = bank('Theme', 'icons theme-switch');
|
|
111
|
+
for (const [name, label] of [['system', 'Theme: follow the system'], ['light', 'Theme: light'], ['dark', 'Theme: dark']]) {
|
|
112
|
+
const b = el('button'); b.type = 'button'; b.dataset.themePick = name; b.setAttribute('aria-label', label); b.title = label;
|
|
113
|
+
b.append(icon(name === 'light' ? 'sun' : name === 'dark' ? 'moon' : 'system'));
|
|
114
|
+
b.addEventListener('click', () => { if (window.ForgeTheme) window.ForgeTheme.set(name); apply(); }); themes.append(b);
|
|
115
|
+
}
|
|
116
|
+
tools.prepend(panels, shells, themes);
|
|
117
|
+
}
|
|
118
|
+
const bell = document.getElementById('bell'); if (bell && !bell.querySelector('.icon')) bell.prepend(icon('bell'));
|
|
119
|
+
const search = document.querySelector('.top-search-label'); if (search && !search.querySelector('.icon')) search.prepend(icon('search'));
|
|
120
|
+
const side = document.querySelector('.sidebar');
|
|
121
|
+
if (side) {
|
|
122
|
+
const t = side.querySelector('.module-tools'); if (t) t.append(foldButton('runs'));
|
|
123
|
+
side.append(splitter('runs'));
|
|
124
|
+
}
|
|
125
|
+
document.addEventListener('keydown', e => {
|
|
126
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
|
|
127
|
+
const t = e.target, typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
128
|
+
if (typing || document.querySelector('dialog[open]')) return;
|
|
129
|
+
const p = Object.keys(PANELS).find(k => PANELS[k].key === e.key);
|
|
130
|
+
if (p) { e.preventDefault(); toggle(p); }
|
|
131
|
+
});
|
|
132
|
+
apply();
|
|
133
|
+
}
|
|
134
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire, { once: true }); else wire();
|
|
135
|
+
|
|
136
|
+
return { state, toggle, setShell, head, splitter, onChange: f => listeners.add(f) };
|
|
137
|
+
})();
|