@giamme/forge 0.11.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 +13 -0
- package/VERSION +1 -1
- package/docs/artifacts.md +2 -1
- package/docs/commands.md +3 -2
- package/package.json +1 -1
- package/scripts/forge-issues.py +1 -1
- package/scripts/forge-ui.py +8 -4
- package/ui/README.md +1 -1
- package/ui/docs/api.md +13 -8
- package/ui/forge_ui/control.py +14 -5
- package/ui/forge_ui/static/findingsview.js +37 -12
- package/ui/forge_ui/static/settings.js +48 -12
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and versions follow the format `{major}.{minor}[.{patch}]` ([...] parts appear only when non-zero).
|
|
7
7
|
|
|
8
|
+
## [0.11.1] - 2026-10-26
|
|
9
|
+
|
|
10
|
+
A bug-fix release for forge-ui and forge issues: the Settings and Open findings views keep your edits and selections honest, forge ui open handles a read-only server with another configuration, and refused labels and warnings say what's wrong.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- forge-ui's Open findings view files exactly what its preview showed, even if you change the scope or the plausible toggle while the drafts load, and shows forge's notes when nothing was filed. Findings hidden by a filter are no longer counted or acted on, and "Select shown open" counts only shown findings toward its limit of 25.
|
|
15
|
+
- forge-ui's Settings page no longer drops a repository-switch confirm when the run list changes. It warns before saving over a file that changed on disk while a confirm was open, and a failed read keeps your unsaved edits on screen with a note instead of replacing the page with an error.
|
|
16
|
+
- `forge ui open` resolves a `FORGE_REGISTRY` that starts with `~` the same way forge's commands do, and replaces a read-only forge-ui running with another configuration instead of refusing to open.
|
|
17
|
+
- The docs now match the code on who creates `control.lock`, `forge config set` and `unset` needing `--repo`, when `forge runs rm` keeps a run directory, and a Fractal plan's hidden pin control.
|
|
18
|
+
- `forge issues` prints its warnings as `warning: …` in the listing too, as it already did for dismissals.
|
|
19
|
+
- When forge-ui refuses an issue label, its message now names the rule the label broke (blank, over 50 characters, starting with `-`, or holding control characters).
|
|
20
|
+
|
|
8
21
|
## [0.11] - 2026-10-26
|
|
9
22
|
|
|
10
23
|
Reviews now remember the last round: a retry's review starts from the previous findings, and a repeated whole-run review from the last report. forge dataset can forecast a plan before it runs and grade how often each reviewer's PASS held, and forge-ui shows a superseded final review as OUTDATED instead of FAIL.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.11
|
|
1
|
+
0.11.1
|
package/docs/artifacts.md
CHANGED
|
@@ -67,7 +67,8 @@ A decomposed run adds:
|
|
|
67
67
|
reviewer_session holds on; present while plan --reviewer-session is on
|
|
68
68
|
<final-review-dir>/review.meta final-review-<epoch>-<pid>/ or integrate-*/final-review/; base=, tip=, source=, spec=, threshold=, integration=, previous=, since=; session= and session_reason= while reviewer_session is set
|
|
69
69
|
control.log one JSON line per human change made with forge control
|
|
70
|
-
scheduler.live
|
|
70
|
+
scheduler.live only while a scheduler runs: {"pid", "started"}
|
|
71
|
+
control.lock the lock that serialises changes to the plan's control files, created by whichever takes it first (forge control, the scheduler or an escalation record) and left in place
|
|
71
72
|
retry_budget, infra_budget only while a scheduler runs: its live budgets (a number each)
|
|
72
73
|
integration.base commit the integration branch started from
|
|
73
74
|
verification.{status,exit,log,command,fingerprint} the combined verification
|
package/docs/commands.md
CHANGED
|
@@ -17,7 +17,7 @@ harness (`/forge`); this command installs forge and drives its optional tools.
|
|
|
17
17
|
| `forge fractal …` | opt-in Fractal execution; see [the command group](fractal.md#forge-fractal-command-group) |
|
|
18
18
|
| `forge runs [--repo DIR \| --all] [--limit N] [--json]` | this repo's recent runs, newest first: run id, kind, age, task states, verification and final review, goal, and the run dir. Read-only; the same summaries forge-ui shows |
|
|
19
19
|
| `forge control show\|set\|pin\|unpin PLAN_DIR …` | change a live plan: `set` takes `qa-threshold`, `timeout`, `escalate-after`, `verify-retries`, `retry-budget` or `infra-retries` (the last two only while a scheduler runs; `--task ID` for `qa-threshold` and `retry-budget`, value `inherit` drops the override); `pin PLAN_DIR TASK SPEC` sets the model the task's next full retry uses once, `unpin` drops it; `--json`. Every change is logged in `control.log`. See [live controls](decomposed-runs.md#changing-a-run-while-it-works) |
|
|
20
|
-
| `forge config show
|
|
20
|
+
| `forge config show [--repo DIR] [--json]` / `forge config set <key> <value> --repo DIR [--json]` / `forge config unset <key> --repo DIR [--json]` | one view of every config source (registry, Jev, the repo's `.forge/verify`, `setup` and `verify-subset`, dataset, `FORGE_*` variables with secrets hidden); `set KEY VALUE` / `unset KEY` edit `repo.verify`, `repo.setup`, `repo.verify-subset` (must contain `{files}`) and, for `set`, `jev.enabled true\|false` (that one does not need `--repo`). `-` as a value reads stdin. Exit 2 on invalid input |
|
|
21
21
|
| `forge runs rm RUN_DIR [--dry-run] [--yes] [--no-archive] [--worktrees] [--branches] [--json]` | delete a run directory but keep what it taught the dataset; see [below](#forge-runs-rm) |
|
|
22
22
|
| `forge issues RUN [--apply] …` | file the review findings a run left open as GitHub issues; see [below](#forge-issues) |
|
|
23
23
|
| `forge ui open\|status\|stop` | forge-ui, the run viewer; see [the command group](forge-ui.md#forge-ui-command-group) |
|
|
@@ -41,7 +41,8 @@ solo run), so a run started meanwhile fails to start instead of racing the delet
|
|
|
41
41
|
What counts as the run's is decided from the run id, not read from the run directory: a worktree
|
|
42
42
|
under `<repo>/../.forge-worktrees/<run id>/` on `forge/<run id>/<task>` or
|
|
43
43
|
`forge/<run id>-integration`. Any other worktree there is `foreign`, one with uncommitted changes
|
|
44
|
-
is `dirty`; neither is touched, and a dirty one keeps the run directory
|
|
44
|
+
is `dirty`; neither is touched, and a dirty one keeps the run directory only when `--worktrees`
|
|
45
|
+
was asked. Branches are reported
|
|
45
46
|
`merged` or `UNMERGED`, so you see what deleting them would lose. `--dry-run` changes nothing and
|
|
46
47
|
reports; `--yes` skips the prompt for the run id (needed without a terminal); `--json` prints one
|
|
47
48
|
object, on failure too. Exit 0 ok, 2 refused input, 3 busy or the run is active, 4 failed during
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giamme/forge",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Multi-model coding with a reviewer that reads the code: plan, build in parallel worktrees, review every real diff, merge only what passes.",
|
|
5
5
|
"keywords": ["claude-code", "codex", "opencode", "agent-skill", "code-review", "multi-model"],
|
|
6
6
|
"license": "MIT",
|
package/scripts/forge-issues.py
CHANGED
|
@@ -1107,7 +1107,7 @@ def print_human(out, applied):
|
|
|
1107
1107
|
f"{s['drafts']} draft(s) ({s['tolerated']} tolerated, {s['accepted']} accepted, "
|
|
1108
1108
|
f"{s['review']} review, {s['plausible']} plausible)")
|
|
1109
1109
|
for warning in out["warnings"]:
|
|
1110
|
-
print(f" warning {warning}")
|
|
1110
|
+
print(f" warning: {warning}")
|
|
1111
1111
|
for note in out["notes"]:
|
|
1112
1112
|
print(f" skipped {note}")
|
|
1113
1113
|
for a in out["already_filed"]:
|
package/scripts/forge-ui.py
CHANGED
|
@@ -355,12 +355,16 @@ def cmd_open(args):
|
|
|
355
355
|
# the record and the live answer must both name this code and the mode asked for
|
|
356
356
|
if (server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
357
357
|
and bool(server.get('read_only')) == read_only and is_read_only(answer) == read_only):
|
|
358
|
-
# Reused,
|
|
359
|
-
#
|
|
358
|
+
# Reused, its CLIs would run with another registry and dataset. A server that takes
|
|
359
|
+
# writes may be in the middle of one, which a restart could cut off: say so instead,
|
|
360
|
+
# and how to restart it. A read-only one has none to cut off: replace it.
|
|
360
361
|
change = config_change(server.get('cli_env'), cli_env)
|
|
361
|
-
if change:
|
|
362
|
+
if change and read_only:
|
|
363
|
+
terminate(server['pid'])
|
|
364
|
+
server = None
|
|
365
|
+
elif change:
|
|
362
366
|
_say(f'error: the running forge-ui (pid {server["pid"]}) has another forge configuration '
|
|
363
|
-
f'for its
|
|
367
|
+
f'for its CLIs: {change}. Run `forge ui stop`, then open again.')
|
|
364
368
|
return 5
|
|
365
369
|
else:
|
|
366
370
|
terminate(server['pid'])
|
package/ui/README.md
CHANGED
|
@@ -266,7 +266,7 @@ takes effect. The routes are in [docs/api.md](docs/api.md).
|
|
|
266
266
|
infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
|
|
267
267
|
exempts it from the run's threshold), its own retry budget and a **model pin**: the model
|
|
268
268
|
its next full retry uses, once. A control that cannot apply (no live scheduler, a merged
|
|
269
|
-
task
|
|
269
|
+
task) is disabled and says why. A Fractal plan's pin control is hidden.
|
|
270
270
|
- **Open findings (`forge issues`):** the findings a run, or every run of its repository,
|
|
271
271
|
left open. The list opens from a quick offline listing and then fills in what is already
|
|
272
272
|
filed; select findings to file as GitHub issues, or dismiss them (and undo that).
|
package/ui/docs/api.md
CHANGED
|
@@ -1012,9 +1012,12 @@ of the other mode: it replaces it as it does one running other code. `forge ui s
|
|
|
1012
1012
|
mode (`read_only` in its JSON).
|
|
1013
1013
|
|
|
1014
1014
|
**The CLIs' configuration.** The server `forge ui open` starts runs without any `FORGE_*` variable
|
|
1015
|
-
of the shell it was started from. The CLIs
|
|
1015
|
+
of the shell it was started from. The CLIs it calls, for reads and writes alike, still need the forge configuration, so
|
|
1016
1016
|
`open` hands them these, as that shell has them (an empty value counts as unset; a relative
|
|
1017
|
-
`FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory
|
|
1017
|
+
`FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory, except that a
|
|
1018
|
+
`FORGE_DATA_HOME` starting with `~` is kept as given, because its reader expands the `~`; a
|
|
1019
|
+
`FORGE_REGISTRY` starting with `~` is not expanded by anything, so it is made absolute like any other
|
|
1020
|
+
relative path):
|
|
1018
1021
|
`FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
|
|
1019
1022
|
`XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
|
|
1020
1023
|
state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
|
|
@@ -1022,12 +1025,14 @@ call gets them on top of the server's own environment; the server's environment
|
|
|
1022
1025
|
changed. No other variable is passed, and none of these is a secret. `server.json` records them as
|
|
1023
1026
|
`cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
|
|
1024
1027
|
|
|
1025
|
-
`open` never reuses a server whose recorded `cli_env` differs from the current shell's.
|
|
1026
|
-
each variable that differs and both values, says to run
|
|
1027
|
-
`5`, prints no link and leaves the server running (it may
|
|
1028
|
-
write). A server
|
|
1029
|
-
|
|
1030
|
-
the new one gets the current shell's
|
|
1028
|
+
`open` never reuses a server whose recorded `cli_env` differs from the current shell's. For a
|
|
1029
|
+
server that takes writes it names each variable that differs and both values, says to run
|
|
1030
|
+
`forge ui stop` and then open again, exits `5`, prints no link and leaves the server running (it may
|
|
1031
|
+
be in the middle of another shell's write). A read-only server (`FORGE_UI_READONLY=1`, asked for
|
|
1032
|
+
again in the same mode) has no write to cut off, so it is replaced, as is one running other code, in
|
|
1033
|
+
the other mode or reading another registry directory: the new one gets the current shell's
|
|
1034
|
+
configuration, `open` prints the link and exits `0`. Read-only servers still run CLIs for their GET
|
|
1035
|
+
routes, so their configuration matters too.
|
|
1031
1036
|
|
|
1032
1037
|
**Serialization.** One write per run key at a time. A second write to a run whose write is still
|
|
1033
1038
|
running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
|
package/ui/forge_ui/control.py
CHANGED
|
@@ -43,6 +43,7 @@ _EXITS = {2: (400, 'invalid'), 3: (409, 'refused'), 4: (500, 'failed')}
|
|
|
43
43
|
# of them is a secret (forge-config.py's SECRET_WORDS), and no other FORGE_* variable is ever passed.
|
|
44
44
|
CLI_ENV_NAMES = ('FORGE_REGISTRY', 'FORGE_DATA_HOME', 'FORGE_DATASET', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME')
|
|
45
45
|
_CLI_ENV_PATHS = ('FORGE_REGISTRY', 'FORGE_DATA_HOME') # read relative to the shell's directory, not the server's
|
|
46
|
+
_CLI_ENV_EXPANDS_HOME = ('FORGE_DATA_HOME',) # the paths whose reader expands a leading ~ itself
|
|
46
47
|
cli_env = {} # set once, before the server starts; empty when it was started by hand
|
|
47
48
|
|
|
48
49
|
|
|
@@ -50,14 +51,15 @@ def cli_env_from(environ):
|
|
|
50
51
|
"""The CLI_ENV_NAMES set in `environ` (an empty value counts as unset, as it does for the CLIs).
|
|
51
52
|
|
|
52
53
|
A relative FORGE_REGISTRY or FORGE_DATA_HOME is made absolute here: the server and its CLIs run in
|
|
53
|
-
another directory than the shell that named it.
|
|
54
|
+
another directory than the shell that named it. A leading ~ is kept only where the reader expands it
|
|
55
|
+
(FORGE_DATA_HOME); a FORGE_REGISTRY of '~/x.tsv' is the relative path ./~/x.tsv to every CLI.
|
|
54
56
|
"""
|
|
55
57
|
out = {}
|
|
56
58
|
for name in CLI_ENV_NAMES:
|
|
57
59
|
value = environ.get(name, '')
|
|
58
60
|
if not value:
|
|
59
61
|
continue
|
|
60
|
-
if name in _CLI_ENV_PATHS and not value.startswith('~'):
|
|
62
|
+
if name in _CLI_ENV_PATHS and not (name in _CLI_ENV_EXPANDS_HOME and value.startswith('~')):
|
|
61
63
|
value = os.path.abspath(value)
|
|
62
64
|
out[name] = value
|
|
63
65
|
return out
|
|
@@ -358,9 +360,16 @@ def _issues_file(ctx):
|
|
|
358
360
|
if not isinstance(labels, list) or len(labels) > MAX_LABELS:
|
|
359
361
|
raise BadBody(f'labels must be a list of at most {MAX_LABELS} strings')
|
|
360
362
|
for label in labels:
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
if not isinstance(label, str):
|
|
364
|
+
raise BadBody('a label must be a text')
|
|
365
|
+
if not label.strip():
|
|
366
|
+
raise BadBody('a label cannot be blank')
|
|
367
|
+
if len(label) > MAX_LABEL:
|
|
368
|
+
raise BadBody(f'a label is at most {MAX_LABEL} characters')
|
|
369
|
+
if label.startswith('-'):
|
|
370
|
+
raise BadBody('a label cannot start with "-"')
|
|
371
|
+
if not _no_control_chars(label):
|
|
372
|
+
raise BadBody('a label cannot hold control characters')
|
|
364
373
|
except BadBody as exc:
|
|
365
374
|
return _invalid(str(exc))
|
|
366
375
|
path = ctx.locate()
|
|
@@ -64,6 +64,20 @@
|
|
|
64
64
|
const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
|
|
65
65
|
return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
|
|
66
66
|
}
|
|
67
|
+
// Fingerprints "Select shown open" adds. `rows` are the rows the filters leave shown. The cap is how many of
|
|
68
|
+
// those are already selected and open — the same rows File and Dismiss take — so a selection a filter hides stays
|
|
69
|
+
// selected and does not count. A shown row that is not open is not added.
|
|
70
|
+
function selectShownOpen(rows, selected) {
|
|
71
|
+
const chosen = new Set(selected instanceof Set || Array.isArray(selected) ? selected : []);
|
|
72
|
+
const add = [];
|
|
73
|
+
for (const r of list(rows)) {
|
|
74
|
+
if (!selectable('file', r) || chosen.has(r.fp)) continue;
|
|
75
|
+
if (selection('file', rows, chosen).count >= MAX_FPS) break;
|
|
76
|
+
chosen.add(r.fp);
|
|
77
|
+
add.push(r.fp);
|
|
78
|
+
}
|
|
79
|
+
return add;
|
|
80
|
+
}
|
|
67
81
|
const bit = v => v ? '1' : '0';
|
|
68
82
|
// The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
|
|
69
83
|
function issuesUrl(key, o) {
|
|
@@ -104,6 +118,11 @@
|
|
|
104
118
|
return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
|
|
105
119
|
});
|
|
106
120
|
}
|
|
121
|
+
// The CLI's `notes` as lines for the dialog, only when the POST created nothing: a bare "0 created" says no reason, the
|
|
122
|
+
// notes ("--only fp: no such open finding") do. When something was created the notes are not about the dialog.
|
|
123
|
+
function emptyNotes(created, notes) {
|
|
124
|
+
return list(created).some(c => c && c.url) ? [] : list(notes).map(n => String(n)).filter(Boolean);
|
|
125
|
+
}
|
|
107
126
|
// An untrusted URL becomes a link only when it is a plain https address.
|
|
108
127
|
const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
|
|
109
128
|
// The marker for a finding whose whole-run review is outdated, or null. The lamp is the hollow unlit
|
|
@@ -176,7 +195,7 @@
|
|
|
176
195
|
const dismissKey = key_('Dismiss', undefined, () => openDismiss());
|
|
177
196
|
const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
|
|
178
197
|
const allKey = key_('Select shown open', undefined, () => {
|
|
179
|
-
for (const
|
|
198
|
+
for (const fp of selectShownOpen(shown(), s.selected)) s.selected.add(fp);
|
|
180
199
|
drawTable(); syncActions();
|
|
181
200
|
});
|
|
182
201
|
const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
|
|
@@ -193,6 +212,9 @@
|
|
|
193
212
|
host.append(dlg);
|
|
194
213
|
|
|
195
214
|
const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
|
|
215
|
+
// What an action takes: the selected rows the filters leave visible. Rows hidden by a filter stay in s.selected, so
|
|
216
|
+
// they come back with the filter, but no button counts them and no action reaches them.
|
|
217
|
+
const picked = action => selection(action, shown(), s.selected);
|
|
196
218
|
function syncScope() {
|
|
197
219
|
for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
|
|
198
220
|
lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
|
|
@@ -201,11 +223,11 @@
|
|
|
201
223
|
function syncActions() {
|
|
202
224
|
actions.hidden = !s.writes;
|
|
203
225
|
if (!s.writes) return;
|
|
204
|
-
const f =
|
|
226
|
+
const f = picked('file'), d = picked('dismiss'), u = picked('undismiss');
|
|
205
227
|
fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
|
|
206
228
|
fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
|
|
207
229
|
undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
|
|
208
|
-
actionNote.textContent = f.note || d.note || u.note || (
|
|
230
|
+
actionNote.textContent = f.note || d.note || u.note || (f.count + u.count ? `${f.count} open, ${u.count} dismissed selected` : '');
|
|
209
231
|
}
|
|
210
232
|
function setLamp(cls, text) {
|
|
211
233
|
lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
|
|
@@ -330,7 +352,7 @@
|
|
|
330
352
|
// dismiss: an optional reason, then the call
|
|
331
353
|
async function openDismiss() {
|
|
332
354
|
reasonHost.replaceChildren();
|
|
333
|
-
const sel =
|
|
355
|
+
const sel = picked('dismiss');
|
|
334
356
|
if (!sel.enabled) return;
|
|
335
357
|
const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
|
|
336
358
|
input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
|
|
@@ -345,7 +367,7 @@
|
|
|
345
367
|
input.focus();
|
|
346
368
|
}
|
|
347
369
|
async function doDismiss(undo, reason) {
|
|
348
|
-
const sel =
|
|
370
|
+
const sel = picked(undo ? 'undismiss' : 'dismiss');
|
|
349
371
|
if (!sel.enabled || s.busy) return;
|
|
350
372
|
s.busy = true; syncActions(); outcomeHost.replaceChildren();
|
|
351
373
|
const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
|
|
@@ -356,17 +378,19 @@
|
|
|
356
378
|
|
|
357
379
|
// filing: drafts for exactly the selection, a preview, then the POST
|
|
358
380
|
async function openFile() {
|
|
359
|
-
const sel =
|
|
381
|
+
const sel = picked('file');
|
|
360
382
|
if (!sel.enabled || s.busy) return;
|
|
361
383
|
s.busy = true; syncActions(); outcomeHost.replaceChildren();
|
|
362
384
|
setLamp('st-running', 'preparing the issue drafts…');
|
|
363
|
-
|
|
385
|
+
// The scope and the toggle stay usable while the drafts are made: the preview files what these drafts were made for.
|
|
386
|
+
const scope = s.scope, plausible = s.plausible;
|
|
387
|
+
const res = await UI.get(issuesUrl(key, { scope, offline: false, plausible, only: sel.fps }));
|
|
364
388
|
s.busy = false; syncActions();
|
|
365
389
|
if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
|
|
366
390
|
setLamp('st-pass', 'drafts ready');
|
|
367
|
-
preview(sel.fps, list(res.data.drafts));
|
|
391
|
+
preview(sel.fps, list(res.data.drafts), { scope, plausible });
|
|
368
392
|
}
|
|
369
|
-
function preview(fps, drafts) {
|
|
393
|
+
function preview(fps, drafts, made) {
|
|
370
394
|
dlg.replaceChildren();
|
|
371
395
|
dlg.append(el('h2', 'File issues on GitHub'));
|
|
372
396
|
const warn = el('p', undefined, 'fnd-warn');
|
|
@@ -399,7 +423,7 @@
|
|
|
399
423
|
labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
|
|
400
424
|
if (parsed.error) return;
|
|
401
425
|
go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
|
|
402
|
-
const res = await UI.post(issuesPath(key), fileBody(
|
|
426
|
+
const res = await UI.post(issuesPath(key), fileBody(made.scope, fps, made.plausible, parsed.labels));
|
|
403
427
|
posting = false;
|
|
404
428
|
if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
|
|
405
429
|
const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
|
|
@@ -410,6 +434,7 @@
|
|
|
410
434
|
ul.append(li);
|
|
411
435
|
}
|
|
412
436
|
results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
|
|
437
|
+
for (const n of emptyNotes(res.data.created, res.data.notes)) results.append(el('p', n, 'muted fnd-result-note'));
|
|
413
438
|
go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
|
|
414
439
|
for (const fp of fps) s.selected.delete(fp);
|
|
415
440
|
load();
|
|
@@ -433,6 +458,6 @@
|
|
|
433
458
|
return { reload: load };
|
|
434
459
|
}
|
|
435
460
|
|
|
436
|
-
return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
|
|
437
|
-
parseLabels, resultLines, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
|
|
461
|
+
return { render, statusOf, filterRows, mergeFiled, selectable, selection, selectShownOpen, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
|
|
462
|
+
parseLabels, resultLines, emptyNotes, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
|
|
438
463
|
});
|
|
@@ -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
|
});
|