@popcorn245/cornfield 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/core/config.js +36 -7
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +9 -0
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +310 -15
- package/explorer/index.html +18 -0
- package/explorer/panels.js +24 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/db.js +15 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +26 -5
- package/server/forge-github.js +5 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +19 -6
- package/server/hooks.js +81 -1
- package/server/proposal-pulls.js +3 -3
- package/server/server.js +4 -2
- package/server/spec-poll.js +34 -3
- package/server/threads.js +275 -0
package/explorer/forge-panel.js
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
* the config says it restarted. Selecting one lights those nodes (a stack brings its containers; a failed deploy lights
|
|
21
21
|
* them red) through the same paint path, turning on the layers they are on; a deploy no `deploys.restarts` names is listed and says it lights nothing.
|
|
22
22
|
* Only one thing is selected at a time, a pull request or a deploy.
|
|
23
|
+
* - A Threads tab (docs/threads.md): the ideas (issues labelled `idea`) and the spec PRs' conversations, newest activity first.
|
|
24
|
+
* Opening one shows its messages, read-only, with a link out to the forge to reply (writing happens there, spec 0005 D6), and
|
|
25
|
+
* lights the nodes its text mentions (`[[node-id]]`, `[[repo/path]]`, a `cornfield-thread` block; core/mentions.js) the way a
|
|
26
|
+
* pull request lights its files. "New idea" opens an issue with the label as the signed-in maintainer: a POST to cornfield's
|
|
27
|
+
* own server, which guards, counts and audits it. A node's panel lists the threads that mention it (`state.threadIndex`).
|
|
23
28
|
* - Keys: Tab reaches the header button, Enter opens the panel with the focus on its title; Escape closes it and
|
|
24
29
|
* gives the focus back to the button. What happens (a list loaded, a pull request selected, what it lights, a
|
|
25
30
|
* failure) is said through the map's live region (`announce`, keyboard.js). Closed, the panel is `inert`.
|
|
@@ -51,7 +56,7 @@
|
|
|
51
56
|
|
|
52
57
|
import { state, nodeById, escHtml, touchScene, onSceneTouch } from './state.js';
|
|
53
58
|
import { render, isNodeVisible } from './render.js';
|
|
54
|
-
import { selectNode } from './panels.js';
|
|
59
|
+
import { selectNode, repaintThreads } from './panels.js';
|
|
55
60
|
import { announce, centreOn } from './keyboard.js';
|
|
56
61
|
import { isPhone, registerSheet, expandSheet, collapseSheet } from './sheets.js';
|
|
57
62
|
import { relativeTime } from './review-model.js';
|
|
@@ -63,6 +68,7 @@ import {
|
|
|
63
68
|
specBlock, buildOffers, planUrl, confirmSpec, confirmBuild, specBody, buildBody, existingWords,
|
|
64
69
|
listKeyOf, litForDeploy, nodesForRestarts, deployWords, deployMeta,
|
|
65
70
|
DRAFTS_URL, notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest,
|
|
71
|
+
threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, ideaRequest, ideaWords,
|
|
66
72
|
} from './forge-panel-model.js';
|
|
67
73
|
|
|
68
74
|
const $ = (id) => document.getElementById(id);
|
|
@@ -85,6 +91,11 @@ const ui = {
|
|
|
85
91
|
landing: null, // what waits to land on its approval for the selected pull request ({ slug, by, since }), or null
|
|
86
92
|
detailError: '',
|
|
87
93
|
lit: null, // litFor(detail, nodes)
|
|
94
|
+
thread: null, // the selected thread's number (Threads tab)
|
|
95
|
+
threadDetail: null, // { thread, at, rate, stale } of the selected one: its messages, as written
|
|
96
|
+
threadError: '',
|
|
97
|
+
litThread: null, // litForThread(thread, nodes)
|
|
98
|
+
idea: freshIdea(), // the New idea form
|
|
88
99
|
deploy: null, // the selected deploy's id; the deploy itself is `deployItem`
|
|
89
100
|
deployItem: null,
|
|
90
101
|
litDeploy: null, // litForDeploy(deployItem, nodes)
|
|
@@ -101,7 +112,16 @@ function freshNotes() {
|
|
|
101
112
|
return { since: '', data: null, loading: false, error: '', confirm: false, posting: false, message: '', bad: false, link: null };
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
/** The New idea form: whether it is open, one send in flight, and what the last send came to */
|
|
116
|
+
function freshIdea() {
|
|
117
|
+
return { open: false, busy: false, message: '', bad: false, link: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
let wired = false;
|
|
121
|
+
let threadToken = 0;
|
|
122
|
+
let indexToken = 0;
|
|
123
|
+
let indexLoading = false;
|
|
124
|
+
let indexCache = null; // { items, nodes, map } the mentions index for the list and map it was made from
|
|
105
125
|
let listToken = 0;
|
|
106
126
|
let pullToken = 0;
|
|
107
127
|
let notesToken = 0;
|
|
@@ -114,7 +134,7 @@ const listKey = () => listKeyOf(ui.kind, ui.filters.state);
|
|
|
114
134
|
const current = () => (ui.kind === 'work' ? ui.work : ui.lists[listKey()]) || null;
|
|
115
135
|
const groupsShown = () => (ui.work ? filterGroups(ui.work.grouped.groups, ui.workFilters) : []);
|
|
116
136
|
const plural = (n, word, more = `${word}s`) => `${n} ${n === 1 ? word : more}`;
|
|
117
|
-
const kindWords = () => ({ pulls: 'pull request', issues: 'issue', deploys: 'deploy' })[ui.kind] || 'piece of work';
|
|
137
|
+
const kindWords = () => ({ pulls: 'pull request', issues: 'issue', threads: 'thread', deploys: 'deploy' })[ui.kind] || 'piece of work';
|
|
118
138
|
/** A link only if it is the forge's web address: a forge's JSON is data, not trusted markup */
|
|
119
139
|
const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
|
|
120
140
|
|
|
@@ -167,7 +187,8 @@ async function loadList(refresh = false, quiet = false) {
|
|
|
167
187
|
if (token !== listToken) return;
|
|
168
188
|
ui.loading = false;
|
|
169
189
|
if (res.ok) {
|
|
170
|
-
ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
190
|
+
ui.lists[key] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
|
|
191
|
+
if (ui.kind === 'threads') repaintThreads();
|
|
171
192
|
if (!quiet) announce(`${plural(filterItems(ui.lists[key].items, ui.kind, ui.filters).length, kindWords())} shown${ui.lists[key].stale ? '. This is an older answer' : ''}`);
|
|
172
193
|
} else {
|
|
173
194
|
ui.error = errorWords(res.status, res.body);
|
|
@@ -180,6 +201,7 @@ async function loadList(refresh = false, quiet = false) {
|
|
|
180
201
|
/** Selects a pull request: asks for its files and checks, then lights what they are on the map */
|
|
181
202
|
async function selectPull(n) {
|
|
182
203
|
const token = ++pullToken;
|
|
204
|
+
dropThread();
|
|
183
205
|
ui.deploy = null;
|
|
184
206
|
ui.deployItem = null;
|
|
185
207
|
ui.litDeploy = null;
|
|
@@ -253,7 +275,10 @@ export function clearPull() {
|
|
|
253
275
|
/** What the selected pull request lights, worked out again: for the nodes now on the map */
|
|
254
276
|
function relight() {
|
|
255
277
|
ui.nodesSeen = state.nodes;
|
|
256
|
-
if (ui.
|
|
278
|
+
if (ui.threadDetail) {
|
|
279
|
+
ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
|
|
280
|
+
state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
|
|
281
|
+
} else if (ui.deployItem) {
|
|
257
282
|
ui.litDeploy = litForDeploy(ui.deployItem, state.nodes);
|
|
258
283
|
state.forgeLit = { changed: ui.litDeploy.changed, failing: ui.litDeploy.failing };
|
|
259
284
|
} else if (ui.detail) {
|
|
@@ -270,7 +295,8 @@ function relight() {
|
|
|
270
295
|
|
|
271
296
|
/** A proposal opening, or the live layer, changes the nodes: the lights follow them */
|
|
272
297
|
function onNodesChange() {
|
|
273
|
-
if ((ui.detail || ui.deployItem) && state.nodes !== ui.nodesSeen) relight();
|
|
298
|
+
if ((ui.detail || ui.deployItem || ui.threadDetail) && state.nodes !== ui.nodesSeen) relight();
|
|
299
|
+
repaintThreads();
|
|
274
300
|
}
|
|
275
301
|
|
|
276
302
|
/** Selects a deploy: nothing to ask (the list holds what it restarted), so its nodes light at once */
|
|
@@ -278,6 +304,7 @@ export function selectDeploy(id) {
|
|
|
278
304
|
const found = ui.lists.deploys && ui.lists.deploys.items.find((d) => d.id === id);
|
|
279
305
|
if (!found) return;
|
|
280
306
|
pullToken++;
|
|
307
|
+
dropThread();
|
|
281
308
|
ui.selected = null;
|
|
282
309
|
ui.detail = null;
|
|
283
310
|
ui.detailError = '';
|
|
@@ -308,6 +335,184 @@ export function clearDeploy() {
|
|
|
308
335
|
if (had) announce(`Deploy ${had.name} cleared`);
|
|
309
336
|
}
|
|
310
337
|
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Threads
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/** Forgets the selected thread without painting: another thing is being selected */
|
|
343
|
+
function dropThread() {
|
|
344
|
+
threadToken++;
|
|
345
|
+
ui.thread = null;
|
|
346
|
+
ui.threadDetail = null;
|
|
347
|
+
ui.threadError = '';
|
|
348
|
+
ui.litThread = null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
|
|
352
|
+
async function selectThread(n) {
|
|
353
|
+
const token = ++threadToken;
|
|
354
|
+
pullToken++;
|
|
355
|
+
ui.selected = null;
|
|
356
|
+
ui.detail = null;
|
|
357
|
+
ui.detailError = '';
|
|
358
|
+
ui.lit = null;
|
|
359
|
+
ui.landing = null;
|
|
360
|
+
ui.deploy = null;
|
|
361
|
+
ui.deployItem = null;
|
|
362
|
+
ui.litDeploy = null;
|
|
363
|
+
ui.thread = n;
|
|
364
|
+
ui.threadDetail = null;
|
|
365
|
+
ui.threadError = '';
|
|
366
|
+
ui.litThread = null;
|
|
367
|
+
state.forgeLit = null;
|
|
368
|
+
touchScene();
|
|
369
|
+
render();
|
|
370
|
+
paint();
|
|
371
|
+
const res = await request(threadUrl(n), false);
|
|
372
|
+
if (token !== threadToken) return;
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
ui.threadError = errorWords(res.status, res.body);
|
|
375
|
+
announce(ui.threadError);
|
|
376
|
+
paint();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
380
|
+
relight();
|
|
381
|
+
$('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
|
|
382
|
+
announce(threadSelectionWords());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Lets go of the selected thread: nothing is lit any more */
|
|
386
|
+
export function clearThread() {
|
|
387
|
+
const had = ui.thread;
|
|
388
|
+
dropThread();
|
|
389
|
+
state.forgeLit = null;
|
|
390
|
+
touchScene();
|
|
391
|
+
render();
|
|
392
|
+
paint();
|
|
393
|
+
if (had !== null) announce(`Thread ${had} cleared`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** What a screen reader hears when a thread is selected */
|
|
397
|
+
function threadSelectionWords() {
|
|
398
|
+
const { thread } = ui.threadDetail;
|
|
399
|
+
const lit = ui.litThread ? ui.litThread.nodes.length : 0;
|
|
400
|
+
const off = ui.litThread ? ui.litThread.unresolved.length : 0;
|
|
401
|
+
return `Thread ${thread.number}, ${thread.title}: ${plural(thread.messages.length, 'message')}, ${lit ? `${plural(lit, 'node')} lit` : 'no node mentioned'}${off ? `, ${off} named that ${off === 1 ? 'is' : 'are'} not on the map` : ''}. Read-only: reply on ${forgeName()}.`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Shows a thread from the map (a node's "Mentioned in"): the panel opens on the Threads tab with that thread selected */
|
|
405
|
+
function openThread(n) {
|
|
406
|
+
if (ui.kind !== 'threads') {
|
|
407
|
+
ui.kind = 'threads';
|
|
408
|
+
ui.actNote = null;
|
|
409
|
+
ui.filters = defaultFilters('threads');
|
|
410
|
+
}
|
|
411
|
+
openForge();
|
|
412
|
+
paintFilters();
|
|
413
|
+
selectThread(n);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The threads list for the node panel's "Mentioned in", read once (quietly) when a node is first selected, and when the Threads tab loads it */
|
|
417
|
+
function ensureThreadsList() {
|
|
418
|
+
if (!ui.config || ui.config.threads === false || ui.lists['threads|open'] || indexLoading) return;
|
|
419
|
+
indexLoading = true;
|
|
420
|
+
const token = ++indexToken;
|
|
421
|
+
request(threadsUrl('open'), false).then((res) => {
|
|
422
|
+
indexLoading = false;
|
|
423
|
+
if (token !== indexToken || !res.ok || ui.lists['threads|open']) return;
|
|
424
|
+
ui.lists['threads|open'] = { items: res.body.items || [], at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '', problem: res.body.problem || '' };
|
|
425
|
+
repaintThreads();
|
|
426
|
+
if (ui.open && ui.kind === 'threads') paint();
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The threads that mention a node on the map now, or null while the list has not been read */
|
|
431
|
+
function threadsFor(nodeId) {
|
|
432
|
+
const list = ui.lists['threads|open'];
|
|
433
|
+
if (!list) return null;
|
|
434
|
+
if (!indexCache || indexCache.items !== list.items || indexCache.nodes !== state.nodes) indexCache = { items: list.items, nodes: state.nodes, map: mentionsIndex(list.items, state.nodes) };
|
|
435
|
+
return indexCache.map.get(nodeId) || [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// New idea
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/** Opens or closes the form; the focus goes to the title, or back to the button */
|
|
443
|
+
function toggleIdea(open) {
|
|
444
|
+
ui.idea.open = open;
|
|
445
|
+
if (open) ui.idea.message = '';
|
|
446
|
+
paintIdea();
|
|
447
|
+
if (open) $('idea-title').focus(); else $('idea-open').focus();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
|
|
451
|
+
async function sendIdea() {
|
|
452
|
+
if (ui.idea.busy) return;
|
|
453
|
+
const asked = ideaRequest($('idea-title').value, $('idea-body').value);
|
|
454
|
+
if (!asked) {
|
|
455
|
+
ui.idea.message = 'Give the idea a title.';
|
|
456
|
+
ui.idea.bad = true;
|
|
457
|
+
ui.idea.link = null;
|
|
458
|
+
paintIdea();
|
|
459
|
+
announce(ui.idea.message);
|
|
460
|
+
$('idea-title').focus();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
ui.idea.busy = true;
|
|
464
|
+
ui.idea.message = '';
|
|
465
|
+
paintIdea();
|
|
466
|
+
announce(`Opening the idea on ${forgeName()}`);
|
|
467
|
+
const res = await post(IDEAS_URL, asked);
|
|
468
|
+
ui.idea.busy = false;
|
|
469
|
+
ui.idea.message = ideaWords(forgeName(), res);
|
|
470
|
+
ui.idea.bad = !res.ok;
|
|
471
|
+
ui.idea.link = res.ok && res.body.issue ? res.body.issue.url : null;
|
|
472
|
+
if (res.ok) {
|
|
473
|
+
ui.idea.open = false;
|
|
474
|
+
$('idea-title').value = '';
|
|
475
|
+
$('idea-body').value = '';
|
|
476
|
+
}
|
|
477
|
+
paintIdea();
|
|
478
|
+
announce(ui.idea.message);
|
|
479
|
+
if (res.ok) {
|
|
480
|
+
ui.lists = {};
|
|
481
|
+
indexCache = null;
|
|
482
|
+
await loadList(true, true);
|
|
483
|
+
ensureThreadsList();
|
|
484
|
+
$('idea-open').focus();
|
|
485
|
+
} else $('idea-title').focus();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
|
|
489
|
+
function paintIdea() {
|
|
490
|
+
const bar = $('forge-idea');
|
|
491
|
+
if (!bar) return;
|
|
492
|
+
bar.hidden = ui.kind !== 'threads';
|
|
493
|
+
$('idea-open').setAttribute('aria-expanded', String(ui.idea.open));
|
|
494
|
+
$('idea-form').hidden = !ui.idea.open;
|
|
495
|
+
$('idea-send').textContent = ui.idea.busy ? 'Opening …' : 'Open the idea';
|
|
496
|
+
$('idea-send').disabled = ui.idea.busy;
|
|
497
|
+
if (ui.idea.open) {
|
|
498
|
+
const label = (ui.config && ui.config.threads && ui.config.threads.label) || 'idea';
|
|
499
|
+
$('idea-words').textContent = `This opens an issue labelled "${label}" on ${forgeName()}, as you. The conversation lives there; cornfield keeps no copy of it.`;
|
|
500
|
+
}
|
|
501
|
+
const message = $('idea-message');
|
|
502
|
+
message.hidden = !ui.idea.message;
|
|
503
|
+
message.classList.toggle('hx-forge-msg--bad', ui.idea.bad);
|
|
504
|
+
message.textContent = ui.idea.message;
|
|
505
|
+
if (ui.idea.message && ui.idea.link && safeUrl(ui.idea.link)) {
|
|
506
|
+
const link = document.createElement('a');
|
|
507
|
+
link.className = 'hx-link';
|
|
508
|
+
link.href = ui.idea.link;
|
|
509
|
+
link.target = '_blank';
|
|
510
|
+
link.rel = 'noreferrer';
|
|
511
|
+
link.textContent = `Open it on ${forgeName()} (new tab)`;
|
|
512
|
+
message.append(' ', link);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
311
516
|
/** What a screen reader hears when a deploy is selected */
|
|
312
517
|
function deploySelectionWords(turnedOn = []) {
|
|
313
518
|
const { name, branch } = ui.deployItem;
|
|
@@ -480,10 +685,10 @@ function paintFilters() {
|
|
|
480
685
|
$('forge-label').innerHTML = option('', 'Any label', !ui.filters.label) + labels.map((l) => option(l, l, l === ui.filters.label)).join('');
|
|
481
686
|
$('forge-author').innerHTML = option('', 'Anyone', !ui.filters.author) + authors.map((a) => option(a, a, a === ui.filters.author)).join('');
|
|
482
687
|
// A deploy has no labels and no author to pick: its list is filtered by state and the search
|
|
483
|
-
$('forge-label').parentElement.hidden = ui.kind === 'deploys';
|
|
688
|
+
$('forge-label').parentElement.hidden = ui.kind === 'deploys' || ui.kind === 'threads';
|
|
484
689
|
$('forge-author').parentElement.hidden = ui.kind === 'deploys';
|
|
485
690
|
// …and its search reads the workflow, what it ran for, the branch and who started it, not a number or a label
|
|
486
|
-
$('forge-text').placeholder = ui.kind === 'deploys' ? 'Workflow, branch, commit, who' : 'Title, number, label';
|
|
691
|
+
$('forge-text').placeholder = ui.kind === 'deploys' ? 'Workflow, branch, commit, who' : ui.kind === 'threads' ? 'Title, number, author' : 'Title, number, label';
|
|
487
692
|
}
|
|
488
693
|
|
|
489
694
|
/** What a deploy lights, said short, for its row: how many nodes, or that it is not mapped */
|
|
@@ -502,8 +707,19 @@ function deployHtml(d) {
|
|
|
502
707
|
<span class="hx-forge-labels"><span class="hx-forge-label">${escHtml(lightsWords(d))}</span></span></button>${out}</li>`;
|
|
503
708
|
}
|
|
504
709
|
|
|
710
|
+
/** One thread in the list: a row that opens it, and a link out to the forge */
|
|
711
|
+
function threadHtml(t) {
|
|
712
|
+
const link = safeUrl(t.url);
|
|
713
|
+
const out = link ? `<a class="hx-forge-out" href="${escHtml(link)}" target="_blank" rel="noreferrer" aria-label="Open thread ${t.number} on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
|
|
714
|
+
const note = [t.proposals && t.proposals.length ? `Proposal ${t.proposals.map((p) => p.slug).join(', ')}` : '', t.idea ? `Idea #${t.idea}` : ''].filter(Boolean).join(' · ');
|
|
715
|
+
return `<li class="hx-forge-item"><button type="button" class="hx-forge-row" data-forge-thread="${t.number}" aria-pressed="${ui.thread === t.number}">
|
|
716
|
+
<span class="hx-forge-title">${escHtml(t.title)}</span>
|
|
717
|
+
<span class="hx-forge-meta"><span class="hx-forge-state hx-forge-state--${escHtml(t.state)}">${escHtml(t.state === 'open' ? 'Open' : 'Closed')}</span> <span class="hx-forge-label">${escHtml(threadKindWords(t))}</span> ${escHtml(threadMeta(t, now()))}</span>${note ? `<span class="hx-forge-meta">${escHtml(note)}</span>` : ''}</button>${out}</li>`;
|
|
718
|
+
}
|
|
719
|
+
|
|
505
720
|
function itemHtml(item, before = '', after = '') {
|
|
506
721
|
if (ui.kind === 'deploys') return deployHtml(item);
|
|
722
|
+
if (ui.kind === 'threads') return threadHtml(item);
|
|
507
723
|
const link = safeUrl(item.url);
|
|
508
724
|
const labels = item.labels.length ? `<span class="hx-forge-labels">${item.labels.map((l) => `<span class="hx-forge-label">${escHtml(l)}</span>`).join('')}</span>` : '';
|
|
509
725
|
const inner = `<span class="hx-forge-title">${escHtml(item.title)}</span>
|
|
@@ -561,7 +777,48 @@ function deployDetailHtml() {
|
|
|
561
777
|
return `${head}${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}${body}`;
|
|
562
778
|
}
|
|
563
779
|
|
|
780
|
+
/** A node button, as in a pull request's files: it selects the node on the map */
|
|
781
|
+
const nodeButton = (n) => `<button type="button" class="hx-forge-node" data-forge-node="${escHtml(n.id)}" aria-label="Show ${escHtml(n.label)} on the map${isNodeVisible(n) ? '' : ' (its layer is off)'}">${escHtml(n.label)}${isNodeVisible(n) ? '' : ' · layer off'}</button>`;
|
|
782
|
+
|
|
783
|
+
/** The selected thread: read-only, its messages as written, a way to reply on the forge, and the nodes its text lights */
|
|
784
|
+
function threadDetailHtml() {
|
|
785
|
+
const head = (title) => `<div class="hx-forge-detail-head"><h3 class="hx-forge-detail-title">${title}</h3><button type="button" class="hx-btn hx-btn--quiet" data-action="clear-forge-thread">Clear</button></div>`;
|
|
786
|
+
if (ui.threadError) return `${head(`Thread ${ui.thread}`)}<p class="hx-forge-notice">${escHtml(ui.threadError)}</p>`;
|
|
787
|
+
if (!ui.threadDetail) return `${head(`Thread ${ui.thread}`)}<p class="hx-faint">Loading its messages …</p>`;
|
|
788
|
+
const { thread: t, stale } = ui.threadDetail;
|
|
789
|
+
const lit = ui.litThread;
|
|
790
|
+
const url = safeUrl(t.url);
|
|
791
|
+
const reply = url ? `<a class="hx-link" href="${escHtml(url)}" target="_blank" rel="noreferrer">Reply on ${escHtml(forgeName())} (new tab)</a>` : '';
|
|
792
|
+
const named = [
|
|
793
|
+
...(t.proposals || []).map((p) => `Proposal <code class="hx-code">${escHtml(p.slug)}</code> in pull request #${p.pull}`),
|
|
794
|
+
...(t.kind === 'proposal' ? [`Spec pull request for <code class="hx-code">${escHtml((t.slugs || []).join(', '))}</code>`] : []),
|
|
795
|
+
...(t.idea ? [`Names idea #${t.idea}`] : []),
|
|
796
|
+
];
|
|
797
|
+
const msgs = t.messages.map((m) => {
|
|
798
|
+
const own = lit.perMessage.get(m.id) || { nodes: [], unresolved: [] };
|
|
799
|
+
const link = safeUrl(m.url) ? ` <a class="hx-link" href="${escHtml(m.url)}" target="_blank" rel="noreferrer" aria-label="Open this message on ${escHtml(forgeName())} (new tab)">↗</a>` : '';
|
|
800
|
+
const chips = own.nodes.length || own.unresolved.length
|
|
801
|
+
? `<span class="hx-forge-nodes hx-thread-nodes">${own.nodes.map(nodeButton).join('')}${own.unresolved.length ? `<span class="hx-faint">not on the map: ${own.unresolved.map((r) => `<code class="hx-code">${escHtml(r)}</code>`).join(' ')}</span>` : ''}</span>`
|
|
802
|
+
: '';
|
|
803
|
+
return `<li class="hx-thread-msg"><p class="hx-forge-meta"><strong class="hx-strong">${escHtml(m.author ? m.author.login : 'someone')}</strong>${m.opening ? ' opened this' : ''} · ${escHtml(relativeTime(m.createdAt, now()))}${link}</p><p class="hx-thread-body">${escHtml(m.body.trim() === '' ? '(no text)' : m.body)}${m.cut ? '\n… (cut here; the rest is on the forge)' : ''}</p>${chips}</li>`;
|
|
804
|
+
}).join('');
|
|
805
|
+
const key = lit.nodes.length ? `<p class="hx-forge-key hx-faint"><span class="hx-mark-dot hx-mark-dot--changed" aria-hidden="true"></span>a node this thread mentions</p>` : '';
|
|
806
|
+
return `${head(`#${t.number} ${escHtml(t.title)}`)}
|
|
807
|
+
<p class="hx-forge-meta"><span class="hx-forge-state hx-forge-state--${escHtml(t.state)}">${t.state === 'open' ? 'Open' : 'Closed'}</span> ${escHtml(threadKindWords(t))} · ${escHtml(threadMeta({ ...t, comments: t.messages.length - (t.kind === 'idea' ? 1 : 0) + (t.hidden || 0) }, now()))}</p>
|
|
808
|
+
<p class="hx-forge-why hx-faint">Read-only here: a thread is a conversation on ${escHtml(forgeName())}, so you reply there. ${reply}</p>
|
|
809
|
+
${named.length ? `<p class="hx-forge-meta">${named.join(' · ')}</p>` : ''}
|
|
810
|
+
${stale ? `<p class="hx-forge-notice">${escHtml(`This is an older answer. ${stale}`)}</p>` : ''}
|
|
811
|
+
<h4 class="hx-forge-sub">${escHtml(`Mentions: ${plural(lit.nodes.length, 'node')} on the map`)}</h4>
|
|
812
|
+
${key}
|
|
813
|
+
${lit.nodes.length ? `<span class="hx-forge-nodes hx-thread-nodes">${lit.nodes.map(nodeButton).join('')}</span>` : '<p class="hx-faint">No message mentions a node. Write [[node-id]] or [[repo/path]] in a message to point at the map.</p>'}
|
|
814
|
+
${lit.unresolved.length ? `<p class="hx-forge-notice">${escHtml(lit.unresolved.join(', '))} ${lit.unresolved.length === 1 ? 'is' : 'are'} mentioned but not on the map, so ${lit.unresolved.length === 1 ? 'it stays' : 'they stay'} text.</p>` : ''}
|
|
815
|
+
<h4 class="hx-forge-sub">${escHtml(plural(t.messages.length, 'message'))}</h4>
|
|
816
|
+
${t.hidden ? `<p class="hx-forge-why hx-faint">${escHtml(plural(t.hidden, 'earlier message'))} not shown; they are on ${escHtml(forgeName())}.</p>` : ''}
|
|
817
|
+
<ol class="hx-thread-msgs" aria-label="Messages">${msgs}</ol>`;
|
|
818
|
+
}
|
|
819
|
+
|
|
564
820
|
function detailHtml() {
|
|
821
|
+
if (ui.thread !== null) return threadDetailHtml();
|
|
565
822
|
if (ui.deployItem) return deployDetailHtml();
|
|
566
823
|
if (ui.selected === null) return '';
|
|
567
824
|
const head = (title, link) => `<div class="hx-forge-detail-head"><h3 class="hx-forge-detail-title">${title}</h3><button type="button" class="hx-btn hx-btn--quiet" data-action="clear-forge-pr">Clear</button></div>${link || ''}`;
|
|
@@ -636,6 +893,7 @@ function findAgain(el) {
|
|
|
636
893
|
if (!el || !el.dataset) return null;
|
|
637
894
|
if (el.dataset.forgePr) return `[data-forge-pr="${el.dataset.forgePr}"]`;
|
|
638
895
|
if (el.dataset.forgeDeploy) return `[data-forge-deploy="${el.dataset.forgeDeploy}"]`;
|
|
896
|
+
if (el.dataset.forgeThread) return `[data-forge-thread="${el.dataset.forgeThread}"]`;
|
|
639
897
|
if (el.dataset.forgeNode) return `[data-forge-node="${el.dataset.forgeNode}"]`;
|
|
640
898
|
if (el.dataset.forgeMerge) return '[data-forge-merge]';
|
|
641
899
|
if (el.dataset.forgeSpec) return `[data-forge-spec="${el.dataset.forgeSpec}"]`;
|
|
@@ -677,7 +935,7 @@ function draw(panel) {
|
|
|
677
935
|
else status.textContent = `${shown.length} of ${plural(list.items.length, kindWords())}${list.at ? `, as of ${relativeTime(list.at, now())}` : ''}`;
|
|
678
936
|
|
|
679
937
|
const notice = $('forge-notice');
|
|
680
|
-
const words = ui.error || (list && list.stale ? `Showing an older answer. ${list.stale}` : '') || (work && list && list.problem ? `Spec pull requests could not all be read: ${list.problem}` : '');
|
|
938
|
+
const words = ui.error || (list && list.stale ? `Showing an older answer. ${list.stale}` : '') || (work && list && list.problem ? `Spec pull requests could not all be read: ${list.problem}` : '') || (ui.kind === 'threads' && list && list.problem ? `Some threads could not all be read: ${list.problem}` : '');
|
|
681
939
|
notice.hidden = !words;
|
|
682
940
|
notice.textContent = words;
|
|
683
941
|
|
|
@@ -695,11 +953,12 @@ function draw(panel) {
|
|
|
695
953
|
else rows.innerHTML = shown.length ? shown.map((i) => itemHtml(i)).join('') : `<li class="hx-forge-empty hx-faint">${list.items.length ? `No ${kindWords()} matches these filters.` : `No ${kindWords()}s here.`}</li>`;
|
|
696
954
|
|
|
697
955
|
const detail = $('forge-detail');
|
|
698
|
-
detail.hidden = ui.selected === null && !ui.deployItem;
|
|
699
|
-
detail.setAttribute('aria-label', ui.deployItem ? 'Selected deploy' : 'Selected pull request');
|
|
956
|
+
detail.hidden = ui.selected === null && !ui.deployItem && ui.thread === null;
|
|
957
|
+
detail.setAttribute('aria-label', ui.thread !== null ? 'Selected thread' : ui.deployItem ? 'Selected deploy' : 'Selected pull request');
|
|
700
958
|
detail.innerHTML = detailHtml();
|
|
701
959
|
|
|
702
|
-
|
|
960
|
+
paintIdea();
|
|
961
|
+
const rate = (ui.threadDetail && ui.threadDetail.rate) || (ui.detail && ui.detail.rate) || (list && list.rate) || '';
|
|
703
962
|
$('forge-rate').textContent = rate;
|
|
704
963
|
paintButton();
|
|
705
964
|
}
|
|
@@ -713,6 +972,7 @@ function drawNotes() {
|
|
|
713
972
|
notice.hidden = !words;
|
|
714
973
|
notice.textContent = words;
|
|
715
974
|
$('forge-detail').hidden = true;
|
|
975
|
+
paintIdea();
|
|
716
976
|
$('forge-rate').textContent = (n.data && n.data.rate) || '';
|
|
717
977
|
paintNotes();
|
|
718
978
|
paintButton();
|
|
@@ -722,11 +982,11 @@ function drawNotes() {
|
|
|
722
982
|
function paintButton() {
|
|
723
983
|
const btn = $('forge-btn');
|
|
724
984
|
if (!btn) return;
|
|
725
|
-
const lit = ui.selected !== null || !!ui.deployItem;
|
|
985
|
+
const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
|
|
726
986
|
const tag = $('forge-btn-lit');
|
|
727
987
|
tag.hidden = !lit;
|
|
728
|
-
tag.textContent = ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
729
|
-
btn.setAttribute('aria-label', ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
988
|
+
tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
989
|
+
btn.setAttribute('aria-label', ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
730
990
|
btn.setAttribute('aria-expanded', String(ui.open));
|
|
731
991
|
}
|
|
732
992
|
|
|
@@ -981,6 +1241,15 @@ export async function refreshForge({ quiet = false } = {}) {
|
|
|
981
1241
|
if (again) { ui.deployItem = again; relight(); }
|
|
982
1242
|
return;
|
|
983
1243
|
}
|
|
1244
|
+
if (ui.thread !== null) {
|
|
1245
|
+
const at = ui.thread;
|
|
1246
|
+
const token = ++threadToken;
|
|
1247
|
+
const res = await request(threadUrl(at), true);
|
|
1248
|
+
if (token !== threadToken || !res.ok) return;
|
|
1249
|
+
ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
|
|
1250
|
+
relight();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
984
1253
|
if (n !== null && ui.selected === n) {
|
|
985
1254
|
const token = ++pullToken;
|
|
986
1255
|
const res = await request(pullUrl(n), true);
|
|
@@ -1027,6 +1296,12 @@ function onClick(e) {
|
|
|
1027
1296
|
if (ui.selected === n) clearPull(); else selectPull(n);
|
|
1028
1297
|
return;
|
|
1029
1298
|
}
|
|
1299
|
+
const thr = t.closest('[data-forge-thread]');
|
|
1300
|
+
if (thr) {
|
|
1301
|
+
const n = Number(thr.dataset.forgeThread);
|
|
1302
|
+
if (ui.thread === n) clearThread(); else selectThread(n);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1030
1305
|
const dep = t.closest('[data-forge-deploy]');
|
|
1031
1306
|
if (dep) {
|
|
1032
1307
|
if (ui.deploy === dep.dataset.forgeDeploy) clearDeploy(); else selectDeploy(dep.dataset.forgeDeploy);
|
|
@@ -1062,6 +1337,10 @@ function onClick(e) {
|
|
|
1062
1337
|
else if (action.dataset.action === 'cancel-notes') { closeNotesConfirm(); announce('Cancelled. Nothing was posted.'); }
|
|
1063
1338
|
else if (action.dataset.action === 'clear-forge-pr') { clearPull(); $('forge-title').focus(); }
|
|
1064
1339
|
else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
|
|
1340
|
+
else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
|
|
1341
|
+
else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
|
|
1342
|
+
else if (action.dataset.action === 'idea-send') sendIdea();
|
|
1343
|
+
else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
|
|
1065
1344
|
}
|
|
1066
1345
|
|
|
1067
1346
|
function onChange(e) {
|
|
@@ -1126,6 +1405,11 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1126
1405
|
if (opts.clock) now = opts.clock;
|
|
1127
1406
|
btn.hidden = false;
|
|
1128
1407
|
panel.hidden = false;
|
|
1408
|
+
// `threads: false` in the config turns the tab off; a server that does not say has threads
|
|
1409
|
+
const threadsOn = config.threads !== false;
|
|
1410
|
+
const tab = panel.querySelector('[data-forge-kind="threads"]');
|
|
1411
|
+
if (tab) tab.hidden = !threadsOn;
|
|
1412
|
+
state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
|
|
1129
1413
|
registerSheet('forge', panel);
|
|
1130
1414
|
paintFilters();
|
|
1131
1415
|
paint();
|
|
@@ -1142,6 +1426,12 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1142
1426
|
confirm.addEventListener('keydown', onConfirmKey);
|
|
1143
1427
|
}
|
|
1144
1428
|
offScene = onSceneTouch(onNodesChange);
|
|
1429
|
+
// A node's panel lists the threads that mention it; a click there shows one in this panel
|
|
1430
|
+
const drawer = $('spec-drawer');
|
|
1431
|
+
if (drawer) drawer.addEventListener('click', (e) => {
|
|
1432
|
+
const open = e.target.closest && e.target.closest('[data-thread-open]');
|
|
1433
|
+
if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
|
|
1434
|
+
});
|
|
1145
1435
|
}
|
|
1146
1436
|
return true;
|
|
1147
1437
|
}
|
|
@@ -1157,6 +1447,11 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
|
|
|
1157
1447
|
listToken++;
|
|
1158
1448
|
pullToken++;
|
|
1159
1449
|
notesToken++;
|
|
1450
|
+
threadToken++;
|
|
1451
|
+
indexToken++;
|
|
1452
|
+
indexLoading = false;
|
|
1453
|
+
indexCache = null;
|
|
1160
1454
|
state.forgeLit = null;
|
|
1161
|
-
|
|
1455
|
+
state.threadIndex = null;
|
|
1456
|
+
Object.assign(ui, { thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes() });
|
|
1162
1457
|
}
|
package/explorer/index.html
CHANGED
|
@@ -211,6 +211,7 @@
|
|
|
211
211
|
<div class="hx-forge-tabs" role="group" aria-label="Show">
|
|
212
212
|
<button type="button" class="hx-forge-tab" data-forge-kind="pulls" aria-pressed="true">Pull requests</button>
|
|
213
213
|
<button type="button" class="hx-forge-tab" data-forge-kind="issues" aria-pressed="false">Issues</button>
|
|
214
|
+
<button type="button" class="hx-forge-tab" data-forge-kind="threads" aria-pressed="false" title="Ideas and spec conversations, with the nodes they mention">Threads</button>
|
|
214
215
|
<button type="button" class="hx-forge-tab" data-forge-kind="deploys" aria-pressed="false">Deploys</button>
|
|
215
216
|
<button type="button" class="hx-forge-tab" data-forge-kind="work" aria-pressed="false" title="Work grouped by issue and proposal, agent-made work marked">Agents</button>
|
|
216
217
|
<button type="button" class="hx-forge-tab" data-forge-kind="notes" aria-pressed="false">Release notes</button>
|
|
@@ -228,6 +229,23 @@
|
|
|
228
229
|
</div>
|
|
229
230
|
<p id="forge-notice" class="hx-forge-notice" hidden></p>
|
|
230
231
|
<p id="forge-act-note" class="hx-forge-notice" hidden></p>
|
|
232
|
+
<!-- New idea (forge-panel.js): opens an issue with the threads label as you, on the forge; the thread itself lives there -->
|
|
233
|
+
<section id="forge-idea" class="hx-forge-notes" aria-label="New idea" hidden>
|
|
234
|
+
<div class="hx-forge-actions">
|
|
235
|
+
<button type="button" id="idea-open" class="hx-btn hx-btn--approve" data-action="idea-open" aria-expanded="false" aria-controls="idea-form">New idea …</button>
|
|
236
|
+
</div>
|
|
237
|
+
<p id="idea-message" class="hx-forge-msg" role="status" hidden></p>
|
|
238
|
+
<div id="idea-form" class="hx-forge-confirm" role="group" aria-labelledby="idea-form-title" hidden>
|
|
239
|
+
<h3 id="idea-form-title" class="hx-forge-confirm-title">New idea</h3>
|
|
240
|
+
<p id="idea-words"></p>
|
|
241
|
+
<label class="hx-forge-field">Title<input type="text" id="idea-title" class="hx-forge-input" maxlength="256" autocomplete="off" /></label>
|
|
242
|
+
<label class="hx-forge-field">What is the idea?<textarea id="idea-body" class="hx-forge-preview hx-thread-compose" rows="6" maxlength="20000" spellcheck="true"></textarea></label>
|
|
243
|
+
<div class="hx-forge-actions">
|
|
244
|
+
<button type="button" id="idea-send" class="hx-btn hx-btn--approve" data-action="idea-send">Open the idea</button>
|
|
245
|
+
<button type="button" class="hx-btn hx-btn--quiet" data-action="idea-cancel">Cancel</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</section>
|
|
231
249
|
<!-- Release notes (forge-panel.js): the pull requests merged since a tag, grouped by the map; copy them, or have the forge keep them as a draft release -->
|
|
232
250
|
<section id="forge-notes" class="hx-forge-notes" aria-label="Release notes" hidden>
|
|
233
251
|
<label class="hx-forge-field">Since tag<select id="notes-since" class="hx-forge-select"></select></label>
|
package/explorer/panels.js
CHANGED
|
@@ -125,6 +125,26 @@ function dataSummary(node) {
|
|
|
125
125
|
return '';
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Threads that mention the node
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** "Mentioned in": the threads whose text names the node (an idea, or a spec PR's conversation); each opens in the issues and PRs panel */
|
|
133
|
+
function threadsSection(node) {
|
|
134
|
+
const index = state.threadIndex;
|
|
135
|
+
const threads = index ? index.for(node.id) : null;
|
|
136
|
+
if (!threads || !threads.length) return '';
|
|
137
|
+
const rows = threads.map((t) => `<div class="hx-line"><span class="hx-glyph" style="--glyph:#fbbf24">◆</span><span><button type="button" class="hx-btn hx-thread-open" data-thread-open="${t.number}" aria-label="Open thread #${t.number}, ${escHtml(t.title)}, in the issues and pull requests panel">#${t.number} ${escHtml(t.title)}</button> <span class="hx-faint">${t.kind === 'proposal' ? 'proposal' : 'idea'}</span></span></div>`);
|
|
138
|
+
return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Draws "Mentioned in" again in the open panel (the list was just read, or the map changed); nothing else in the panel moves */
|
|
142
|
+
export function repaintThreads() {
|
|
143
|
+
const box = $('drawer-threads');
|
|
144
|
+
if (!box || !state.selectedNode) return;
|
|
145
|
+
box.innerHTML = threadsSection(state.selectedNode);
|
|
146
|
+
}
|
|
147
|
+
|
|
128
148
|
// ---------------------------------------------------------------------------
|
|
129
149
|
// The drawer
|
|
130
150
|
// ---------------------------------------------------------------------------
|
|
@@ -179,6 +199,10 @@ export async function selectNode(node) {
|
|
|
179
199
|
const mockups = nodeMockupsHtml(node.id);
|
|
180
200
|
if (mockups) (proposed ? metaList.firstElementChild : metaList).insertAdjacentHTML(proposed ? 'afterend' : 'afterbegin', mockups);
|
|
181
201
|
|
|
202
|
+
// The threads that mention it (docs/threads.md): filled in place once the list is read
|
|
203
|
+
metaList.insertAdjacentHTML('beforeend', `<div id="drawer-threads">${threadsSection(node)}</div>`);
|
|
204
|
+
if (state.threadIndex) state.threadIndex.ensure();
|
|
205
|
+
|
|
182
206
|
// What the live layer knows of it (spec 0029): a count, a linked machine's item, contents and sets; repainted in place by live.js
|
|
183
207
|
metaList.insertAdjacentHTML('beforeend', `<div id="drawer-live">${liveSection(node)}</div>`);
|
|
184
208
|
hydrateLivePhotos($('drawer-live'));
|
package/explorer/state.js
CHANGED
|
@@ -119,6 +119,13 @@ export const state = {
|
|
|
119
119
|
*/
|
|
120
120
|
forgeLit: null,
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Which threads mention which nodes (forge-panel.js, docs/threads.md), for a node's panel: `{ for(nodeId) -> thread[] | null,
|
|
124
|
+
* ensure(), open(number) }`, or null when threads are off or there is no forge. `for` answers null until the list has been
|
|
125
|
+
* read; `ensure` reads it (once) and repaints the panel's "Mentioned in"; `open` shows a thread in the issues and PRs panel.
|
|
126
|
+
*/
|
|
127
|
+
threadIndex: null,
|
|
128
|
+
|
|
122
129
|
/**
|
|
123
130
|
* Changes whenever anything the scene is built from changes (scene.js). render.js rebuilds the
|
|
124
131
|
* scene only when this moved: never for a camera change or a packet frame.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popcorn245/cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "popcorn245 (https://github.com/popcorn245)",
|
package/server/db.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
|
|
13
13
|
* a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
|
|
14
14
|
* landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
|
|
15
|
+
* thread_seen the (repo, issue, comment) triples whose thread.* hook event was sent, and each thread's last-seen time, so a
|
|
16
|
+
* restart sends none twice and a look asks the forge only for what is new (server/hooks.js, server/threads.js);
|
|
17
|
+
* a row with `issue = 0` says the repo's threads have been looked at once
|
|
15
18
|
* sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
|
|
16
19
|
*
|
|
17
20
|
* The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
|
|
@@ -133,6 +136,18 @@ export const MIGRATIONS = Object.freeze([
|
|
|
133
136
|
ALTER TABLE sessions ADD COLUMN token_expires_at TEXT; -- when the access token stops working; NULL when it does not expire
|
|
134
137
|
ALTER TABLE sessions ADD COLUMN expiring_sent_at TEXT; -- when the \`session.expiring\` hook event was sent; NULL before
|
|
135
138
|
`,
|
|
139
|
+
// 6: threads (server/threads.js, server/hooks.js): which comments of a thread have been announced. The thread itself is the
|
|
140
|
+
// forge's (an issue's comments, or a spec PR's conversation); this only remembers what was told, never what was said
|
|
141
|
+
`
|
|
142
|
+
CREATE TABLE thread_seen (
|
|
143
|
+
repo TEXT NOT NULL, -- 'owner/name'
|
|
144
|
+
issue INTEGER NOT NULL, -- the issue or pull request number of the thread; 0 with comment '' says the repo has been looked at once
|
|
145
|
+
comment TEXT NOT NULL, -- '' the thread itself (opened); a comment's id (message); 'link:<id>' or 'link:body' (linked); 'pr:<n>' a spec PR that names the thread
|
|
146
|
+
stamp TEXT NOT NULL DEFAULT '', -- the forge's own time: the comment's last edit, or (on the '' row) the thread's last update when it was looked at
|
|
147
|
+
at TEXT NOT NULL, -- when cornfield first saw it
|
|
148
|
+
PRIMARY KEY (repo, issue, comment)
|
|
149
|
+
) STRICT;
|
|
150
|
+
`,
|
|
136
151
|
]);
|
|
137
152
|
|
|
138
153
|
/** The version a file is at: its `user_version` */
|