@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.
- package/explorer/forge-panel-model.js +4 -2
- package/explorer/forge-panel.js +92 -15
- package/explorer/panels.js +6 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/explorer/forge-panel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
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')
|
|
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;
|
package/explorer/panels.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
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.
|
|
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)",
|