@popcorn245/cornfield 0.12.0 → 0.13.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.
@@ -304,10 +304,12 @@ export function mentionsOf(refs, nodes) {
304
304
  * @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
305
305
  */
306
306
  export function litForThread(thread, nodes) {
307
- const all = linksOf(thread.messages.map((m) => m.body));
307
+ // A thread that is not there (not loaded, or an answer without one) lights nothing, and never throws
308
+ const messages = thread && Array.isArray(thread.messages) ? thread.messages.filter((m) => m && typeof m.body === 'string') : [];
309
+ const all = linksOf(messages.map((m) => m.body));
308
310
  const got = mentionsOf(all.refs, nodes);
309
311
  const perMessage = new Map();
310
- for (const m of thread.messages) perMessage.set(m.id, messageNodes(m.body, nodes));
312
+ for (const m of messages) perMessage.set(m.id, messageNodes(m.body, nodes));
311
313
  return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
312
314
  }
313
315
 
@@ -130,7 +130,8 @@ function freshNotes() {
130
130
 
131
131
  /** The New idea form: whether it is open, one send in flight, and what the last send came to */
132
132
  function freshIdea() {
133
- return { open: false, busy: false, message: '', bad: false, link: null };
133
+ // `opener` is the id of the node whose panel button that opened the form ('' when the bar's own button did), `node` the node it was about
134
+ return { open: false, busy: false, message: '', bad: false, link: null, opener: '', node: null };
134
135
  }
135
136
 
136
137
  let wired = false;
@@ -338,7 +339,7 @@ function relight() {
338
339
  if (ui.runSel) {
339
340
  const run = runById(ui.runSel);
340
341
  state.forgeLit = run ? litOfRun(run) : null;
341
- } else if (ui.threadDetail) {
342
+ } else if (ui.threadDetail && ui.threadDetail.thread) {
342
343
  ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
343
344
  state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
344
345
  } else if (ui.deployItem) {
@@ -413,6 +414,9 @@ function dropThread() {
413
414
  }
414
415
 
415
416
  /** Selects a thread: asks for its messages (read-only), then lights the nodes they mention */
417
+ /** The thread a successful answer holds, or null (a failure, or an answer without one) */
418
+ const threadOf = (res) => (res && res.ok && res.body && res.body.thread && typeof res.body.thread === 'object' && Array.isArray(res.body.thread.messages) ? res.body.thread : null);
419
+
416
420
  async function selectThread(n) {
417
421
  const token = ++threadToken;
418
422
  pullToken++;
@@ -435,13 +439,15 @@ async function selectThread(n) {
435
439
  paint();
436
440
  const res = await request(threadUrl(n), false);
437
441
  if (token !== threadToken) return;
438
- if (!res.ok) {
439
- ui.threadError = errorWords(res.status, res.body);
442
+ // An answer without a thread in it is a failure too: nothing to show or light
443
+ const thread = threadOf(res);
444
+ if (!thread) {
445
+ ui.threadError = res.ok ? `Thread ${n} could not be read: the answer had no thread in it. Refresh to ask again.` : errorWords(res.status, res.body);
440
446
  announce(ui.threadError);
441
447
  paint();
442
448
  return;
443
449
  }
444
- ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
450
+ ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
445
451
  relight();
446
452
  $('forge-panel').querySelector('.hx-forge-body').scrollTop = 0; // the thread's messages are what was asked for, not the row that was tapped
447
453
  announce(threadSelectionWords());
@@ -507,9 +513,45 @@ function threadsFor(nodeId) {
507
513
  /** Opens or closes the form; the focus goes to the title, or back to the button */
508
514
  function toggleIdea(open) {
509
515
  ui.idea.open = open;
510
- if (open) ui.idea.message = '';
516
+ if (open) { ui.idea.message = ''; ui.idea.opener = ''; ui.idea.node = null; }
511
517
  paintIdea();
512
- if (open) $('idea-title').focus(); else $('idea-open').focus();
518
+ if (open) $('idea-title').focus(); else focusIdeaOpener();
519
+ }
520
+
521
+ /** Puts the focus back on what opened the form: the node panel's button while it is still there, else the New idea button */
522
+ function focusIdeaOpener() {
523
+ const from = ui.idea.opener ? [...document.querySelectorAll('[data-thread-start]')].find((b) => b.dataset.threadStart === ui.idea.opener) : null;
524
+ (from && !from.closest('[inert]') ? from : $('idea-open')).focus();
525
+ }
526
+
527
+ /** A node id that reads back as one mention: no brackets or line breaks, as core/mentions.js parses them */
528
+ const MENTIONABLE = /^[^[\]\r\n]{1,200}$/;
529
+
530
+ /** "Start a thread about this" (a node's panel): the New idea form, with the node's label as the title and its mention in the text */
531
+ function startThread(node) {
532
+ if (!node || ui.idea.busy || !ui.config || ui.config.threads === false) return;
533
+ if (ui.kind !== 'threads') {
534
+ ui.kind = 'threads';
535
+ ui.actNote = null;
536
+ ui.filters = defaultFilters('threads');
537
+ }
538
+ openForge();
539
+ paintFilters();
540
+ ui.idea.open = true;
541
+ ui.idea.message = '';
542
+ ui.idea.bad = false;
543
+ ui.idea.link = null;
544
+ ui.idea.node = { id: node.id, label: node.label };
545
+ ui.idea.opener = node.id;
546
+ // Never lose a draft: a title already typed stays, and text already typed stays under the mention, which is added once (`.value` is
547
+ // text, never markup, so the label and the id need no escaping here)
548
+ const title = $('idea-title');
549
+ if (title.value.trim() === '') title.value = String(node.label || node.id).slice(0, 256);
550
+ const text = $('idea-body');
551
+ const mention = MENTIONABLE.test(node.id) ? `[[${node.id}]]` : '';
552
+ if (mention && !text.value.includes(mention)) text.value = text.value.trim() === '' ? `${mention}\n\n` : `${mention}\n\n${text.value}`;
553
+ paintIdea();
554
+ $('idea-title').focus();
513
555
  }
514
556
 
515
557
  /** The one write: a POST to cornfield's own server, which opens an issue with the label as the maintainer */
@@ -541,13 +583,36 @@ async function sendIdea() {
541
583
  }
542
584
  paintIdea();
543
585
  announce(ui.idea.message);
544
- if (res.ok) {
586
+ if (res.ok) await afterIdea(res);
587
+ else $('idea-title').focus();
588
+ }
589
+
590
+ /**
591
+ * After an idea is opened: the list read again, the new thread selected (so what it mentions lights), the focus back on what opened
592
+ * the form, and all of it said once. The idea is on the forge whatever happens here, so a failure is said, never thrown.
593
+ */
594
+ async function afterIdea(res) {
595
+ const about = ui.idea.node;
596
+ const n = res.body && res.body.number;
597
+ let trouble = '';
598
+ try {
545
599
  ui.lists = {};
546
600
  indexCache = null;
547
601
  await loadList(true, true);
548
602
  ensureThreadsList();
549
- $('idea-open').focus();
550
- } else $('idea-title').focus();
603
+ // The new thread is selected in the list, so what it mentions lights on the map once its messages are in
604
+ if (Number.isInteger(n)) await selectThread(n);
605
+ } catch (err) {
606
+ trouble = ` The list could not be read again (${err instanceof Error ? err.message : err}); Refresh asks for it.`;
607
+ }
608
+ // Then the focus goes back to what opened the form: after the selection, which draws the node's panel (and its button) again
609
+ try { focusIdeaOpener(); } catch { /* the page moved on */ }
610
+ ui.idea.opener = '';
611
+ ui.idea.node = null;
612
+ const shown = ui.thread === n && ui.threadDetail && ui.threadDetail.thread ? ` ${threadSelectionWords()}` : ui.thread === n && ui.threadError ? ` ${ui.threadError}` : '';
613
+ // Opened from a node: say that its "Mentioned in" lists the new thread
614
+ const listed = about && (threadsFor(about.id) || []).some((t) => t.number === n);
615
+ announce(`${ui.idea.message}${shown}${listed ? ` It is listed under Mentioned in for ${about.label}.` : ''}${trouble}`);
551
616
  }
552
617
 
553
618
  /** The New idea bar and form, from `ui.idea`; typed text and the focus are left alone */
@@ -1518,8 +1583,9 @@ export async function refreshForge({ quiet = false } = {}) {
1518
1583
  const at = ui.thread;
1519
1584
  const token = ++threadToken;
1520
1585
  const res = await request(threadUrl(at), true);
1521
- if (token !== threadToken || !res.ok) return;
1522
- ui.threadDetail = { thread: res.body.thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1586
+ const thread = threadOf(res);
1587
+ if (token !== threadToken || !thread) return;
1588
+ ui.threadDetail = { thread, at: res.body.at, rate: res.body.rate || '', stale: res.body.stale || '' };
1523
1589
  relight();
1524
1590
  return;
1525
1591
  }
@@ -1620,7 +1686,16 @@ function onClick(e) {
1620
1686
  else if (action.dataset.action === 'clear-forge-deploy') { clearDeploy(); $('forge-title').focus(); }
1621
1687
  else if (action.dataset.action === 'clear-forge-thread') { clearThread(); $('forge-title').focus(); }
1622
1688
  else if (action.dataset.action === 'idea-open') toggleIdea(!ui.idea.open);
1623
- else if (action.dataset.action === 'idea-send') sendIdea();
1689
+ else if (action.dataset.action === 'idea-send') {
1690
+ // Nothing of it may escape as an unhandled rejection: what went wrong is said, and the form is usable again
1691
+ sendIdea().catch((err) => {
1692
+ ui.idea.busy = false;
1693
+ ui.idea.message = `Something went wrong in the page: ${err instanceof Error ? err.message : err}`;
1694
+ ui.idea.bad = true;
1695
+ paintIdea();
1696
+ announce(ui.idea.message);
1697
+ });
1698
+ }
1624
1699
  else if (action.dataset.action === 'idea-cancel') { toggleIdea(false); announce('Cancelled. Nothing was opened.'); }
1625
1700
  }
1626
1701
 
@@ -1690,7 +1765,7 @@ export function initForgePanel(config, opts = {}) {
1690
1765
  const threadsOn = config.threads !== false;
1691
1766
  const tab = panel.querySelector('[data-forge-kind="threads"]');
1692
1767
  if (tab) tab.hidden = !threadsOn;
1693
- state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread } : null;
1768
+ state.threadIndex = threadsOn ? { for: threadsFor, ensure: ensureThreadsList, open: openThread, start: startThread, forge: forgeName } : null;
1694
1769
  registerSheet('forge', panel);
1695
1770
  paintFilters();
1696
1771
  paint();
@@ -1725,7 +1800,9 @@ export function initForgePanel(config, opts = {}) {
1725
1800
  const drawer = $('spec-drawer');
1726
1801
  if (drawer) drawer.addEventListener('click', (e) => {
1727
1802
  const open = e.target.closest && e.target.closest('[data-thread-open]');
1728
- if (open && state.threadIndex) openThread(Number(open.dataset.threadOpen));
1803
+ if (open && state.threadIndex) { openThread(Number(open.dataset.threadOpen)); return; }
1804
+ const start = e.target.closest && e.target.closest('[data-thread-start]');
1805
+ if (start && state.threadIndex && state.selectedNode) startThread(state.selectedNode);
1729
1806
  });
1730
1807
  }
1731
1808
  return true;
@@ -132,10 +132,13 @@ function dataSummary(node) {
132
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
133
  function threadsSection(node) {
134
134
  const index = state.threadIndex;
135
- const threads = index ? index.for(node.id) : null;
136
- if (!threads || !threads.length) return '';
135
+ if (!index) return '';
136
+ const threads = index.for(node.id);
137
+ // "Start a thread about this" shows whenever threads are on and a maintainer is signed in (the panel only sets `start` then)
138
+ const start = index.start ? `<div class="hx-line"><button type="button" class="hx-btn" data-thread-start="${escHtml(node.id)}" aria-label="Start a thread about this: opens the New idea form for ${escHtml(index.forge())}, with this node mentioned">Start a thread about this</button></div>` : '';
139
+ if (!threads || !threads.length) return start;
137
140
  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);
141
+ return section(`Mentioned in ${threads.length} ${threads.length === 1 ? 'thread' : 'threads'}:`, rows) + start;
139
142
  }
140
143
 
141
144
  /** Draws "Mentioned in" again in the open panel (the list was just read, or the map changed); nothing else in the panel moves */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popcorn245/cornfield",
3
- "version": "0.12.0",
3
+ "version": "0.13.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)",