clavarium 0.2.0-next.6 → 0.2.0-next.8

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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.6",
3
+ "version": "0.2.0-next.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.0-next.6",
9
+ "version": "0.2.0-next.8",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
- "clavarium-parley": "0.10.0-next.3",
14
+ "clavarium-parley": "0.10.0-next.4",
15
15
  "mermaid": "11.17.2",
16
16
  "node-pty": "^1.1.0",
17
17
  "pdfjs-dist": "6.4.299",
@@ -686,9 +686,9 @@
686
686
  }
687
687
  },
688
688
  "node_modules/clavarium-parley": {
689
- "version": "0.10.0-next.3",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.3.tgz",
691
- "integrity": "sha512-a750ZvTUDngz8nMdmja3/slCYCUCxM24uFEdgM1X4dNZ666Pf3HEPBbN9GwGgU6F+Ozc1JML5o87tCAMqcRyGQ==",
689
+ "version": "0.10.0-next.4",
690
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.4.tgz",
691
+ "integrity": "sha512-axfpTiDMmxO0RofGc475DywHuoOLz/Jnh1o44WbyOHba6PCj0KDE/WBo36OJQCt47/3AQWMlttYn5nSBvDV1hg==",
692
692
  "license": "MIT",
693
693
  "bin": {
694
694
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.6",
3
+ "version": "0.2.0-next.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@xterm/addon-fit": "^0.11.0",
45
45
  "@xterm/xterm": "^6.0.0",
46
- "clavarium-parley": "0.10.0-next.3",
46
+ "clavarium-parley": "0.10.0-next.4",
47
47
  "mermaid": "11.17.2",
48
48
  "node-pty": "^1.1.0",
49
49
  "pdfjs-dist": "6.4.299",
package/server/api.mjs CHANGED
@@ -382,7 +382,7 @@ export async function handleApi(request, response, conversations, headers, resou
382
382
  if (kept && gitKeeper) {
383
383
  if (kept[2] === 'keep') return send(200, gitKeeper.set(kept[1], body)), true;
384
384
  if (kept[2] === 'refresh') return send(200, await gitKeeper.refresh(kept[1])), true;
385
- return send(200, await gitKeeper.check(kept[1], body)), true;
385
+ return send(200, body?.again === true ? await gitKeeper.checkAgain(kept[1], body) : await gitKeeper.check(kept[1], body)), true;
386
386
  }
387
387
  /* Automatic sending belongs to the attachment: set in the conversation's panel. */
388
388
  const sending = /^\/api\/conversations\/([^/]+)\/git\/([a-z0-9-]{1,32})\/auto$/.exec(pathname);
@@ -101,14 +101,14 @@ export class GitChecks {
101
101
  return Boolean(await this.#conversations.running(conversation).catch(() => false));
102
102
  }
103
103
 
104
- /* -> { status: 'passed' | 'failed' | 'none' | 'unavailable', output, machine, at, head, target }.
104
+ /* -> { status: 'passed' | 'failed' | 'none' | 'unavailable', output, machine, at, head, branch, target }.
105
105
  'none': nothing to run (no hook, no command). 'unavailable': no
106
106
  machine to run them in, or it did not answer.
107
107
  `machine`: the conversation whose machine must run them (automatic
108
108
  sending: the one whose settings decided), else any that has it. */
109
109
  async run(repository, { head, branch, target, remoteTip, remote, command = '', hook = true, seconds = 600, machine: wanted }) {
110
110
  const at = this.#now().toISOString();
111
- const base = { head, target, at, machine: null };
111
+ const base = { head, branch, target, at, machine: null };
112
112
  if (!hook && !command) return { ...base, status: 'none', output: 'No check is set: no pre-push hook asked for, no check command.' };
113
113
  const machine = wanted === undefined ? await this.machineFor(repository) : (await this.runs(wanted, repository) ? wanted : undefined);
114
114
  if (!machine) return { ...base, status: 'unavailable', output: 'No machine that has this repository attached is running: the checks could not run. Open a conversation it is attached to, then look again.' };
@@ -91,6 +91,9 @@ export const DEFAULTS = Object.freeze({
91
91
  change, so a few seconds more are enough to let a commit series finish.
92
92
  `confirmed`: the patterns of the list that needed the owner's word and
93
93
  had it. `perHour` 0: no cap. */
94
+ /* The last checks kept per repository: the end of their output, and how many targets. */
95
+ export const LAST_OUTPUT_MAX = 20_000;
96
+ export const LAST_TARGETS = 20;
94
97
  export const AUTO_DEFAULTS = Object.freeze({ branches: [], confirmed: [], quietSeconds: 5, perHour: 20, create: false, checks: true });
95
98
  export const QUIET_MAX = 86_400;
96
99
  export const PER_HOUR_MAX = 1000;
@@ -205,7 +208,7 @@ const upTo = (id) => (short(id) ? ` (up to ${short(id)})` : '');
205
208
  const seeCommits = (name, head, base, n) => (short(head) && short(base) && Number.isInteger(n) && n > 0 ? [`commits:${name}.${head.slice(0, 12)}.${base.slice(0, 12)}.${Math.min(n, 9999)}`] : []);
206
209
 
207
210
  export class GitKeeper {
208
- #store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
211
+ #store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #last; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
209
212
 
210
213
  /* `review`: GitReview (reads, the reference, Send). `checks`: GitChecks.
211
214
  `alerts`: what tells the owner's phone. `tellAgent(repository, line,
@@ -213,8 +216,11 @@ export class GitKeeper {
213
216
  repository (only to that conversation's, when one is named); it
214
217
  resolves false when nobody could be told (then it is tried again).
215
218
  `thread(conversation, event)`: one line in that conversation's chat
216
- (Talk.event: kept only where its chat is on). */
217
- constructor({ store, review, resources, checks, alerts, now = () => new Date(), tellAgent = async () => {}, thread = () => {} }) {
219
+ (Talk.event: kept only where its chat is on). `last`: a Store where
220
+ the last checks of each repository and target are kept, so that they
221
+ outlive a restart (none: only in memory). */
222
+ constructor({ store, review, resources, checks, alerts, now = () => new Date(), tellAgent = async () => {}, thread = () => {}, last = null }) {
223
+ this.#last = last;
218
224
  this.#store = store; this.#review = review; this.#resources = resources; this.#checks = checks; this.#alerts = alerts; this.#now = now; this.#tellAgent = tellAgent; this.#thread = thread;
219
225
  review.listen({ read: (name, failure, got) => this.#readOutcome(name, failure, got), refresh: (name, result) => this.#refreshOutcome(name, result) });
220
226
  review.useWaivers?.((name) => this.settings(name).fsck);
@@ -257,6 +263,7 @@ export class GitKeeper {
257
263
  auto,
258
264
  origin: hasRemote ? { path: `/resources/${folder}`, kept: this.#resources.hasOrigin(r.name), ...(record.mirror ?? {}) } : null,
259
265
  checks: this.#results.get(r.name) ?? null,
266
+ last: this.lastChecks(r.name),
260
267
  read: record.lastRead ?? null,
261
268
  blocked: Object.keys(record.blocked ?? {}),
262
269
  log: record.log ?? [],
@@ -361,7 +368,7 @@ export class GitKeeper {
361
368
  /* A conversation archived: all its settings, whatever the repository. */
362
369
  forgetConversation(conversation) { for (const record of this.#store.all()) this.forgetAttachment(record.name, conversation); }
363
370
 
364
- forget(name) { this.#store.remove(name); this.#held.delete(name); this.#results.delete(name); }
371
+ forget(name) { this.#store.remove(name); this.#held.delete(name); this.#results.delete(name); this.#last?.remove(name); }
365
372
 
366
373
  /* ---------- what every read and every refresh says ---------- */
367
374
 
@@ -479,9 +486,39 @@ export class GitKeeper {
479
486
  const s = this.settings(name);
480
487
  const result = await this.#checks.run(name, { head: seen.head, branch: seen.branch, target: seen.target, remoteTip: seen.remoteTip, remote: this.#review.remoteOf(name), command: s.checkCommand, hook: s.runHook, seconds: s.checkSeconds, ...(machine ? { machine } : {}) });
481
488
  this.#results.set(name, result);
489
+ this.#keepLast(name, result);
482
490
  return result;
483
491
  }
484
492
 
493
+ /* THE LAST CHECKS, kept: one per target of the repository (the newest
494
+ LAST_TARGETS), with the end of their output. Said "outdated" when the
495
+ agent's branch they ran on has moved since. */
496
+ #keepLast(name, result) {
497
+ if (!this.#last) return;
498
+ const target = typeof result.target === 'string' ? result.target : '';
499
+ const kept = { status: result.status, head: result.head ?? null, branch: result.branch ?? null, target: target || null, at: result.at, machine: result.machine ?? null, output: String(result.output ?? '').slice(-LAST_OUTPUT_MAX) };
500
+ const before = Object.entries(this.#last.get(name)?.targets ?? {}).filter(([t]) => t !== target);
501
+ const targets = Object.fromEntries([...before, [target, kept]].sort((a, b) => String(b[1].at).localeCompare(String(a[1].at))).slice(0, LAST_TARGETS));
502
+ try { this.#last.put({ name, targets }); } catch { /* kept in memory still: a full disk does not stop the checks */ }
503
+ }
504
+ /* [{ status, head, branch, target, at, machine, output, outdated }], the newest first. */
505
+ lastChecks(name) {
506
+ const targets = this.#last?.get(name)?.targets ?? {};
507
+ let tips = {};
508
+ try { tips = this.#review.tips?.(name) ?? {}; } catch { /* not read yet */ }
509
+ return Object.values(targets).filter((x) => x && typeof x === 'object').sort((a, b) => String(b.at).localeCompare(String(a.at)))
510
+ .map((x) => ({ ...x, outdated: Boolean(x.branch && x.head && tips[x.branch]?.id && tips[x.branch].id !== x.head) }));
511
+ }
512
+ /* "Run again", from the last checks: on the tip of that branch now, for that target. */
513
+ async checkAgain(name, body) {
514
+ const r = this.#repository(name);
515
+ const target = typeof body?.target === 'string' && body.target ? body.target : undefined;
516
+ const branch = typeof body?.branch === 'string' && body.branch ? body.branch : undefined;
517
+ const out = await this.#review.outgoing(r.name, { target, branch });
518
+ if (!out?.head || !out.target) throw new Refusal(409, out?.message || 'There is no commit to check on this branch.');
519
+ return this.#check(r.name, { head: out.head, branch: out.branch, target: out.target, remoteTip: out.remoteTip ?? null });
520
+ }
521
+
485
522
  /* Send, from the page: as GitReview.push, and refused when the owner
486
523
  requires passing checks and none passed for this commit and branch. */
487
524
  async send(name, body) {
package/server/guide.mjs CHANGED
@@ -86,7 +86,7 @@ export function commonText() {
86
86
  '- Check out branches in the main folder of the repository or under /work, never in /tmp: /tmp is emptied when the machine restarts.',
87
87
  '- Secrets are environment variables: never print them, never write them to a file or a commit.',
88
88
  /* Installed by the server in every machine (toolbox.mjs): the tool is never asked to install it. */
89
- '- To talk to the person, when their chat is on: parley --help.',
89
+ '- To talk to the person, when their chat is on: parley guide says how (parley --help, every command).',
90
90
  /* Dictation into the terminal is marked once per message (app.js); in the chat, parley read says it. */
91
91
  '- A message in your terminal that starts with a microphone sign (U+1F3A4), or that parley read says was dictated, came by voice: a word may be misheard; when a key word seems wrong, ask rather than guess.',
92
92
  '- The rest is yours to explore (ls, --help).',
@@ -113,9 +113,9 @@ export function ownText({ name, tool, workKept, resources = [], chat, online, se
113
113
  out.push(`- ${repo}: its origin, read-only and kept up to date, is ${origin}. Its branches: git -C ${repo} fetch ${origin} '+refs/heads/*:refs/remotes/origin/*'`);
114
114
  }
115
115
  out.push(!chat ? '- Chat with the person: off. Do not use parley.'
116
- : '- Chat with the person: on. Parley is installed: run parley --help.');
117
- /* How to write there: the chat renders Markdown; what matters stays in sight. */
118
- if (chat) out.push('- In the chat, write as in this terminal (Markdown: short lists, a bold label per point, tables); a long message gets --short, a title that holds any action or decision; one key sentence in "> [!IMPORTANT]"; a picture of /work where it belongs: ![before](/work/a.png); close the questions they answered another way (parley close).');
116
+ : '- Chat with the person: on. Parley is installed: run parley guide.');
117
+ /* How Parley is used is Parley's to say (parley guide); what is here is this chat's: it renders Markdown. */
118
+ if (chat) out.push('- The chat shows Markdown (lists, tables, "> [!IMPORTANT]") and pictures of /work: ![before](/work/a.png).');
119
119
  out.push(online === false ? '- Internet: off.' : '- Internet: on.');
120
120
  if (secrets.length) out.push(`- Secrets given, by name only: ${list(secrets.map((s) => plain(s, 40)))}.`);
121
121
  const own = lines.map((l) => plain(l)).filter(Boolean).slice(0, LINES_MAX);
package/server/main.mjs CHANGED
@@ -313,7 +313,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
313
313
  return heard;
314
314
  };
315
315
  /* What happened to a repository is said in the chat of the conversations it is attached to (talk.mjs). */
316
- const gitKeeper = new GitKeeper({ store: new Store(`${dataDir}/git-keep.json`), review: gitReview, resources: folders, alerts, tellAgent,
316
+ const gitKeeper = new GitKeeper({ store: new Store(`${dataDir}/git-keep.json`), last: new Store(`${dataDir}/git-checks.json`), review: gitReview, resources: folders, alerts, tellAgent,
317
317
  thread: (conversation, event) => talk.event(conversation, event),
318
318
  checks: new GitChecks({ vms, conversations: all, resources: folders }) });
319
319
  const gitTimer = gitKeeper.start(gitEveryMs);
package/web/app.js CHANGED
@@ -2561,18 +2561,18 @@
2561
2561
 
2562
2562
  /* A repository, on the right: its branch, what would go, the diagram. */
2563
2563
  async function gitDetail(holder, name, { place = false } = {}) {
2564
- const facts = el('div', 'as-facts'); facts.append(el('p', 'hint', 'Looking at its branches...'));
2564
+ const facts = el('div', 'as-facts'); facts.append(el('p', 'hint busy', 'Looking at its branches...'));
2565
2565
  const acts = el('div', 'as-acts');
2566
2566
  const review = el('button', '', 'Open the review'); review.type = 'button'; review.dataset.act = 'review';
2567
2567
  const send = el('button', 'primary', 'Send...'); send.type = 'button'; send.dataset.act = 'send'; send.hidden = true;
2568
2568
  acts.append(review, send);
2569
2569
  /* In a conversation, its files too, below. */
2570
2570
  if (!place) { const files = el('button', '', 'Files'); files.type = 'button'; files.dataset.act = 'files'; files.addEventListener('click', () => { embedFiles(holder, name); files.disabled = true; }); acts.prepend(files); }
2571
- const diagram = el('div', 'as-graph'); diagram.append(el('p', 'hint', 'Drawing the branches...'));
2571
+ const diagram = el('div', 'as-graph'); diagram.append(el('p', 'hint busy', 'Drawing the branches...'));
2572
2572
  holder.append(facts, chapter('Branches', acts), diagram);
2573
2573
  const back = place ? { label: name, go: () => { void paintAssets(); } } : { label: name, go: () => { void paintRoomAssets(); } };
2574
2574
  review.addEventListener('click', () => { void openGit(name, { back }); });
2575
- const [out, graph] = await Promise.all([getJson(`/git/${name}/outgoing`, null), getJson(`/git/${name}/graph`, null)]);
2575
+ const [out, graph, keep] = await Promise.all([getJson(`/git/${name}/outgoing`, null), getJson(`/git/${name}/graph`, null), getJson(`/git/${name}/keep`, null)]);
2576
2576
  facts.replaceChildren();
2577
2577
  if (out) {
2578
2578
  const line = el('p', 'as-line');
@@ -2585,10 +2585,48 @@
2585
2585
  card('State', { ahead: 'ahead', 'up-to-date': 'up to date', diverged: 'behind: pull first', 'no-remote': 'no remote', unreachable: 'remote not answering', empty: 'empty' }[out.state] ?? out.state, out.state === 'diverged' || out.state === 'unreachable' ? 'bad' : ''),
2586
2586
  card('Remote', out.remote ? hostOf(out.remote) : 'none'));
2587
2587
  facts.append(line, cards);
2588
+ const last = lastFor(keep?.last, out);
2589
+ if (last) facts.append(lastChecksCard(name, { ...last, outdated: moved(last, out) }, back));
2588
2590
  if (out.state === 'ahead' && out.target) { send.hidden = false; send.textContent = `Send to ${out.target}...`; send.addEventListener('click', () => { void openGit(name, { back, target: out.target, branch: out.branch ?? '' }); }); }
2589
2591
  }
2590
2592
  diagram.replaceChildren(graph ? window.clavariumGraph.draw(graph, { pick: (c) => { void openGit(name, { commit: c.id, back }); } }) : el('p', 'hint', 'The branches could not be read.'));
2591
2593
  }
2594
+ /* THE LAST CHECKS of a repository (gitkeeper.mjs keeps them): those for the
2595
+ branch and target shown, else the newest. Their output is text, always. */
2596
+ const CHECK_WORDS = { passed: ['Passed', 'ok'], failed: ['Failed', 'bad'], none: ['Nothing to check', ''], unavailable: ['Could not run', 'warn'] };
2597
+ function lastFor(list, out) {
2598
+ if (!Array.isArray(list) || list.length === 0) return null;
2599
+ return list.find((x) => x.target === out?.target && x.branch === out?.branch) ?? list.find((x) => x.target === out?.target) ?? list[0];
2600
+ }
2601
+ /* Outdated: the branch they ran on has another tip now, as the server last read it or as just seen. */
2602
+ const moved = (last, out) => {
2603
+ const tip = (out?.agentBranches ?? []).find((x) => x.name === last.branch)?.id;
2604
+ return Boolean(last.outdated || (tip && last.head && tip !== last.head));
2605
+ };
2606
+ function lastChecksCard(name, last, back) {
2607
+ const box = el('div', 'as-lastchecks'); box.dataset.status = String(last.status ?? '');
2608
+ const head = el('div', 'as-lastchecks-head');
2609
+ const [word, tone] = CHECK_WORDS[last.status] ?? [String(last.status ?? ''), ''];
2610
+ head.append(el('small', '', 'Last checks'), el('span', `as-badge check${tone ? ` ${tone}` : ''}`, word));
2611
+ if (last.outdated) { const old = el('span', 'as-badge outdated', 'outdated'); old.title = `${last.branch ?? 'The branch'} has moved since they ran.`; head.append(old); }
2612
+ const shortId = typeof last.head === 'string' ? last.head.slice(0, 7) : '';
2613
+ const line = el('p', 'as-line', `${shortId ? `on ${shortId}` : 'on no commit'} \u00b7 ${last.at ? sinceWords(last.at) : ''}${last.target ? ` \u00b7 to ${last.target}` : ''}`);
2614
+ if (last.at) line.title = when(last.at);
2615
+ const acts = el('div', 'as-acts');
2616
+ const again = el('button', '', 'Run again'); again.type = 'button'; again.dataset.act = 'checks-again';
2617
+ const see = el('button', 'as-link', 'See the output'); see.type = 'button'; see.dataset.act = 'checks-output';
2618
+ see.addEventListener('click', () => { void openGit(name, { back, target: last.target ?? '', branch: last.branch ?? '', tab: 'checks' }); });
2619
+ again.addEventListener('click', async () => {
2620
+ again.disabled = true; again.textContent = 'Running...';
2621
+ let result;
2622
+ try { result = await post(`${API}/git/${name}/check`, { again: true, target: last.target ?? '', branch: last.branch ?? '' }); }
2623
+ catch (error) { result = { ...last, status: 'unavailable', output: error.message, at: new Date().toISOString(), outdated: false }; }
2624
+ box.replaceWith(lastChecksCard(name, { ...result, outdated: false }, back));
2625
+ });
2626
+ acts.append(again, see);
2627
+ box.append(head, line, acts);
2628
+ return box;
2629
+ }
2592
2630
  /* The files of a folder or a mailbox, in a conversation: the Files sheet, moved into the detail. */
2593
2631
  let filesHome = null;
2594
2632
  function embedFiles(holder, name) {
@@ -3511,7 +3549,7 @@
3511
3549
  else the one that moved last). `gitConfirm`: more than the page could
3512
3550
  show, so Send asks once more. `gitFsck`: the owner's fsck waivers. */
3513
3551
  let gitShown = ''; let gitHead = null; let gitTarget = null; let gitAsked = ''; let gitState = ''; let gitRequire = false;
3514
- let gitBranch = null; let gitAskedBranch = ''; let gitConfirm = false; let gitFsck = {};
3552
+ let gitBranch = null; let gitAskedBranch = ''; let gitConfirm = false; let gitFsck = {}; let gitLast = [];
3515
3553
  const NEW_BRANCH = '\u0000new';
3516
3554
  function paintChecks(result) {
3517
3555
  const state = $('gitview-checks-state'); state.className = '';
@@ -3555,7 +3593,7 @@
3555
3593
  try { const r = await fetch(`${API}/git/${name}/keep`); keep = await r.json(); if (!r.ok) throw new Error(keep.error); } catch { return; }
3556
3594
  if (gitShown !== name) return;
3557
3595
  const s = keep.settings;
3558
- gitRequire = s.requireForSend; gitFsck = s.fsck ?? {};
3596
+ gitRequire = s.requireForSend; gitFsck = s.fsck ?? {}; gitLast = Array.isArray(keep.last) ? keep.last : [];
3559
3597
  $('gitview-runhook').checked = s.runHook; $('gitview-checkcommand').value = s.checkCommand; $('gitview-checkseconds').value = String(s.checkSeconds);
3560
3598
  $('gitview-require').checked = s.requireForSend; $('gitview-refreshminutes').value = String(s.refreshMinutes);
3561
3599
  $('gitview-alert').checked = s.alert;
@@ -3693,6 +3731,7 @@
3693
3731
  $('gitview-error').hidden = true; $('gitview-push').hidden = true; $('gitview-check').hidden = true; $('gitview-checks').hidden = true;
3694
3732
  $('gitview-offer-row').hidden = true; $('gitview-guards').hidden = true; $('gitview-dropped').hidden = true; $('gitview-remote-go').hidden = true;
3695
3733
  $('gitview-state').textContent = commit ? 'Looking at this commit...' : 'Looking at what was committed...';
3734
+ $('gitview-state').classList.add('busy');
3696
3735
  $('gitview-numbers').replaceChildren();
3697
3736
  $('gitview-commits').replaceChildren(); $('gitview-stat').textContent = ''; $('gitview-diff').replaceChildren(); $('gitview-tree').replaceChildren();
3698
3737
  $('gitview-n-files').textContent = ''; $('gitview-n-commits').textContent = ''; $('gitview-n-checks').textContent = '';
@@ -3700,6 +3739,7 @@
3700
3739
  if (commit) $('gitview-target-row').hidden = $('gitview-branch-row').hidden = true;
3701
3740
  gitHead = null; gitTarget = null; gitState = ''; gitConfirm = false;
3702
3741
  paintReviewTab();
3742
+ gitLast = [];
3703
3743
  if (!commit) await paintKeep();
3704
3744
  let out;
3705
3745
  try {
@@ -3711,7 +3751,9 @@
3711
3751
  const r = await fetch(`${API}/git/${name}/${commit ? 'commit' : 'outgoing'}${query.size ? `?${query}` : ''}`);
3712
3752
  out = await r.json();
3713
3753
  if (!r.ok) throw Object.assign(new Error(out.error || 'The repository could not be read.'), { offer: out.offer });
3754
+ $('gitview-state').classList.remove('busy');
3714
3755
  } catch (error) {
3756
+ $('gitview-state').classList.remove('busy');
3715
3757
  if (gitShown !== name || gitCommit !== commit) return;
3716
3758
  $('gitview-state').textContent = ''; $('gitview-error').textContent = error.message; $('gitview-error').hidden = false;
3717
3759
  /* An fsck refusal the owner may waive (old histories only): offered, never done for them. */
@@ -3762,6 +3804,12 @@
3762
3804
  is shown, and Send waits for them (it is refused without them only
3763
3805
  if the owner asked for that). */
3764
3806
  if (!commit && out.state === 'ahead') await runChecks();
3807
+ else if (!commit) {
3808
+ /* Nothing to send now: the last checks kept for this branch and target, as they were. */
3809
+ const found = lastFor(gitLast.filter((x) => x.target === out.target), out);
3810
+ const last = found ? { ...found, outdated: moved(found, out) } : null;
3811
+ if (last) { $('gitview-checks').hidden = false; paintChecks(last); $('gitview-checks-state').textContent += ` On ${String(last.head ?? '').slice(0, 7)}, ${sinceWords(last.at)}${last.outdated ? ' (outdated: the branch moved since)' : ''}.`; }
3812
+ }
3765
3813
  }
3766
3814
  /* `commit`: one commit of the diagram. `back`: {label, go} -- where the page goes back to. */
3767
3815
  /* The review is shown inside the page it was opened from, never over it:
package/web/style.css CHANGED
@@ -1086,6 +1086,12 @@ header .ic, #folders .ic { width: 18px; height: 18px; fill: none; stroke: curren
1086
1086
  .as-card b { font-size: 1.05rem; }
1087
1087
  .as-card b.warn { color: var(--warn); } .as-card b.ok { color: var(--ok); } .as-card b.bad { color: var(--danger); }
1088
1088
  .as-acts { display: flex; gap: 8px; flex-wrap: wrap; }
1089
+ .as-lastchecks { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
1090
+ .as-lastchecks-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1091
+ .as-lastchecks-head small { color: var(--ink2); }
1092
+ .as-badge.check.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
1093
+ .as-badge.check.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
1094
+ .as-badge.check.warn, .as-badge.outdated { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
1089
1095
  .as-users { display: flex; flex-direction: column; gap: 6px; }
1090
1096
  .as-picker { padding: 8px; border: 1px dashed var(--line); border-radius: 10px; }
1091
1097
  .as-chips { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -1256,3 +1262,7 @@ select.pill { appearance: auto; }
1256
1262
  .group-head .caret { font-size: 1.1rem; line-height: 1; text-align: center; }
1257
1263
  #machine-size[data-waiting] select { color: var(--ink2); border-style: dashed; }
1258
1264
  #machine-size-wait { color: var(--brass); font-weight: 600; }
1265
+
1266
+ /* Something is being fetched: a small turning ring before the words (a still ring without motion). */
1267
+ .busy::before { content: ""; display: inline-block; width: .95em; height: .95em; margin-right: .5em; vertical-align: -.17em; border-radius: 50%; border: 2px solid color-mix(in srgb, currentColor 25%, transparent); border-top-color: var(--brass); }
1268
+ @media (prefers-reduced-motion: no-preference) { .busy::before { animation: busy-turn .8s linear infinite; } @keyframes busy-turn { to { transform: rotate(360deg); } } }