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.
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +1 -1
- package/server/gitchecks.mjs +2 -2
- package/server/gitkeeper.mjs +41 -4
- package/server/guide.mjs +4 -4
- package/server/main.mjs +1 -1
- package/web/app.js +53 -5
- package/web/style.css +10 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.0-next.
|
|
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.
|
|
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.
|
|
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.
|
|
690
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.
|
|
691
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
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);
|
package/server/gitchecks.mjs
CHANGED
|
@@ -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.' };
|
package/server/gitkeeper.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
117
|
-
/* How
|
|
118
|
-
if (chat) out.push('-
|
|
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: .');
|
|
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); } } }
|