cornfield 0.6.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/agent-contract.js +728 -0
- package/core/agent-contract.schema.json +259 -0
- package/core/config.js +36 -7
- package/core/defuse.js +17 -0
- 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 +2 -1
- package/server/db.js +15 -0
- package/server/defuse.js +3 -12
- 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
|
@@ -24,17 +24,21 @@
|
|
|
24
24
|
* where a step's plan is read, what the confirm step shows of it, what is sent to confirm it, and what is said of one that is made already
|
|
25
25
|
* landingUrl, cancelLandingUrl, landingWords
|
|
26
26
|
* a spec pull request waiting to land on its approval (`proposals.approveLands`, server/lander.js): where it is read, where it is cancelled, and the words
|
|
27
|
+
* threadsUrl, threadUrl, IDEAS_URL, threadKindWords, threadMeta, litForThread, mentionsIndex, mentionsOf, messageNodes, ideaRequest, ideaWords
|
|
28
|
+
* the Threads tab (docs/threads.md): what to ask, a thread's words, which nodes its text lights and which threads mention a node
|
|
27
29
|
* notesUrl, sinceChoices, notesSummary, validTag, confirmWords, postedWords, draftRequest
|
|
28
30
|
* the Release notes view: what to ask, the tags to start from, and the words around a draft
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
33
|
import { relativeTime } from './review-model.js';
|
|
34
|
+
import { nodesForFile, linksOf, parseMentions, resolveMentions } from '../core/mentions.js';
|
|
32
35
|
|
|
33
|
-
export const KINDS = Object.freeze(['pulls', 'issues', 'deploys', 'notes']);
|
|
36
|
+
export const KINDS = Object.freeze(['pulls', 'issues', 'threads', 'deploys', 'notes']);
|
|
34
37
|
/** The states a list offers: [value, label]; a pull request that landed is "merged", one closed without landing "closed" */
|
|
35
38
|
export const STATES = Object.freeze({
|
|
36
39
|
pulls: [['open', 'Open'], ['merged', 'Merged'], ['closed', 'Closed'], ['all', 'All']],
|
|
37
40
|
issues: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
|
|
41
|
+
threads: [['open', 'Open'], ['closed', 'Closed'], ['all', 'All']],
|
|
38
42
|
deploys: [['all', 'All'], ['success', 'Succeeded'], ['failure', 'Failed'], ['running', 'Running or waiting'], ['other', 'Cancelled or skipped']],
|
|
39
43
|
});
|
|
40
44
|
/** How many a list asks for (the server's own default; its most is 300) */
|
|
@@ -50,9 +54,13 @@ export const defaultFilters = (kind = 'pulls') => ({ state: kind === 'deploys' ?
|
|
|
50
54
|
export const serverState = (state) => (state === 'merged' ? 'closed' : state);
|
|
51
55
|
|
|
52
56
|
/** The address of a list */
|
|
53
|
-
export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
|
|
57
|
+
export const listUrl = (kind, state) => (kind === 'deploys' ? `/api/forge/deploys?limit=${LIST_LIMIT}` : kind === 'threads' ? threadsUrl(state) : `/api/forge/${kind}?state=${serverState(state)}&limit=${LIST_LIMIT}`);
|
|
54
58
|
/** What a list is kept under: a deploys list is filtered by state here, so every state is one list */
|
|
55
59
|
export const listKeyOf = (kind, state) => (kind === 'deploys' ? 'deploys' : `${kind}|${serverState(state)}`);
|
|
60
|
+
/** The address of the threads list for a state, of one thread, and where a new idea is posted */
|
|
61
|
+
export const threadsUrl = (state) => `/api/forge/threads?state=${state}&limit=${LIST_LIMIT}`;
|
|
62
|
+
export const threadUrl = (n) => `/api/forge/threads/${n}`;
|
|
63
|
+
export const IDEAS_URL = '/api/forge/threads';
|
|
56
64
|
/** The address of one pull request with its files and checks */
|
|
57
65
|
export const pullUrl = (n) => `/api/forge/pulls/${n}`;
|
|
58
66
|
|
|
@@ -152,25 +160,8 @@ export function checksWords(checks) {
|
|
|
152
160
|
|
|
153
161
|
const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
154
162
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
* (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them.
|
|
158
|
-
* @param {string} path a repo-relative path
|
|
159
|
-
* @param {{ id: string, file?: string }[]} nodes
|
|
160
|
-
* @returns {object[]} the nodes, [] when none holds it
|
|
161
|
-
*/
|
|
162
|
-
export function nodesForFile(path, nodes) {
|
|
163
|
-
const p = clean(path);
|
|
164
|
-
let best = -1;
|
|
165
|
-
let out = [];
|
|
166
|
-
for (const node of nodes) {
|
|
167
|
-
if (typeof node.file !== 'string' || node.file === '') continue;
|
|
168
|
-
const file = clean(node.file);
|
|
169
|
-
if (p !== file && !p.startsWith(`${file}/`)) continue;
|
|
170
|
-
if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
|
|
171
|
-
}
|
|
172
|
-
return out;
|
|
173
|
-
}
|
|
163
|
+
// The map's rule for a file's node(s) is core/mentions.js's too (a thread's [[server/auth.js]] resolves the same way)
|
|
164
|
+
export { nodesForFile };
|
|
174
165
|
|
|
175
166
|
const TEST_PATH = /(?:^|\/)(?:tests?|__tests__|spec|specs)\/|\.(?:test|spec)\.[a-z]+$|_test\.[a-z]+$|(?:^|\/)test_[^/]+\.py$/i;
|
|
176
167
|
/** Whether a path looks like a test file */
|
|
@@ -279,6 +270,82 @@ export function litForDeploy(deploy, nodes) {
|
|
|
279
270
|
: { nodes: hit, missing, changed: ids, failing: new Set() };
|
|
280
271
|
}
|
|
281
272
|
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Threads: a conversation on the forge that points at the map (core/mentions.js)
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/** "Idea", or "Proposal 0040-meals" for a spec PR's conversation */
|
|
278
|
+
export const threadKindWords = (t) => (t.kind === 'proposal' ? `Proposal${t.slugs && t.slugs.length ? ` ${t.slugs.join(', ')}` : ''}` : 'Idea');
|
|
279
|
+
|
|
280
|
+
/** "#41 · maintainer-one · 3 comments · 2 days ago" (the time of the last message) */
|
|
281
|
+
export function threadMeta(t, nowMs) {
|
|
282
|
+
const count = t.kind === 'proposal' && !t.comments ? 'no comments' : `${t.comments} ${t.comments === 1 ? 'comment' : 'comments'}`;
|
|
283
|
+
return [`#${t.number}`, t.author ? t.author.login : 'someone', count, relativeTime(t.updatedAt, nowMs)].filter(Boolean).join(' · ');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The nodes the references in some texts stand for, against the map now on screen. `refs` are as the server gives them (or
|
|
288
|
+
* as `linksOf` finds them); what is on no node stays text.
|
|
289
|
+
* @param {string[]} refs
|
|
290
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
291
|
+
* @returns {{ ids: string[], nodes: object[], unresolved: string[], resolved: { ref: string, ids: string[] }[] }}
|
|
292
|
+
*/
|
|
293
|
+
export function mentionsOf(refs, nodes) {
|
|
294
|
+
const got = resolveMentions(refs, nodes);
|
|
295
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
296
|
+
return { ...got, nodes: got.ids.map((id) => byId.get(id)).filter(Boolean) };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* What an open thread lights: the nodes its messages mention, as a pull request's files light theirs (amber, the rest
|
|
301
|
+
* faded). `messages` carry `body`; each message also says which nodes are its own (for the chips under it).
|
|
302
|
+
* @param {{ messages: { id: string, body: string }[] }} thread
|
|
303
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
304
|
+
* @returns {{ nodes: object[], unresolved: string[], changed: Set<string>, failing: Set<string>, perMessage: Map<string, { nodes: object[], unresolved: string[] }> }}
|
|
305
|
+
*/
|
|
306
|
+
export function litForThread(thread, nodes) {
|
|
307
|
+
const all = linksOf(thread.messages.map((m) => m.body));
|
|
308
|
+
const got = mentionsOf(all.refs, nodes);
|
|
309
|
+
const perMessage = new Map();
|
|
310
|
+
for (const m of thread.messages) perMessage.set(m.id, messageNodes(m.body, nodes));
|
|
311
|
+
return { nodes: got.nodes, unresolved: got.unresolved, changed: new Set(got.ids), failing: new Set(), perMessage };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The nodes one message mentions (its `[[…]]` and its `cornfield-thread` block), and the references that are on none */
|
|
315
|
+
export function messageNodes(body, nodes) {
|
|
316
|
+
const got = parseMentions(body);
|
|
317
|
+
const found = mentionsOf([...got.refs, ...(got.block ? got.block.nodes : [])], nodes);
|
|
318
|
+
return { nodes: found.nodes, unresolved: found.unresolved };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Which threads mention which nodes, from the list the server gave (each thread's references as written), against the map
|
|
323
|
+
* on screen.
|
|
324
|
+
* @param {{ number: number, refs: string[] }[]} items
|
|
325
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
326
|
+
* @returns {Map<string, object[]>} node id -> the threads, in the order of `items`
|
|
327
|
+
*/
|
|
328
|
+
export function mentionsIndex(items, nodes) {
|
|
329
|
+
const index = new Map();
|
|
330
|
+
for (const t of items) {
|
|
331
|
+
for (const id of resolveMentions(t.refs || [], nodes).ids) index.set(id, [...(index.get(id) || []), t]);
|
|
332
|
+
}
|
|
333
|
+
return index;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A new idea's request body: the title and the text, trimmed, or null when there is no title */
|
|
337
|
+
export function ideaRequest(title, body) {
|
|
338
|
+
const t = String(title ?? '').trim();
|
|
339
|
+
return t ? { title: t, body: String(body ?? '').trim() } : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** What a new idea came to, in words */
|
|
343
|
+
export function ideaWords(forgeName, res) {
|
|
344
|
+
if (res.ok) return `Opened idea #${res.body.number} on ${forgeName}. It shows in the list within a minute.`;
|
|
345
|
+
if (res.status === 401) return 'Your sign-in has ended. Sign in again to open an idea.';
|
|
346
|
+
return errorWords(res.status, res.body);
|
|
347
|
+
}
|
|
348
|
+
|
|
282
349
|
// ---------------------------------------------------------------------------
|
|
283
350
|
// The picture
|
|
284
351
|
// ---------------------------------------------------------------------------
|
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
|
}
|