What's next, issues and milestones from Forgejo, GitLab or GitHub, in docked panes

Install · What's Next · In pictures · What it needs · Settings · Bugs and ideas
Your next issue, in a docked pane beside the conversation. From Forgejo, GitLab or GitHub.
<img src="images/whats-next.png" alt="What's next in a terminal: an issue in progress, the next issue due soon, the Queue under a heading per scope, and the issue closed last" width="1100">
What's Next is a mod: a change to Claude Code's own interface. It needs an up to date version of Claude Code, in the terminal. Its panes do not load on claude.ai or in Cowork, and in a Code session of the Claude desktop app it stops at setup (see Settings).
Install, in Claude Code (answer y to add the marketplace, then pick a scope):
/plugin install whats-next --marketplace egribnau/whats-next
Update, from a shell, then run /reload-plugins in an open session:
claude plugin marketplace update egribnau
claude plugin update whats-next@egribnau
/next opens docked panes on the issues of the repo behind the current directory's origin:
m): narrows What's next and Issues to one milestone.l): narrows What's next and Issues to the issues with every label ticked (and), with one of them (or), or without a label (No label); with or, No label ticks along with the labels. A second press on a label's box leaves it out (☒): docs, not bug. Its button names what is set, All when nothing is. Tick epic for the epics alone.h): what each sign and colour means.| Command | Opens |
|---|---|
/next | the panes, with What's next in front and the keyboard on it |
/next help | What's next on the help page |
/next, the command is /whats-next in that session, and a toast says so.| Issues | Milestones |
|---|---|
| <img src="images/issues.png" alt="Issues by milestone: an epic folded open on its children, another folded" width="482"> | <img src="images/milestones.png" alt="Milestones: one milestone folded open on its epics and issues" width="482"> |
| An issue | Its menu |
| <img src="images/issue.png" alt="An issue's detail: its epic, child, milestone, label, pull request and description" width="482"> | <img src="images/menu.png" alt="The menu of an issue's detail, each item with its hotkey" width="482"> |
| Its labels | The label filter |
| <img src="images/labels.png" alt="The labels list in an issue's detail: each label in its colour, the issue's own checked" width="482"> | <img src="images/label-filter.png" alt="The label filter in Issues: one label set, one left out, and the issues that match behind it" width="482"> |
| A milestone | Help |
| <img src="images/milestone.png" alt="A milestone's detail: due date, bar, its issues as a tree and its description" width="482"> | <img src="images/help.png" alt="The help page: the marks, fold marks, signs, counts, colours and buttons" width="482"> |
| Setup, stopped at the token | |
| <img src="images/setup.png" alt="Setup: repo and forge found, no token yet, and what to do" width="482"> | |
| Compact off | Compact on |
| <img src="images/compact-off.png" alt="What's next with an empty line between rows and before each heading" width="482"> | <img src="images/compact-on.png" alt="What's next without an empty line between rows, still one before each heading" width="482"> |
A repo on one of these forges, and an access token for it:
| Forge | Token setting | To read | To assign, close and change labels, milestones and epics |
|---|---|---|---|
| Forgejo or Gitea | Forgejo tokens | read:issue, read:repository, read:user | write:issue in place of read:issue, and push rights on the repo |
| gitlab.com | GitLab.com token | read_api | api, and Reporter or higher on the project |
| GitLab on your own host | Private GitLab token | read_api | api, and Reporter or higher on the project |
| github.com or GitHub Enterprise | GitHub token | fine-grained: Issues read; classic: repo (public_repo on a public repo) | fine-grained: Issues read and write; classic: repo; and push rights on the repo |
Tested on Forgejo, GitLab Free and GitHub. Blockers come from Forgejo's dependencies, GitHub's "blocked by" relations and GitLab's blocking links; GitLab has blocking links only in EE, where What's Next reads them but they are not tested here. On GitLab Free nothing shows as blocked.
Until all is set, the pane walks you through setup: the repo, the forge at its host, the token, whether it reads the repo, and whether What's Next may assign, close and change labels, milestones and epics or is read only.
A token goes only to the host it is for, and What's Next shows it nowhere. It reads tokens only from its own settings: no environment variables, no files of other tools.
Set the tokens with /plugin configure whats-next@egribnau; the other settings are also in /config, under What's Next.
| Setting | Default | What it does |
|---|---|---|
| Forgejo tokens | empty | Forgejo or Gitea tokens. A bare token is for every host; more hosts take host=token pairs separated by spaces (git.example.org=<token> codeberg.org=<token>), and a pair wins over a bare token. |
| GitLab.com token | empty | The token for gitlab.com. |
| Private GitLab token | empty | The token for your own GitLab, any host but gitlab.com; host=token pairs for more than one host. |
| GitHub token | empty | The token for github.com and GitHub Enterprise; host=token pairs for more than one host. |
| Epic label | epic | The label that makes an issue an epic. |
| Compact | ask | on: lists without an empty line between rows. ask: What's Next asks once. |
| Icons | ask | icons: buttons as Nerd Font icons; text: as words. ask: What's Next asks once, after setup. |
| Debug | off | A line under the bottom line counts the requests to the forge, for the last action and the session. |
Tokens are kept in secure storage. In a Code session of the Claude desktop app they stay empty, so the pane stops at setup's Token step: What's Next is for the terminal.
How the panes draw and order issues is in docs.
Report a bug or ask for a feature in an issue on GitHub, with the bug report or feature request template.
What's Next takes no code from others: no pull requests, patches or code in issues. Code from someone else would have to be checked for anything malicious, and that is not done here. Pull requests are closed unread.
Add your clone as a marketplace and install from it; Claude Code then reads the folder itself, and /reload-plugins picks up an edit without a push:
claude plugin marketplace add <path to your clone>
claude plugin install whats-next@egribnau
Checks and tests are listed in .claude/CLAUDE.md.
Copyright (C) 2026 Erwin Gribnau.
What's Next is free software under the GNU Affero General Public License, version 3 only (AGPL-3.0-only): see LICENSE. It comes with no warranty.
What's Next talks only to the host in the repo's origin, over HTTPS. It sends nothing anywhere else: no other hosts, no usage data. Which host that is comes from your repo, so the code names no fixed address.
| What | To | With |
|---|---|---|
Which forge the host runs: /api/v1/version, /api/v4/version and /api/v3/meta in turn, until one answers. Not asked for github.com and gitlab.com. | the repo's host | no token |
| The repo, your account, and its issues, milestones, labels, epics and blockers; what you change: assignee, closed, labels, milestone, epic | Forgejo https://<host>/api/v1, GitLab https://<host>/api/v4, github.com https://api.github.com, GitHub Enterprise https://<host>/api/v3 | the token setting for that forge and host: Authorization header, PRIVATE-TOKEN on GitLab |
| On Forgejo, your account's tokens, to read the scopes of the one in use (matched on its last eight characters) | https://<host>/api/v1/users/<you>/tokens | the Forgejo token |
A token goes only to the host it is for (see What it needs). Tokens come only from the four token settings, kept in secure storage: no environment variable and no file of another tool is read for one, and the tests check this.
On your computer it runs these programs, each with the words shown and nothing else:
git remote get-url origin and git rev-parse --is-inside-work-tree, to find the repo;pwd, to name the folder in setup when it holds no repo;open on macOS, else xdg-open, with a page's https address, when you open an issue, a milestone or What's Next's own repo in the browser. An address the forge gives that is not https:// is not opened: nothing else it fetches is ever run.It reads one file, its own .claude-plugin/plugin.json, for the version on its bottom line, and sends nothing of it anywhere. It reads no other file and no environment variable.
In Claude Code it:
/config, each only when you answer its question about it after setup: no other setting, no permission and no environment variable;/next (/whats-next when another has /next), and opens, draws and closes its panes.Its hooks: session.start adds the command and, after a reload with the panes open, loads again; classic.SessionStart loads again after /clear; command.run runs /next; config.set passes every setting on unchanged and only notes a change to Icons or Compact, so the panes follow it; the ui hooks draw the panes and follow their scrolling, focus and closing.
The scripts in docs/ are for working on What's Next: the mod never runs them. check-tokens.sh and check-marketplace.sh only read the mod's files to check them, and change none; shoot.py takes the screenshots in images/ for this README.
hooks/register.tsx 1234 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Hook, Register, RenderInput, Timer } from 'claude-code'
3
4import type { Comment, Detail, Epic, IssueDetail, IssueRow, Issues, Label, LabelFilter, Milestone, MilestoneFilter, Overview, ParentChoices, Ref, Repo, RepoLabels, Setup, TokenSource, Where } from '../types'
5import { draw, type Look } from './draw'
6import { inMilestone } from './epics'
7import { configureCommand, type Forge, type TokenSetting } from './forge'
8import { ALL, filterWord, isAll, NO_LABELS, sameFilter } from './filter'
9import { changesSince, loadRepo, noteChange, noteParents, parentsSince, type Known } from './load'
10import { anyToken, checkForge, findRepo, trackerFor, type Tokens } from './setup'
11import { message, slug, type Send, type Tracker } from './tracker'
12import * as step from './where'
13import { HERE, issueOf, milestoneOf } from './where'
14import { ISSUES_PANE, MILESTONES_PANE, PANE } from './panes'
15import { frame, hidden, listShown, views, type Acts, type Pane, type Shown } from './views/frame'
16import { helpView } from './views/help'
17import { issueView } from './views/issue'
18import { issuesView } from './views/issues'
19import { milestonesView } from './views/milestones'
20import { nextView } from './views/next'
21import { compactChoice, iconsChoice, setupView } from './views/setup'
22
23// The command, and the one it falls back to when another has /next.
24const COMMAND = 'next'
25const FALLBACK_COMMAND = 'whats-next'
26
27const repo = atom({ plugin: 'whats-next', key: 'repo' } as const, null)
28const version = atom({ plugin: 'whats-next', key: 'version' } as const, null)
29const token = atom({ plugin: 'whats-next', key: 'token' } as const, null)
30const setup = atom({ plugin: 'whats-next', key: 'setup' } as const, { kind: 'checking' })
31const overview = atom({ plugin: 'whats-next', key: 'overview' } as const, { kind: 'idle' })
32// Per pane, keyed by its id: what was loaded for the issue it shows. Each
33// pane opens issues in a detail of its own; where it is, hooks/panes.ts.
34const detail = atom({ plugin: 'whats-next', key: 'detail' } as const, { kind: 'idle' })
35const parentChoices = atom({ plugin: 'whats-next', key: 'parentChoices' } as const, { kind: 'idle' })
36const spinner = atom({ plugin: 'whats-next', key: 'spinner' } as const, 0)
37const iconsChosen = atom({ plugin: 'whats-next', key: 'icons' } as const, null)
38const compactChosen = atom({ plugin: 'whats-next', key: 'compact' } as const, null)
39const issues = atom({ plugin: 'whats-next', key: 'issues' } as const, { kind: 'idle' })
40const unfolded = atom({ plugin: 'whats-next', key: 'unfolded' } as const, [])
41const treeKids = atom({ plugin: 'whats-next', key: 'treeKids' } as const, {})
42const issuesFolded = atom({ plugin: 'whats-next', key: 'issuesFolded' } as const, false)
43const descriptionUnfolded = atom({ plugin: 'whats-next', key: 'descriptionUnfolded' } as const, false)
44const milestones = atom({ plugin: 'whats-next', key: 'milestones' } as const, null)
45const milestonesUnfolded = atom({ plugin: 'whats-next', key: 'milestonesUnfolded' } as const, [])
46const milestoneError = atom({ plugin: 'whats-next', key: 'milestoneError' } as const, null)
47const msIssues = atom({ plugin: 'whats-next', key: 'milestoneIssues' } as const, {})
48const closedMilestones = atom({ plugin: 'whats-next', key: 'closedMilestones' } as const, [])
49const closedShown = atom({ plugin: 'whats-next', key: 'closedShown' } as const, false)
50const panesOpen = atom({ plugin: 'whats-next', key: 'panesOpen' } as const, [])
51const filterChosen = atom({ plugin: 'whats-next', key: 'filterChosen' } as const, null)
52const filterShown = atom({ plugin: 'whats-next', key: 'filterShown' } as const, null)
53const filterOpen = atom({ plugin: 'whats-next', key: 'filterOpen' } as const, false)
54const labelFilter = atom({ plugin: 'whats-next', key: 'labelFilter' } as const, { labels: [], mode: 'and' })
55const repoLabels = atom({ plugin: 'whats-next', key: 'repoLabels' } as const, { kind: 'idle' })
56const childrenToo = atom({ plugin: 'whats-next', key: 'childrenToo' } as const, true)
57const requests = atom({ plugin: 'whats-next', key: 'requests' } as const, null)
58// Per pane, keyed by its id: where it is (hooks/where.ts).
59const where = atom({ plugin: 'whats-next', key: 'where' } as const, HERE)
60
61// Where pane `pane` is, and the pane moved by one of where.ts's steps.
62function placeOf($: EngineInterface, pane: string): Promise<Where> {
63 return read($, memberOf(where, { requestId: pane }))
64}
65function go($: EngineInterface, pane: string, change: (w: Where) => Where) {
66 return update($, memberOf(where, { requestId: pane }), change)
67}
68// The milestone the Milestones pane has open, null when it shows its list.
69async function milestoneShown($: EngineInterface) {
70 return milestoneOf(await placeOf($, MILESTONES_PANE))
71}
72
73// Every request What's Next sends the forge, counted, so Debug can say what each action cost.
74let requestsMade = 0
75function send($: EngineInterface): Send {
76 return (url, init) => {
77 requestsMade++
78 return $.http.fetch(url, init)
79 }
80}
81
82// The forge each host runs, as setup's check found it.
83const forges = new Map<string, Forge>()
84
85// The tracker behind repo `r`, as setup's check found its forge.
86function tracker($: EngineInterface, r: Repo): Tracker {
87 return trackerFor(send($), r, forges.get(r.host), configTokens, configLabel)
88}
89
90// Runs an action and records how many requests it sent, under `label`.
91async function counted<T>($: EngineInterface, label: string, action: () => Promise<T>): Promise<T> {
92 const before = requestsMade
93 const result = await action()
94 const count = requestsMade - before
95 await update($, requests, last => ({ label, count, total: (last?.total ?? 0) + count }))
96 return result
97}
98
99// The token settings' text, read in `register`; a reload reads them again.
100const configTokens: Tokens = {
101 'Forgejo tokens': '',
102 'GitLab.com token': '',
103 'Private GitLab token': '',
104 'GitHub token': '',
105}
106// The Icons setting: ask, icons or text.
107let configIcons = 'ask'
108// The Compact setting: ask, on or off.
109let configCompact = 'ask'
110// The Epic label setting: the label that makes an issue an epic.
111let configLabel = 'epic'
112// The Debug setting: count What's Next's requests on a line under the bottom line.
113let configDebug = false
114// The command that sets a token, with the marketplace What's Next was
115// installed from once session.start has read it off /config.
116let configure = configureCommand([])
117
118// The /config row key of What's Next's setting `field`: `whats-next.<field>`
119// from a folder, else `whats-next@<marketplace>.<field>`, and a marketplace
120// name may hold a dot (`configureCommand` reads the same keys).
121const settingKey = (field: string) => new RegExp(`^whats-next(@.*)?\\.${field}$`)
122
123// A choice of Icons or Compact, as setup's question asks it.
124type Choice = { field: 'icons'; value: 'icons' | 'text' } | { field: 'compact'; value: 'on' | 'off' }
125
126// Saves a choice: in the session's state, so it shows at once, and in /config
127// where the session offers the setting. Each atom is written out, as the
128// scan that validates a hook reads them.
129async function chooseSetting($: EngineInterface, choice: Choice) {
130 if (choice.field === 'icons') await update($, iconsChosen, () => choice.value)
131 else await update($, compactChosen, () => choice.value)
132 const keys = new Set((await $.config.list()).map(r => r.key))
133 const done = await saveChoice($, keys, choice)
134 if (done?.deny !== undefined) $.ui.toast(`${choice.field === 'icons' ? 'Icons' : 'Compact'} not saved: ${done.deny}`)
135}
136
137// Writes a choice to its /config row among `keys`, the rows the session
138// offers: What's Next from a folder, from its marketplace, or from the
139// directory (`synced`). Each key is written out, as the directory reads a
140// setting's key only as fixed text at the call; from any other marketplace
141// the choice holds for the session only. docs/check-marketplace.sh knows
142// these keys name the marketplace.
143async function saveChoice($: EngineInterface, keys: Set<string>, choice: Choice) {
144 if (choice.field === 'icons') {
145 if (keys.has('whats-next.icons')) return $.config.set({ key: 'whats-next.icons', value: choice.value })
146 if (keys.has('whats-next@egribnau.icons')) return $.config.set({ key: 'whats-next@egribnau.icons', value: choice.value })
147 if (keys.has('whats-next@synced.icons')) return $.config.set({ key: 'whats-next@synced.icons', value: choice.value })
148 } else {
149 if (keys.has('whats-next.compact')) return $.config.set({ key: 'whats-next.compact', value: choice.value })
150 if (keys.has('whats-next@egribnau.compact')) return $.config.set({ key: 'whats-next@egribnau.compact', value: choice.value })
151 if (keys.has('whats-next@synced.compact')) return $.config.set({ key: 'whats-next@synced.compact', value: choice.value })
152 }
153 return null
154}
155
156// Each step in turn, stopping at the first that fails (hooks/setup.ts has
157// the steps): this reads `git`, keeps what each step found and passes the
158// settings in.
159async function check($: EngineInterface): Promise<Repo | null> {
160 await update($, setup, (): Setup => ({ kind: 'checking' }))
161 await update($, token, () => anyToken(configTokens))
162
163 // Each command written out, as the plugin directory reads it.
164 const out = (run: { exitCode: number; stdout: string } | null) => (run?.exitCode === 0 ? run.stdout.trim() : null)
165 const url = out(await $.process.run(['git', 'remote', 'get-url', 'origin']).catch(() => null))
166 const found = await findRepo(url, async () => {
167 const inRepo = url !== null || out(await $.process.run(['git', 'rev-parse', '--is-inside-work-tree']).catch(() => null)) === 'true'
168 const pwd = await $.process.run(['pwd']).catch(() => null)
169 return { inRepo, dir: (pwd?.exitCode === 0 ? pwd.stdout.trim() : 'this folder').replace(/^\/(?:Users|home)\/[^/]+/, '~') }
170 })
171 const r = found.repo
172 await update($, repo, () => r)
173 await update($, version, () => null)
174 if (!r) {
175 await update($, setup, () => found.setup)
176 return null
177 }
178
179 const checked = await checkForge(send($), r, configTokens, configLabel, forges.get(r.host))
180 forges.set(r.host, checked.forge)
181 await update($, token, () => checked.token)
182 await update($, version, () => checked.version)
183 await update($, setup, () => checked.setup)
184 return r
185}
186
187// What is known of the repo, built by each load and kept until the next
188// Refresh or /next; while a load asks again, the last one's.
189let known: Known | null = null
190
191// Asks the tracker for what the panes show: the open milestones, your open
192// issues, the repo's open issues, and the epics. Then the milestones, the
193// milestone filter, What's next and Issues follow. What was known before
194// goes to What's next, which tells an issue that left the lists from one
195// that was never in them.
196async function load($: EngineInterface, r: Repo) {
197 const before = known
198 if ((await read($, overview)).kind !== 'ok') await update($, overview, (): Overview => ({ kind: 'loading' }))
199 if ((await read($, issues)).kind !== 'ok') await update($, issues, (): Issues => ({ kind: 'loading' }))
200 const s = await read($, setup)
201 const ready = s.kind === 'ready'
202 const loaded = await loadRepo(tracker($, r), {
203 me: ready ? s.login : null,
204 features: ready ? s.features : { time: true, deps: true, epics: true },
205 changes: changesSince(`${r.host}/${slug(r)}`, await $.clock.now()),
206 parents: parentsSince(`${r.host}/${slug(r)}`, await $.clock.now()),
207 })
208 const { issuesError, epicsError } = loaded
209 if (issuesError !== null) await update($, overview, (): Overview => ({ kind: 'error', message: issuesError }))
210 const issuesFailed = issuesError ?? epicsError
211 if (issuesFailed !== null) await update($, issues, (): Issues => ({ kind: 'error', message: issuesFailed }))
212 known = loaded
213 await update($, milestones, () => loaded.milestones)
214 await update($, closedMilestones, () => loaded.closedMilestones)
215 await showFilter($)
216 if (issuesError === null) await showNext($, before)
217 if (issuesFailed === null) await showIssues($)
218 // The milestones folded open, and the one in its detail, are asked again;
219 // the rest cost nothing.
220 await update($, msIssues, () => ({}))
221 const asked = await read($, milestonesUnfolded)
222 const inDetail = await milestoneShown($)
223 for (const title of inDetail === null || asked.includes(inDetail) ? asked : [...asked, inDetail]) await showMilestone($, title)
224}
225
226// The open issues of milestone `title`, for the Milestones pane.
227async function showMilestone($: EngineInterface, title: string) {
228 if (!known) return
229 const issues = await known.milestone(title)
230 await update($, msIssues, shown => ({ ...shown, [title]: issues }))
231 await showTree($)
232}
233
234// The open epics at the top of a group's tree, as the trees start.
235const topOf = (l: Parameters<typeof inMilestone>[0]): Epic[] =>
236 inMilestone(l).epics.map(e => ({ number: e.number, title: e.title, state: e.state, due: e.due, children: e.children ?? null }))
237
238// The children every tree shown needs, as far as it is folded open: the
239// Issues pane's, each milestone's folded open, and the one in its detail.
240async function showTree($: EngineInterface) {
241 if (!known) return
242 const shown = await read($, issues)
243 const trees: Epic[][] = shown.kind === 'ok' ? shown.groups.map(g => topOf(g.issues)) : []
244 const issuesOf = await read($, msIssues)
245 const inDetail = await milestoneShown($)
246 for (const title of new Set([...(await read($, milestonesUnfolded)), ...(inDetail === null ? [] : [inDetail])])) {
247 const listed = issuesOf[title]
248 if (listed) trees.push(topOf(listed))
249 }
250 const kids = await known.grow(trees, new Set(await read($, unfolded)))
251 await update($, treeKids, () => kids)
252}
253
254// Folds milestone `title` open or shut; open the first time, its issues are asked.
255async function foldMilestone($: EngineInterface, title: string) {
256 const was = await read($, milestonesUnfolded)
257 await update($, milestonesUnfolded, () => (was.includes(title) ? was.filter(t => t !== title) : [...was, title]))
258 if (!known || was.includes(title) || title in (await read($, msIssues))) return
259 await counted($, title, () => showMilestone($, title))
260}
261
262// Opens milestone `title` in its detail, or the list again for null. Its
263// issues are asked unless a fold already did.
264// It opens as its rules say, every time (DRAWING.md, A detail scrolls).
265async function openMilestone($: EngineInterface, title: string | null) {
266 await go($, MILESTONES_PANE, w => step.openMilestone(w, title))
267 await update($, milestoneError, () => null)
268 await update($, issuesFolded, () => false)
269 await update($, descriptionUnfolded, () => false)
270 if (!known || title === null || title in (await read($, msIssues))) return
271 await counted($, title, () => showMilestone($, title))
272}
273
274// Closes milestone `m`, or opens it again, then shows the list, read again,
275// where it moved to Closed or back. A refusal keeps the detail open, shown
276// under its actions.
277async function closeMilestone($: EngineInterface, m: Milestone, closed: boolean) {
278 if (!known) return
279 const why = await known.tracker.closeMilestone(m, closed)
280 if (why !== null) {
281 await update($, milestoneError, () => `Could not ${closed ? 'close' : 'reopen'} ${m.title}: ${why.replace(/\.$/, '')}.`)
282 return
283 }
284 await openMilestone($, null)
285 await refresh($)
286}
287
288// The milestone filter's options from what is open, and what it is set to:
289// the person's choice while it is still offered, else where you work
290// (docs/ORDERING.md).
291async function showFilter($: EngineInterface) {
292 if (!known) return
293 const options = known.options()
294 const wanted = await read($, filterChosen)
295 const set = options.find(o => sameFilter(wanted, o.filter))?.filter ?? (await known.startingFilter(options, await $.clock.now()))
296 await update($, filterShown, () => ({ options, set }))
297}
298
299// What the milestone filter is set to, All until it is loaded.
300async function filterSet($: EngineInterface): Promise<MilestoneFilter> {
301 return (await read($, filterShown))?.set ?? ALL
302}
303
304// What's next within the milestone filter. `before`: what was known before
305// a load; without it (the milestone filter switching) nothing left the lists.
306async function showNext($: EngineInterface, before: Known | null = null) {
307 const k = known
308 if (!k) return
309 const next = await k.whatsNext(await filterSet($), await $.clock.now(), await read($, labelFilter))
310 await update($, overview, (): Overview => ({ kind: 'ok', ...next }))
311 // An issue the pane listed open before the load, and lists open no more
312 // (closed, or taken by someone else), closes: What's next shows. One it
313 // opened on purpose from elsewhere, the Last closed issue or one reached
314 // through Blocked by or Blocks, stays.
315 await go($, PANE, w => {
316 const n = issueOf(w)
317 return n !== null && before !== null && before.listedOpen(n) && !k.listedOpen(n) ? step.closed(w) : w
318 })
319}
320
321// The open issues within the milestone filter, by milestone. What was shown
322// stays up while this loads, so the milestone filter does not vanish under
323// the person's hand.
324async function showIssues($: EngineInterface) {
325 if (!known) return
326 const groups = await known.issues(await filterSet($))
327 if (groups === null) return
328 await update($, issues, (): Issues => ({ kind: 'ok', groups, openIssues: known?.openIssues() ?? 0 }))
329 await showTree($)
330}
331
332// Folds issue `n` open or shut in a tree; open, the children it now shows
333// are asked for, those not asked before.
334async function foldIssue($: EngineInterface, n: number) {
335 const was = await read($, unfolded)
336 await update($, unfolded, () => (was.includes(n) ? was.filter(x => x !== n) : [...was, n]))
337 if (!known || was.includes(n)) return
338 await counted($, `#${n}`, () => showTree($))
339}
340
341// Sets the milestone filter for both panes and closes its list.
342async function setFilter($: EngineInterface, filter: MilestoneFilter, label: string) {
343 await update($, filterOpen, () => false)
344 await update($, filterChosen, () => filter)
345 if (!known) return
346 await counted($, label, async () => {
347 await showFilter($)
348 if ((await read($, overview)).kind === 'ok') await showNext($)
349 await showIssues($)
350 })
351}
352
353// Sets the label filter for both panes, by `change`; its list stays open
354// for the next. What's next follows; Issues draws from what it has.
355async function setLabelFilter($: EngineInterface, change: (f: LabelFilter) => LabelFilter, label: string) {
356 await update($, labelFilter, f => change(Array.isArray(f?.labels) ? f : NO_LABELS))
357 if (!known) return
358 await counted($, label, async () => {
359 if ((await read($, overview)).kind === 'ok') await showNext($)
360 })
361}
362
363// The issues read for a detail view, by repo and number, kept until the next
364// Refresh or /next, so opening one again asks Forgejo nothing.
365const issuesRead = new Map<string, IssueDetail>()
366
367// Reads issue `n` for the detail view of pane `pane`, or shows it from memory.
368// `quiet`: the detail shown stays until the new one is read, with no
369// Loading … between, as after a label's change with the labels list open.
370// Refusal `error` in red under the heading of pane `pane`'s detail, or
371// none for null. Only a new action or Refresh takes it away: a detail read
372// again keeps it, as a press made before it may finish after it.
373async function showRefusal($: EngineInterface, pane: string, error: string | null) {
374 await update($, memberOf(detail, { requestId: pane }), (d): Detail => (d.kind === 'ok' ? { ...d, error: error ?? undefined } : d))
375}
376
377async function loadDetail($: EngineInterface, r: Repo, pane: string, n: number, quiet = false) {
378 const k = known
379 // A refusal shown on this issue stays (showRefusal), and so does a wait.
380 const kept = (d: Detail) => (d.kind === 'ok' && d.issue.number === n ? d.error : undefined)
381 const still = (d: Detail) => (d.kind === 'ok' && d.issue.number === n && waits.has(pane) ? d.busy : undefined)
382 if (!k) return
383 const key = `${r.host}/${slug(r)}#${n}`
384 const cached = issuesRead.get(key)
385 // The issues it is a child of, from what the lists know: one request the
386 // first time, none once Next has asked.
387 const parentsOf = () => k.parents(n)
388 // Whether it waits on an open issue that is not its own child, for its mark.
389 const blockedOf = (i: IssueDetail) => k.blocked(i, (i.blockedBy ?? []).filter(x => x.state === 'open').map(x => x.number))
390 // The issues it holds, for its Children lines: an epic's, or on GitHub
391 // any issue's sub-issues.
392 const childrenOf = async (): Promise<Ref[] | null> => {
393 const all = await k.children(n)
394 return all ? all.map(c => ({ number: c.number, title: c.title, state: c.state, assignees: c.assignees, blocked: c.blocked, milestone: c.milestone })) : null
395 }
396 if (cached) {
397 const [parents, blocked, children] = await Promise.all([parentsOf(), blockedOf(cached), childrenOf()])
398 await update($, memberOf(detail, { requestId: pane }), (d): Detail => ({ kind: 'ok', issue: cached, parents, blocked, children, error: kept(d), busy: still(d) }))
399 return
400 }
401 if (!quiet) await update($, memberOf(detail, { requestId: pane }), (): Detail => ({ kind: 'loading', number: n }))
402 try {
403 const [issue, parents, children] = await Promise.all([k.issue(n), parentsOf(), childrenOf()])
404 const blocked = await blockedOf(issue)
405 issuesRead.set(key, issue)
406 // Another issue opened while this one loaded wins.
407 if (issueOf(await placeOf($, pane)) !== n) return
408 await update($, memberOf(detail, { requestId: pane }), (d): Detail => ({ kind: 'ok', issue, parents, blocked, children, error: kept(d), busy: still(d) }))
409 } catch (err) {
410 if (issueOf(await placeOf($, pane)) !== n) return
411 await update($, memberOf(detail, { requestId: pane }), (): Detail => ({ kind: 'error', number: n, message: message(err) }))
412 }
413}
414
415// Opens issue `n` in the detail of pane `pane`, from its list: Back goes to
416// the list.
417async function openIssue($: EngineInterface, pane: string, n: number) {
418 await go($, pane, w => step.open(w, n))
419 await loadIssue($, pane, n)
420}
421
422// The help page (DRAWING.md, The help page) in pane `pane`, over what it
423// shows, at its top; Back returns to that, as it was.
424async function showHelp($: EngineInterface, pane: string) {
425 if ((await placeOf($, pane)).help) return
426 await go($, pane, step.help)
427 await update($, filterOpen, () => false)
428}
429
430function leaveHelp($: EngineInterface, pane: string) {
431 return go($, pane, step.leaveHelp)
432}
433
434// Opens issue `n` in pane `pane`, reached from the one shown through its
435// Blocked by or Blocks: Back returns to that one.
436async function followIssue($: EngineInterface, pane: string, n: number) {
437 await go($, pane, w => step.follow(w, n))
438 await loadIssue($, pane, n)
439}
440
441// Back: the issue the open one was reached from, else the list.
442async function goBack($: EngineInterface, pane: string) {
443 await go($, pane, step.back)
444 await loadIssue($, pane, issueOf(await placeOf($, pane)))
445}
446
447// Loads issue `n` for pane `pane`, which has moved to it; null shows no detail.
448async function loadIssue($: EngineInterface, pane: string, n: number | null) {
449 const r = await read($, repo)
450 if (n === null || !r) {
451 await update($, memberOf(detail, { requestId: pane }), (): Detail => ({ kind: 'idle' }))
452 return
453 }
454 await counted($, `#${n}`, () => loadDetail($, r, pane, n))
455}
456
457// Pane `pane` shows its list again: What's next, Issues or Milestones.
458async function backToList($: EngineInterface, pane: string) {
459 await go($, pane, step.list)
460 await update($, memberOf(detail, { requestId: pane }), (): Detail => ({ kind: 'idle' }))
461}
462
463// Checks setup and, once it passes, loads What's next and Issues and,
464// where an issue is open, that issue again. The Issues and Milestones panes
465// follow what the load found (`followPane`), unless `follow` is off: /next
466// opens them itself, in its own order, once What's next is placed.
467async function refresh($: EngineInterface, follow = true): Promise<Repo | null> {
468 issuesRead.clear()
469 for (const pane of [PANE, ISSUES_PANE, MILESTONES_PANE]) await showRefusal($, pane, null)
470 await update($, repoLabels, (): RepoLabels => ({ kind: 'idle' }))
471 await update($, childrenToo, () => true)
472 const r = await check($)
473 if (r && (await read($, setup)).kind === 'ready') {
474 const before = await read($, issues)
475 const hadMilestones = ((await read($, milestones)) ?? []).length > 0
476 await load($, r)
477 const after = await read($, issues)
478 if (follow && after.kind === 'ok') {
479 await followPane($, ISSUES_PANE, 'Issues', before.kind === 'ok' && before.openIssues > 0, after.openIssues > 0)
480 }
481 if (follow) await followPane($, MILESTONES_PANE, 'Milestones', hadMilestones, ((await read($, milestones)) ?? []).length > 0)
482 for (const pane of [PANE, ISSUES_PANE, MILESTONES_PANE]) {
483 const n = issueOf(await placeOf($, pane))
484 if (n !== null) await loadDetail($, r, pane, n)
485 else await update($, memberOf(detail, { requestId: pane }), (): Detail => ({ kind: 'idle' }))
486 }
487 // A labels list open is asked for again, and the labels' colours while
488 // the label filter is set to some.
489 const panes = [PANE, ISSUES_PANE, MILESTONES_PANE]
490 const open = (await Promise.all(panes.map(async pane => (await placeOf($, pane)).labels))).some(Boolean)
491 if (open || !isAll(await read($, labelFilter))) await loadLabels($)
492 } else {
493 await update($, overview, (): Overview => ({ kind: 'idle' }))
494 await update($, issues, (): Issues => ({ kind: 'idle' }))
495 }
496 return r
497}
498
499// The Issues or Milestones pane, `id`, follows a load that found the first
500// open issue or milestone (`had` none before, `has` one now), or none left:
501// it opens as a tab next to What's next, without the focus, or closes. A
502// pane the person closed while there was something to show stays closed
503// until /next. One opened without the person asking (a reload) may wait
504// undrawn on a narrow terminal; the next Refresh, which they ask for, opens
505// it again so it is drawn.
506async function followPane($: EngineInterface, id: string, title: string, had: boolean, has: boolean) {
507 // The engine's record says whether it is drawn; without it, What's Next's own
508 // list of open panes, taken as drawn.
509 const panes = await $.ui.panes().catch(async () => (await read($, panesOpen)).map(id => ({ id, isPlaced: true })))
510 const pane = panes.find(p => p.id === id)
511 if (has && (pane ? !pane.isPlaced : !had)) {
512 await $.ui.open({ id, title })
513 await update($, panesOpen, ids => (ids.includes(id) ? ids : [...ids, id]))
514 } else if (!has && pane) {
515 await $.ui.close({ id })
516 await update($, panesOpen, ids => ids.filter(i => i !== id))
517 }
518}
519
520// Opens `url` in the browser of the computer the session runs on: `open`
521// on macOS, `xdg-open` elsewhere. Each command written out, as the plugin
522// directory reads it. Only an https address: an issue's or a milestone's
523// comes from the forge, and `open` would start a file or an app it named.
524async function openUrl($: EngineInterface, url: string) {
525 if (!/^https:\/\/[^\s]+$/.test(url)) {
526 $.ui.toast(`Not opened: ${url} is not an https address`)
527 return
528 }
529 if ((await $.process.run(['open', url]).catch(() => null))?.exitCode === 0) return
530 if ((await $.process.run(['xdg-open', url]).catch(() => null))?.exitCode === 0) return
531 $.ui.toast(`Could not open a browser for ${url}`)
532}
533
534
535// Changes issue `n` with `change` (`what` names it for a refusal), then
536// reads it again with What's next and Issues, so the change shows
537// everywhere. Pane `pane` goes back to its list, unless it `stay`s on the
538// issue; a refusal keeps the issue open there, shown under its actions.
539// `after` goes after the issue's number in the refusal (` to v1.2`);
540// `also`, once the change is done, the other issues it changed.
541async function writeIssue(
542 $: EngineInterface,
543 pane: string,
544 n: number,
545 change: (t: Tracker) => Promise<string | null>,
546 what: string,
547 stay = false,
548 after = '',
549 also: () => number[] = () => [],
550): Promise<boolean> {
551 const r = await read($, repo)
552 const k = known
553 if (!r || !k) return false
554 await showRefusal($, pane, null)
555 const why = await change(k.tracker)
556 if (why !== null) {
557 const error = `Could not ${what} #${n}${after}: ${why.replace(/\.$/, '')}.`
558 await showRefusal($, pane, error)
559 return false
560 }
561 const key = `${r.host}/${slug(r)}#${n}`
562 issuesRead.delete(key)
563 const others = also()
564 for (const o of others) issuesRead.delete(`${r.host}/${slug(r)}#${o}`)
565 if (!stay) await backToList($, pane)
566 for (const p of [PANE, ISSUES_PANE, MILESTONES_PANE]) {
567 if (issueOf(await placeOf($, p)) === n) await loadDetail($, r, p, n, stay && p === pane)
568 }
569 // The issue as the change left it, read again unless a pane just did, so
570 // the lists show the change even where the forge's lists lag it.
571 const fresh = issuesRead.get(key) ?? (await k.issue(n).catch(() => null))
572 if (fresh) noteChange(`${r.host}/${slug(r)}`, fresh, await $.clock.now())
573 for (const o of others) {
574 const read = await k.issue(o).catch(() => null)
575 if (read) noteChange(`${r.host}/${slug(r)}`, read, await $.clock.now())
576 }
577 await load($, r)
578 return true
579}
580
581// You on issue `i`, or off it; other assignees stay.
582async function assignMe($: EngineInterface, pane: string, i: IssueDetail, assign: boolean): Promise<boolean> {
583 const me = known?.me
584 if (!me) return false
585 const others = i.assignees.filter(a => a !== me)
586 const assignees = assign ? [...others, me] : others
587 return writeIssue($, pane, i.number, t => t.assign(i.number, assignees), assign ? 'assign' : 'unassign')
588}
589
590// The repo's labels for the labels list, by name, read once until Refresh.
591async function loadLabels($: EngineInterface) {
592 const r = await read($, repo)
593 const k = known
594 if (!r || !k) return
595 const at = `${r.host}/${slug(r)}`
596 await update($, repoLabels, (): RepoLabels => ({ kind: 'loading', repo: at }))
597 try {
598 const all = await k.tracker.labels()
599 const labels = [...all].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
600 await update($, repoLabels, (): RepoLabels => ({ kind: 'ok', repo: at, labels }))
601 } catch (err) {
602 await update($, repoLabels, (): RepoLabels => ({ kind: 'error', repo: at, message: message(err) }))
603 }
604}
605
606// The labels list of pane `pane` opened, or shut; opened, the repo's labels
607// are asked for unless they are known (DRAWING-issue.md, The labels list).
608async function toggleLabels($: EngineInterface, pane: string) {
609 await update($, filterOpen, () => false)
610 await go($, pane, step.toggleLabels)
611 if (!(await placeOf($, pane)).labels) return
612 const r = await read($, repo)
613 const had = await read($, repoLabels)
614 if (r && (had.kind === 'ok' || had.kind === 'loading') && had.repo === `${r.host}/${slug(r)}`) return
615 await counted($, 'Labels', () => loadLabels($))
616}
617
618// Panes whose detail waits on the forge, each with the press it waits for,
619// so a press that finishes late ends only its own wait.
620const waits = new Map<string, symbol>()
621
622// The spinner's timer while any pane waits; the frame it turns to, the
623// spinner atom.
624let spinning: Timer | null = null
625const SPIN_MS = 120
626
627// Pane `pane`'s detail waiting on the forge for its `list`, its spinner
628// turning (DRAWING-issue.md, The labels list): a function that ends this
629// wait, or null when it waits already and the press is passed over.
630async function wait($: EngineInterface, pane: string, list: 'labels' | 'milestones' | 'parents') {
631 if (waits.has(pane)) return null
632 const mine = Symbol(pane)
633 waits.set(pane, mine)
634 const at = memberOf(detail, { requestId: pane })
635 await update($, at, (d): Detail => (d.kind === 'ok' ? { ...d, busy: { list } } : d))
636 spinning ??= $.clock.every(SPIN_MS, () => void update($, spinner, n => n + 1))
637 return async () => {
638 if (waits.get(pane) !== mine) return
639 waits.delete(pane)
640 if (waits.size === 0) {
641 spinning?.cancel()
642 spinning = null
643 }
644 await update($, at, (d): Detail => (d.kind === 'ok' ? { ...d, busy: undefined } : d))
645 }
646}
647
648// Label `l` added to issue `i`, or taken off; the tick moves at once, and
649// the pane stays on the issue, its labels list open for the next, waiting
650// until the forge answers (DRAWING-issue.md, The labels list).
651async function setLabel($: EngineInterface, pane: string, i: IssueDetail, l: Label, add: boolean) {
652 const free = await wait($, pane, 'labels')
653 if (!free) return false
654 try {
655 const at = memberOf(detail, { requestId: pane })
656 const tick = (has: boolean) =>
657 update($, at, (d): Detail => {
658 if (d.kind !== 'ok' || d.issue.number !== i.number) return d
659 const others = d.issue.labels.filter(x => x.name !== l.name)
660 return { ...d, issue: { ...d.issue, labels: has ? [...others, { name: l.name, color: l.color }] : others } }
661 })
662 await tick(add)
663 const change = async (t: Tracker) => {
664 const why = await t.label(i.number, l, add)
665 await free()
666 return why
667 }
668 const done = await writeIssue($, pane, i.number, change, add ? `add ${l.name} to` : `take ${l.name} off`, true)
669 // Refused: the tick as it was, the refusal kept.
670 if (!done) await tick(!add)
671 return done
672 } finally {
673 await free()
674 }
675}
676
677// The milestone list of pane `pane` opened, or shut (DRAWING-issue.md, The
678// milestone list): the open milestones are known already, so nothing is asked.
679async function toggleMilestones($: EngineInterface, pane: string) {
680 await update($, filterOpen, () => false)
681 await go($, pane, step.toggleMilestones)
682}
683
684// Issue `i` moved to milestone `m`, or out of its own for null, and with
685// Children too ticked its open children in its old milestone, `children`;
686// the list shuts and the pane stays on the issue (DRAWING-issue.md, The
687// milestone list). The issue refused, no child is tried; a child refused,
688// the rest still move, and the refusal names it.
689async function setMilestone($: EngineInterface, pane: string, i: IssueDetail, m: Milestone | null, children: number[]) {
690 await go($, pane, step.shutMilestones)
691 const from = i.milestone?.title ?? null
692 const free = await wait($, pane, 'milestones')
693 if (!free) return false
694 try {
695 // The Milestone line shows the move at once; refused, as it was.
696 const at = memberOf(detail, { requestId: pane })
697 const show = (to: IssueDetail['milestone']) =>
698 update($, at, (d): Detail => (d.kind === 'ok' && d.issue.number === i.number ? { ...d, issue: { ...d.issue, milestone: to } } : d))
699 await show(m ? { title: m.title, due: m.due } : null)
700 const done = await moveMilestone($, pane, i, m, children, from, free)
701 if (!done) await show(i.milestone)
702 return done
703 } finally {
704 await free()
705 }
706}
707
708// setMilestone's move, its wait ended by `free` once the forge answered.
709async function moveMilestone($: EngineInterface, pane: string, i: IssueDetail, m: Milestone | null, children: number[], from: string | null, free: () => Promise<void>) {
710 const kids = (await read($, childrenToo)) ? children : []
711 const moved: number[] = []
712 const refused: { n: number; why: string }[] = []
713 const change = async (t: Tracker) => {
714 const why = await t.milestone(i.number, m)
715 if (why !== null) return why
716 for (const c of kids) {
717 const no = await t.milestone(c, m)
718 if (no === null) moved.push(c)
719 else refused.push({ n: c, why: no })
720 }
721 await free()
722 return null
723 }
724 const done = await writeIssue(
725 $,
726 pane,
727 i.number,
728 change,
729 m ? 'move' : 'take',
730 true,
731 m ? ` to ${m.title}` : from ? ` out of ${from}` : ' out of its milestone',
732 () => moved,
733 )
734 const first = refused[0]
735 if (done && first) {
736 const which = refused.map(x => `#${x.n}`).join(', ')
737 const error = m
738 ? `Moved #${i.number} to ${m.title}; could not move ${which}: ${first.why.replace(/\.$/, '')}.`
739 : `Took #${i.number} out of ${from ?? 'its milestone'}; could not take ${which} out: ${first.why.replace(/\.$/, '')}.`
740 await showRefusal($, pane, error)
741 }
742 return done
743}
744
745// The epic list of pane `pane` opened, or shut (DRAWING-issue.md, The epic
746// list); opened, what it offers issue `i` is worked out from what is known,
747// its `children` left out.
748async function toggleParents($: EngineInterface, pane: string, i: IssueDetail, children: number[]) {
749 await update($, filterOpen, () => false)
750 await go($, pane, step.toggleParents)
751 const k = known
752 if (!(await placeOf($, pane)).parents || !k) return
753 const at = memberOf(parentChoices, { requestId: pane })
754 await update($, at, (): ParentChoices => ({ kind: 'loading', number: i.number }))
755 const offered = await k.choices(i.number, i.milestone?.title ?? null, children)
756 await update($, at, (): ParentChoices => ({ kind: 'ok', number: i.number, ...offered }))
757}
758
759// Issue `i` put under `p`, or taken out of it; for null taken out of each of
760// `parents`, the ones it is under. The ticks move at once, and the pane
761// stays on the issue, its epic list open for the next (DRAWING-issue.md, The
762// epic list). What the forge took is remembered over its lists for a while,
763// as GitHub's answer the old parent for some seconds.
764async function setParent($: EngineInterface, pane: string, i: IssueDetail, p: number | null, chosen: boolean, parents: number[]) {
765 const put = p !== null && chosen
766 const free = await wait($, pane, 'parents')
767 if (!free) return false
768 try {
769 const r = await read($, repo)
770 const at = memberOf(detail, { requestId: pane })
771 const shown = await read($, at)
772 const offered = await read($, memberOf(parentChoices, { requestId: pane }))
773 const was = shown.kind === 'ok' ? (shown.parents ?? []) : []
774 const refOf = (n: number) =>
775 was.find(x => x.number === n) ?? (offered.kind === 'ok' ? [...offered.epics, ...offered.issues].find(x => x.number === n) : undefined)
776 const nested = known?.tracker.nested === true
777 const tick = async (under: number[]) =>
778 update($, at, (d): Detail =>
779 d.kind === 'ok' && d.issue.number === i.number ? { ...d, parents: under.map(refOf).filter(x => x !== undefined) } : d,
780 )
781 const after = (under: number[], q: number, put: boolean) => (put ? (nested ? [q] : [...under, q]) : under.filter(x => x !== q))
782 await tick(p === null ? [] : after(parents, p, put))
783 let under = [...parents]
784 let done = true
785 const asked = p === null ? parents : [p]
786 const last = asked[asked.length - 1]
787 for (const q of asked) {
788 const change = async (t: Tracker) => {
789 const why = await t.parent(i.number, q, put)
790 if (q === last) await free()
791 if (why !== null) return why
792 under = after(under, q, put)
793 known?.setParents(i.number, under)
794 if (r) noteParents(`${r.host}/${slug(r)}`, i.number, under, await $.clock.now())
795 return null
796 }
797 done = await writeIssue($, pane, i.number, change, put ? 'put' : 'take', true, put ? ` under #${q}` : ` out of #${q}`, () => [q])
798 if (!done) {
799 await free()
800 break
801 }
802 }
803 // Refused: the ticks as the forge left them, the refusal kept.
804 if (!done) await tick(under)
805 return done
806 } finally {
807 await free()
808 }
809}
810
811// Children too ticked if not, unticked if so.
812function toggleChildrenToo($: EngineInterface) {
813 return update($, childrenToo, too => !too)
814}
815
816// What a draft is made from: the issue, and its comments when known.
817type DraftSource = Pick<IssueRow, 'number' | 'title' | 'url' | 'body'> & {
818 commentList?: Comment[] | null
819}
820
821// The prompt What's Next drafts for an issue: what to do, where the issue is, what it
822// says, and the last word on it.
823function draftFor(i: DraftSource): string {
824 const parts = [`Work on #${i.number}: ${i.title}\n${i.url}`]
825 const body = i.body.trim()
826 if (body) parts.push(body)
827 const latest = i.commentList?.at(-1)
828 const last = latest?.body.trim()
829 if (last) parts.push(`Last comment, by ${latest?.author}:\n${last}`)
830 return parts.join('\n\n')
831}
832
833// Puts the draft for `i` in the prompt box, never sending it: over an empty
834// box, under what is typed there otherwise.
835// With the actions allowed and the issue not yet yours, it is assigned to
836// you first. Its pane goes back to its list once the draft is in, unless
837// the assign was refused.
838async function workOn($: EngineInterface, i: DraftSource, take?: { pane: string; issue: IssueDetail }) {
839 let taken = false
840 let refused = false
841 if (take) {
842 const s = await read($, setup)
843 const me = known?.me
844 if (s.kind === 'ready' && s.actions.kind !== 'read-only' && me && !take.issue.assignees.includes(me)) {
845 taken = await counted($, `assign #${i.number}`, () => assignMe($, take.pane, take.issue, true))
846 refused = !taken
847 }
848 }
849 try {
850 const draft = draftFor(i)
851 const box = await $.prompt.read()
852 const typed = box.text.trim() !== ''
853 const done = await $.prompt.fill(typed ? { text: `\n\n${draft}`, mode: 'append' } : { text: draft, mode: 'replace' })
854 $.ui.toast(
855 done.isFilled
856 ? `#${i.number} is ${taken ? 'yours and ' : ''}in the prompt: Esc to edit it there`
857 : `Could not put #${i.number} in the prompt${done.refusal ? ` (${done.refusal})` : ''}`,
858 )
859 if (take && done.isFilled && !refused) await backToList($, take.pane)
860 } catch (err) {
861 $.ui.toast(`Could not put #${i.number} in the prompt: ${message(err)}`)
862 }
863}
864
865// The element that last took the focus ring, by pane id.
866const lastFocus = new Map<string, string | undefined>()
867
868// A detail scrolls its own rows, between its top row and its bottom line,
869// which stay in place: the wheel and the page keys move them by `e.by`.
870const scrollDetail: Hook<'ui.scroll'> = async ($, e, next) => {
871 const v = views.get(e.requestId)
872 if (!v) return next(e)
873 const max = Math.max(0, v.rows - v.room)
874 await go($, e.requestId, w => step.scrollTo(w, Math.min(max, Math.max(0, w.scroll + e.by))))
875 return {}
876}
877
878// Tab and the arrows pass over the hidden hotkeys: on to the first control,
879// or, coming back from it, no further. A control out of a detail's view is
880// scrolled into it; the last control scrolls the detail to its end, so the
881// text after it is reached too, and the first to its top.
882const focusMoved: Hook<'ui.focus'> = async ($, e, next) => {
883 const hotkeys = hidden.get(e.requestId)
884 if (e.element !== undefined && hotkeys?.keys.has(e.element)) {
885 if (lastFocus.get(e.requestId) === hotkeys.first) return {}
886 lastFocus.set(e.requestId, hotkeys.first)
887 return next({ ...e, element: hotkeys.first })
888 }
889 lastFocus.set(e.requestId, e.element)
890 // A line out of the labels list's, the milestone list's or the epic
891 // list's view scrolls that list, not the detail.
892 const listed = listShown.get(e.requestId)
893 // The label filter's `and · or`, ▸ All and No label are its list's first lines, above the labels.
894 if (listed?.list === 'labels' && ['label-and', 'label-or', 'label-all', 'label-none'].includes(e.element ?? '')) {
895 if ((await placeOf($, e.requestId)).labels?.scroll) await go($, e.requestId, w => step.scrollLabels(w, 0))
896 return next(e)
897 }
898 const line = /^(toggle-label|pick-milestone|pick-parent)-(\d+)$/.exec(e.element ?? '')
899 const lists = { 'toggle-label': 'labels', 'pick-milestone': 'milestones', 'pick-parent': 'parents' } as const
900 if (listed && line && lists[line[1] as keyof typeof lists] === listed.list) {
901 const k = Number(line[2]) + listed.first
902 const place = await placeOf($, e.requestId)
903 const off = place[listed.list]?.scroll ?? 0
904 const to = k < off ? k : k >= off + listed.room ? k - listed.room + 1 : off
905 const scroll = { labels: step.scrollLabels, milestones: step.scrollMilestones, parents: step.scrollParents }[listed.list]
906 if (to !== off) await go($, e.requestId, w => scroll(w, to))
907 return next(e)
908 }
909 const v = views.get(e.requestId)
910 const row = e.element === undefined ? undefined : v?.at.get(e.element)
911 if (v && row !== undefined) {
912 const off = (await placeOf($, e.requestId)).scroll
913 const rows = [...v.at.values()]
914 const to =
915 row === Math.max(...rows)
916 ? Math.max(0, v.rows - v.room)
917 : row === Math.min(...rows)
918 ? 0
919 : row < off
920 ? row
921 : row >= off + v.room
922 ? row - v.room + 1
923 : off
924 if (to !== off) await go($, e.requestId, w => step.scrollTo(w, to))
925 }
926 return next(e)
927}
928
929// A pane closing, by What's Next or by the person: the other one, now without a tab,
930// names itself in its title row again.
931const paneClosed: Hook<'ui.close'> = async ($, e, next) => {
932 await update($, panesOpen, ids => ids.filter(id => id !== e.id))
933 await go($, e.id, step.dropHelp)
934 return next(e)
935}
936
937// A change to Icons or Compact in /config, or by What's Next's own choice buttons:
938// the session follows it, so the setting wins over an earlier choice.
939const settingChanged: Hook<'config.set'> = async ($, e, next) => {
940 const done = await next(e)
941 const field = settingKey('(icons|compact)').exec(e.key)?.[2]
942 const value = String(e.value)
943 if (field === 'icons' && (value === 'icons' || value === 'text' || value === 'ask')) {
944 configIcons = value
945 await update($, iconsChosen, () => (value === 'ask' ? null : value))
946 }
947 if (field === 'compact' && (value === 'on' || value === 'off' || value === 'ask')) {
948 configCompact = value
949 await update($, compactChosen, () => (value === 'ask' ? null : value))
950 }
951 return done
952}
953
954// What's Next's own version and repository, from its manifest, read once by
955// the code that runs: a session keeps the version it loaded until
956// /reload-plugins, even when the folder on disk has moved on. Each is null
957// where the manifest has none or could not be read.
958type Built = { version: string | null; repository: string | null }
959let ownManifest: Promise<Built> | null = null
960function builtManifest($: EngineInterface): Promise<Built> {
961 const word = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null)
962 const from = (path: string) =>
963 $.fs.read(path).then(text => {
964 const m = JSON.parse(text) as { version?: unknown; repository?: unknown }
965 return { version: word(m.version), repository: word(m.repository) }
966 })
967 // The plugin's folder, or the .claude-plugin folder in it.
968 const root = $.plugin.root
969 ownManifest ??= from(`${root}/.claude-plugin/plugin.json`)
970 .catch(() => from(`${root}/plugin.json`))
971 .catch(() => ({ version: null, repository: null }))
972 return ownManifest
973}
974
975// The session's state a view draws, for pane `e`, read once per render: the
976// views take no engine (hooks/views/frame.tsx).
977async function shownState($: EngineInterface, e: RenderInput<'Pane'>): Promise<Shown> {
978 return {
979 repo: await read($, repo),
980 version: await read($, version),
981 token: await read($, token),
982 setup: await read($, setup),
983 overview: await read($, overview),
984 spinner: await read($, spinner),
985 issues: await read($, issues),
986 panesOpen: await read($, panesOpen),
987 filterOpen: await read($, filterOpen),
988 repoLabels: await read($, repoLabels),
989 childrenToo: await read($, childrenToo),
990 unfolded: await read($, unfolded),
991 treeKids: await read($, treeKids),
992 issuesFolded: await read($, issuesFolded),
993 descriptionUnfolded: await read($, descriptionUnfolded),
994 milestones: await read($, milestones),
995 milestonesUnfolded: await read($, milestonesUnfolded),
996 milestoneIssues: await read($, msIssues),
997 closedMilestones: await read($, closedMilestones),
998 closedShown: await read($, closedShown),
999 milestoneError: await read($, milestoneError),
1000 detail: await read($, memberOf(detail, e)),
1001 parentChoices: await read($, memberOf(parentChoices, e)),
1002 milestoneShown: await milestoneShown($),
1003 }
1004}
1005
1006// Every pane draws here: it reads the look once, then picks the view that
1007// draws what the pane shows (hooks/views).
1008async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
1009 // A list draws no detail; the detail below sets its own again.
1010 views.delete(e.requestId)
1011 hidden.delete(e.requestId)
1012 listShown.delete(e.requestId)
1013 const id = e.requestId
1014 const r = await read($, repo)
1015 const s = await read($, setup)
1016 const m = await read($, overview)
1017 const icons = (await read($, iconsChosen)) ?? configIcons
1018 const compact = (await read($, compactChosen)) ?? configCompact
1019 const look: Look = {
1020 ui: $.ui.resolve(e),
1021 columns: e.props.bodyColumns,
1022 compact: compact === 'on',
1023 icons: icons === 'icons',
1024 today: await $.clock.now(),
1025 filter: await read($, filterShown),
1026 labels: await read($, labelFilter),
1027 unblocks: m.kind === 'ok' ? m.unblocks : {},
1028 dueBy: m.kind === 'ok' ? m.dueBy : {},
1029 }
1030 const built = await builtManifest($)
1031 const acts: Acts = {
1032 refresh: () => counted($, 'Refresh', () => refresh($)),
1033 showHelp: () => showHelp($, id),
1034 leaveHelp: () => leaveHelp($, id),
1035 setFilter: (filter, label) => setFilter($, filter, label),
1036 setLabelFilter: (change, label) => setLabelFilter($, change, label),
1037 openIssue: (pane, n) => openIssue($, pane, n),
1038 followIssue: n => followIssue($, id, n),
1039 goBack: () => goBack($, id),
1040 foldIssue: n => foldIssue($, n),
1041 foldMilestone: title => foldMilestone($, title),
1042 openMilestone: title => openMilestone($, title),
1043 closeMilestone: (ms, closed) => closeMilestone($, ms, closed),
1044 assignMe: (i, assign) => assignMe($, id, i, assign),
1045 writeIssue: (n, change, what) => writeIssue($, id, n, change, what),
1046 toggleLabels: () => toggleLabels($, id),
1047 setLabel: (i, l, add) => setLabel($, id, i, l, add),
1048 toggleMilestones: () => toggleMilestones($, id),
1049 setMilestone: (i, m, children) => setMilestone($, id, i, m, children),
1050 toggleChildrenToo: () => toggleChildrenToo($),
1051 toggleParents: (i, children) => toggleParents($, id, i, children),
1052 setParent: (i, p, chosen, parents) => setParent($, id, i, p, chosen, parents),
1053 workOn: i => workOn($, i, { pane: id, issue: i }),
1054 openUrl: url => openUrl($, url),
1055 counted: (label, action) => counted($, label, action),
1056 chooseIcons: value => chooseSetting($, { field: 'icons', value }),
1057 chooseCompact: value => chooseSetting($, { field: 'compact', value }),
1058 go: change => go($, id, change),
1059 setFilterOpen: change => update($, filterOpen, change),
1060 foldIssues: () => update($, issuesFolded, x => !x),
1061 setDescription: change => update($, descriptionUnfolded, change),
1062 toggleClosed: () => update($, closedShown, o => !o),
1063 }
1064 const p: Pane = {
1065 e,
1066 id,
1067 place: await placeOf($, id),
1068 // The pane's full height, so the last rows sit at its bottom.
1069 height: e.props.scroll.bodyRows,
1070 look,
1071 draw: draw(look),
1072 known,
1073 shown: await shownState($, e),
1074 // The tracker behind the repo, for its name and where it makes tokens.
1075 tracker: r ? tracker($, r) : null,
1076 acts,
1077 version: built.version,
1078 repository: built.repository,
1079 requests: configDebug ? await read($, requests) : null,
1080 repoName: r ? slug(r) : '',
1081 }
1082 const f = await frame(p)
1083 const open = issueOf(p.place)
1084 if (p.place.help) return helpView(p, f)
1085 if (s.kind === 'ready' && r && icons === 'ask') return iconsChoice(p, f)
1086 if (s.kind === 'ready' && r && compact === 'ask') return compactChoice(p, f)
1087 if (s.kind === 'ready' && r && open !== null) return issueView(p, f, r, open)
1088 if (id === MILESTONES_PANE && s.kind === 'ready' && r) return milestonesView(p, f, s, r)
1089 if (id === ISSUES_PANE && s.kind === 'ready' && r) return issuesView(p, f, r)
1090 if (s.kind === 'ready' && r && m.kind !== 'idle' && m.kind !== 'error') return nextView(p, f, m, r)
1091 return setupView(p, f, configure)
1092}
1093
1094// A /clear starts the session's state over, What's Next's list of open
1095// panes too, and fires no session.start; the panes themselves stay up. With
1096// one of them up, take the list from the engine's record and load again.
1097async function loadAfterClear($: EngineInterface) {
1098 const ours = (await $.ui.panes().catch(() => [])).map(p => p.id).filter(id => id === PANE || id === ISSUES_PANE || id === MILESTONES_PANE)
1099 if (ours.length === 0) return
1100 await update($, panesOpen, () => ours)
1101 await counted($, 'clear', () => refresh($))
1102}
1103
1104// /next, or /whats-next where another command has /next: `e.command` is
1105// the one registered.
1106const nextRun: Hook<'command.run'> = async ($, e) => {
1107 const word = e.args.trim()
1108 return counted($, word ? `/${e.command} ${word}` : `/${e.command}`, () => runNext($, e.command, word))
1109}
1110
1111// /next opens the panes with What's next in front; /next help opens What's
1112// next on the help page. `name`: the command as registered.
1113async function runNext($: EngineInterface, name: string, word: string) {
1114 const lower = word.toLowerCase()
1115 const want = lower === '' ? 'both' : lower === 'help' ? 'help' : null
1116 if (!want) {
1117 return { text: `/${name} does not know "${word}". Try /${name}, or /${name} help.` }
1118 }
1119 const openNext = async (title: string) => {
1120 await $.ui.open({ id: PANE, title })
1121 await update($, panesOpen, ids => (ids.includes(PANE) ? ids : [...ids, PANE]))
1122 }
1123 // The milestone filter starts where you work again, and the label filter
1124 // on no labels: a choice holds until /next.
1125 await update($, filterChosen, () => null)
1126 await update($, labelFilter, (): LabelFilter => NO_LABELS)
1127 // The load opens no pane of its own: the Issues and Milestones tabs come
1128 // below, after What's next.
1129 await openNext("What's Next")
1130 const r = await refresh($, false)
1131 const s = await read($, setup)
1132 const shown = await read($, issues)
1133 const hasIssues = shown.kind === 'ok' && shown.openIssues > 0
1134 if (r) await openNext("What's next")
1135 // The Issues pane, a tab next to What's next, only while there are open
1136 // issues (#99, decided 4).
1137 if (hasIssues) {
1138 await $.ui.open({ id: ISSUES_PANE, title: 'Issues' })
1139 await update($, panesOpen, ids => (ids.includes(ISSUES_PANE) ? ids : [...ids, ISSUES_PANE]))
1140 } else if (shown.kind === 'ok' && (await read($, panesOpen)).includes(ISSUES_PANE)) {
1141 await $.ui.close({ id: ISSUES_PANE })
1142 }
1143 // The Milestones pane, a tab after Issues, only while there are open
1144 // milestones; one the person closed opens again.
1145 const ms = await read($, milestones)
1146 const open = await read($, panesOpen)
1147 if (r && s.kind === 'ready' && ms !== null && ms.length > 0 && !open.includes(MILESTONES_PANE)) {
1148 await $.ui.open({ id: MILESTONES_PANE, title: 'Milestones' })
1149 await update($, panesOpen, ids => (ids.includes(MILESTONES_PANE) ? ids : [...ids, MILESTONES_PANE]))
1150 } else if (ms !== null && ms.length === 0 && open.includes(MILESTONES_PANE)) {
1151 await $.ui.close({ id: MILESTONES_PANE })
1152 }
1153 // A pane opened new comes in front of the rest, and only `focus` brings an
1154 // open one back: What's next last, with the keyboard.
1155 await $.ui.open({ id: PANE, title: r ? "What's next" : "What's Next", focus: true })
1156 if (want === 'help') {
1157 await showHelp($, PANE)
1158 return { text: "What's Next's help is open in What's next: what each sign and colour means." }
1159 }
1160 const o = await read($, overview)
1161 if (s.kind !== 'ready' || !r) return { text: "What's Next needs setting up: see its pane." }
1162 const shownFilter = await read($, filterShown)
1163 // The filter's word in a sentence.
1164 const showing = shownFilter ? filterWord(shownFilter.set) : 'All'
1165 if (o.kind !== 'ok') return { text: `What's Next is ready for ${slug(r)}, but could not load its issues.` }
1166 if (o.inProgress.length + o.queue.length + o.blocked.length === 0 && !o.next) {
1167 return { text: `What's Next is ready for ${slug(r)}: no open issues.` }
1168 }
1169 const scope = o.next ? o.scopes[o.next.number] : undefined
1170 const where =
1171 scope?.kind === 'due' ? ', due soon' : scope?.kind === 'epic' ? `, in epic #${scope.number}` : scope?.kind === 'milestone' ? `, in ${scope.title}` : ''
1172 const then = o.next ? `next is #${o.next.number}${where}` : o.blocked.length > 0 ? 'nothing available' : 'nothing else open'
1173 const filter = shownFilter && shownFilter.set.kind !== 'all' ? ` (${showing})` : ''
1174 return { text: `What's Next is ready for ${slug(r)}: ${o.inProgress.length} in progress, ${then}${filter}.` }
1175}
1176
1177export const register: Register = (on, options) => {
1178 configTokens['Forgejo tokens'] = String(options.token ?? '').trim()
1179 configTokens['GitLab.com token'] = String(options.gitlabComToken ?? '').trim()
1180 configTokens['Private GitLab token'] = String(options.gitlabTokens ?? '').trim()
1181 configTokens['GitHub token'] = String(options.githubTokens ?? '').trim()
1182 configIcons = String(options.icons ?? 'ask')
1183 configCompact = String(options.compact ?? 'ask')
1184 configLabel = String(options.label ?? 'epic').trim() || 'epic'
1185 configDebug = options.debug === true
1186
1187 on('session.start', async ($, e, next) => {
1188 // Which marketplace What's Next came from, for the command that sets a token.
1189 configure = configureCommand(((await $.config.list().catch(() => [])) ?? []).map(r => r.key))
1190 // /next, unless another plugin, the person or an MCP server has it:
1191 // then /whats-next. A `next` whose plugin the engine does not name
1192 // cannot be told from another's, so it counts as taken.
1193 const commands = await $.command.list().catch(() => [])
1194 const taken = commands.some(c => c.name === COMMAND && !(c.source === 'plugin' && c.plugin !== undefined && /^whats-next(@|$)/.test(c.plugin)))
1195 await $.command.register({
1196 name: taken ? FALLBACK_COMMAND : COMMAND,
1197 description: "Your issues and what's next, for this repo",
1198 argumentHint: '[help]',
1199 })
1200 if (taken) $.ui.toast("/next is taken by another command: What's Next is /whats-next in this session.")hooks/draw.tsx 639 lines1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { DueBy, Epic, FilterShown, IssueRow, LabelFilter } from '../types'
4import { bar, markOf, standing, summary, tally, type Folding, type Mark, type Standing, type TreeLine } from './epics'
5import { ofMilestone, sameFilter } from './filter'
6import { day, dueSoon, dueText, readable } from './issue'
7
8// How every row and line is drawn (docs/DRAWING.md and
9// DRAWING-issue.md): the issue row and its facts line, the epic row and its
10// for-you line, rules, marks, due dates, trees and the bottom line. Each
11// reads the look and its arguments, nothing else; what a press does is
12// handed in as `onPress`, so drawing changes no state.
13
14// What the drawing reads, once per render: the surface's elements, the
15// pane's width, Compact, Icons, today, the milestone filter, how many
16// open issues wait on each issue as far as What's next knows, and whose due
17// date counts for an issue due soon by its epic's or milestone's.
18export type Look = {
19 ui: Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
20 columns: number
21 compact: boolean
22 icons: boolean
23 today: number
24 filter: FilterShown | null
25 // What the label filter is set to, What's next's and Issues' both.
26 labels: LabelFilter
27 unblocks: Readonly<Record<number, number>>
28 dueBy: Readonly<Record<number, DueBy>>
29}
30
31// Text cut to its first 3 lines and `… n more lines` is cut only from 5
32// lines on: a 4th line takes the row the button would.
33export const FOLD_AT = 4
34
35// Nerd Font icons (Material Design set): Prompt's, and three more the
36// one-time choice shows, so a font that lacks them is seen to.
37export const ICON = {
38 refresh: '\u{F0450}',
39 work: '\u{F0674}',
40 open: '\u{F03CC}',
41 back: '\u{F004D}',
42} as const
43
44// `text` cut to `width` characters, ending in … when it was longer. A Text
45// inside a Button wraps rather than truncates, so titles are cut here.
46export function fit(text: string, width: number): string {
47 if (width <= 0) return ''
48 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
49}
50
51// Pieces of one line, each with its look, cut once at `width` as `fit` cuts
52// a text: the pieces past the cut are left out, the one it falls in ends in ….
53type Piece = { text: string; bold?: boolean; color?: string; dim?: boolean }
54function fitParts(pieces: readonly Piece[], width: number): Piece[] {
55 const whole = fit(pieces.map(p => p.text).join(''), width)
56 let at = 0
57 return pieces.flatMap(p => {
58 const text = whole.slice(at, at + p.text.length)
59 at += p.text.length
60 return text ? [{ ...p, text }] : []
61 })
62}
63
64// A due date's colour: red once passed, amber within 7 days, dim otherwise.
65function dueLook(iso: string, now: number): { color?: string; dimColor?: boolean } {
66 const soon = dueSoon(iso, now)
67 return soon === 'passed' ? { color: 'error' } : soon === 'soon' ? { color: 'warning' } : { dimColor: true }
68}
69
70// How a place shows its due date (docs/DRAWING.md): the short form (30 Oct) or
71// the long one (30 Oct, in 19 days); `bare` leaves out the ⧗ before it, `done`
72// draws it dim, and `width` pads it to a column. No due date shows nothing.
73export type DueShown = { form: 'short' | 'long'; bare?: boolean; done?: boolean; width?: number }
74
75// The words of a due date as `o` shows it; null where nothing is shown.
76function dueWords(iso: string | null, now: number, o: DueShown): string | null {
77 const words = iso ? `${o.bare ? '' : '⧗ '}${o.form === 'short' ? day(iso, now) : dueText(iso, now).text}` : null
78 if (words === null) return o.width ? ''.padEnd(o.width) : null
79 return o.width ? words.padEnd(o.width) : words
80}
81
82// How long ago `iso` was, as a short age: 5m, 3h, 6d, 3w, 4mo, 2y.
83export function age(iso: string, now: number): string {
84 const s = Math.max(0, (now - Date.parse(iso)) / 1000)
85 if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`
86 if (s < 86400) return `${Math.floor(s / 3600)}h`
87 const d = Math.floor(s / 86400)
88 if (d < 14) return `${d}d`
89 if (d < 60) return `${Math.floor(d / 7)}w`
90 if (d < 730) return `${Math.floor(d / 30)}mo`
91 return `${Math.floor(d / 365)}y`
92}
93
94// Whom a child is with, for the right of its row: the first assignee of
95// one with others, or of one blocked that someone holds.
96export const whoOf = (c: { assignees: string[] }, st: Standing) =>
97 st === 'withOthers' || (st === 'blocked' && c.assignees.length > 0) ? (c.assignees[0] ?? '') : ''
98
99// One part of a line made of parts: its words, dim unless it has a
100// colour, or drawn as `node` where it is more than one text.
101export type Part = { text: string; color?: string; node?: RenderChildren }
102
103// A row's mark and the space after it (DRAWING-issue.md, Marks).
104// Closed in a detail: a grey ✓ where the mark goes.
105export const CLOSED_MARK: Mark = { glyph: '✓', color: 'inactive' }
106
107// The bar and count take 15 columns at the end of a row, the count alone 6;
108// a closed row ends in ✓ where an open one's count ends.
109export const COUNT = 15
110export const CLOSED_END = '✓'.padStart(COUNT - 1)
111
112// One issue as every list shows it (DRAWING-issue.md): its mark, number
113// and title on a button that opens it, and under it the facts line: what
114// blocks it, how many it unblocks, its due date, its milestone (unless the
115// milestone filter is set to it), its labels in colour and its age. `lead` indents it in a tree; `who` names
116// whom it is with, dim at the right.
117export type RowIssue = Pick<IssueRow, 'number' | 'title' | 'labels' | 'milestone' | 'created' | 'due'>
118export type RowLook = {
119 key: string
120 buttonKey: string
121 numWidth: number
122 standing: Standing
123 bold?: boolean
124 lead?: number
125 who?: string
126 blockedBy?: number[]
127 // Leaves the milestone out of the facts, where the list is one milestone's.
128 noMilestone?: boolean
129 // A row with children of its own, in the tree: its fold mark, coloured by
130 // its standing, a target of its own (none for one shown above), and how
131 // many of its children are closed, of all, at the right.
132 fold?: { state: Folding; key: string; onPress?: () => unknown }
133 count?: string
134 // Said dim on the second line, in place of the facts.
135 note?: string
136 onPress: () => unknown
137}
138
139// How a tree is drawn in a pane: its first level `lead` columns in, each
140// level two more; its rows are addressed `button`-n, open their issue with
141// `onOpen` and fold it with `onFold`. `drawn` counts each issue drawn in the
142// pane so far, so one shown again gets another address; `at` sets its
143// closing lines apart.
144export type TreeLook = {
145 lead: number
146 numWidth: number
147 button: string
148 drawn: Map<number, number>
149 at: string
150 onOpen: (n: number) => unknown
151 onFold: (n: number) => unknown
152}
153
154// An open epic at the top of a tree: where it sits, its addresses, whether
155// it is folded open, and what pressing its mark or the rest of it does.
156export type EpicLook = {
157 key: string
158 foldKey: string
159 openKey: string
160 lead: number
161 numWidth: number
162 open: boolean
163 noMilestone?: boolean
164 onFold: () => unknown
165 onOpen: () => unknown
166}
167
168// With Debug on: what the last action and the session asked the forge.
169export type Requests = { label: string; count: number; total: number }
170
171// The drawing for one render, made from its look.
172export type Draw = ReturnType<typeof draw>
173
174export function draw(look: Look) {
175 const { Box, Text, Button } = look.ui
176 const { columns, today } = look
177
178 // A button's label: its icon when icons are on, else its words.
179 const say = (icon: string, words: string) => (look.icons ? icon : words)
180
181 // A due date, drawn: every due date in the panes is drawn by this.
182 const dueDate = (key: string, iso: string | null, now: number, o: DueShown): RenderChildren | null => {
183 const words = dueWords(iso, now, o)
184 if (words === null) return null
185 return (
186 <Text key={key} {...(iso && !o.done ? dueLook(iso, now) : { dimColor: true })}>
187 {words}
188 </Text>
189 )
190 }
191 const line = (key: string, text: string, props: Record<string, unknown> = {}) => (
192 <Text key={key} wrap="truncate-end" {...props}>
193 {text}
194 </Text>
195 )
196 // `… n more lines` under text cut to its first lines, Description's or a
197 // comment's: a dim button that shows the rest (DRAWING-issue.md).
198 const moreLine = (key: string, n: number, onPress: () => unknown) => (
199 <Box key={`${key}-row`} height={1} flexShrink={0}>
200 <Button key={key} plain dimColor onPress={onPress}>
201 {`… ${n} more lines`}
202 </Button>
203 </Box>
204 )
205 // Parts joined by a dim ` · ` (DRAWING.md, Lines that hold several parts).
206 const joined = (parts: readonly Part[]): RenderChildren[] =>
207 parts.flatMap((p, k) => [
208 ...(k > 0 ? [<Text key={`sep-${k}`} dimColor>{' · '}</Text>] : []),
209 p.node ?? (
210 <Text key={`part-${k}`} color={p.color} dimColor={!p.color}>
211 {p.text}
212 </Text>
213 ),
214 ])
215 // How many open issues block an issue, and how many wait on it, on its
216 // facts line: each a count, dim; which ones, the issue detail
217 // (DRAWING-issue.md, The facts line). ↦: the bar this issue, the arrow
218 // what it opens.
219 const blockedPart = (n: number): Part => ({ text: `⊘ ${n}` })
220 const unblocksPart = (n: number): Part => ({ text: `↦ ${n}` })
221 // A due date as one part of a line: none where there is no due date, so
222 // a line never starts with ` · `.
223 const duePart = (key: string, iso: string | null, now: number, o: DueShown): Part[] =>
224 iso ? [{ text: dueWords(iso, now, o) ?? '', node: dueDate(key, iso, now, o) }] : []
225 // A heading in the Queue (DRAWING.md, Headings): the scope of the rows
226 // under it, a rule's `── ` dim, then its words bold; no line after it.
227 const heading = (key: string, part: Part) => (
228 <Text key={key} wrap="truncate-end">
229 <Text dimColor>{'── '}</Text>
230 <Text bold>{part.node ?? part.text}</Text>
231 </Text>
232 )
233 // A line of parts `lead` columns in: one text, cut once at its end, so a
234 // narrow pane cuts its last parts and never squeezes them one by one.
235 const partsLine = (key: string, lead: number, parts: readonly Part[]) => (
236 <Text key={key} wrap="truncate-end">
237 {' '.repeat(lead)}
238 {joined(parts)}
239 </Text>
240 )
241 // A rule (DRAWING.md, Rules): `── ` dim, its name bold, what belongs to it
242 // after ` · `, and the line to the pane's edge, in one text. One that is a
243 // target is one Button: one that folds starts with its + or − instead,
244 // plain; one that opens something (`open` absent) keeps its `── `.
245 const rule = (key: string, name: string, after: readonly Part[] = [], press?: { key: string; open?: boolean; onPress: () => unknown }) => {
246 const folds = press?.open !== undefined
247 const lead = folds ? `${press.open ? '−' : '+'} ` : '── '
248 const used = lead.length + name.length + after.reduce((n, p) => n + 3 + p.text.length, 0) + 1
249 // Text inside a Button wraps instead of truncating: there a rule too
250 // long for the pane is cut here, once at its end, its parts as words.
251 const cut = press && used > columns
252 const shown = cut ? fitParts([{ text: lead, dim: !folds }, { text: name, bold: true }, ...after.flatMap(p => [{ text: ' · ', dim: true }, { text: p.text, color: p.color, dim: !p.color }])], columns) : null
253 const text = shown ? (
254 <Text wrap="truncate-end">
255 {shown.map((p, i) => (
256 <Text key={`piece-${i}`} bold={p.bold} color={p.color} dimColor={p.dim}>
257 {p.text}
258 </Text>
259 ))}
260 </Text>
261 ) : (
262 <Text wrap="truncate-end">
263 <Text dimColor={!folds}>{lead}</Text>
264 <Text bold>{name}</Text>
265 {after.length > 0 && <Text dimColor>{' · '}</Text>}
266 {joined(after)}
267 <Text dimColor>{` ${'─'.repeat(Math.max(0, columns - used))}`}</Text>
268 </Text>
269 )
270 return (
271 <Box key={key} height={1} flexShrink={0}>
272 {press ? (
273 <Button key={press.key} plain onPress={press.onPress}>
274 {text}
275 </Button>
276 ) : (
277 text
278 )}
279 </Box>
280 )
281 }
282 const markText = (m: Mark) => (
283 <Text key="mark" color={m.color} dimColor={m.dim}>
284 {`${m.glyph} `}
285 </Text>
286 )
287 // An issue's number at the right of its column, and a space: blue, grey
288 // once closed (DRAWING-issue.md, Number and title).
289 const numberText = (n: number, width: number, closed = false) => (
290 <Text key="number" color={closed ? 'inactive' : 'suggestion'}>{`${`#${n}`.padStart(width - 1)} `}</Text>
291 )
292 // How many are closed, of all, at the end of a row: the bar where the row
293 // has room for one, then the count, dim in 6 columns (DRAWING.md, Counts).
294 const barText = (closed: number, total: number): RenderChildren[] => {
295 const b = bar(closed, total)
296 return [
297 b.filled ? (
298 <Text key="filled" color="success">
299 {b.filled}
300 </Text>
301 ) : null,
302 b.empty ? (
303 <Text key="empty" dimColor>
304 {b.empty}
305 </Text>
306 ) : null,
307 ]
308 }
309 const countText = (closed: number, total: number, withBar: boolean): RenderChildren[] => [
310 ...(withBar ? barText(closed, total) : []),
311 <Text key="count" dimColor>
312 {`${closed}/${total}`.padStart(6)}
313 </Text>,
314 ]
315 // A closed epic: one grey row, `lead` columns in, ending in ✓ where an open
316 // one's count ends; it opens its detail.
317 const closedRow = (key: string, buttonKey: string, lead: number, n: number, numWidth: number, title: string, onPress: () => unknown) => {
318 const width = Math.max(0, columns - lead - numWidth - COUNT)
319 return (
320 <Box key={key} flexShrink={0}>
321 <Button key={buttonKey} plain onPress={onPress}>
322 <Text wrap="truncate-end" color="inactive">
323 {`${' '.repeat(lead)}${`#${n}`.padStart(numWidth - 1)} ${fit(title, width).padEnd(width)} ${CLOSED_END}`}
324 </Text>
325 </Button>
326 </Box>
327 )
328 }
329 // How many children or issues under an unfolded row are closed: one grey
330 // line in the mark column, `lead` columns in.
331 const closedLine = (key: string, lead: number, n: number) => line(key, `${' '.repeat(lead)}✓ ${n} closed`, { color: 'inactive' })
332 // A row at the top of a tree that folds (DRAWING.md, Rows that fold): its
333 // mark, a target that folds it, then the rest of the row, `body`, a target
334 // that opens its detail.
335 const foldRow = (o: {
336 lead: number
337 mark: Mark
338 foldKey: string
339 onFold: () => unknown
340 openKey: string
341 onOpen: () => unknown
342 body: RenderChildren[]
343 color?: string
344 }) => (
345 <Box height={1}>
346 {o.lead > 0 && <Text>{' '.repeat(o.lead)}</Text>}
347 <Button key={o.foldKey} plain onPress={o.onFold}>
348 {markText(o.mark)}
349 </Button>
350 <Button key={o.openKey} plain onPress={o.onOpen}>
351 <Text wrap="truncate-end" color={o.color}>
352 {o.body}
353 </Text>
354 </Button>
355 </Box>
356 )
357 // A wrapped paragraph in which `a command` shows as a command and an
358 // https:// address as a link; the backticks themselves are not drawn.
359 const para = (key: string, text: string, props: Record<string, unknown> = {}) => {
360 const parts: RenderChildren[] = []
361 text.split(/`([^`]+)`/).forEach((piece, i) => {
362 if (i % 2 === 1) {
363 parts.push(<Text key={`${key}-c${i}`} color="warning">{piece}</Text>)
364 return
365 }
366 piece.split(/(https:\/\/\S+?)(?=[.,;:)]?(?:\s|$))/).forEach((bit, j) => {
367 if (bit === '') return
368 parts.push(
369 j % 2 === 1 ? (
370 <Text key={`${key}-u${i}-${j}`} color="suggestion" underline>
371 {bit}
372 </Text>
373 ) : (
374 bit
375 ),
376 )
377 })
378 })
379 return (
380 <Text key={key} wrap="wrap" {...props}>
381 {parts}
382 </Text>
383 )
384 }
385 // Labels as text, each in its Forgejo colour (made lighter when too dark),
386 // dim when it has none; commas between them dim.
387 const labelTexts = (labels: readonly { name: string; color: string }[]) =>
388 labels.flatMap((l, k) => {
389 const color = readable(l.color)
390 return [
391 ...(k > 0 ? [<Text key={`lsep-${k}`} dimColor>{', '}</Text>] : []),
392 <Text key={`label-${k}`} color={color} dimColor={!color}>
393 {l.name}
394 </Text>,
395 ]
396 })
397 // Compact: no empty line between rows, anywhere.
398 const gap = look.compact ? 0 : 1
399 // A section still ends in an empty line with Compact on, where its rows
400 // no longer leave one (DRAWING.md, Rules).
401 const sectionEnd = (key: string) => (look.compact ? [<Box key={`end-${key}`} height={1} flexShrink={0} />] : [])
402 // How many open issues wait on issue `n`, as far as What's next knows.
403 const waiting = (n: number) => look.unblocks[n] ?? 0
404 // The facts line's recipe (DRAWING-issue.md, The facts line), for an
405 // issue row and an epic's alike: what blocks it, how many it unblocks, its
406 // due date, its milestone (unless the list is that milestone's), its
407 // labels and its age.
408 // One due date on the line: the one that counts (ORDERING.md, Due soon).
409 // An epic's in the place of its own, `#30 ⧗ 19 Oct`; a milestone's after
410 // its name, `v1.3 ⧗ 20 Oct`, where the line shows the milestone; else
411 // its own.
412 const factsOf = (i: RowIssue, o: { blockedBy?: number[]; noMilestone?: boolean }): Part[] => {
413 const shownFilter = look.filter
414 const shownMilestone = !o.noMilestone && i.milestone !== null && !(shownFilter && sameFilter(shownFilter.set, ofMilestone(i.milestone)))
415 const by = look.dueBy[i.number]
416 const short: DueShown = { form: 'short' }
417 const whose = (key: string, name: RenderChildren, text: string, due: string): Part => ({
418 text: `${text} ${dueWords(due, today, short) ?? ''}`,
419 node: [name, <Text key={`${key}-sp`}>{' '}</Text>, dueDate(key, due, today, short)],
420 })
421 const msDue = by?.from.kind === 'milestone' && shownMilestone ? by.due : null
422 const due: Part[] =
423 by?.from.kind === 'epic'
424 ? [whose('due', <Text key="due-epic" color="suggestion">{`#${by.from.number}`}</Text>, `#${by.from.number}`, by.due)]
425 : msDue !== null
426 ? []
427 : duePart('due', i.due, today, short)
428 const milestone: Part[] = !shownMilestone
429 ? []
430 : msDue !== null
431 ? [whose('milestone-due', <Text key="due-milestone" dimColor>{i.milestone ?? ''}</Text>, i.milestone ?? '', msDue)]
432 : [{ text: i.milestone ?? '' }]
433 return [
434 ...(o.blockedBy && o.blockedBy.length > 0 ? [blockedPart(o.blockedBy.length)] : []),
435 ...(waiting(i.number) > 0 ? [unblocksPart(waiting(i.number))] : []),
436 ...due,
437 ...milestone,
438 ...(i.labels.length > 0 ? [{ text: '', node: labelTexts(i.labels) }] : []),
439 ...(i.created ? [{ text: `✎ ${age(i.created, today)}` }] : []),
440 ]
441 }
442 const issueRow = (i: RowIssue, o: RowLook) => {
443 const lead = ' '.repeat(o.lead ?? 0)
444 const dim = o.standing === 'blocked'
445 const facts: Part[] = o.note ? [{ text: o.note }] : factsOf(i, o)
446 const room = columns - (o.lead ?? 0) - 2 - o.numWidth - (o.count !== undefined ? 6 : 0)
447 const title =
448 o.who || o.count !== undefined
449 ? `${fit(i.title, room - (o.who ? o.who.length + 1 : 0)).padEnd(room - (o.who ? o.who.length + 1 : 0))}${o.who ? ' ' : ''}`
450 : fit(i.title, room)
451 const fold = o.fold
452 const mark = markOf(o.standing, fold?.state ?? null)
453 const rest = [
454 numberText(i.number, o.numWidth),
455 dim ? <Text key="title" dimColor>{title}</Text> : title,
456 o.who ? <Text key="who" dimColor>{o.who}</Text> : null,
457 o.count !== undefined ? <Text key="count" dimColor>{o.count.padStart(6)}</Text> : null,
458 ]
459 return (
460 <Box key={o.key} flexDirection="column" marginBottom={gap} flexShrink={0}>
461 {fold ? (
462 // Two targets, as an epic's: the mark folds, the rest opens its detail.
463 <Box height={1}>
464 <Text>{lead}</Text>
465 {fold.onPress ? (
466 <Button key={fold.key} plain onPress={fold.onPress}>
467 {markText(mark)}
468 </Button>
469 ) : (
470 markText(mark)
471 )}
472 <Button key={o.buttonKey} plain onPress={o.onPress}>
473 <Text wrap="truncate-end">{rest}</Text>
474 </Button>
475 </Box>
476 ) : (
477 <Button key={o.buttonKey} plain onPress={o.onPress}>
478 <Text wrap="truncate-end" bold={o.bold}>
479 {lead}
480 {markText(mark)}
481 {rest}
482 </Text>
483 </Button>
484 )}
485 {/* One row: a line too long for the pane is cut, but would still
486 take the height of its overflow. The indent keeps its width, so
487 a cut line starts under the title like the others. */}
488 {facts.length > 0 && (
489 <Box height={1}>
490 <Box key="indent" flexShrink={0}>
491 <Text>{`${lead}${' '.repeat(2 + o.numWidth)}`}</Text>
492 </Box>
493 {partsLine('facts', 0, facts)}
494 </Box>
495 )}
496 </Box>
497 )
498 }
499 // The lines under an unfolded row, as `tree` in epics.ts laid them out:
500 // each child an issue row, and the closing lines.
501 const tree = (under: TreeLine[], o: TreeLook): RenderChildren[] =>
502 under.map((l, k) => {
503 const lead = o.lead + 2 * l.depth
504 if (l.kind !== 'child') {
505 // A closing line sets itself apart from a row after it.
506 const after = under[k + 1]?.kind === 'child' ? gap : 0
507 const at = `${o.at}-${k}`
508 const text = `${' '.repeat(lead + o.numWidth)}${l.kind === 'unknown' ? 'children unknown' : l.kind === 'none' ? 'no children' : 'Loading the children…'}`
509 return (
510 <Box key={`${l.kind}-${at}`} flexShrink={0} marginBottom={after}>
511 {l.kind === 'closed' ? closedLine(`${l.kind}-line-${at}`, lead, l.closed) : line(`${l.kind}-line-${at}`, text, { dimColor: true })}
512 </Box>
513 )
514 }
515 const c = l.child
516 // An issue shown twice needs another address the second time.
517 const times = (o.drawn.get(c.number) ?? 0) + 1
518 o.drawn.set(c.number, times)
519 const at = times === 1 ? `${c.number}` : `${c.number}@${times}`
520 const st = standing(c)
521 return issueRow(c, {
522 key: `${o.button}-row-${at}`,
523 buttonKey: `${o.button}-${at}`,
524 numWidth: o.numWidth,
525 lead,
526 standing: st,
527 who: whoOf(c, st),
528 blockedBy: st === 'blocked' ? c.blockedBy : undefined,
529 ...(l.fold && {
530 fold:
531 l.fold === 'shown above'
532 ? { state: l.fold, key: `fold-${at}` }
533 : { state: l.fold, key: `fold-${at}`, onPress: () => o.onFold(c.number) },
534 count: l.count ? `${l.count.closed}/${l.count.total}` : '',
535 ...(l.fold === 'shown above' && { note: 'shown above' }),
536 }),
537 onPress: () => o.onOpen(c.number),
538 })
539 })
540
541 // The for-you line under a folded epic: `children` first, dim, so its
542 // `⊘ n` is not read as the epic's own (DRAWING-issue.md, The for-you line);
543 // `no children` and `children unknown` say it already.
544 const forChildren = (key: string, lead: number, parts: readonly Part[]) =>
545 parts.length === 1 && (parts[0]?.text === 'no children' || parts[0]?.text === 'children unknown') ? (
546 partsLine(key, lead, parts)
547 ) : (
548 <Text key={key} wrap="truncate-end">
549 {' '.repeat(lead)}
550 <Text dimColor>{'children '}</Text>
551 {joined(parts)}
552 </Text>
553 )
554 // An open epic at the top of a tree, in Issues and under a milestone: its
555 // fold mark, number, bold title, bar and count; under it its facts line,
556 // as any issue row's; folded, then the for-you line: what there is for
557 // you in it (DRAWING-issue.md, The for-you line).
558 const epicRow = (x: Epic, o: EpicLook) => {
559 const facts = factsOf(
560 { number: x.number, title: x.title, labels: x.labels ?? [], milestone: x.milestone ?? null, created: x.created ?? '', due: x.due },
561 { blockedBy: x.blockedBy, noMilestone: o.noMilestone },
562 )
563 const under = o.lead + 2 + o.numWidth
564 const t = tally(x.children ?? [])
565 const width = Math.max(0, columns - o.lead - 2 - o.numWidth - COUNT)
566 return (
567 <Box key={o.key} flexDirection="column" flexShrink={0} marginBottom={o.open ? 0 : gap}>
568 {foldRow({
569 lead: o.lead,
570 mark: markOf(null, o.open ? 'unfolded' : 'folded'),
571 foldKey: o.foldKey,
572 onFold: o.onFold,
573 openKey: o.openKey,
574 onOpen: o.onOpen,
575 body: [
576 numberText(x.number, o.numWidth),
577 <Text key="title" bold>
578 {`${fit(x.title, width).padEnd(width)} `}
579 </Text>,
580 ...countText(t.closed, t.total, true),
581 ],
582 })}
583 {facts.length > 0 && partsLine(`facts-${o.key}`, under, facts)}
584 {!o.open && forChildren(`under-${o.key}`, under, summary(x))}
585 </Box>
586 )
587 }
588
589 // The bottom line of every pane and detail: a rule naming What's Next and
590 // its `version`, which opens the repo (`onPress`; none without one)
591 // (DRAWING.md, Rules). Cut at its end on a narrow pane, as every rule.
592 // An empty line before it, as before every rule, kept when the list or
593 // detail above is cut at its bottom.
594 const bottomLine = (version: string | null, onPress?: () => unknown) => [
595 <Box key="where-gap" height={1} flexShrink={0} />,
596 rule('where', "What's Next", version ? [{ text: `v${version}` }] : [], onPress ? { key: 'repository', onPress } : undefined),
597 ]
598 // With Debug on, under the bottom line: what the last action and the session
599 // asked the forge, its parts dim (DRAWING.md, Lines that hold several parts).
600 const requestsLine = (req: Requests | null) =>
601 req && (
602 <Box key="requests" height={1} flexShrink={0}>
603 {partsLine('requests-text', 0, [{ text: `↯ ${req.label}: ${req.count} requests` }, { text: `${req.total} this session` }])}
604 </Box>
605 )
606
607 return {
608 say,
609 dueDate,
610 line,
611 moreLine,
612 joined,
613 blockedPart,
614 unblocksPart,
615 duePart,
616 partsLine,
617 heading,
618 rule,
619 markText,
620 numberText,
621 barText,
622 countText,
623 closedRow,
624 closedLine,
625 foldRow,
626 para,
627 labelTexts,
628 gap,
629 sectionEnd,
630 factsOf,
631 issueRow,
632 tree,
633 forChildren,
634 epicRow,
635 bottomLine,
636 requestsLine,
637 }
638}
639hooks/epics.ts 298 lines1import type { Child, Epic, EpicChild, MilestoneFilter } from '../types'
2import { holds } from './filter'
3
4// Whether an issue is assigned to you.
5export const isMine = (assignees: readonly string[], me: string | null): boolean => me !== null && assignees.includes(me)
6
7// Whether what blocks an issue counts for you: not when it is yours (you
8// know what holds it up), nor when it is closed (it waits on nothing). Where
9// it does not, the forge need not be asked for its blockers.
10export const blockersCount = (state: string, mine: boolean): boolean => state === 'open' && !mine
11
12// What blocks an issue for you, from the open issues the forge says it waits
13// on: those that count (`blockersCount`) with its own children left out,
14// which it never waits on (DRAWING-issue.md, Detail lines that name an issue).
15// The one place the rule is written; `standing` draws what comes of it.
16export function stands(
17 i: { state: string; blockedBy: readonly number[] },
18 mine: boolean,
19 own: Iterable<number> = [],
20): { blocked: boolean; blockedBy: number[] } {
21 if (!blockersCount(i.state, mine)) return { blocked: false, blockedBy: [] }
22 const children = new Set(own)
23 const blockedBy = i.blockedBy.filter(n => !children.has(n))
24 return { blocked: blockedBy.length > 0, blockedBy }
25}
26
27// A child as the forge says it, and how it stands for you (`own`: its own
28// children, when they are known).
29export function childOf(c: EpicChild, me: string | null, own: Iterable<number> = []): Child {
30 const mine = isMine(c.assignees, me)
31 return { ...c, mine, ...stands(c, mine, own) }
32}
33
34// How an open child stands for you: yours, available, with others, or
35// blocked. Yours wins over blocked: you know what holds it up.
36export type Standing = 'yours' | 'available' | 'withOthers' | 'blocked'
37
38export function standing(c: Pick<Child, 'mine' | 'blocked' | 'assignees'>): Standing {
39 if (c.mine) return 'yours'
40 if (c.blocked) return 'blocked'
41 return c.assignees.length > 0 ? 'withOthers' : 'available'
42}
43
44// The mark of a row (docs/DRAWING-issue.md, Marks): its shape
45// says whether it folds, its colour how it stands for you. `st` null: a row
46// with no standing, an epic or milestone at the top of a tree; `closed`
47// draws it grey.
48export type Mark = { glyph: string; color?: string; dim?: boolean }
49export function markOf(st: Standing | null, fold: Folding | null, closed = false): Mark {
50 if (fold === 'shown above') return { glyph: '↑', dim: true }
51 const look = closed ? { color: 'inactive' } : st === 'yours' ? { color: 'success' } : st === 'blocked' ? { dim: true } : {}
52 if (fold) return { glyph: fold === 'unfolded' ? '⊖' : '⊕', ...look }
53 const glyph = st === 'yours' ? '●' : st === 'blocked' ? '⊘' : st === 'withOthers' ? '◌' : st === 'available' ? '○' : ' '
54 return { glyph, ...look }
55}
56
57const ORDER: Record<Standing, number> = { yours: 0, available: 1, withOthers: 2, blocked: 3 }
58
59// The open children in the order the pane lists them: yours, available,
60// with others, blocked; each by number.
61export function openChildren(children: readonly Child[]): Child[] {
62 return children
63 .filter(c => c.state === 'open')
64 .sort((a, b) => ORDER[standing(a)] - ORDER[standing(b)] || a.number - b.number)
65}
66
67export type Tally = { closed: number; total: number } & Record<Standing, number>
68
69export function tally(children: readonly Child[]): Tally {
70 const t: Tally = { closed: 0, total: children.length, yours: 0, available: 0, withOthers: 0, blocked: 0 }
71 for (const c of children) {
72 if (c.state === 'closed') t.closed++
73 else t[standing(c)]++
74 }
75 return t
76}
77
78// How far an epic is, as `width` cells: filled for its closed children.
79export function bar(closed: number, total: number, width = 8): { filled: string; empty: string } {
80 const n = total === 0 ? 0 : Math.round((closed / total) * width)
81 return { filled: '▰'.repeat(n), empty: '▱'.repeat(width - n) }
82}
83
84// The line under a folded epic: what there is for you in it, or that it is
85// done but still open (all closed, amber). Each part has its colour.
86export function summary(e: Epic): { text: string; color?: string }[] {
87 if (e.children === null) return [{ text: 'children unknown' }]
88 const t = tally(e.children)
89 if (t.total === 0) return [{ text: 'no children' }]
90 if (t.closed === t.total) return [{ text: 'all closed', color: 'warning' }]
91 return forYou(t)
92}
93
94// What there is for you among open issues (DRAWING-issue.md, The for-you
95// line): each standing's mark and its count, in the order the rows are
96// listed, `● 2 · ○ 2 · ◌ 3 · ⊘ 1`; yours green, the rest dim. A standing
97// with none is left out.
98export function forYou(t: Tally): { text: string; color?: string }[] {
99 return (Object.keys(ORDER) as Standing[])
100 .filter(st => t[st] > 0)
101 .map(st => ({ text: `${markOf(st, null).glyph} ${t[st]}`, ...(st === 'yours' && { color: 'success' }) }))
102}
103
104// The open epics in view that show at the top of their group: each one no
105// open epic in view holds as a child. An epic in a loop of epics, held only
106// from within the loop, would show nowhere: the lowest such number shows at
107// the top, and what it reaches under it.
108export function roots(open: readonly { number: number; children: readonly Child[] | null }[]): Set<number> {
109 const inView = new Map(open.map(e => [e.number, e]))
110 const under = (n: number) =>
111 (inView.get(n)?.children ?? []).filter(c => c.state === 'open' && inView.has(c.number)).map(c => c.number)
112 const held = new Set(open.flatMap(e => under(e.number)))
113 const top = new Set(open.filter(e => !held.has(e.number)).map(e => e.number))
114 const reached = new Set<number>()
115 const walk = (n: number) => {
116 if (reached.has(n)) return
117 reached.add(n)
118 under(n).forEach(walk)
119 }
120 top.forEach(walk)
121 for (const n of [...inView.keys()].sort((a, b) => a - b)) {
122 if (reached.has(n)) continue
123 top.add(n)
124 walk(n)
125 }
126 return top
127}
128
129// A milestone's issues as its tree starts: its open epics that no other open
130// epic in it holds, the issues no open epic in it holds, and its closed epics.
131export function inMilestone<T extends Child & { children?: Child[] | null }>(
132 issues: readonly T[],
133): { epics: T[]; plain: T[]; closedEpics: T[] } {
134 const open = issues.filter(i => i.children !== undefined && i.state === 'open')
135 const top = roots(open.map(e => ({ number: e.number, children: e.children ?? null })))
136 const held = new Set(open.flatMap(e => (e.children ?? []).map(c => c.number)))
137 return {
138 epics: open.filter(e => top.has(e.number)),
139 plain: issues.filter(i => i.children === undefined && !held.has(i.number)),
140 closedEpics: issues.filter(i => i.children !== undefined && i.state === 'closed'),
141 }
142}
143
144// How a row with children is folded: its mark, or shown above, where an
145// issue already in the tree shows again and does not unfold.
146export type Folding = 'folded' | 'unfolded' | 'shown above'
147
148// One line under an unfolded issue, `depth` levels below the epic at the
149// top: an open child, with its fold and its count when it has children;
150// how many children are closed; or that its children are unknown, none, or
151// still loading.
152export type TreeLine =
153 | { kind: 'child'; child: Child; depth: number; fold: Folding | null; count: { closed: number; total: number } | null }
154 | { kind: 'closed'; depth: number; closed: number }
155 | { kind: 'unknown' | 'none' | 'loading'; depth: number }
156
157// The tree under each open epic at the top, in the order the pane draws
158// them: the lines under it, none while it is folded. `kids(n)` is what is
159// loaded of issue n's children: undefined when not yet. An issue has
160// children when the forge counts sub-issues for it, else when it is an
161// epic (`epics`) not known to have none. `missing`: the issues whose
162// children are needed and not loaded: those unfolded, and those drawn
163// folded whose count is not known, one level ahead.
164export function tree(
165 top: readonly Epic[],
166 kids: (n: number) => readonly Child[] | null | undefined,
167 unfolded: ReadonlySet<number>,
168 epics: ReadonlySet<number>,
169): { lines: Map<number, TreeLine[]>; missing: number[] } {
170 const seen = new Set<number>()
171 const missing = new Set<number>()
172 const under = (children: readonly Child[] | null | undefined, depth: number, n: number): TreeLine[] => {
173 if (children === undefined) {
174 missing.add(n)
175 return [{ kind: 'loading', depth }]
176 }
177 if (children === null) return [{ kind: 'unknown', depth }]
178 if (children.length === 0) return [{ kind: 'none', depth }]
179 const lines: TreeLine[] = []
180 // A child's own children never count as what blocks it.
181 const unblocked = children.map(c => {
182 const s = stands(c, c.mine, (kids(c.number) ?? []).map(k => k.number))
183 return s.blockedBy.length === c.blockedBy.length ? c : { ...c, ...s }
184 })
185 for (const c of openChildren(unblocked)) {
186 const own = kids(c.number)
187 const has = c.subIssues ? c.subIssues.total > 0 : epics.has(c.number) && own?.length !== 0
188 if (!has) {
189 seen.add(c.number)
190 lines.push({ kind: 'child', child: c, depth, fold: null, count: null })
191 continue
192 }
193 if (!c.subIssues && own === undefined) missing.add(c.number)
194 const t = own ? tally(own) : null
195 const count = c.subIssues ?? (t ? { closed: t.closed, total: t.total } : null)
196 if (seen.has(c.number)) {
197 lines.push({ kind: 'child', child: c, depth, fold: 'shown above', count })
198 continue
199 }
200 seen.add(c.number)
201 const open = unfolded.has(c.number)
202 lines.push({ kind: 'child', child: c, depth, fold: open ? 'unfolded' : 'folded', count })
203 if (open) lines.push(...under(own, depth + 1, c.number))
204 }
205 const closed = children.filter(c => c.state === 'closed').length
206 if (closed > 0) lines.push({ kind: 'closed', depth, closed })
207 return lines
208 }
209 const lines = new Map<number, TreeLine[]>()
210 for (const e of top) {
211 seen.add(e.number)
212 if (e.state === 'open') lines.set(e.number, unfolded.has(e.number) ? under(e.children, 0, e.number) : [])
213 }
214 return { lines, missing: [...missing] }
215}
216
217// A milestone, open or closed: its due date, how many of its issues are
218// closed of all, and for Milestone detail its text, its page on the forge
219// and when it closed (null when the forge does not say).
220export type MilestoneRow = {
221 title: string
222 due: string | null
223 closed: number
224 total: number
225 description?: string
226 url?: string
227 closedAt?: string | null
228 // The forge's id for it, to close or reopen it; on GitLab, the group it
229 // belongs to when it comes from a parent group.
230 id?: number
231 group?: string
232}
233
234// Open milestones, due first; one without a due date after those with one.
235export const dueFirst = (milestones: readonly MilestoneRow[]) =>
236 [...milestones].sort(
237 (a, b) => (a.due === null ? 1 : 0) - (b.due === null ? 1 : 0) || (a.due ?? '').localeCompare(b.due ?? '') || a.title.localeCompare(b.title),
238 )
239
240// The groups the milestone filter shows: one milestone's issues, those without one,
241// or for All each open milestone's in the milestone filter's order and No milestone last.
242// Issues in a closed milestone show under none of them.
243export function groupsFor<T extends { milestone: string | null }>(
244 filter: MilestoneFilter,
245 milestones: readonly MilestoneRow[],
246 issues: readonly T[],
247): { milestone: MilestoneRow | null; issues: T[] }[] {
248 const groups: { milestone: MilestoneRow | null; issues: T[] }[] = []
249 for (const m of dueFirst(milestones)) {
250 if (!holds(filter, m.title)) continue
251 const mine = issues.filter(e => e.milestone === m.title)
252 if (mine.length > 0) groups.push({ milestone: m, issues: mine })
253 }
254 if (holds(filter, null)) {
255 const none = issues.filter(e => e.milestone === null)
256 if (none.length > 0) groups.push({ milestone: null, issues: none })
257 }
258 return groups
259}
260
261// Under the label filter (#99, decided 2b): whether issue `n`, with
262// `labels`, is in it (`match`), or an open issue loaded under it is, as far
263// as `kids` knows them. An issue with a label left out (`out`) goes, and
264// what is under it with it (#99, decided 7). An issue met again in a loop
265// leads nowhere new.
266export function leadsTo(
267 n: number,
268 labels: readonly { name: string }[],
269 kids: (n: number) => readonly Child[] | null | undefined,
270 match: (labels: readonly { name: string }[]) => boolean,
271 out: (labels: readonly { name: string }[]) => boolean = () => false,
272 seen = new Set<number>(),
273): boolean {
274 if (out(labels)) return false
275 if (match(labels)) return true
276 if (seen.has(n)) return false
277 seen.add(n)
278 return (kids(n) ?? []).some(c => c.state === 'open' && leadsTo(c.number, c.labels, kids, match, out, seen))
279}
280
281// The lines under an unfolded epic as the label filter leaves them: each
282// child that `keeps` says stays, and what is under it; one that goes takes
283// the lines under it along. The closing lines stay.
284export function narrowed(lines: readonly TreeLine[], keeps: (c: Child) => boolean): TreeLine[] {
285 const out: TreeLine[] = []
286 let gone: number | null = null
287 for (const l of lines) {
288 if (gone !== null && l.depth > gone) continue
289 gone = null
290 if (l.kind === 'child' && !keeps(l.child)) {
291 gone = l.depth
292 continue
293 }
294 out.push(l)
295 }
296 return out
297}
298hooks/forge.ts 120 lines1import { message, type Send } from './tracker'
2
3// The forge a repo's host runs. `version`: as the forge names it, raw for
4// Forgejo (`12.0.1+gitea-1.22.0`); null when it would not say, and for
5// github.com. `unknown`: no forge What's Next knows answered; `no-answer`: the host
6// did not answer at all.
7export type Forge =
8 | { kind: 'forgejo'; version: string }
9 | { kind: 'gitlab'; version: string | null }
10 | { kind: 'github'; version: string | null }
11 | { kind: 'unknown'; reason: 'no-answer' | 'other'; detail: string }
12
13// Hosts known without a request.
14const KNOWN: Record<string, Forge> = {
15 'github.com': { kind: 'github', version: null },
16 'gitlab.com': { kind: 'gitlab', version: null },
17}
18
19// The forge at `host`: a known host by its name, any other by asking the
20// version endpoints of Forgejo, GitLab and GitHub Enterprise in turn, with no
21// token. A host that does not answer the first is not asked again.
22export async function detect(send: Send, host: string): Promise<Forge> {
23 const known = KNOWN[host.toLowerCase()]
24 if (known) return known
25 const base = `https://${host}`
26 const accept = { method: 'GET', headers: { Accept: 'application/json' } }
27
28 const first = await send(`${base}/api/v1/version`, accept).catch((err: unknown) => message(err))
29 if (typeof first === 'string') return { kind: 'unknown', reason: 'no-answer', detail: first }
30 const forgejo = first.ok ? field(first.text, 'version') : undefined
31 if (typeof forgejo === 'string') return { kind: 'forgejo', version: forgejo }
32
33 // GitLab asks for a token even here: its JSON refusal is answer enough.
34 const gitlab = await send(`${base}/api/v4/version`, accept).catch(() => null)
35 if (gitlab?.ok) {
36 const v = field(gitlab.text, 'version')
37 if (typeof v === 'string') return { kind: 'gitlab', version: v }
38 }
39 if (gitlab && (gitlab.status === 401 || gitlab.status === 403) && field(gitlab.text, 'message') !== undefined) {
40 return { kind: 'gitlab', version: null }
41 }
42
43 // GitHub Enterprise Server answers /api/v3/meta without a token.
44 const github = await send(`${base}/api/v3/meta`, accept).catch(() => null)
45 if (github?.ok) {
46 const v = field(github.text, 'installed_version') ?? github.headers['x-github-enterprise-version']
47 if (v !== undefined || field(github.text, 'verifiable_password_authentication') !== undefined) {
48 return { kind: 'github', version: typeof v === 'string' ? v : null }
49 }
50 }
51
52 return { kind: 'unknown', reason: 'other', detail: `HTTP ${first.status} for /api/v1/version` }
53}
54
55// A forge's name with its version, as the pane shows it. Forgejo's carries
56// `+gitea-…`, which is how it is told from Gitea.
57export function forgeName(f: Forge): string {
58 switch (f.kind) {
59 case 'forgejo':
60 return `${f.version.includes('+gitea-') ? 'Forgejo' : 'Gitea'} ${f.version.replace(/\+.*$/, '')}`
61 case 'gitlab':
62 return f.version ? `GitLab ${f.version}` : 'GitLab'
63 case 'github':
64 return f.version ? `GitHub Enterprise ${f.version}` : 'GitHub'
65 case 'unknown':
66 return 'unknown'
67 }
68}
69
70// One field of a JSON object, or undefined when the text is not one.
71function field(text: string, name: string): unknown {
72 try {
73 const json = JSON.parse(text) as unknown
74 return json && typeof json === 'object' ? (json as Record<string, unknown>)[name] : undefined
75 } catch {
76 return undefined
77 }
78}
79
80// The token settings, each kept in secure storage: one for Forgejo, for
81// GitLab one for gitlab.com and one for a private GitLab, and one for GitHub.
82export type TokenSetting = 'Forgejo tokens' | 'GitLab.com token' | 'Private GitLab token' | 'GitHub token'
83
84// The marketplace What's Next is published in, as the repo's
85// .claude-plugin/marketplace.json names it; docs/check-marketplace.sh
86// holds the two to each other.
87export const MARKETPLACE = 'egribnau'
88
89// The command that sets What's Next's tokens. `keys`: the session's /config
90// row keys, where What's Next's own read `whats-next@<marketplace>.<field>`
91// once installed. Run from a folder (`@inline`), or with no row of its own,
92// it names the marketplace What's Next is published in.
93export function configureCommand(keys: string[]): string {
94 const from = keys.map(k => /^whats-next@(.+)\.[^.]+$/.exec(k)?.[1]).find(m => m !== undefined && m !== 'inline' && m !== 'builtin')
95 return `/plugin configure whats-next@${from ?? MARKETPLACE}`
96}
97
98// The GitLab token setting for `host`: gitlab.com has its own.
99export function gitlabSetting(host: string): TokenSetting {
100 return host.toLowerCase() === 'gitlab.com' ? 'GitLab.com token' : 'Private GitLab token'
101}
102
103// A token from one setting's text: a bare token is for every host, more
104// hosts take `host=token` pairs, separated by spaces, and a pair wins over a
105// bare token. `host`: whether a pair gave it. With no host to ask for (no
106// repo yet), any token there counts.
107export function tokenFor(text: string, host: string | null): { value: string; host: boolean } | null {
108 const entries = text.split(/\s+/).filter(Boolean)
109 const pairs = entries.filter(t => t.includes('='))
110 const bare = entries.find(t => !t.includes('='))
111 if (host !== null) {
112 const pair = pairs.find(t => t.slice(0, t.indexOf('=')).toLowerCase() === host.toLowerCase())
113 const value = pair?.slice(pair.indexOf('=') + 1)
114 if (value) return { value, host: true }
115 }
116 if (bare) return { value: bare, host: false }
117 if (host === null && pairs[0]) return { value: pairs[0].slice(pairs[0].indexOf('=') + 1), host: true }
118 return null
119}
120hooks/filter.ts 118 lines1import type { FilterShown, LabelFilter, MilestoneFilter } from '../types'
2import { dueFirst, type MilestoneRow } from './epics'
3
4// The milestone filter (docs/TERMINOLOGY.md): what What's next
5// and Issues show, set to a milestone, No milestone (the issues without one) or All.
6// No other place knows how it is written down.
7
8export const ALL: MilestoneFilter = { kind: 'all' }
9export const NONE: MilestoneFilter = { kind: 'none' }
10
11// The filter set to milestone `title`, or to No milestone for none.
12export const ofMilestone = (title: string | null): MilestoneFilter => (title === null ? NONE : { kind: 'milestone', title })
13
14// Whether an issue with milestone `milestone` is in what `f` is set to.
15export const holds = (f: MilestoneFilter, milestone: string | null) =>
16 f.kind === 'all' || (f.kind === 'none' ? milestone === null : f.title === milestone)
17
18// Whether `a` is set to what `b` is. Anything that is no filter, as a
19// session's state from older code holds, is set to nothing.
20export function sameFilter(a: unknown, b: MilestoneFilter): boolean {
21 if (typeof a !== 'object' || a === null) return false
22 const f = a as Partial<{ kind: string; title: string }>
23 return f.kind === b.kind && (b.kind !== 'milestone' || f.title === b.title)
24}
25
26// Its words on the filter's button: the milestone's title, No milestone or All.
27export const filterLabel = (f: MilestoneFilter) => (f.kind === 'milestone' ? f.title : f.kind === 'none' ? 'No milestone' : 'All')
28
29// Its word in a sentence: no milestone.
30export const filterWord = (f: MilestoneFilter) => (f.kind === 'none' ? 'no milestone' : filterLabel(f))
31
32// What it is set to after a sentence's words: " in v1.2", " without a
33// milestone", or nothing for All.
34export const within = (f: MilestoneFilter) => (f.kind === 'all' ? '' : f.kind === 'none' ? ' without a milestone' : ` in ${f.title}`)
35
36// One text per setting, for a button's key and for what is remembered per setting.
37export const filterKey = (f: MilestoneFilter) => (f.kind === 'milestone' ? `m:${f.title}` : f.kind)
38
39export type FilterOption = FilterShown['options'][number]
40
41// What the milestone filter offers: All, then No milestone when some has
42// none, then each open milestone that has an open issue or an epic, due
43// first (#99, decided 6d).
44export function filterOptions(milestones: readonly MilestoneRow[], items: readonly { milestone: string | null }[]): FilterOption[] {
45 const options: FilterOption[] = [{ filter: ALL, label: filterLabel(ALL) }]
46 if (items.some(e => e.milestone === null)) options.push({ filter: NONE, label: filterLabel(NONE) })
47 return options.concat(
48 dueFirst(milestones)
49 .filter(m => items.some(e => e.milestone === m.title))
50 .map(m => ({ filter: ofMilestone(m.title), label: m.title, due: m.due })),
51 )
52}
53
54// The label filter (docs/TERMINOLOGY.md): set to no labels, All, it shows every issue.
55export const NO_LABELS: LabelFilter = { labels: [], mode: 'and' }
56
57// Whether an issue with `labels` is in what `f` is set to: it has every
58// label set (and) or one of them (or); with No label set, it has no label
59// at all, or with or one of the labels set too (#99, decided 6b).
60// Anything that is no label filter, as a session's state from older code
61// holds, is set to no labels.
62export function matches(f: LabelFilter | null | undefined, labels: readonly { name: string }[]): boolean {
63 if (leavesOut(f, labels)) return false
64 const set = Array.isArray(f?.labels) ? f.labels : []
65 const bare = f?.none === true && labels.length === 0
66 if (set.length === 0) return f?.none === true ? bare : true
67 const has = new Set(labels.map(l => l.name))
68 return f?.mode === 'or' ? bare || set.some(l => has.has(l)) : set.every(l => has.has(l))
69}
70
71// The labels `f` leaves out (☒), and whether `labels` holds one of them:
72// such an issue is never in it, under and and or alike (#99, decided 7, S1).
73export const leftOut = (f: LabelFilter | null | undefined): string[] => (Array.isArray(f?.not) ? f.not : [])
74export const leavesOut = (f: LabelFilter | null | undefined, labels: readonly { name: string }[]) =>
75 leftOut(f).some(n => labels.some(l => l.name === n))
76
77// Whether `f` is set on All: no label set, none left out, not No label;
78// anything that is no label filter is.
79export const isAll = (f: LabelFilter | null | undefined) =>
80 !(Array.isArray(f?.labels) && f.labels.length > 0) && f?.none !== true && leftOut(f).length === 0
81
82// What a press on label `name`'s box makes of `f`: ☐ set (☑), ☑ left out
83// (☒), ☒ neither (☐) again (#99, decided 7, X1).
84export function stepLabel(f: LabelFilter, name: string): LabelFilter {
85 const not = leftOut(f)
86 if (f.labels.includes(name)) return { ...withLabel(f, name, false), not: [...not.filter(n => n !== name), name] }
87 if (not.includes(name)) return { ...f, not: not.filter(n => n !== name) }
88 return withLabel(f, name, true)
89}
90
91// Label `name` set in `f` (`on`) or taken out of it; under and, a label set
92// leaves No label, under or it ticks along with it (#99, decided 6c).
93export const withLabel = (f: LabelFilter, name: string, on: boolean): LabelFilter => ({
94 ...f,
95 none: f.mode === 'or' && f.none === true,
96 labels: on ? [...f.labels.filter(l => l !== name), name] : f.labels.filter(l => l !== name),
97})
98
99// `f` set to and or or; and takes No label out while a label is set, as
100// the two never match together (#99, decided K1).
101export const withMode = (f: LabelFilter, mode: LabelFilter['mode']): LabelFilter => ({
102 ...f,
103 mode,
104 none: f.none === true && (mode === 'or' || f.labels.length === 0),
105})
106
107// The word No label, on the button and in the list.
108export const NO_LABEL = 'No label'
109
110// Its words on the filter's button, as the milestone filter's: All, No
111// label, or the labels set joined by the word set, `docs and readme`, with
112// No label first when it ticks along: `No label or docs`; then each label
113// left out after `not`: `docs, not bug`, `not bug, not wontfix`.
114export const labelWords = (f: LabelFilter): { word: string } | { labels: string[]; join: string; none: boolean; not: string[] } =>
115 f.labels.length === 0 && leftOut(f).length === 0
116 ? { word: f.none === true ? NO_LABEL : 'All' }
117 : { labels: f.labels, join: ` ${f.mode} `, none: f.none === true && (f.mode === 'or' || f.labels.length === 0), not: leftOut(f) }
118hooks/load.ts 556 lines1import type { Child, DueBy, Epic, Features, IssueDetail, IssueGroup, IssueRow, LabelFilter, LastClosed, MilestoneFilter, MilestoneIssue, Ref, Scope } from '../types'
2import { blockersCount, childOf, groupsFor, isMine, stands, tree, type MilestoneRow } from './epics'
3import { ALL, filterKey, filterOptions, holds, matches, NO_LABELS, ofMilestone, sameFilter, type FilterOption } from './filter'
4import { dueSoon } from './issue'
5import { order, pickNext, soonAt, type Scoped } from './next'
6import { message, type Closed, type EpicRow, type OpenIssue, type Tracker } from './tracker'
7
8// What is remembered of a repo until the next Refresh or /next, so the
9// milestone filter can switch without asking the tracker again: its open
10// milestones, your open issues, the available ones and those with others
11// (`listed`: whether the lists answered), the blockers checked so far, the
12// epics, and the children of each epic loaded so far. For where you work:
13// the issue you closed last within each milestone filter setting, the open
14// epic each issue is a child of, and the children's numbers of each epic,
15// as asked.
16type Memory = {
17 milestones: MilestoneRow[]
18 closedMilestones: MilestoneRow[]
19 mine: IssueRow[]
20 free: IssueRow[]
21 taken: OpenIssue[]
22 listed: boolean
23 blockers: Map<number, number[] | null>
24 epics: EpicRow[]
25 children: Map<number, Child[] | null>
26 lastClosed: Map<string, Closed | null>
27 parents: Map<number, number[] | null>
28 members: Map<number, number[] | null>
29}
30
31const oldestFirst = (a: IssueRow, b: IssueRow) => a.created.localeCompare(b.created)
32
33// The issues What's Next changed lately, by repo, each as it was read again
34// right after the change, with when. A forge's lists may lag a change (GitHub's
35// do, by a few seconds), while the issue itself is up to date at once: for
36// CHANGE_MS the lists show these as the change left them.
37const CHANGE_MS = 30_000
38const changedByRepo = new Map<string, Map<number, { issue: IssueDetail; at: number }>>()
39
40// Issue `issue` of repo `repo` (host/owner/name), just changed at `now`.
41export function noteChange(repo: string, issue: IssueDetail, now: number) {
42 let changed = changedByRepo.get(repo)
43 if (!changed) changedByRepo.set(repo, (changed = new Map()))
44 changed.set(issue.number, { issue, at: now })
45}
46
47// The issues of `repo` changed within CHANGE_MS of `now`; older ones are forgotten.
48export function changesSince(repo: string, now: number): IssueDetail[] {
49 const changed = changedByRepo.get(repo)
50 if (!changed) return []
51 for (const [n, c] of changed) if (now - c.at > CHANGE_MS) changed.delete(n)
52 return [...changed.values()].map(c => c.issue)
53}
54
55// The parents of issues What's Next put under one or took out of lately,
56// by repo, with when: a forge may answer the old ones for a while (GitHub
57// does), so for CHANGE_MS they are taken as the change left them.
58const parentsByRepo = new Map<string, Map<number, { parents: number[]; at: number }>>()
59
60// Issue `n` of repo `repo` (host/owner/name), now under `parents`, at `now`.
61export function noteParents(repo: string, n: number, parents: number[], now: number) {
62 let changed = parentsByRepo.get(repo)
63 if (!changed) parentsByRepo.set(repo, (changed = new Map()))
64 changed.set(n, { parents, at: now })
65}
66
67// The parents of `repo`'s issues changed within CHANGE_MS of `now`, by issue.
68export function parentsSince(repo: string, now: number): Map<number, number[]> {
69 const changed = parentsByRepo.get(repo)
70 if (!changed) return new Map()
71 for (const [n, c] of changed) if (now - c.at > CHANGE_MS) changed.delete(n)
72 return new Map([...changed].map(([n, c]) => [n, c.parents]))
73}
74
75const toRow = (i: IssueDetail): IssueRow => ({
76 number: i.number,
77 title: i.title,
78 milestone: i.milestone?.title ?? null,
79 labels: i.labels,
80 created: i.created,
81 due: i.due,
82 url: i.url,
83 body: i.body,
84 updated: i.updated,
85})
86
87// Your issues, the one updated last first.
88const latestFirst = (l: readonly IssueRow[]) => [...l].sort((a, b) => b.updated.localeCompare(a.updated))
89
90// What is known of a repo: built once per load, it asks the tracker the
91// rest once and remembers it until the next Refresh or /next.
92export type Known = Awaited<ReturnType<typeof loadRepo>>
93
94// Asks tracker `t` for what the panes show: the open milestones, your open
95// issues, the repo's open issues, and the epics unless `features` turns
96// them off. A list that fails is named, for the pane that shows it. A
97// lookup that fails, now or later, answers unknown, as the Tracker contract
98// says. `me`: who you are, from the setup check. `changes`: issues changed
99// lately, which count as they are there, over what the lists say: open and
100// assigned to `me` is yours, open and assigned to nobody is free.
101// `parents`: the parents of issues changed lately, over what the forge says.
102export async function loadRepo(t: Tracker, o: { me: string | null; features: Features; changes?: IssueDetail[]; parents?: ReadonlyMap<number, number[]> }) {
103 const { me, features } = o
104 // A setup check from older code may not say, which counts as on.
105 const deps = features.deps !== false
106 const milestones = await t.milestones()
107 const closedMilestones = await t.closedMilestones(3)
108 let mine: IssueRow[] = []
109 let free: IssueRow[] = []
110 let taken: OpenIssue[] = []
111 let issuesError: string | null = null
112 try {
113 const changes = o.changes ?? []
114 const changed = new Set(changes.map(i => i.number))
115 const open = changes.filter(i => i.state === 'open')
116 mine = [
117 ...(await t.mine()).filter(i => !changed.has(i.number)),
118 ...open.filter(i => me !== null && i.assignees.includes(me)).map(toRow),
119 ].sort(oldestFirst)
120 const yours = new Set(mine.map(i => i.number))
121 const listed = (await t.open()).filter(i => !changed.has(i.number) && !yours.has(i.number))
122 free = [
123 ...listed.filter(i => i.assignees.length === 0).map(({ assignees: _, ...row }) => row),
124 ...open.filter(i => i.assignees.length === 0).map(toRow),
125 ].sort(oldestFirst)
126 taken = [
127 ...listed.filter(i => i.assignees.length > 0),
128 ...open
129 .filter(i => i.assignees.length > 0 && !(me !== null && i.assignees.includes(me)))
130 .map(i => ({ ...toRow(i), assignees: i.assignees })),
131 ]
132 } catch (err) {
133 issuesError = message(err)
134 }
135 let found: EpicRow[] = []
136 let epicsError: string | null = null
137 if (features.epics !== false) {
138 try {
139 found = await t.epics()
140 } catch (err) {
141 epicsError = message(err)
142 }
143 }
144 const known: Memory = {
145 milestones,
146 closedMilestones,
147 mine,
148 free,
149 taken,
150 listed: issuesError === null,
151 blockers: new Map(),
152 epics: found,
153 children: new Map(),
154 lastClosed: new Map(),
155 parents: new Map(o.parents ?? []),
156 members: new Map(),
157 }
158 const openEpics = new Set(known.epics.filter(e => e.state === 'open').map(e => e.number))
159
160 // The open issues issue `n` waits on, asked once and remembered; null
161 // without dependencies, or once the tracker would not say.
162 async function blockersOf(n: number): Promise<number[] | null> {
163 if (!deps) return null
164 if (known.blockers.has(n)) return known.blockers.get(n) ?? null
165 if ([...known.blockers.values()].includes(null)) return null
166 const open = await t.blockers(n).catch(() => null)
167 known.blockers.set(n, open)
168 return open
169 }
170
171 // The children of issue `n`, asked once and remembered, shared by every
172 // pane and the issue detail; null when the forge would not say.
173 async function kidsOf(n: number): Promise<Child[] | null> {
174 if (!known.children.has(n)) known.children.set(n, await t.children(n).then(l => l && l.map(c => childOf(c, me)), () => null))
175 return known.children.get(n) ?? null
176 }
177
178 // The issue assigned to you that closed last within `filter`, asked once
179 // per setting and remembered. The one within All is also the last within
180 // its own milestone.
181 async function lastClosedIn(filter: MilestoneFilter): Promise<Closed | null> {
182 const key = filterKey(filter)
183 if (known.lastClosed.has(key)) return known.lastClosed.get(key) ?? null
184 const within = filter.kind === 'milestone' ? known.milestones.find(m => m.title === filter.title) : filter.kind
185 const found = within && me !== null ? await t.lastClosed(within, me).catch(() => null) : null
186 known.lastClosed.set(key, found)
187 if (filter.kind === 'all' && found) {
188 const own = filterKey(ofMilestone(found.milestone))
189 if (!known.lastClosed.has(own)) known.lastClosed.set(own, found)
190 }
191 return found
192 }
193
194 // The open epics issue `n` is a child of, asked once and remembered;
195 // null when the forge would not say. Where children do not nest, an epic
196 // is no child: an epic that depends on it waits for it (README),
197 // and nothing is asked.
198 async function parentsOf(n: number): Promise<number[] | null> {
199 if (!t.nested && known.epics.some(e => e.number === n)) return []
200 if (!known.parents.has(n)) known.parents.set(n, await t.parentsOf(n).catch(() => null))
201 return known.parents.get(n) ?? null
202 }
203
204 // The first of them, where one epic is what counts: where you work, and
205 // the epic Last closed was in.
206 const parentOf = async (n: number): Promise<number | null> => (await parentsOf(n))?.[0] ?? null
207
208 // The epics above issue `n`, nearest first: its epic, and on a forge where
209 // children nest, each parent up the tree, a loop stopped where it repeats.
210 async function epicsAbove(n: number): Promise<number[]> {
211 const chain: number[] = []
212 for (let e = await parentOf(n); e !== null && !chain.includes(e) && e !== n; ) {
213 chain.push(e)
214 if (!t.nested) break
215 e = await parentOf(e)
216 }
217 return chain
218 }
219
220 // The numbers of epic `n`'s children: from the children loaded, else
221 // asked once and remembered.
222 async function membersOf(n: number): Promise<number[] | null> {
223 const loaded = known.children.get(n)
224 if (loaded) return loaded.map(c => c.number)
225 if (!known.members.has(n)) known.members.set(n, await t.members(n).catch(() => null))
226 return known.members.get(n) ?? null
227 }
228
229 // The due date that counts for each open issue known (ORDERING.md, Due
230 // soon): the earliest of its own, each open epic's it is in, and its
231 // milestone's; `by` says whose when it is not its own, which wins a tie.
232 // Only an epic due soon can make an issue due soon, so only those epics'
233 // children are asked for, once each.
234 async function dueDates(now: number): Promise<Map<number, { due: string; by: DueBy['from'] | null }>> {
235 const msDue = new Map(known.milestones.map(m => [m.title, m.due]))
236 const epicsOf = new Map<number, { number: number; due: string }[]>()
237 for (const e of known.epics) {
238 if (e.state !== 'open' || e.due === null || dueSoon(e.due, now) === 'later') continue
239 const due = e.due
240 for (const n of (await membersOf(e.number)) ?? []) epicsOf.set(n, [...(epicsOf.get(n) ?? []), { number: e.number, due }])
241 }
242 const dates = new Map<number, { due: string; by: DueBy['from'] | null }>()
243 for (const i of [...known.mine, ...known.free, ...known.taken]) {
244 const ms = i.milestone !== null ? (msDue.get(i.milestone) ?? null) : null
245 const all: { due: string; by: DueBy['from'] | null }[] = [
246 ...(i.due !== null ? [{ due: i.due, by: null }] : []),
247 ...(epicsOf.get(i.number) ?? []).map(e => ({ due: e.due, by: { kind: 'epic' as const, number: e.number } })),
248 ...(ms !== null && i.milestone !== null ? [{ due: ms, by: { kind: 'milestone' as const, title: i.milestone } }] : []),
249 ]
250 const first = all.reduce<(typeof all)[number] | undefined>((a, b) => (a === undefined || Date.parse(b.due) < Date.parse(a.due) ? b : a), undefined)
251 if (first) dates.set(i.number, first)
252 }
253 return dates
254 }
255
256 // Whether issue `i` waits on an open issue among `open`, its own children
257 // left out: an epic on Forgejo depends on each of its children
258 // (DRAWING-issue.md, Detail lines that name an issue). Its children are
259 // asked for only when it is blocked without them left out.
260 async function blockedOutside(i: { number: number; state: string; assignees: readonly string[] }, open: number[] | null): Promise<boolean> {
261 const mine = isMine(i.assignees, me)
262 const waits = (own: readonly number[]) => stands({ state: i.state, blockedBy: open ?? [] }, mine, own).blocked
263 if (!waits([])) return false
264 const children = known.epics.some(e => e.number === i.number) || t.nested ? ((await membersOf(i.number)) ?? []) : []
265 return waits(children)
266 }
267
268 // The open issues of milestone `title` (null: those without one), as epic
269 // children are: yours, free and with others, each not yours checked for
270 // blockers as What's next checks its own, by number. An open epic among
271 // them carries its children, shared with every tree, which never count as
272 // its blockers. `epicsToo`: the open epics the lists left out are taken
273 // from the epics' own list. Its closed epics come last. null when the
274 // issues could not be listed.
275 async function issuesIn(title: string | null, epicsToo = false): Promise<MilestoneIssue[] | null> {
276 if (!known.listed) return null
277 const listed = [
278 ...known.mine.map(i => ({ i, assignees: me !== null ? [me] : [], mine: true })),
279 ...known.free.map(i => ({ i, assignees: [] as string[], mine: false })),
280 ...known.taken.map(i => ({ i, assignees: i.assignees, mine: false })),
281 ]
282 const seen = new Set(listed.map(x => x.i.number))
283 const epicRows = epicsToo
284 ? known.epics
285 .filter(e => e.state === 'open' && !seen.has(e.number))
286 .map(e => {
287 const assignees = e.assignees ?? []
288 const i = { number: e.number, title: e.title, milestone: e.milestone, labels: e.labels ?? [], created: e.created ?? '', due: e.due }
289 return { i, assignees, mine: isMine(assignees, me) }
290 })
291 : []
292 const all = [...listed, ...epicRows].filter(x => x.i.milestone === title).sort((a, b) => a.i.number - b.i.number)
293 const out: MilestoneIssue[] = []
294 for (const { i, assignees, mine } of all) {
295 const epic = openEpics.has(i.number)
296 const children = epic ? await kidsOf(i.number) : undefined
297 const open = blockersCount('open', mine) ? await blockersOf(i.number) : null
298 const { blocked, blockedBy } = stands({ state: 'open', blockedBy: open ?? [] }, mine, (children ?? []).map(c => c.number))
299 out.push({
300 ...(epic ? { children } : {}),
301 number: i.number,
302 title: i.title,
303 state: 'open',
304 assignees,
305 mine,
306 blocked,
307 labels: i.labels,
308 milestone: i.milestone,
309 created: i.created,
310 due: i.due,
311 blockedBy,
312 })
313 }
314 for (const e of known.epics.filter(e => e.state === 'closed' && e.milestone === title).sort((a, b) => a.number - b.number)) {
315 out.push({
316 number: e.number,
317 title: e.title,
318 state: 'closed',
319 assignees: [],
320 mine: false,
321 blocked: false,
322 labels: e.labels ?? [],
323 milestone: title,
324 created: '',
325 due: e.due,
326 blockedBy: [],
327 children: null,
328 })
329 }
330 return out
331 }
332
333 // Issue `p`, a parent, as the issue detail names it: its number, title
334 // and state, whether it is an epic, and what its mark needs: whom it is
335 // assigned to and whether it is blocked. null for one that is not among
336 // the open issues and epics known.
337 async function refOf(p: number): Promise<(Ref & { epic: boolean }) | null> {
338 const epic = known.epics.find(e => e.number === p)
339 const issue = [...known.mine, ...known.free, ...known.taken].find(i => i.number === p)
340 if (!epic && !issue) return null
341 // Yours, with others, or nobody's: as the lists hold it.
342 const taken = known.taken.find(i => i.number === p)
343 const assignees = epic ? (epic.assignees ?? []) : taken ? taken.assignees : me && known.mine.some(i => i.number === p) ? [me] : []
344 const state = epic ? epic.state : 'open'
345 const open = blockersCount(state, isMine(assignees, me)) ? await blockersOf(p) : null
346 const blocked = await blockedOutside({ number: p, state, assignees }, open)
347 return { number: p, title: epic?.title ?? issue?.title ?? '', state, epic: epic !== undefined, assignees, blocked }
348 }
349
350 return {
351 // The tracker it asks: what the panes change goes to it too.
352 tracker: t,
353 // Who you are, from the setup check.
354 me,
355 // Why a list failed, for the pane that shows it; null when it answered.
356 issuesError,
357 epicsError,
358 // Whether the open issues could be listed.
359 listed: known.listed,
360 // The open milestones, and the last three closed.
361 milestones,
362 closedMilestones,
363 // The open epics, by number.
364 openEpics: openEpics as ReadonlySet<number>,
365
366 // Whether issue `n` is open and yours or available, as the lists hold it.
367 listedOpen: (n: number) => [...known.mine, ...known.free].some(i => i.number === n),
368
369 // What the milestone filter offers: the milestones of the open issues
370 // and epics.
371 options: (): FilterOption[] => filterOptions(known.milestones, [...known.mine, ...known.free, ...known.epics.filter(e => e.state === 'open')]),
372
373 // Where the milestone filter starts on /next (docs/ORDERING.md):
374 // the milestone of your issue in progress updated last, else of the
375 // issue you closed last in the repo, when `options` offer it; else the
376 // first milestone offered, else All. All too when an issue of yours or
377 // an available one is due soon outside it.
378 async startingFilter(options: readonly FilterOption[], now = 0): Promise<MilestoneFilter> {
379 const from = latestFirst(known.mine)[0] ?? (await lastClosedIn(ALL))
380 const start = from ? options.find(x => sameFilter(x.filter, ofMilestone(from.milestone))) : undefined
381 const set = (start ?? options.find(x => x.filter.kind === 'milestone'))?.filter ?? ALL
382 const dates = await dueDates(now)
383 const soon = (i: IssueRow) => soonAt(dates.get(i.number)?.due ?? null, now) < Infinity
384 return [...known.mine, ...known.free].some(i => soon(i) && !holds(set, i.milestone)) ? ALL : set
385 },
386
387 // What's next within `filter`: yours in it are In progress; the
388 // available ones in it are taken in the order docs/ORDERING.md
389 // sets out, from where you work outward, and the first with no open
390 // blockers is Next; the Queue after it is checked too, up to its limit.
391 // A tracker without dependencies is not asked for blockers, so its Queue
392 // is only limited; nor is one that would not say for an issue
393 // (dependencies off without the setup check knowing, an error), until
394 // the next Refresh.
395 async whatsNext(filter: MilestoneFilter, now = 0, labels: LabelFilter = NO_LABELS) {
396 const dates = await dueDates(now)
397 const dueOf = (i: IssueRow) => dates.get(i.number)?.due ?? null
398 const soon = (i: IssueRow) => soonAt(dueOf(i), now)
399 // Due soon first, earliest first; the rest as they were, oldest first.
400 const inProgress = known.mine
401 .filter(i => holds(filter, i.milestone) && matches(labels, i.labels))
402 .map((i, k) => ({ i, k, at: soon(i) }))
403 .sort((a, b) => a.at - b.at || a.k - b.k)
404 .map(x => x.i)
405 const free = known.free.filter(i => holds(filter, i.milestone) && matches(labels, i.labels))
406 const dueOutside = known.free.filter(i => !holds(filter, i.milestone) && matches(labels, i.labels) && soon(i) < Infinity).length
407 const last = await lastClosedIn(filter)
408 const from = latestFirst(inProgress)[0] ?? last
409 const chain = from ? await epicsAbove(from.number) : []
410 const scopes: Scoped[] = []
411 for (const e of chain) {
412 const kids = await membersOf(e)
413 if (kids) scopes.push({ scope: { kind: 'epic', number: e }, holds: i => kids.includes(i.number) })
414 }
415 const ms = from?.milestone ?? null
416 if (filter.kind === 'all' && ms !== null) scopes.push({ scope: { kind: 'milestone', title: ms }, holds: i => i.milestone === ms })
417 // An epic that waits on its children is no issue they unblock: it is
418 // their epic (DRAWING-issue.md, The facts line).
419 const epicNumbers = new Set(known.epics.map(e => e.number))
420 const waitingOn = (n: number) => [...known.blockers].filter(([w, by]) => !epicNumbers.has(w) && by?.includes(n)).length
421 const unblocksOf = (i: IssueRow) => i.unblocks ?? waitingOn(i.number)
422 const ordered = order(free, { scopes, dueOf, unblocks: unblocksOf, now })
423 const { next, queue, blocked, more } = await pickNext(
424 ordered.map(x => x.issue),
425 blockersOf,
426 )
427 const scopeOf: Record<number, Scope> = {}
428 for (const x of ordered) if (x.scope) scopeOf[x.issue.number] = x.scope
429 // Counted after the walk, which may have found more that wait; for every
430 // open issue known, as every pane's facts line says it.
431 const unblocks: Record<number, number> = {}
432 for (const i of [...known.mine, ...known.free, ...known.taken]) {
433 const n = unblocksOf(i)
434 if (n > 0) unblocks[i.number] = n
435 }
436 // Whose due date counts, for the facts line of every pane, where it is
437 // due soon and not the issue's own.
438 const dueBy: Record<number, DueBy> = {}
439 for (const [n, d] of dates) if (d.by && soonAt(d.due, now) < Infinity) dueBy[n] = { due: d.due, from: d.by }
440 const lastClosed: LastClosed | null = last
441 ? { ...last, epic: from === last ? (chain[0] ?? null) : await parentOf(last.number) }
442 : null
443 return { inProgress, next, queue, blocked, more, scopes: scopeOf, unblocks, dueBy, dueOutside, lastClosed }
444 },
445
446 // How many open issues the repo has, epics among them, which decides
447 // whether Issues opens.
448 openIssues: () => new Set([...known.mine, ...known.free, ...known.taken].map(i => i.number).concat([...openEpics])).size,
449
450 // The Issues pane: the groups of `filter`, by milestone, each with its
451 // open issues and closed epics as `issuesIn` lists them. null when the
452 // issues could not be listed.
453 async issues(filter: MilestoneFilter): Promise<IssueGroup[] | null> {
454 if (!known.listed) return null
455 const items = [...known.mine, ...known.free, ...known.taken, ...known.epics]
456 const groups: IssueGroup[] = []
457 for (const g of groupsFor(filter, known.milestones, items)) {
458 const m = g.milestone
459 const issues = (await issuesIn(m?.title ?? null, true)) ?? []
460 groups.push({ title: m?.title ?? null, due: m?.due ?? null, closed: m?.closed ?? 0, total: m?.total ?? 0, issues })
461 }
462 return groups
463 },
464
465 // The open issues of milestone `title`, for the Milestones pane: its
466 // closed epics come last, for a closed milestone to show.
467 milestone: (title: string) => issuesIn(title),
468
469 // What there is for you in milestone `title`, from what is known without
470 // asking the tracker: yours from the lists; available, with others and
471 // blocked count only once `checked`, when every issue in it that is not
472 // yours was checked for blockers (or none can be: dependencies off, or
473 // the tracker would not say). Epics are left out. null when the issues
474 // could not be listed.
475 forYou(title: string): { checked: boolean; yours: number; available: number; withOthers: number; blocked: number } | null {
476 if (!known.listed) return null
477 const inIt = <T extends { number: number; milestone: string | null }>(l: T[]) => l.filter(i => i.milestone === title && !openEpics.has(i.number))
478 const others = [...inIt(known.free).map(i => ({ i, taken: false })), ...inIt(known.taken).map(i => ({ i, taken: true }))]
479 const noneAsked = !deps || [...known.blockers.values()].includes(null)
480 const blockedNow = (n: number) => !noneAsked && (known.blockers.get(n) ?? []).length > 0
481 return {
482 checked: noneAsked || others.every(x => known.blockers.has(x.i.number)),
483 yours: inIt(known.mine).length,
484 available: others.filter(x => !x.taken && !blockedNow(x.i.number)).length,
485 withOthers: others.filter(x => x.taken && !blockedNow(x.i.number)).length,
486 blocked: others.filter(x => blockedNow(x.i.number)).length,
487 }
488 },
489
490 // Asks for the children each tree needs, as far as `unfolded` reaches,
491 // each issue once. `trees`: the epics at the top of each tree a pane
492 // draws (the Issues pane, a milestone). Returns every child list loaded
493 // so far, by issue.
494 async grow(trees: readonly (readonly Epic[])[], unfolded: ReadonlySet<number>): Promise<Record<number, Child[] | null>> {
495 const kids = (n: number) => (known.children.has(n) ? (known.children.get(n) ?? null) : undefined)
496 // Each round loads what the last one found missing.
497 for (;;) {
498 const missing = [...new Set(trees.flatMap(top => tree(top, kids, unfolded, openEpics).missing))]
499 if (missing.length === 0) break
500 await Promise.all(missing.map(kidsOf))
501 }
502 return Object.fromEntries(known.children)
503 },
504
505 // Everything the issue detail shows of issue `n`, as `features` allow.
506 issue: (n: number): Promise<IssueDetail> => t.issue(n, features),
507
508 // The issues `n` is a child of, as the issue detail names them: each
509 // one's number, title and state, whether it is an epic, and what its
510 // mark needs: whom it is assigned to and whether it is blocked. In the
511 // epic list's order, the Issues pane's; one not among the open issues and
512 // epics known is left out; none when the forge would not say.
513 async parents(n: number): Promise<(Ref & { epic: boolean })[]> {
514 const listed = groupsFor(ALL, known.milestones, known.epics.filter(e => e.state === 'open')).flatMap(g => g.issues.map(e => e.number))
515 const at = (x: number) => (listed.includes(x) ? listed.indexOf(x) : listed.length)
516 const found = await Promise.all([...((await parentsOf(n)) ?? [])].sort((a, b) => at(a) - at(b)).map(refOf))
517 return found.filter(r => r !== null)
518 },
519
520 // Issue `n` now under `parents`, as a change just left it.
521 setParents(n: number, parents: number[]) {
522 known.parents.set(n, parents)
523 },
524
525 // What issue `n` may be put under, as the epic list offers it
526 // (DRAWING-issue.md, The epic list): the open epics in the Issues pane's
527 // order, and where children nest (GitHub) the open issues of its
528 // milestone `m` that are no epic; issue `n` and its `children` left out,
529 // so there is no loop.
530 async choices(n: number, m: string | null, children: readonly number[]) {
531 const skip = new Set([n, ...children])
532 const epics = groupsFor(ALL, known.milestones, known.epics.filter(e => e.state === 'open' && !skip.has(e.number))).flatMap(g =>
533 g.issues.map(e => e.number),
534 )
535 const issues =
536 t.nested && m !== null
537 ? [...known.mine, ...known.free, ...known.taken].filter(i => i.milestone === m && !openEpics.has(i.number) && !skip.has(i.number)).map(i => i.number)
538 : []
539 const refs = async (l: number[]) => (await Promise.all(l.map(refOf))).filter(r => r !== null)
540 return { epics: await refs(epics), issues: await refs(issues) }
541 },
542
543 // The children of issue `n`, as the issue detail lists them: an epic's,
544 // or on a forge where children nest any issue's. null for an issue that
545 // holds none, or when the forge would not say.
546 async children(n: number): Promise<Child[] | null> {
547 if (!known.epics.some(e => e.number === n) && !t.nested) return null
548 return kidsOf(n)
549 },
550
551 // Whether issue `i`, as the issue detail shows it, waits on an open issue
552 // among `open`, its own children left out.
553 blocked: blockedOutside,
554 }
555}
556hooks/setup.ts 128 lines1import type { Repo, Setup, TokenSource } from '../types'
2import { detect, gitlabSetting, tokenFor, type Forge, type TokenSetting } from './forge'
3import { forgejo } from './forgejo'
4import { github } from './github'
5import { gitlab } from './gitlab'
6import { withoutToken, type Send, type Tracker } from './tracker'
7
8// Setup's steps, and the tracker they end in. It takes plain inputs: the
9// origin remote, the token settings' text and the engine's fetch. The Hard
10// rule on tokens is kept here: a token comes only from the settings, goes
11// only to the forge setup found at its host, and what the tracker says never
12// carries it.
13
14// The token settings' text, as `register` reads it.
15export type Tokens = Record<TokenSetting, string>
16
17// A token and where it came from.
18type TokenFound = { value: string | null; from: TokenSource }
19
20// The host, owner and repo a git remote URL names, in any of the forms git
21// takes: ssh://git@host:2222/o/r.git, git@host:o/r.git, https://host/o/r.
22export function parseRemote(url: string): Repo | null {
23 const m =
24 /^(?:ssh|https?|git):\/\/(?:[^@/]+@)?([^:/]+)(?::\d+)?\/(.+?)\/([^/]+?)(?:\.git)?\/?$/.exec(url) ??
25 /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+?)\/([^/]+?)(?:\.git)?\/?$/.exec(url)
26 if (!m?.[1] || !m[2] || !m[3]) return null
27 return { host: m[1], owner: m[2], name: m[3] }
28}
29
30// The token for `host` (null: no repo yet) from one token setting, kept in
31// secure storage. The settings are the only source, so the token is the
32// same in the terminal and the app.
33export function readToken(tokens: Tokens, setting: TokenSetting, host: string | null): TokenFound {
34 const t = tokenFor(tokens[setting], host)
35 return t ? { value: t.value, from: { setting, host: t.host ? host : null } } : { value: null, from: null }
36}
37
38// Where a token is from while the host is not known yet (no repo): the first
39// of the four settings that has one, in the order of the settings.
40export function anyToken(tokens: Tokens): TokenSource {
41 for (const setting of ['Forgejo tokens', 'GitLab.com token', 'Private GitLab token', 'GitHub token'] as const) {
42 const found = readToken(tokens, setting, null)
43 if (found.value !== null) return found.from
44 }
45 return null
46}
47
48// The GitLab token for `host`. gitlab.com's comes from GitLab.com token, else
49// from a `gitlab.com=` pair in Private GitLab token, as older setups have it,
50// never from that setting's bare token. Any other host's comes from Private
51// GitLab token.
52function gitlabToken(tokens: Tokens, host: string): TokenFound {
53 if (gitlabSetting(host) === 'Private GitLab token') return readToken(tokens, 'Private GitLab token', host)
54 const com = readToken(tokens, 'GitLab.com token', host)
55 if (com.value !== null) return com
56 const old = tokenFor(tokens['Private GitLab token'], host)
57 return old?.host ? { value: old.value, from: { setting: 'Private GitLab token', host } } : { value: null, from: null }
58}
59
60// The token for the forge found at `host`; none for a forge that is unknown.
61function tokenOfForge(tokens: Tokens, forge: Forge, host: string): TokenFound {
62 return forge.kind === 'gitlab'
63 ? gitlabToken(tokens, host)
64 : forge.kind === 'github'
65 ? readToken(tokens, 'GitHub token', host)
66 : forge.kind === 'forgejo'
67 ? readToken(tokens, 'Forgejo tokens', host)
68 : { value: null, from: null }
69}
70
71// The tracker of one forge for repo `r`, with `token` cut out of what it says.
72function trackerOf(send: Send, kind: 'forgejo' | 'gitlab' | 'github', r: Repo, token: string | null, label: string, version: string | null): Tracker {
73 return withoutToken(
74 kind === 'gitlab'
75 ? gitlab(send, r, token, version, label)
76 : kind === 'github'
77 ? github(send, r, token, version, label)
78 : forgejo(send, r, token, label, version ?? undefined),
79 token,
80 )
81}
82
83// The tracker behind repo `r`: GitLab or GitHub where setup found it, else
84// Forgejo. A token goes only to the forge setup found at the host (`forge`):
85// with none found, the Forgejo tracker gets no token.
86export function trackerFor(send: Send, r: Repo, forge: Forge | undefined, tokens: Tokens, label: string): Tracker {
87 return forge?.kind === 'gitlab' || forge?.kind === 'github'
88 ? trackerOf(send, forge.kind, r, tokenOfForge(tokens, forge, r.host).value, label, forge.version)
89 : trackerOf(send, 'forgejo', r, forge?.kind === 'forgejo' ? tokenOfForge(tokens, forge, r.host).value : null, label, null)
90}
91
92// Step Repo: the repo behind `origin` (null: no such remote). With no repo,
93// `setup` says why; `folder` is asked then, for whether the session is in a
94// git repo at all and where it runs (`~` for the home folder).
95export async function findRepo(
96 origin: string | null,
97 folder: () => Promise<{ inRepo: boolean; dir: string }>,
98): Promise<{ repo: Repo; setup: null } | { repo: null; setup: Setup }> {
99 const repo = origin ? parseRemote(origin) : null
100 if (repo) return { repo, setup: null }
101 const { inRepo, dir } = await folder()
102 const setup: Setup = !inRepo
103 ? { kind: 'no-repo', reason: 'not-git', dir }
104 : origin === null
105 ? { kind: 'no-repo', reason: 'no-origin', dir }
106 : { kind: 'no-repo', reason: 'bad-origin', dir, origin: origin.replace(/^([a-z+]+:\/\/)[^@/]*@/i, '$1') }
107 return { repo: null, setup }
108}
109
110// What setup found past the repo. `token`: where the token came from. With an
111// unknown forge there is no tracker, and `setup` says why.
112export type Found = { forge: Forge; token: TokenSource; version: string | null; setup: Setup; tracker: Tracker | null }
113
114// Steps Forge, Token, Access and Actions for `r`: the forge at its host, then
115// the tracker's own steps: a token, and the token reading the repo. A forge
116// already found at the host (`known`) is used as it is, so a check again does
117// not ask the host what it is; one that was not recognised is asked again.
118export async function checkForge(send: Send, r: Repo, tokens: Tokens, label: string, known?: Forge): Promise<Found> {
119 const forge = known && known.kind !== 'unknown' ? known : await detect(send, r.host)
120 if (forge.kind === 'unknown') {
121 return { forge, token: null, version: null, setup: { kind: 'not-forgejo', reason: forge.reason, detail: forge.detail }, tracker: null }
122 }
123 const tok = tokenOfForge(tokens, forge, r.host)
124 const tracker = trackerOf(send, forge.kind, r, tok.value, label, forge.version)
125 const c = await tracker.check()
126 return { forge, token: tok.from, version: c.version, setup: c.setup, tracker }
127}
128hooks/tracker.ts 151 lines1import type { EngineInterface } from 'claude-code'
2
3import type { EpicChild, Features, IssueDetail, IssueRow, Label, Repo, Setup } from '../types'
4import type { MilestoneRow } from './epics'
5import type { TokenSetting } from './forge'
6
7// Repo `r` as its owner and name: boats/radar.
8export const slug = (r: Repo) => `${r.owner}/${r.name}`
9
10// One HTTP request, as the pane sends it: counted, so Debug can say what
11// each action cost.
12export type Send = (url: string, init: Parameters<EngineInterface['http']['fetch']>[1]) => ReturnType<EngineInterface['http']['fetch']>
13
14// A GET to `url`, with the tracker's headers.
15export type Get = (url: string) => ReturnType<Send>
16
17// One request's JSON through `get`, or null when it failed: a request that
18// threw, an answer that is not ok, or a body that is not JSON.
19export async function fetchJson<T>(get: Get, url: string): Promise<T | null> {
20 const res = await get(url).catch(() => null)
21 if (!res?.ok) return null
22 try {
23 return JSON.parse(res.text) as T
24 } catch {
25 return null
26 }
27}
28
29// An open issue in the repo, with who it is assigned to: none makes it free.
30export type OpenIssue = IssueRow & { assignees: string[] }
31
32// A closed issue, as Last closed needs it: when it closed, and its milestone.
33export type Closed = { number: number; title: string; closed: string; milestone: string | null }
34
35// An issue with the Epic label, open or closed, with its milestone's title,
36// its own due date and whom it is assigned to.
37// `labels` and `created` for an epic's facts line, as the lists give them.
38export type EpicRow = {
39 number: number
40 title: string
41 state: 'open' | 'closed'
42 milestone: string | null
43 due: string | null
44 assignees?: string[]
45 labels?: { name: string; color: string }[]
46 created?: string
47}
48
49// What setup's check found past the repo: the tracker's version as shown,
50// once it answered, and how far setup got.
51export type Checked = { version: string | null; setup: Setup }
52
53// What the pane asks a tracker. The pane renders what these return and
54// never builds a URL. A list that fails throws, naming what failed; a
55// lookup that fails answers null, which the pane shows as unknown.
56export type Tracker = {
57 // The tracker at the repo's host, the token, and what it may read and do.
58 check(): Promise<Checked>
59 // The forge's name, for the pane's texts.
60 name: 'Forgejo' | 'GitLab' | 'GitHub'
61 // Where to create a token, with which scopes, and the setting it goes in,
62 // for setup's guide.
63 tokensUrl: string
64 scopes: string
65 setting: TokenSetting
66 // The scope a token needs to assign and close.
67 writeScope: string
68 // The open milestones; none when the list is refused.
69 milestones(): Promise<MilestoneRow[]>
70 // The last `n` milestones closed, newest first; none when the list is refused.
71 closedMilestones(n: number): Promise<MilestoneRow[]>
72 // Your open issues in this repo, and all its open issues.
73 mine(): Promise<IssueRow[]>
74 open(): Promise<OpenIssue[]>
75 // The issues with the Epic label, open and closed.
76 epics(): Promise<EpicRow[]>
77 // The open issues issue `n` waits on, by number.
78 blockers(n: number): Promise<number[] | null>
79 // The children of epic `n`, each open one checked for what blocks it.
80 children(n: number): Promise<EpicChild[] | null>
81 // The numbers of epic `n`'s children, open and closed, without checking
82 // them for blockers: one request. null when the forge would not say.
83 members(n: number): Promise<number[] | null>
84 // The open epics issue `n` is a child of, in the forge's order; on GitHub
85 // its parent issue, of any kind, alone. None for none; null when the
86 // forge would not say.
87 parentsOf(n: number): Promise<number[] | null>
88 // Whether a child can have children of its own (GitHub): then where you
89 // work widens through each parent up the tree.
90 nested: boolean
91 // The issue assigned to `me` that closed last, within a milestone, those
92 // without one ('none'), or all: one request. null for none, or when the
93 // forge would not say.
94 lastClosed(within: MilestoneRow | 'none' | 'all', me: string): Promise<Closed | null>
95 // Everything the detail view shows of issue `n`; what `features` turns
96 // off is not asked for.
97 issue(n: number, features: Features): Promise<IssueDetail>
98 // Issue `n` given these assignees, or closed or open again: null when
99 // done, else why not.
100 assign(n: number, assignees: string[]): Promise<string | null>
101 close(n: number, closed: boolean): Promise<string | null>
102 // The repo's labels, on GitLab its groups' too, by name; throws, naming
103 // what failed, when the forge refuses.
104 labels(): Promise<Label[]>
105 // Label `l` added to issue `n`, or taken off: null when done, else why not.
106 label(n: number, l: Label, on: boolean): Promise<string | null>
107 // Issue `n` moved to open milestone `m`, or out of its own for null: null
108 // when done, else why not.
109 milestone(n: number, m: MilestoneRow | null): Promise<string | null>
110 // Issue `n` put under parent `p`, or taken out of it: on Forgejo `p`
111 // depends on `n`, on GitLab they are linked as related, on GitHub `n`
112 // becomes a sub-issue of `p`, out of the parent it had. null when done,
113 // else why not.
114 parent(n: number, p: number, on: boolean): Promise<string | null>
115 // Milestone `m` closed or open again: null when done, else why not.
116 closeMilestone(m: MilestoneRow, closed: boolean): Promise<string | null>
117}
118
119// An error's message, without the host's `whats-next: $.http.fetch: ` in front.
120export const message = (err: unknown) =>
121 (err instanceof Error ? err.message : String(err)).replace(/^[\w-]+: \$\.[\w.]+: /, '')
122
123// Tracker `t` with `token` cut out of everything it answers and every error
124// it throws: a forge's refusal, or a page that is not JSON, may echo the
125// request back, and what a tracker says reaches the panes, toasts and the
126// prompt.
127export function withoutToken(t: Tracker, token: string | null): Tracker {
128 if (!token) return t
129 const cut = (v: unknown): unknown =>
130 typeof v === 'string'
131 ? v.split(token).join('<token>')
132 : Array.isArray(v)
133 ? v.map(cut)
134 : v && typeof v === 'object'
135 ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cut(x)]))
136 : v
137 const sealed = Object.entries(t).map(([k, v]) => [
138 k,
139 typeof v === 'function'
140 ? async (...args: unknown[]) => {
141 try {
142 return cut(await (v as (...a: unknown[]) => Promise<unknown>)(...args))
143 } catch (err) {
144 throw new Error(cut(message(err)) as string)
145 }
146 }
147 : v,
148 ])
149 return Object.fromEntries(sealed) as Tracker
150}
151hooks/where.ts 120 lines1import type { DetailFolds, Page, Where } from '../types'
2
3// Where a pane is: one value per pane, and the steps from one to the next
4// (docs/DRAWING.md, A detail scrolls, The menu, The help page).
5// Each step is a plain function of the value; nothing here reads the engine.
6// The detail loaded from the forge is data, kept apart.
7
8// How an issue's detail opens: Description folded, Comments open on the
9// newest comment alone.
10export const FOLDED: DetailFolds = { description: false, commentsShut: false, comments: null, whole: [] }
11
12// A pane showing its list, at its top, its menu shut.
13export const HERE: Where = { page: { kind: 'list' }, scroll: 0, menu: false, help: null }
14
15// The issue the pane shows, null for a list or a milestone's detail.
16export const issueOf = (w: Where): number | null => (w.page.kind === 'issue' ? w.page.number : null)
17
18// The milestone the pane has open, in its detail or with an issue over it.
19export const milestoneOf = (w: Where): string | null =>
20 w.page.kind === 'milestone' ? w.page.title : w.page.kind === 'issue' ? w.page.milestone : null
21
22// What is folded in the issue detail shown.
23export const foldsOf = (w: Where): DetailFolds => (w.page.kind === 'issue' ? w.page.folds : FOLDED)
24
25// A page shown as its rules say: at its top, its menu, labels list and
26// milestone list shut. Help is left as it is.
27const fresh = (w: Where, page: Page): Where => ({ ...w, page, scroll: 0, menu: false, labels: null, milestones: null, parents: null })
28
29// Issue `n` from the list, or from the milestone detail the pane has open.
30export const open = (w: Where, n: number): Where =>
31 fresh(w, { kind: 'issue', number: n, trail: [], folds: FOLDED, milestone: milestoneOf(w) })
32
33// Issue `n`, reached from the issue shown through its Blocked by or Blocks:
34// Back returns to that one.
35export const follow = (w: Where, n: number): Where =>
36 w.page.kind === 'issue'
37 ? fresh(w, { ...w.page, number: n, trail: [...w.page.trail, w.page.number], folds: FOLDED })
38 : open(w, n)
39
40// What an issue's Back leaves for: the milestone detail it was opened from, else the list.
41const under = (w: Where): Page => {
42 const title = milestoneOf(w)
43 return title === null ? { kind: 'list' } : { kind: 'milestone', title }
44}
45
46// The list again (or the milestone detail the issue was opened from).
47export const list = (w: Where): Where => fresh(w, under(w))
48
49// Back: the issue the one shown was reached from, else the list.
50export function back(w: Where): Where {
51 if (w.page.kind !== 'issue') return list(w)
52 const from = w.page.trail.at(-1)
53 if (from === undefined) return list(w)
54 return fresh(w, { ...w.page, number: from, trail: w.page.trail.slice(0, -1), folds: FOLDED })
55}
56
57// The issue shown is no longer open: what the pane shows falls back as Back
58// does, where it stands.
59export const closed = (w: Where): Where => (w.page.kind === 'issue' ? { ...w, page: under(w) } : w)
60
61// Milestone `title` in its detail, or the list again for null.
62export const openMilestone = (w: Where, title: string | null): Where =>
63 fresh(w, title === null ? { kind: 'list' } : { kind: 'milestone', title })
64
65// Changes what is folded in the issue detail shown.
66export const fold = (w: Where, change: (f: DetailFolds) => DetailFolds): Where =>
67 w.page.kind === 'issue' ? { ...w, page: { ...w.page, folds: change(w.page.folds) } } : w
68
69// The page scrolled to row `to`.
70export const scrollTo = (w: Where, to: number): Where => ({ ...w, scroll: to })
71
72// The menu opened if shut, shut if open; and shut. It shuts the labels list,
73// the milestone list and the epic list.
74export const toggleMenu = (w: Where): Where => ({ ...w, menu: !w.menu, labels: null, milestones: null, parents: null })
75export const shutMenu = (w: Where): Where => ({ ...w, menu: false })
76
77// The issue detail's labels list opened at its top if shut, shut if open;
78// it shuts the menu, the milestone list and the epic list (DRAWING-issue.md,
79// The labels list). On a list, the label filter's list, the same way; a
80// milestone's detail has none.
81export const toggleLabels = (w: Where): Where =>
82 w.page.kind === 'milestone' ? w : { ...w, menu: false, milestones: null, parents: null, labels: w.labels ? null : { scroll: 0 } }
83
84// The labels list shut, as the milestone filter's list opening leaves it.
85export const shutLabels = (w: Where): Where => ({ ...w, labels: null })
86
87// The labels list scrolled to row `to`.
88export const scrollLabels = (w: Where, to: number): Where => (w.labels ? { ...w, labels: { scroll: to } } : w)
89
90// The issue detail's milestone list opened at its top if shut, shut if
91// open; it shuts the menu, the labels list and the epic list
92// (DRAWING-issue.md, The milestone list).
93export const toggleMilestones = (w: Where): Where =>
94 w.page.kind !== 'issue' ? w : { ...w, menu: false, labels: null, parents: null, milestones: w.milestones ? null : { scroll: 0 } }
95
96// The milestone list shut, as a press on one of its lines leaves it.
97export const shutMilestones = (w: Where): Where => ({ ...w, milestones: null })
98
99// The milestone list scrolled to row `to`.
100export const scrollMilestones = (w: Where, to: number): Where => (w.milestones ? { ...w, milestones: { scroll: to } } : w)
101
102// The issue detail's epic list opened at its top if shut, shut if open; it
103// shuts the menu, the labels list and the milestone list (DRAWING-issue.md,
104// The epic list).
105export const toggleParents = (w: Where): Where =>
106 w.page.kind !== 'issue' ? w : { ...w, menu: false, labels: null, milestones: null, parents: w.parents ? null : { scroll: 0 } }
107
108// The epic list scrolled to row `to`.
109export const scrollParents = (w: Where, to: number): Where => (w.parents ? { ...w, parents: { scroll: to } } : w)
110
111// The help page over what is shown, at its top; the scroll that page had is
112// kept for Back. Opened already, nothing changes.
113export const help = (w: Where): Where => (w.help ? w : { ...w, help: { scroll: w.scroll }, menu: false, labels: null, milestones: null, parents: null, scroll: 0 })
114
115// Back from the help page: what it was opened over, as it was.
116export const leaveHelp = (w: Where): Where => ({ ...w, help: null, menu: false, scroll: w.help?.scroll ?? 0 })
117
118// The pane closed: its help page goes with it.
119export const dropHelp = (w: Where): Where => ({ ...w, help: null })
120hooks/panes.ts 5 lines1// The panes' ids: What's next, Issues and Milestones.
2export const PANE = 'next'
3export const ISSUES_PANE = 'next-issues'
4export const MILESTONES_PANE = 'next-milestones'
5hooks/views/frame.tsx 553 lines1import type { PluginState, RenderChildren, RenderInput } from 'claude-code'
2
3import type { Detail, IssueDetail, Label, LabelFilter, Milestone, MilestoneFilter, ParentChoices, Where } from '../../types'
4import type { Draw, Look, Requests } from '../draw'
5import { fit } from '../draw'
6import { filterKey, isAll, labelWords, leftOut, NO_LABEL, sameFilter, stepLabel, withMode } from '../filter'
7import { readable, wrap } from '../issue'
8import type { Known } from '../load'
9import { measure } from '../measure'
10import type { Tracker } from '../tracker'
11import * as step from '../where'
12
13// What every view of a pane shares: what it is given to draw with, and the
14// frame around its rows (the top row, the menu, the milestone filter's
15// list, a detail's scrolling, the bottom line).
16
17// A detail as last drawn in each pane, by pane id: how many rows it has,
18// how many show, and the row each Button starts on. A pane showing a list
19// has none: the engine's window stays where it is.
20export const views = new Map<string, { rows: number; room: number; at: Map<string, number> }>()
21
22// The Buttons each pane draws hidden, for the hotkeys of its menu while it
23// is shut (DRAWING.md, The menu), and the first control drawn after them.
24export const hidden = new Map<string, { keys: Set<string>; first: string }>()
25
26// The issue detail's labels list or milestone list as last drawn in each
27// pane, by pane id: which, and how many of its lines show, for the focus to
28// scroll it (DRAWING-issue.md, The labels list); `first`, the lines above
29// the first one a focus names. A pane without either open has none.
30export const listShown = new Map<string, { list: 'labels' | 'milestones' | 'parents'; lines: number; room: number; first: number }>()
31
32// What a press does, for the views: each changes what the panes show, so
33// none of it is done while drawing.
34export type Acts = {
35 refresh: () => unknown
36 showHelp: () => unknown
37 leaveHelp: () => unknown
38 setFilter: (filter: MilestoneFilter, label: string) => unknown
39 setLabelFilter: (change: (f: LabelFilter) => LabelFilter, label: string) => unknown
40 openIssue: (pane: string, n: number) => unknown
41 followIssue: (n: number) => unknown
42 goBack: () => unknown
43 foldIssue: (n: number) => unknown
44 foldMilestone: (title: string) => unknown
45 openMilestone: (title: string | null) => unknown
46 closeMilestone: (m: Milestone, closed: boolean) => Promise<unknown>
47 assignMe: (i: IssueDetail, on: boolean) => Promise<boolean>
48 writeIssue: (n: number, change: (t: Tracker) => Promise<string | null>, what: string) => Promise<boolean>
49 toggleLabels: () => unknown
50 setLabel: (i: IssueDetail, l: Label, on: boolean) => Promise<boolean>
51 toggleMilestones: () => unknown
52 setMilestone: (i: IssueDetail, m: Milestone | null, children: number[]) => Promise<boolean>
53 toggleChildrenToo: () => unknown
54 toggleParents: (i: IssueDetail, children: number[]) => unknown
55 setParent: (i: IssueDetail, p: number | null, on: boolean, parents: number[]) => Promise<boolean>
56 workOn: (i: IssueDetail) => unknown
57 openUrl: (url: string) => unknown
58 counted: <T>(label: string, action: () => Promise<T>) => Promise<T>
59 chooseIcons: (value: 'icons' | 'text') => unknown
60 chooseCompact: (value: 'on' | 'off') => unknown
61 // The pane moved by one of where.ts's steps.
62 go: (change: (w: Where) => Where) => Promise<unknown>
63 // The milestone filter's list opened or shut.
64 setFilterOpen: (change: (open: boolean) => boolean) => Promise<unknown>
65 // In the milestone detail: Issues folded, Description unfolded, or the
66 // closed milestones shown in the list.
67 foldIssues: () => unknown
68 setDescription: (change: (open: boolean) => boolean) => unknown
69 toggleClosed: () => unknown
70}
71
72// The session's state, as types/index.d.ts has it.
73type State = PluginState['whats-next']
74
75// What the views draw from the session's state, read once per render by
76// hooks/register.tsx: the views take no engine. `detail` and
77// `parentChoices` are this pane's; `milestoneShown` the milestone the
78// Milestones pane has open, null when it shows its list.
79export type Shown = Pick<
80 State,
81 | 'repo'
82 | 'version'
83 | 'token'
84 | 'setup'
85 | 'overview'
86 | 'spinner'
87 | 'issues'
88 | 'panesOpen'
89 | 'filterOpen'
90 | 'repoLabels'
91 | 'childrenToo'
92 | 'unfolded'
93 | 'treeKids'
94 | 'issuesFolded'
95 | 'descriptionUnfolded'
96 | 'milestones'
97 | 'milestonesUnfolded'
98 | 'milestoneIssues'
99 | 'closedMilestones'
100 | 'closedShown'
101 | 'milestoneError'
102> & { detail: Detail; parentChoices: ParentChoices; milestoneShown: string | null }
103
104// What a view is given: the pane it draws and where that pane is, the look
105// and the drawing made from it, what is known of the repo, the session's
106// state it draws, the tracker behind it, and what a press does. `version` and `repository` are
107// What's Next's own, for the bottom line; `requests` the Debug line's.
108export type Pane = {
109 e: RenderInput<'Pane'>
110 id: string
111 place: Where
112 height: number
113 look: Look
114 draw: Draw
115 known: Known | null
116 shown: Shown
117 tracker: Tracker | null
118 acts: Acts
119 version: string | null
120 repository: string | null
121 requests: Requests | null
122 // The repo's name, owner/name, for what a list says of it.
123 repoName: string
124}
125
126// What a pane can do, in its menu (DRAWING.md, The menu): each its words,
127// its hotkey and what pressing it does. Pressing one shuts the menu.
128export type Action = { key: string; label: string; hotkey: string; onPress: () => unknown }
129
130// What the trees know: the children loaded of any issue (undefined while
131// not asked yet), and the repo's open epics.
132export function treeKnown(p: Pane) {
133 const loadedKids = p.shown.treeKids
134 const kidsOf = (n: number) => (n in loadedKids ? loadedKids[n] : undefined)
135 const openEpics = p.known?.openEpics ?? new Set<number>()
136 return { kidsOf, openEpics }
137}
138
139export type Frame = Awaited<ReturnType<typeof frame>>
140
141// The frame of pane `p`, read once per render.
142export async function frame(p: Pane) {
143 const { id, look, draw: d, acts } = p
144 const { Box, Text, Button } = look.ui
145 const { columns } = look
146 const height = p.height
147 const bottomRule = d.bottomLine(p.version, p.repository ? () => acts.openUrl(p.repository ?? '') : undefined)
148 const counter = d.requestsLine(p.requests)
149 // The milestone filter both panes share, and what it is set to.
150 const shownFilter = look.filter
151 const setOption = shownFilter?.options.find(o => sameFilter(o.filter, shownFilter.set))
152 const refreshAction: Action = { key: 'refresh', label: 'Refresh', hotkey: 'r', onPress: acts.refresh }
153 // Help, the last line of every menu but the help page's own.
154 const helpAction: Action = { key: 'help', label: 'Help', hotkey: 'h', onPress: acts.showHelp }
155 const menuShown = p.place.menu
156 const run = (a: Action) => async () => {
157 await acts.go(step.shutMenu)
158 await a.onPress()
159 }
160 // [ ☰ ] at the right of a top row; it shuts the milestone filter's list,
161 // as that shuts the menu: one list under the top row at a time. With the
162 // labels list or the milestone list open it shuts that list alone.
163 const menuButton = (
164 <Button
165 key="menu"
166 label="☰"
167 onPress={async () => {
168 await acts.setFilterOpen(() => false)
169 await acts.go(w => (w.labels ? step.toggleLabels(w) : w.milestones ? step.toggleMilestones(w) : w.parents ? step.toggleParents(w) : step.toggleMenu(w)))
170 }}
171 />
172 )
173 // A list opened under the top row at its right, the menu's, the
174 // milestone filter's or the labels list: drawn over the rows beneath,
175 // which stay where they are, in a dim frame. `width` is its lines', each
176 // padded to it so it covers what is under it. The last child of the pane,
177 // so it is painted last. `scroll`: a list that may run past the pane is
178 // cut to it, moved up by that many lines, as a detail's rows are. `head`:
179 // a line above them that stays when they scroll. `top`: the pane row its
180 // frame starts on, under the top row unless it says otherwise.
181 const overlay = (key: string, width: number, lines: RenderChildren[], scroll?: number, head: RenderChildren = null, top = 1) => {
182 const room = overlayRoom(top) - (head ? 1 : 0)
183 const cut = scroll !== undefined && lines.length > room
184 return (
185 <Box
186 key={key}
187 position="absolute"
188 top={top}
189 left={Math.max(0, columns - width - 2)}
190 width={Math.min(columns, width + 2)}
191 flexDirection="column"
192 borderStyle="single"
193 borderDimColor
194 >
195 {head}
196 {cut ? (
197 <Box key={`${key}-room`} height={room} flexShrink={0} flexDirection="column" overflow="hidden">
198 <Box key={`${key}-lines`} marginTop={-Math.min(scroll, lines.length - room)} height={lines.length} flexShrink={0} flexDirection="column">
199 {lines}
200 </Box>
201 </Box>
202 ) : (
203 lines
204 )}
205 </Box>
206 )
207 }
208 // How many lines a list cut by `overlay` shows, its head line included,
209 // its frame starting on pane row `top`.
210 function overlayRoom(top = 1) {
211 return Math.max(2, height - top - 2 - (counter ? 1 : 0))
212 }
213 // The pane row a detail's control `key` is drawn on, below top row `top`
214 // and scrolled as `detailFrame` scrolls it; null when it is not drawn.
215 const rowOf = (top: RenderChildren, body: RenderChildren[], key: string): number | null => {
216 const shown = measure(body)
217 const at = shown.at.get(key)
218 if (at === undefined) return null
219 const above = measure(top).rows + 1
220 const room = Math.max(1, height - above - 2 - (counter ? 1 : 0))
221 const offset = Math.min(Math.max(0, shown.rows - room), p.place.scroll)
222 return above + at - offset
223 }
224 // The menu open: each action's words and, dim at the right, its hotkey;
225 // `note`, dim under them, says what it lacks.
226 const MENU = 17
227 const menuLines = (actions: readonly Action[], note?: string, top = 1) =>
228 menuShown &&
229 overlay('menu-lines', MENU, [
230 ...actions.map(a => (
231 <Box key={`menu-line-${a.key}`} height={1} flexShrink={0}>
232 <Button key={`menu-${a.key}`} plain onPress={run(a)}>
233 <Text>
234 {fit(a.label, MENU - 2).padEnd(MENU - 1)}
235 <Text dimColor>{a.hotkey}</Text>
236 </Text>
237 </Button>
238 </Box>
239 )),
240 ...(note
241 ? wrap(note, MENU).map((t, k) => (
242 <Box key={`menu-note-${k}`} height={1} flexShrink={0}>
243 <Text dimColor>{t}</Text>
244 {t.length < MENU && <Text>{' '.repeat(MENU - t.length)}</Text>}
245 </Box>
246 ))
247 : []),
248 ], undefined, null, top)
249 // Every action's hotkey, the menu shut or open: Buttons drawn hidden, at
250 // the start of the top row, before `first`, the first control drawn.
251 const hotkeys = (actions: readonly Action[], first: string) => {
252 hidden.set(id, { keys: new Set(actions.map(a => a.key)), first })
253 return (
254 <Box key="hotkeys" display="none">
255 {actions.map(a => (
256 <Button key={a.key} label={a.label} hotkey={a.hotkey} onPress={run(a)} />
257 ))}
258 </Box>
259 )
260 }
261 // With another What's Next pane open the engine draws tabs that name both, so a
262 // title row leaves its pane's name out.
263 const tabbed = p.shown.panesOpen.some(other => other !== id)
264 // The label filter both list panes share, and the colour of each label
265 // the repo has, once its labels were read.
266 const labelsSet = look.labels
267 const known = p.shown.repoLabels
268 const colorOf = (name: string) => (known.kind === 'ok' ? (known.labels.find(l => l.name === name)?.color ?? '') : '')
269 // The label filter's button says what it is set to, as the milestone
270 // filter's does (#99, decided 5): All, No label, or the labels set, each
271 // in its colour, joined by `and` or `or`, dim, No label first when it
272 // ticks along, then each label left out after a dim `not` (#99, decided
273 // 7); cut with … at `room` columns.
274 const labelsButton = (room: number) => {
275 const words = labelWords(labelsSet)
276 const parts: RenderChildren[] = []
277 if ('word' in words) parts.push(words.word)
278 else {
279 let left = Math.max(1, room)
280 // Each name with the dim words before it.
281 const set = words.none ? [NO_LABEL, ...words.labels] : words.labels
282 const names = [
283 ...set.map((name, k) => ({ name, before: k > 0 ? words.join : '', plain: words.none && k === 0 })),
284 ...words.not.map((name, k) => ({ name, before: set.length + k > 0 ? ', not ' : 'not ', plain: false })),
285 ]
286 names.forEach(({ name: l, before, plain }, k) => {
287 if (left <= 0) return
288 if (before) {
289 const join = fit(before, left)
290 parts.push(<Text key={`labels-join-${k}`} dimColor>{join}</Text>)
291 left -= join.length
292 if (left <= 0 || join.endsWith('…')) return void (left = 0)
293 }
294 const text = fit(l, left)
295 const color = plain ? undefined : readable(colorOf(l))
296 parts.push(
297 <Text key={`labels-set-${k}`} color={color} dimColor={!plain && !color}>
298 {text}
299 </Text>,
300 )
301 left -= text.length
302 if (text.endsWith('…')) left = 0
303 })
304 }
305 return (
306 <Button key="labels" hotkey="l" onPress={acts.toggleLabels}>
307 {parts}
308 {' ▾'}
309 </Button>
310 )
311 }
312 // The title row both lists share: the pane's name when no tab names it,
313 // the label filter and the milestone filter, the menu (docs/TERMINOLOGY.md,
314 // label filter). Milestones, which shows every milestone, has neither filter.
315 const titleRow = (name: string, withFilter = true) => {
316 const shown = withFilter ? setOption : undefined
317 // What the labels button may take: the row, less the name, the milestone
318 // filter's button, the menu's and the gaps, and its own `[ `, ` ▾ ]`.
319 const room = shown ? columns - (tabbed ? 0 : name.length + 2) - (shown.label.length + 6) - 2 - 5 - 2 - 6 : 0
320 return (
321 <Box key="top" flexDirection="column" flexShrink={0}>
322 <Box key="title" height={1} flexShrink={0}>
323 {hotkeys([refreshAction, helpAction], shown ? 'labels' : 'menu')}
324 {!tabbed && (
325 <Text bold wrap="truncate-end">
326 {name}
327 </Text>
328 )}
329 <Box flexGrow={1} />
330 {shown && labelsButton(room)}
331 {shown && <Box width={2} />}
332 {shown && (
333 <Button
334 key="milestone"
335 label={`${shown.label} ▾`}
336 hotkey="m"
337 onPress={async () => {
338 await acts.go(w => step.shutLabels(step.shutMenu(w)))
339 await acts.setFilterOpen(o => !o)
340 }}
341 />
342 )}
343 <Box width={2} />
344 {menuButton}
345 </Box>
346 </Box>
347 )
348 }
349 // The label filter's list, open over a list's rows under the top row
350 // (docs/TERMINOLOGY.md, label filter): `Labels` and its ✕, then `and · or`,
351 // the one set bold, then ▸ All, then No label: under and ▸ No label, an
352 // action as All, under or a box that ticks along with the labels (#99,
353 // decided 6c); then the repo's labels, each after a box that says
354 // whether the filter is set to it; a press sets it or takes it out, and
355 // the list stays open for the next. Cut to the pane and scrolled by its
356 // focus, as the issue detail's labels list.
357 // A list's line that is an action, not a choice among the rest: a dim ▸
358 // before its words, bold while the filter is set to it (#99, decided 6c).
359 const actionText = (words: string, set: boolean, width: number) => (
360 <Text>
361 <Text dimColor>{'▸ '}</Text>
362 <Text bold={set}>{fit(words, width - 2).padEnd(width - 2)}</Text>
363 </Text>
364 )
365 const labelFilterList = (top: number) => {
366 const said = (key: string, text: string, props: Record<string, unknown>) => (
367 <Box key={key} height={1} flexShrink={0}>
368 <Text {...props}>{text}</Text>
369 </Box>
370 )
371 // A label set that the repo's list does not hold (renamed, taken off,
372 // or the list not read) keeps its box, so it can be taken out.
373 const listed = known.kind === 'ok' ? known.labels : []
374 const labels: Label[] = [
375 ...listed,
376 ...[...labelsSet.labels, ...leftOut(labelsSet)].filter(n => !listed.some(l => l.name === n)).map(name => ({ name, color: '' })),
377 ]
378 const width = Math.min(columns - 2, Math.max(10, ...labels.map(l => l.name.length + 2)))
379 const head = (
380 <Box key="labels-shut-head" height={1} flexShrink={0}>
381 <Text dimColor>{fit('Labels', width - 2).padEnd(width - 1)}</Text>
382 <Button key="labels-shut" plain onPress={acts.toggleLabels}>
383 <Text>✕</Text>
384 </Button>
385 </Box>
386 )
387 if (labels.length === 0) {
388 const text =
389 known.kind === 'ok' ? `No labels in ${p.repoName}.` : known.kind === 'error' ? `Could not load the labels: ${known.message}.` : 'Loading …'
390 const w = Math.min(columns - 2, Math.max(10, text.length))
391 return overlay('labels-list', w, [said('labels-said', fit(text, w).padEnd(w), known.kind === 'error' ? { color: 'error' } : { dimColor: true })], undefined, head, top)
392 }
393 const mode = (m: LabelFilter['mode']) => (
394 <Button key={`label-${m}`} plain onPress={() => acts.setLabelFilter(f => withMode(f, m), m)}>
395 <Text bold={labelsSet.mode === m} dimColor={labelsSet.mode !== m}>
396 {m}
397 </Text>
398 </Button>
399 )
400 const lines = [
401 <Box key="label-modes" height={1} flexShrink={0}>
402 {mode('and')}
403 <Text dimColor>{' · '}</Text>
404 {mode('or')}
405 <Text>{' '.repeat(Math.max(0, width - 8))}</Text>
406 </Box>,
407 // ▸ All sets it on every issue at once.
408 <Box key="label-all-line" height={1} flexShrink={0}>
409 <Button key="label-all" plain onPress={() => acts.setLabelFilter(f => ({ ...f, labels: [], none: false, not: [] }), 'All')}>
410 {actionText('All', isAll(labelsSet), width)}
411 </Button>
412 </Box>,
413 // No label: under and, an action that sets it on the issues without a
414 // label alone; under or, a box that adds them to the labels set.
415 <Box key="label-none-line" height={1} flexShrink={0}>
416 {labelsSet.mode === 'or' ? (
417 <Button key="label-none" plain onPress={() => acts.setLabelFilter(f => ({ ...f, none: f.none !== true }), NO_LABEL)}>
418 <Text>{`${labelsSet.none === true ? '☑' : '☐'} ${NO_LABEL}`.padEnd(width)}</Text>
419 </Button>
420 ) : (
421 <Button key="label-none" plain onPress={() => acts.setLabelFilter(f => ({ ...f, labels: [], none: true, not: [] }), NO_LABEL)}>
422 {actionText(NO_LABEL, labelsSet.none === true && labelsSet.labels.length === 0 && leftOut(labelsSet).length === 0, width)}
423 </Button>
424 )}
425 </Box>,
426 // A press steps the box: ☐ set ☑, left out ☒, then ☐ again (#99, decided 7).
427 ...labels.map((l: Label, k) => {
428 const on = labelsSet.labels.includes(l.name)
429 const out = leftOut(labelsSet).includes(l.name)
430 const shortName = fit(l.name, width - 2)
431 return (
432 <Box key={`label-line-${k}`} height={1} flexShrink={0}>
433 <Button
434 key={`toggle-label-${k}`}
435 plain
436 onPress={() =>
437 acts.setLabelFilter(f => stepLabel(f, l.name), `${on ? 'leave out' : out ? 'unset' : 'set'} ${l.name}`)
438 }
439 >
440 <Text>
441 {on ? '☑ ' : out ? '☒ ' : '☐ '}
442 {d.labelTexts([{ name: shortName, color: l.color }])}
443 {' '.repeat(Math.max(0, width - 2 - shortName.length))}
444 </Text>
445 </Button>
446 </Box>
447 )
448 }),
449 ]
450 listShown.set(id, { list: 'labels', lines: lines.length, room: overlayRoom(top) - 1, first: 3 })
451 return overlay('labels-list', width, lines, p.place.labels?.scroll ?? 0, head, top)
452 }
453 // What a list draws over its rows: the milestone filter's list, open,
454 // ▸ All and ▸ No milestone, then each milestone with its due date, the
455 // one set in bold (#99, decided 6d); or the menu.
456 const listOverlay = async (withFilter = true) => {
457 const top = 1
458 if (withFilter && setOption !== undefined && p.place.labels) return labelFilterList(top)
459 const listOpen = withFilter && shownFilter !== null && setOption !== undefined && p.shown.filterOpen
460 if (listOpen && shownFilter) {
461 return overlay(
462 'choices',
463 20,
464 shownFilter.options.map(o => (
465 <Box key={`choice-${filterKey(o.filter)}`} height={1} flexShrink={0}>
466 <Button key={`milestone-${filterKey(o.filter)}`} plain onPress={() => acts.setFilter(o.filter, o.label)}>
467 {o.filter.kind === 'milestone' ? (
468 <Text bold={sameFilter(o.filter, shownFilter.set)}>
469 {fit(o.label, 13).padEnd(14)}
470 {d.dueDate('due', o.due ?? null, look.today, { form: 'short', bare: true, width: 6 })}
471 </Text>
472 ) : (
473 actionText(o.label, sameFilter(o.filter, shownFilter.set), 20)
474 )}
475 </Button>
476 </Box>
477 )),
478 undefined,
479 null,
480 top,
481 )
482 }
483 return menuLines([refreshAction, helpAction], undefined, top)
484 }
485 // A list: its title row, its rows, then what sits above the bottom line
486 // (`last`), the bottom line and what the list draws over its rows.
487 const listFrame = async (top: RenderChildren, rows: RenderChildren[], over: RenderChildren, last: RenderChildren = null) => (
488 <Box flexDirection="column" height={height}>
489 {top}
490 <Box key="gap" height={1} flexShrink={0} />
491 <Box key="list" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
492 {rows}
493 </Box>
494 {last}
495 {bottomRule}
496 {counter}
497 {over}
498 </Box>
499 )
500 // A detail: its top row, which stays, then its rows, scrolled by the mod
501 // (DRAWING.md, A detail scrolls), then the bottom line, which stays.
502 const detailFrame = (top: RenderChildren, body: RenderChildren[], over: RenderChildren) => {
503 const shown = measure(body)
504 const room = Math.max(1, height - measure(top).rows - 1 - 2 - (counter ? 1 : 0))
505 const offset = Math.min(Math.max(0, shown.rows - room), p.place.scroll)
506 views.set(id, { rows: shown.rows, room, at: shown.at })
507 return (
508 <Box flexDirection="column" height={height}>
509 {top}
510 <Box key="gap" height={1} flexShrink={0} />
511 <Box key="detail" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
512 {/* As tall as its rows, measured, and moved up by a negative margin:
513 without a height the engine fits it to the room and squeezes rows
514 out of it, and a placed Box's negative top it does not keep. */}
515 <Box key="rows" marginTop={-offset} width={columns} height={shown.rows} flexShrink={0} flexDirection="column">
516 {body}
517 </Box>
518 </Box>
519 {bottomRule}
520 {counter}
521 {over}
522 </Box>
523 )
524 }
525 // A detail's top row: its hotkeys, [ ← ] that goes `back`, then `extra`
526 // (Prompt on an issue) and the menu.
527 const detailTop = (actions: readonly Action[], back: () => unknown, extra: RenderChildren = null) => (
528 <Box key="top" flexDirection="column" flexShrink={0}>
529 <Box key="title" height={1} flexShrink={0}>
530 {hotkeys(actions, 'back')}
531 <Button key="back" label="←" hotkey="b" onPress={back} />
532 <Box flexGrow={1} />
533 {extra}
534 {extra && <Box width={2} />}
535 {menuButton}
536 </Box>
537 </Box>
538 )
539 // A heading with, on its right, the pane's own action when it has one.
540 const heading = (text: string, action?: { label: string }) => (
541 <Box key="title" height={1} flexShrink={0}>
542 {text && (
543 <Text bold wrap="truncate-end">
544 {text}
545 </Text>
546 )}
547 <Box flexGrow={1} />
548 {action && <Button key="refresh" label={action.label} hotkey="r" onPress={acts.refresh} />}
549 </Box>
550 )
551 return { refreshAction, helpAction, menuLines, overlay, overlayRoom, rowOf, titleRow, listOverlay, listFrame, detailFrame, detailTop, heading }
552}
553hooks/views/help.tsx 80 lines1import type { RenderChildren } from 'claude-code'
2
3import { forYou, markOf } from '../epics'
4import { HELP, type Look as Colour, type Sign } from '../help'
5import { wrap } from '../issue'
6import type { Frame, Pane } from './frame'
7
8// The help page, in place of the list or detail it was opened over
9// (DRAWING.md, The help page): each sign drawn by the helpers the panes
10// draw it with, in a gutter, and its words wrapped under themselves.
11export function helpView(p: Pane, f: Frame) {
12 const { draw: d, acts } = p
13 const { Box, Text } = p.look.ui
14 const now = p.look.today
15 const LOOK: Record<Colour, Record<string, unknown>> = {
16 plain: {},
17 dim: { dimColor: true },
18 blue: { color: 'suggestion' },
19 green: { color: 'success' },
20 amber: { color: 'warning' },
21 red: { color: 'error' },
22 grey: { color: 'inactive' },
23 purple: { color: 'merged' },
24 }
25 const signOf = (x: Sign): RenderChildren => {
26 switch (x.kind) {
27 case 'mark':
28 return d.markText(markOf(x.standing, x.fold, x.closed))
29 case 'due':
30 return d.dueDate('due', new Date(now + x.days * 86400000).toISOString(), now, { form: 'short' })
31 case 'bar':
32 return d.countText(x.closed, x.total, true)
33 case 'forYou':
34 return [
35 ...(x.children ? [<Text key="children" dimColor>{'children '}</Text>] : []),
36 ...d.joined(forYou({ closed: 0, total: 0, ...x })),
37 ]
38 case 'blockedBy':
39 return d.joined([d.blockedPart(x.count)])
40 case 'unblocks':
41 return d.joined([d.unblocksPart(x.count)])
42 case 'label':
43 return d.labelTexts([{ name: x.name, color: x.color }])
44 case 'text':
45 return x.parts.map((part, k) => (
46 <Text key={`part-${k}`} {...LOOK[part.look]}>
47 {part.text}
48 </Text>
49 ))
50 }
51 }
52 // The text a sign draws, to size the gutter: as wide as the widest sign
53 // and two spaces (DRAWING.md, The help page).
54 const plain = (n: RenderChildren): string =>
55 n === null || n === undefined || typeof n === 'boolean'
56 ? ''
57 : typeof n === 'string' || typeof n === 'number'
58 ? String(n)
59 : Array.isArray(n)
60 ? (n as readonly RenderChildren[]).map(plain).join('')
61 : plain((n as { children?: RenderChildren }).children ?? (n as { props?: { children?: RenderChildren } }).props?.children ?? null)
62 const GUTTER = Math.max(...HELP.flatMap(s => s.lines.map(l => plain(signOf(l.sign)).length))) + 2
63 const room = Math.max(10, p.look.columns - GUTTER)
64 const body: RenderChildren[] = HELP.flatMap((section, k) => [
65 ...(k > 0 ? [<Box key={`help-gap-${k}`} height={1} flexShrink={0} />] : []),
66 d.rule(`help-rule-${k}`, section.name),
67 ...section.lines.flatMap((l, j) =>
68 wrap(l.words, room).map((words, w) => (
69 <Box key={`help-${k}-${j}-${w}`} height={1} flexShrink={0}>
70 <Box key="sign" width={GUTTER} flexShrink={0}>
71 {w === 0 && <Text wrap="truncate-end">{signOf(l.sign)}</Text>}
72 </Box>
73 {d.line('words', words)}
74 </Box>
75 )),
76 ),
77 ])
78 return f.detailFrame(f.detailTop([f.refreshAction], acts.leaveHelp), body, f.menuLines([f.refreshAction]))
79}
80