@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 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, control.lock only while a scheduler runs: {"pid", "started"}; the lock serialises forge control
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\|set\|unset [--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`. `-` as a value reads stdin. Exit 2 on invalid input |
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 too. Branches are reported
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.0",
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",
@@ -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"]:
@@ -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, it would run this shell's writes with another registry and dataset, and a
359
- # restart could cut off a write in flight: say so instead, and how to restart it.
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 writes: {change}. Run `forge ui stop`, then open again.')
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, a Fractal plan's pin) is disabled and says why.
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 its writes call still need the forge configuration, so
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. It names
1026
- each variable that differs and both values, says to run `forge ui stop` and then open again, exits
1027
- `5`, prints no link and leaves the server running (it may be in the middle of another shell's
1028
- write). A server
1029
- running other code, in the other mode or reading another registry directory is still replaced, and
1030
- the new one gets the current shell's configuration.
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.
@@ -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 (not isinstance(label, str) or not label.strip() or len(label) > MAX_LABEL
362
- or label.startswith('-') or not _no_control_chars(label)):
363
- raise BadBody(f'a label must be a text of 1 to {MAX_LABEL} characters, without control characters')
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 r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
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 = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
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 || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
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 = selection('dismiss', s.rows, s.selected);
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 = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
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 = selection('file', s.rows, s.selected);
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
- const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
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(s.scope, fps, s.plausible, parsed.labels));
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
- host.replaceChildren(head, picker, note, body);
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 && !(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; }
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 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
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 = msg || 'could not reach forge-ui'; s.data = null; s.dataText = null; drawBody(); return;
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
- 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'));
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', () => { s.editors[name] = edited(s.editors[name], ta.value); sync(); });
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
  });