SLOPSHOPPER

What's Next

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

newpanecommandtoastprocessnetwork
v1.1.6AGPL-3.0-onlyupdated 2026-10-18egribnau/whats-next
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whats-next
│ ┃ What's next ✕ › fix the failing auth test and add an audit log call │ ┃ Set up What's Next │ ┃ ✓ Repo acme/app ⏺ Read(src/auth.ts) │ ┃ ✓ Forge GitHub at github.com ⎿ Read 6 lines │ ┃ ✗ Token none found ⏺ Update(src/auth.ts) │ ┃ · Access ⎿ Added 2 lines, removed 1 line │ ┃ · Actions ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ What to do │ ┃ 1. Create a token at ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ https://github.com/settings/personal-access- │ ┃ tokens with Issues read and write, or read ✻ Worked for 42s · done 4:20 PM │ ┃ for read only; a classic token needs repo. │ ┃ 2. Run /plugin configure whats-next@egribnau › /next │ ┃ and paste it as GitHub token, alone or as ⎿ whats-next: What's Next needs setting up: see its pane. │ ┃ github.com=<token> next to tokens for other │ ┃ hosts. │ ┃ │ ┃ [ Check Again ] │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · What's next
Set up What's Next ✓ Repo acme/app ✓ Forge GitHub at github.com ✗ Token none found · Access · Actions What to do 1. Create a token at https://github.com/settings/personal-access-tokens with Issues read and write, or read for read only; a classic token needs repo. 2. Run /plugin configure whats-next@egribnau and paste it as GitHub token, alone or as github.com=<token> next to tokens for other hosts. [ Check Again ]
Pane · next-issues
Set up What's Next ✓ Repo acme/app ✓ Forge GitHub at github.com ✗ Token none found · Access · Actions What to do 1. Create a token at https://github.com/settings/personal-access-tokens with Issues read and write, or read for read only; a classic token needs repo. 2. Run /plugin configure whats-next@egribnau and paste it as GitHub token, alone or as github.com=<token> next to tokens for other hosts. [ Check Again ]
Pane · next-milestones
Set up What's Next ✓ Repo acme/app ✓ Forge GitHub at github.com ✗ Token none found · Access · Actions What to do 1. Create a token at https://github.com/settings/personal-access-tokens with Issues read and write, or read for read only; a classic token needs repo. 2. Run /plugin configure whats-next@egribnau and paste it as GitHub token, alone or as github.com=<token> next to tokens for other hosts. [ Check Again ]
README

What's Next for Claude Code

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">

  • Next: the first available issue: due soon first, then ordered by where you work and how many issues wait on it.
  • Every open issue by milestone, epics folded open on their children, and milestones with their progress; narrowed by milestone and by label.
  • Assign yourself, close an issue, change its labels, milestone or epic, or put it into your prompt, without leaving Claude Code.

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 and update

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

What's Next

/next opens docked panes on the issues of the repo behind the current directory's origin:

  • What's next: your issues In progress, Next and the Queue after it. Next is the first available issue: assigned to no one, not blocked. An issue due within 7 days or past due comes first, by its own due date, its epic's or its milestone's. After those, Next follows on from where you work: the epic or milestone of your issue in progress, or of the issue you closed last.
  • Issues: the open issues of a milestone, its epics first, each unfolding to its children.
  • Milestones: every open milestone, with its epics and issues.
  • Milestone filter (m): narrows What's next and Issues to one milestone.
  • Label filter (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.
  • Issue detail: epic, children, blockers, due date, milestone, labels, pull requests, commits, description and comments. Assign yourself, close, reopen, add or take off a label, move it to another milestone (its children too), put it under an epic or take it out of one, open in the browser, or put the issue into your prompt.
  • Help (h): what each sign and colour means.
CommandOpens
/nextthe panes, with What's next in front and the keyboard on it
/next helpWhat's next on the help page
  • Esc returns to the prompt.
  • When another plugin already has /next, the command is /whats-next in that session, and a toast says so.

In pictures

IssuesMilestones
<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 issueIts 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 labelsThe 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 milestoneHelp
<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 offCompact 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">

What it needs

A repo on one of these forges, and an access token for it:

ForgeToken settingTo readTo assign, close and change labels, milestones and epics
Forgejo or GiteaForgejo tokensread:issue, read:repository, read:userwrite:issue in place of read:issue, and push rights on the repo
gitlab.comGitLab.com tokenread_apiapi, and Reporter or higher on the project
GitLab on your own hostPrivate GitLab tokenread_apiapi, and Reporter or higher on the project
github.com or GitHub EnterpriseGitHub tokenfine-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.

Settings

Set the tokens with /plugin configure whats-next@egribnau; the other settings are also in /config, under What's Next.

SettingDefaultWhat it does
Forgejo tokensemptyForgejo 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 tokenemptyThe token for gitlab.com.
Private GitLab tokenemptyThe token for your own GitLab, any host but gitlab.com; host=token pairs for more than one host.
GitHub tokenemptyThe token for github.com and GitHub Enterprise; host=token pairs for more than one host.
Epic labelepicThe label that makes an issue an epic.
Compactaskon: lists without an empty line between rows. ask: What's Next asks once.
Iconsaskicons: buttons as Nerd Font icons; text: as words. ask: What's Next asks once, after setup.
DebugoffA 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.

Bugs and ideas

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.

Working on What's Next

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.

Licence

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 it sends where

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.

WhatToWith
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 hostno token
The repo, your account, and its issues, milestones, labels, epics and blockers; what you change: assignee, closed, labels, milestone, epicForgejo https://<host>/api/v1, GitLab https://<host>/api/v4, github.com https://api.github.com, GitHub Enterprise https://<host>/api/v3the 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>/tokensthe 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:

  • reads your prompt only when you press Prompt on an issue, and only to see whether it already holds text: the issue goes under what you typed, or in alone. The issue goes to Claude only when you send the prompt. It reads nothing else of the conversation, and sends none of it to the forge;
  • writes only its own settings Icons and Compact in /config, each only when you answer its question about it after setup: no other setting, no permission and no environment variable;
  • adds the command /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.

Source 25 files
hooks/register.tsx 1234 lines
1import { 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 lines
1import 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}
639
hooks/epics.ts 298 lines
1import 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}
298
hooks/forge.ts 120 lines
1import { 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}
120
hooks/filter.ts 118 lines
1import 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) }
118
hooks/load.ts 556 lines
1import 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}
556
hooks/setup.ts 128 lines
1import 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}
128
hooks/tracker.ts 151 lines
1import 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}
151
hooks/where.ts 120 lines
1import 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 })
120
hooks/panes.ts 5 lines
1// 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'
5
hooks/views/frame.tsx 553 lines
1import 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}
553
hooks/views/help.tsx 80 lines
1import 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