@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.
@@ -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.deployItem) {
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
- const rate = (ui.detail && ui.detail.rate) || (list && list.rate) || '';
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
- Object.assign(ui, { 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() });
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
  }
@@ -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>
@@ -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.7.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` */