@marcomigozzi/trailhead 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
// The map pane (#208): a docked Pane listing the active map's open tickets by state.
|
|
2
|
-
// It reads the active map from `.trailhead/active-map` and the tickets through `gh`,
|
|
3
|
-
// while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
|
|
2
|
+
// It reads the active map from `.trailhead/active-map` and the tickets through `gh`, both under the
|
|
3
|
+
// pinned repo (repo-pin.ts), not the session's cwd, only while the pane is open, and never faster than MIN_REFRESH_MS. Claude Code 2.1.287+ only.
|
|
4
4
|
import { atom, read, update } from 'claude-code'
|
|
5
5
|
import type { EngineInterface, On } from 'claude-code'
|
|
6
6
|
|
|
7
|
-
import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapSummary, PaneTicket, UatParent, WorkState } from '../../types'
|
|
7
|
+
import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, OpenTotal, PaneTicket, RepoPin, UatParent, WorkState } from '../../types'
|
|
8
8
|
import {
|
|
9
9
|
ageLabel,
|
|
10
10
|
axisStart,
|
|
11
|
+
CANCEL_LABEL,
|
|
11
12
|
type BurndownSeries,
|
|
12
13
|
burndownEnd,
|
|
13
14
|
burndownSeries,
|
|
14
15
|
failureReason,
|
|
15
16
|
footerStatus,
|
|
16
17
|
groupTickets,
|
|
18
|
+
type HeldPress,
|
|
17
19
|
headingRoom,
|
|
18
20
|
HISTORY_LIMIT,
|
|
21
|
+
isTimerLate,
|
|
22
|
+
keepOpenTotal,
|
|
19
23
|
LIST_LIMIT,
|
|
20
24
|
MAP_LABEL,
|
|
25
|
+
MAPS_READ_LIMIT,
|
|
21
26
|
mapRowLayout,
|
|
22
27
|
nextViewed,
|
|
28
|
+
openTotalLabel,
|
|
29
|
+
openTotalQuery,
|
|
23
30
|
parseActiveMap,
|
|
24
31
|
parseDashboard,
|
|
25
32
|
parseHistory,
|
|
33
|
+
parseIssuesPage,
|
|
26
34
|
parseMap,
|
|
27
35
|
parseMaps,
|
|
28
36
|
parseMapsRead,
|
|
37
|
+
parseOpenTotal,
|
|
29
38
|
parseTickets,
|
|
30
39
|
parseUatParents,
|
|
31
40
|
rasterCells,
|
|
@@ -37,7 +46,11 @@ import {
|
|
|
37
46
|
withUatParents,
|
|
38
47
|
whiteboardHeading,
|
|
39
48
|
whiteboardRowLayout,
|
|
49
|
+
withLabel,
|
|
40
50
|
} from './map-pane-view'
|
|
51
|
+
import { checkoutArg, currentRepo, isPinCheckout, isSamePin, pinKey, pinPath, refreshPin, toplevelOf } from './repo-pin'
|
|
52
|
+
import { isMainTurnRunning } from './turn-state'
|
|
53
|
+
import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
|
|
41
54
|
import { fit, isMissing, sameValue } from './work-state-view'
|
|
42
55
|
|
|
43
56
|
export const PANE_ID = 'trailhead-map'
|
|
@@ -46,16 +59,26 @@ export const PANEL_COMMAND = 'trailhead:panel'
|
|
|
46
59
|
export const MIN_REFRESH_MS = 15_000
|
|
47
60
|
export const TIMER_MS = 180_000
|
|
48
61
|
export const GH_TIMEOUT_MS = 20_000
|
|
62
|
+
// How late past its due time a timer may fire before it is held dead (isTimerLate) and started again.
|
|
63
|
+
export const TIMER_GRACE_MS = 30_000
|
|
49
64
|
// A list unchanged for this long is still written, so its age label moves on.
|
|
50
65
|
const AGE_REFRESH_MS = 60_000
|
|
66
|
+
// Shown when no GitHub repo was ever pinned; the pane then makes no `gh` call.
|
|
67
|
+
const NO_REPO = 'Not in a GitHub repository: open the session in a trailhead repo'
|
|
51
68
|
const ACTIVE_MAP_PATH = '.trailhead/active-map'
|
|
52
69
|
const TICKET_FIELDS = 'number,title,labels,assignees,url'
|
|
53
|
-
const UAT_FIELDS = 'number,body'
|
|
54
70
|
const MAP_FIELDS = 'title,body,url'
|
|
55
71
|
const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
|
|
56
72
|
const MAPS_FIELDS = 'number,title,url'
|
|
57
|
-
const MAPS_LIMIT =
|
|
58
|
-
const
|
|
73
|
+
const MAPS_LIMIT = MAPS_READ_LIMIT
|
|
74
|
+
const PAGE_SIZE = 100
|
|
75
|
+
const MAPS_QUERY = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:${MAPS_LIMIT},states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}totalCount}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}`
|
|
76
|
+
// The open tickets of one label, newest first like `gh issue list`; paged by the module (PAGE_SIZE a page).
|
|
77
|
+
const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:[$label],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}`
|
|
78
|
+
// Every open UAT follow-up with its body and labels, newest first; paged by the module (PAGE_SIZE a page, at
|
|
79
|
+
// most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
|
|
80
|
+
const UAT_QUERY = `query($owner:String!,$repo:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:["trailhead:uat"],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number body labels(first:50){nodes{name}}}pageInfo{hasNextPage endCursor}}}}`
|
|
81
|
+
const UAT_MAX_PAGES = 10
|
|
59
82
|
const DASHBOARD_FIELDS = 'number,url'
|
|
60
83
|
// Same colours as the band.
|
|
61
84
|
const ACCENT = '#7aa2f7'
|
|
@@ -71,6 +94,7 @@ const EMPTY: MapPaneData = {
|
|
|
71
94
|
tickets: [],
|
|
72
95
|
maps: [],
|
|
73
96
|
whiteboard: null,
|
|
97
|
+
openTotal: null,
|
|
74
98
|
unverifiedCount: 0,
|
|
75
99
|
isCapped: false,
|
|
76
100
|
history: null,
|
|
@@ -98,23 +122,32 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
|
|
|
98
122
|
// The dashboard view (maps index + whiteboard tickets) in place of a map; a map press leaves it.
|
|
99
123
|
export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
|
|
100
124
|
export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
|
|
101
|
-
// The ticket whose `work` press is queued; one at a time.
|
|
125
|
+
// The ticket whose `work` or `resume` press is queued; one at a time.
|
|
102
126
|
export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
|
|
127
|
+
// Whether that press waits for the running turn to end (cancellable) or was sent to the host.
|
|
128
|
+
export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
|
|
103
129
|
// Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
|
|
104
130
|
export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
|
|
105
131
|
const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
|
|
106
132
|
const WORK_COMMAND = 'trailhead:work'
|
|
133
|
+
const RESUME_COMMAND = 'trailhead:resume'
|
|
107
134
|
// The built-in that empties the conversation; the work verb starts in a clean one.
|
|
108
135
|
const CLEAR_COMMAND = 'clear'
|
|
109
136
|
// The same refs the band's atoms are written under; read here to mark the ticket in play.
|
|
110
137
|
const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
111
138
|
const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
112
139
|
const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
|
|
140
|
+
// The repo pin refreshPin keeps (repo-pin.ts). The module scan wants the atom spelled in the file that reads it,
|
|
141
|
+
// so this copy and the one in repo-pin.ts must keep the same { plugin, key }.
|
|
142
|
+
const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
|
|
113
143
|
|
|
114
144
|
type Dollar = EngineInterface
|
|
115
145
|
type Timer = { cancel: () => void }
|
|
146
|
+
// The slow timer with the time it should fire next: the host drops a refused period silently, so the handle
|
|
147
|
+
// alone cannot tell a live timer from a dead one.
|
|
148
|
+
type Tracked = { timer: Timer; dueAt: number }
|
|
116
149
|
|
|
117
|
-
|
|
150
|
+
function debug($: Dollar, message: string): void {
|
|
118
151
|
try {
|
|
119
152
|
$.ui.log(message, { to: 'debug' })
|
|
120
153
|
} catch {
|
|
@@ -126,19 +159,34 @@ const debug = ($: Dollar, message: string) => {
|
|
|
126
159
|
// A hot reload resets it, which costs at most one early refresh.
|
|
127
160
|
let queue: Promise<void> = Promise.resolve()
|
|
128
161
|
let generation = 0
|
|
162
|
+
// Bumped by every pin move, same repo or not: a read that spans one is stale even when `generation` held.
|
|
163
|
+
let pinMoves = 0
|
|
129
164
|
let lastStartedAt: number | null = null
|
|
130
|
-
|
|
131
|
-
let
|
|
165
|
+
// When the last tracker write finished: a turn end that finds it newer than the last read start reads at once.
|
|
166
|
+
let lastWriteAt: number | null = null
|
|
167
|
+
let trailing: Tracked | null = null
|
|
168
|
+
let ticker: Tracked | null = null
|
|
132
169
|
// What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
|
|
133
170
|
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
|
|
134
171
|
let carried: Carried | null = null
|
|
135
|
-
//
|
|
136
|
-
|
|
172
|
+
// A `work` or `resume` press made during a running turn: the panel keeps it, and sends nothing, until the
|
|
173
|
+
// main turn ends. One slot, shared with `inFlight` (the pendingWork claim): never both at once.
|
|
174
|
+
let held: HeldPress | null = null
|
|
175
|
+
// The id of the press whose /clear and verb are being sent: /clear empties the pendingWork atom meanwhile.
|
|
176
|
+
let inFlight: number | null = null
|
|
177
|
+
// The press being sent, until its /clear is called: a cancel marks it and the dispatch drops it at that line.
|
|
178
|
+
let dispatching: { id: number; number: number; isCancelled: boolean; isSent: boolean } | null = null
|
|
179
|
+
// The number of a press between its claim and the choice to hold it or send it.
|
|
180
|
+
let claiming: { number: number; isCancelled: boolean } | null = null
|
|
181
|
+
let pressCount = 0
|
|
137
182
|
|
|
138
183
|
async function readActiveMap($: Dollar): Promise<number | null> {
|
|
184
|
+
const pin = await read($, repoPin)
|
|
185
|
+
// No repo pinned: there is nothing to read the marker from.
|
|
186
|
+
if (pin === null) return read($, activeMap)
|
|
139
187
|
let next: number | null
|
|
140
188
|
try {
|
|
141
|
-
next = parseActiveMap((await $.fs.read(ACTIVE_MAP_PATH)) as string)
|
|
189
|
+
next = parseActiveMap((await $.fs.read(pinPath(pin, ACTIVE_MAP_PATH))) as string)
|
|
142
190
|
} catch (err) {
|
|
143
191
|
// A missing marker means no active map; any other failure is transient: keep the last one.
|
|
144
192
|
if (!isMissing(err)) return read($, activeMap)
|
|
@@ -158,41 +206,93 @@ async function setPane($: Dollar, next: MapPaneData) {
|
|
|
158
206
|
|
|
159
207
|
type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
160
208
|
|
|
161
|
-
|
|
209
|
+
// Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
|
|
210
|
+
async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
|
|
162
211
|
try {
|
|
163
|
-
const out = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
|
|
212
|
+
const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
|
|
164
213
|
return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
|
|
165
214
|
} catch (error) {
|
|
166
215
|
return { ok: false, reason: failureReason({ error }) }
|
|
167
216
|
}
|
|
168
217
|
}
|
|
169
218
|
|
|
170
|
-
const readList = ($: Dollar, target: number) =>
|
|
219
|
+
const readList = ($: Dollar, pin: RepoPin, target: number, kept: OpenTotal | null) =>
|
|
171
220
|
Promise.all([
|
|
172
|
-
gh($, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
|
|
173
|
-
|
|
174
|
-
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
175
|
-
readMaps(
|
|
176
|
-
readUat($, `trailhead:map-${target}`),
|
|
221
|
+
gh($, pin, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
|
|
222
|
+
readOpen($, pin, `trailhead:map-${target}`),
|
|
223
|
+
gh($, pin, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
224
|
+
readMaps($, pin, kept),
|
|
225
|
+
readUat($, pin, `trailhead:map-${target}`),
|
|
177
226
|
])
|
|
178
227
|
|
|
179
|
-
// The
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
228
|
+
// The open tickets of one label as the JSON array `gh issue list --json` prints, so one parser reads both.
|
|
229
|
+
// `gh issue list --label` goes through the search API, which lags a write by seconds; the GraphQL
|
|
230
|
+
// `repository.issues` read does not, so a ticket just created or closed shows at once. Paged up to LIST_LIMIT
|
|
231
|
+
// rows (`also`: a second label the rows must carry, GraphQL's `labels` being any-of). A page that fails or is
|
|
232
|
+
// unreadable discards the whole GraphQL read (never a partial list) and the `gh issue list` runs once instead.
|
|
233
|
+
const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string): Promise<GhResult> => {
|
|
234
|
+
const rows: unknown[] = []
|
|
235
|
+
let cursor: string | null = null
|
|
236
|
+
while (rows.length < LIST_LIMIT) {
|
|
237
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
|
|
238
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${TICKETS_QUERY}`, '-f', `label=${label}`]
|
|
239
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
240
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
241
|
+
if (page === null) return readOpenFallback($, pin, label, also)
|
|
242
|
+
rows.push(...page.rows)
|
|
243
|
+
if (!page.hasNextPage) break
|
|
244
|
+
cursor = page.endCursor
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, stdout: JSON.stringify(also === undefined ? rows : withLabel(rows, also)) }
|
|
184
247
|
}
|
|
185
248
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
|
|
250
|
+
gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
|
|
251
|
+
|
|
252
|
+
// The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
|
|
253
|
+
// one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
|
|
254
|
+
// show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
|
|
255
|
+
// that fails or is unreadable, or pages left at UAT_MAX_PAGES, make the read null (never a partial map, no search
|
|
256
|
+
// fallback). Null is the failure value: the rows still group by label, only the parent line goes.
|
|
257
|
+
const readUat = async ($: Dollar, pin: RepoPin, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
|
|
258
|
+
const rows: unknown[] = []
|
|
259
|
+
let cursor: string | null = null
|
|
260
|
+
for (let n = 0; n < UAT_MAX_PAGES; n++) {
|
|
261
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
|
|
262
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${UAT_QUERY}`]
|
|
263
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
264
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
265
|
+
if (page === null) return null
|
|
266
|
+
rows.push(...page.rows)
|
|
267
|
+
if (!page.hasNextPage) return parseUatParents(JSON.stringify(withLabel(rows, scope)))
|
|
268
|
+
cursor = page.endCursor
|
|
269
|
+
}
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The open maps of the repo, the whiteboard's counts and the open-ticket total across them. A failed or
|
|
274
|
+
// unreadable maps read is null: the caller keeps the last good list. One GraphQL call gives every map with its
|
|
275
|
+
// sub-issue counts and the whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead
|
|
276
|
+
// (counts null, the whiteboard null: the row is drawn name-only, never stale; the total null too, as a sum over a
|
|
277
|
+
// list of unknown completeness is never shown). Both bounded at MAPS_LIMIT open maps.
|
|
278
|
+
type MapsResult = { maps: readonly MapSummary[] | null; whiteboard: MapCounts | null; openTotal: OpenTotal | null }
|
|
279
|
+
|
|
280
|
+
// The total over the open maps just read, one aliased `totalCount` query. A failed or unreadable count keeps
|
|
281
|
+
// `kept` only while it was computed over exactly these maps (and the same cut), else it is hidden, never stale.
|
|
282
|
+
const readOpenTotal = async ($: Dollar, pin: RepoPin, read: MapsRead, kept: OpenTotal | null): Promise<OpenTotal | null> => {
|
|
283
|
+
const maps = read.maps.map(m => m.number)
|
|
284
|
+
const out = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${openTotalQuery(maps)}`])
|
|
285
|
+
const total = out.ok ? parseOpenTotal(out.stdout, maps) : null
|
|
286
|
+
return total === null ? keepOpenTotal(kept, maps, read.isMapsCapped) : { total, maps, isMapsCapped: read.isMapsCapped }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const readMaps = async ($: Dollar, pin: RepoPin, kept: OpenTotal | null): Promise<MapsResult> => {
|
|
290
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, which gh resolves with its host too.
|
|
291
|
+
const counted = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
|
|
192
292
|
const withCounts = counted.ok ? parseMapsRead(counted.stdout) : null
|
|
193
|
-
if (withCounts !== null) return withCounts
|
|
194
|
-
const out = await gh($, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
|
|
195
|
-
return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null }
|
|
293
|
+
if (withCounts !== null) return { maps: withCounts.maps, whiteboard: withCounts.whiteboard, openTotal: await readOpenTotal($, pin, withCounts, kept) }
|
|
294
|
+
const out = await gh($, pin, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
|
|
295
|
+
return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null, openTotal: null }
|
|
196
296
|
}
|
|
197
297
|
|
|
198
298
|
// What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
|
|
@@ -211,13 +311,18 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
|
|
|
211
311
|
}
|
|
212
312
|
|
|
213
313
|
// One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
|
|
214
|
-
// (generation) or the target moved on, checked before every write; a moved
|
|
215
|
-
async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
314
|
+
// (generation) or the target or pin moved on, checked before every write; a moved one is read again.
|
|
315
|
+
async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
|
|
216
316
|
const target = await readTarget($)
|
|
217
317
|
if (token !== generation) return
|
|
318
|
+
// Checked before every write: the last test is the token, then the pin-move count, with no await after
|
|
319
|
+
// them. A pin that moved to another repo (even with the same map number there) makes the read stale; the
|
|
320
|
+
// target is not read under the new pin for it. A move that landed during the checks shows in the count.
|
|
218
321
|
const isStale = async () => {
|
|
219
322
|
if (token !== generation) return true
|
|
220
|
-
|
|
323
|
+
const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
|
|
324
|
+
if (token !== generation) return true
|
|
325
|
+
if (!isMoved && moves === pinMoves) return false
|
|
221
326
|
wantsAnother()
|
|
222
327
|
return true
|
|
223
328
|
}
|
|
@@ -225,35 +330,35 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
|
225
330
|
|
|
226
331
|
if (target === null) {
|
|
227
332
|
// No map to show: the open maps alone, so one can be picked.
|
|
228
|
-
const mapsRead = await readMaps(
|
|
333
|
+
const mapsRead = await readMaps($, pin, before.openTotal)
|
|
229
334
|
const maps = mapsRead.maps ?? before.maps
|
|
230
335
|
if (await isStale()) return
|
|
231
|
-
return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard })
|
|
336
|
+
return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
232
337
|
}
|
|
233
338
|
const isSameMap = before.map?.number === target
|
|
234
339
|
if (!isSameMap) {
|
|
235
340
|
if (await isStale()) return
|
|
236
|
-
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, isLoading: true })
|
|
341
|
+
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
|
|
237
342
|
}
|
|
238
343
|
|
|
239
|
-
const [view, list, historyRead, mapsRead, uatParents] = await readList($, target)
|
|
344
|
+
const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
|
|
240
345
|
const maps = mapsRead.maps ?? before.maps
|
|
241
|
-
const { whiteboard } = mapsRead
|
|
346
|
+
const { whiteboard, openTotal } = mapsRead
|
|
242
347
|
await dropClosedView($, mapsRead.maps)
|
|
243
348
|
const now = await $.clock.now()
|
|
244
349
|
if (await isStale()) return
|
|
245
350
|
|
|
246
351
|
const kept = isSameMap ? before : EMPTY
|
|
247
352
|
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
248
|
-
if (failure !== null) return setPane($, { ...kept, maps, whiteboard, error: failure, isLoading: false })
|
|
353
|
+
if (failure !== null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: failure, isLoading: false })
|
|
249
354
|
const map = view.ok ? parseMap(view.stdout, target) : null
|
|
250
355
|
const tickets = list.ok ? parseTickets(list.stdout) : null
|
|
251
|
-
if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, error: 'gh returned unreadable output', isLoading: false })
|
|
356
|
+
if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: 'gh returned unreadable output', isLoading: false })
|
|
252
357
|
|
|
253
358
|
// A history that failed alone keeps the last good one of this map (with its own time), never another map's.
|
|
254
359
|
const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
|
|
255
360
|
const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
|
|
256
|
-
const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
361
|
+
const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, openTotal, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
257
362
|
const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
|
|
258
363
|
if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
|
|
259
364
|
await setPane($, next)
|
|
@@ -261,27 +366,31 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
|
261
366
|
|
|
262
367
|
// The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
|
|
263
368
|
// as a map read: a result is not committed once the pane closed or the dashboard was left.
|
|
264
|
-
async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void) {
|
|
369
|
+
async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
|
|
265
370
|
const isStale = async () => {
|
|
266
371
|
if (token !== generation) return true
|
|
267
|
-
|
|
372
|
+
const isOther = !isSamePin(await read($, repoPin), pin)
|
|
373
|
+
const isLeft = !(await read($, isDashboard))
|
|
374
|
+
if (token !== generation) return true
|
|
375
|
+
if (!isOther && !isLeft && moves === pinMoves) return false
|
|
268
376
|
wantsAnother()
|
|
269
377
|
return true
|
|
270
378
|
}
|
|
271
379
|
const before = await read($, dashboardPane)
|
|
380
|
+
const keptTotal = (await read($, mapPane)).openTotal
|
|
272
381
|
if (before.fetchedAt === null && !before.isLoading && before.error === null) {
|
|
273
382
|
await update($, dashboardPane, () => ({ ...before, isLoading: true }))
|
|
274
383
|
}
|
|
275
384
|
const [list, index, mapsRead, uatParents] = await Promise.all([
|
|
276
|
-
|
|
277
|
-
gh($, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
278
|
-
readMaps(
|
|
279
|
-
readUat($, 'trailhead:whiteboard'),
|
|
385
|
+
readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
|
|
386
|
+
gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
387
|
+
readMaps($, pin, keptTotal),
|
|
388
|
+
readUat($, pin, 'trailhead:whiteboard'),
|
|
280
389
|
])
|
|
281
390
|
const now = await $.clock.now()
|
|
282
391
|
if (await isStale()) return
|
|
283
392
|
const shown = await read($, mapPane)
|
|
284
|
-
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard })
|
|
393
|
+
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
285
394
|
await dropClosedView($, mapsRead.maps)
|
|
286
395
|
|
|
287
396
|
const kept = await read($, dashboardPane)
|
|
@@ -295,6 +404,35 @@ async function fetchDashboard($: Dollar, token: number, wantsAnother: () => void
|
|
|
295
404
|
if (!sameValue(kept, next)) await update($, dashboardPane, () => next)
|
|
296
405
|
}
|
|
297
406
|
|
|
407
|
+
// Checks the repo the session is in and pins it. A move to another GitHub repo empties what the pane
|
|
408
|
+
// holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
|
|
409
|
+
let paneRepo: string | null = null
|
|
410
|
+
// Called by refreshPin right before the pin atom changes, from the pane's refresh and the band's alike. A move
|
|
411
|
+
// to another repo (or to no pin) drops the reads in flight and, to another repo, empties what the pane holds for
|
|
412
|
+
// the old one, then asks for a fresh read (a closed pane makes that a no-op), so its tickets never show under the
|
|
413
|
+
// new repo. A move inside the same repo leaves the read in flight to its own stale check, which reads again.
|
|
414
|
+
export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
|
|
415
|
+
pinMoves += 1
|
|
416
|
+
const key = pin === null ? null : pinKey(pin)
|
|
417
|
+
const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
|
|
418
|
+
if (isOtherRepo || pin === null) generation += 1
|
|
419
|
+
paneRepo = key ?? paneRepo
|
|
420
|
+
if (!isOtherRepo) return
|
|
421
|
+
await update($, mapPane, () => EMPTY)
|
|
422
|
+
await update($, dashboardPane, () => EMPTY_DASHBOARD)
|
|
423
|
+
await update($, activeMap, () => null)
|
|
424
|
+
await update($, viewedMap, () => null)
|
|
425
|
+
await update($, isDashboard, () => false)
|
|
426
|
+
void requestRefresh($, { bypassFloor: true })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function syncPin($: Dollar, init: { isStart?: boolean } = {}): Promise<RepoPin | null> {
|
|
430
|
+
return refreshPin($, { ...init, onMove: pin => onPinMove($, pin) })
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const safeSyncPin = ($: Dollar, init: { isStart?: boolean } = {}) =>
|
|
434
|
+
syncPin($, init).catch(err => debug($, `trailhead mod: repo pin failed: ${String(err)}`))
|
|
435
|
+
|
|
298
436
|
async function isPaneUp($: Dollar): Promise<boolean> {
|
|
299
437
|
return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
|
|
300
438
|
}
|
|
@@ -303,37 +441,74 @@ function stopTimers() {
|
|
|
303
441
|
generation += 1
|
|
304
442
|
// A fill timer dropped with the pane must not leave the next pane unfillable.
|
|
305
443
|
isFilling = false
|
|
306
|
-
trailing?.cancel()
|
|
444
|
+
trailing?.timer.cancel()
|
|
307
445
|
trailing = null
|
|
308
|
-
|
|
446
|
+
lastWriteAt = null
|
|
447
|
+
ticker?.timer.cancel()
|
|
309
448
|
ticker = null
|
|
310
449
|
}
|
|
311
450
|
|
|
451
|
+
// A timer past its due time by more than the grace never fired: drop it, so the caller starts another.
|
|
452
|
+
// Both the slow timer and the trailing refresh are tracked, as the host may silently drop either.
|
|
453
|
+
const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
|
|
454
|
+
if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
|
|
455
|
+
t.timer.cancel()
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const startTrailing = ($: Dollar, now: number, waitMs: number): Tracked => ({
|
|
460
|
+
timer: $.clock.after(waitMs, () => {
|
|
461
|
+
trailing = null
|
|
462
|
+
void requestRefresh($)
|
|
463
|
+
}),
|
|
464
|
+
dueAt: now + waitMs,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
const startTicker = ($: Dollar, now: number): Tracked => ({
|
|
468
|
+
timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
|
|
469
|
+
dueAt: now + TIMER_MS,
|
|
470
|
+
})
|
|
471
|
+
|
|
312
472
|
// `bypassFloor`: the caller's last result was dropped unseen, so reading again is not a repeat.
|
|
313
|
-
|
|
473
|
+
// `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
|
|
474
|
+
// by when the search index (history, the list fallback) has caught up.
|
|
475
|
+
// `isTick`: the slow timer fired, so its next due time moves on.
|
|
476
|
+
// `isTurnEnd`: the turn is over; if a write landed after the last read started, the read runs inside the floor
|
|
477
|
+
// too, so the pane shows the turn's final tracker state without waiting on a timer the host may have dropped.
|
|
478
|
+
type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean; isTurnEnd?: boolean }
|
|
314
479
|
|
|
315
480
|
// Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
|
|
316
481
|
// just after a refresh still shows.
|
|
317
482
|
async function decide($: Dollar, request: Request) {
|
|
318
483
|
if (!(await isPaneUp($))) return
|
|
319
|
-
|
|
484
|
+
// Nothing pinned: no repo to read, so no call.
|
|
485
|
+
const pin = await syncPin($)
|
|
486
|
+
const moves = pinMoves
|
|
487
|
+
if (pin === null) return
|
|
320
488
|
const now = await $.clock.now()
|
|
321
|
-
|
|
489
|
+
if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
|
|
490
|
+
// A timer the host dropped is started again here, else the pane would wait on it for good.
|
|
491
|
+
ticker = liveOrNull(ticker, now) ?? startTicker($, now)
|
|
492
|
+
// Same for the trailing refresh: a dead handle would keep `??=` below from arming a live one.
|
|
493
|
+
trailing = liveOrNull(trailing, now)
|
|
494
|
+
// Strictly newer: a read that started at the write's own instant already saw it.
|
|
495
|
+
const isFlush = request.isTurnEnd === true && lastWriteAt !== null && (lastStartedAt === null || lastWriteAt > lastStartedAt)
|
|
496
|
+
const decision = request.bypassFloor === true || isFlush ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
|
|
322
497
|
if (!decision.run) {
|
|
323
|
-
|
|
324
|
-
trailing = $.clock.after(decision.waitMs, () => {
|
|
325
|
-
trailing = null
|
|
326
|
-
void requestRefresh($)
|
|
327
|
-
})
|
|
328
|
-
}
|
|
498
|
+
trailing ??= startTrailing($, now, decision.waitMs)
|
|
329
499
|
return
|
|
330
500
|
}
|
|
331
501
|
lastStartedAt = now
|
|
332
|
-
trailing
|
|
333
|
-
|
|
502
|
+
// A flush leaves the trailing refresh alone: it still owes the read after the search index caught up.
|
|
503
|
+
if (!isFlush) {
|
|
504
|
+
trailing?.timer.cancel()
|
|
505
|
+
trailing = null
|
|
506
|
+
}
|
|
507
|
+
// Never bypassFloor: a burst of writes still costs one read per floor plus this one trailing.
|
|
508
|
+
if (request.isWrite === true) trailing = startTrailing($, now, MIN_REFRESH_MS)
|
|
334
509
|
const again = () => void requestRefresh($, { bypassFloor: true })
|
|
335
|
-
if (await read($, isDashboard)) await fetchDashboard($, generation, again)
|
|
336
|
-
else await fetchMap($, generation, again)
|
|
510
|
+
if (await read($, isDashboard)) await fetchDashboard($, pin, generation, moves, again)
|
|
511
|
+
else await fetchMap($, pin, generation, moves, again)
|
|
337
512
|
}
|
|
338
513
|
|
|
339
514
|
// Does nothing while the pane is closed: the data exists only for the pane.
|
|
@@ -362,6 +537,7 @@ async function restoreAfterClear($: Dollar) {
|
|
|
362
537
|
const kept = carried
|
|
363
538
|
carried = null
|
|
364
539
|
try {
|
|
540
|
+
await syncPin($)
|
|
365
541
|
if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
|
|
366
542
|
if (kept?.isDashboard === true) await update($, isDashboard, () => true)
|
|
367
543
|
const active = await readActiveMap($)
|
|
@@ -373,32 +549,251 @@ async function restoreAfterClear($: Dollar) {
|
|
|
373
549
|
void requestRefresh($, { bypassFloor: true })
|
|
374
550
|
}
|
|
375
551
|
|
|
376
|
-
// Edits of the tracker
|
|
377
|
-
|
|
552
|
+
// Edits of the tracker from a Bash call, one regex over one shell segment (`[^\n;&|]*`) so a read elsewhere
|
|
553
|
+
// in the line never counts. Three kinds: `gh issue <verb>`; a `gh api` call on a `repos/.../issues` path that
|
|
554
|
+
// writes (an explicit POST/PATCH/PUT/DELETE, or a -f/-F/--field/--raw-field/--input, which makes gh default to
|
|
555
|
+
// POST) and does not say `-X GET`; and the body helper, which rewrites a map's body through the API.
|
|
556
|
+
export const GH_ISSUE_WRITE =
|
|
557
|
+
/\bgh\b[^\n;&|]*\bissue\s+(?:edit|close|create|comment|reopen|delete|transfer)\b|\bgh\b(?=[^\n;&|]*\sapi\s)(?=[^\n;&|]*\brepos\/[^\s;&|]*\/issues\b)(?=[^\n;&|]*(?:\s(?:-X|--method)[\s=]*["']?(?:POST|PATCH|PUT|DELETE)\b|\s(?:-[fF]|--field\b|--raw-field\b|--input\b)))(?![^\n;&|]*\s(?:-X|--method)[\s=]*["']?GET\b)|\btrailhead-issue-body\.js\b/
|
|
558
|
+
|
|
559
|
+
// The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
|
|
560
|
+
// move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
|
|
561
|
+
// be read is `unknown`, refused, but not told it is elsewhere.
|
|
562
|
+
async function whereIsSession($: Dollar, pin: RepoPin): Promise<'in' | 'out' | 'unknown'> {
|
|
563
|
+
try {
|
|
564
|
+
const { repo, toplevel } = await currentRepo($)
|
|
565
|
+
return isPinCheckout(pin, repo, toplevel ?? (await toplevelOf($))) ? 'in' : 'out'
|
|
566
|
+
} catch {
|
|
567
|
+
return 'unknown'
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Where the press stands, read at the press and again at the send: the pin to hand over, or the refusal.
|
|
572
|
+
async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' } | { refusal: string }> {
|
|
573
|
+
const pin = await read($, repoPin)
|
|
574
|
+
if (pin === null) return { refusal: 'no repo pinned' }
|
|
575
|
+
const where = await whereIsSession($, pin)
|
|
576
|
+
if (where === 'unknown') return { refusal: 'checkout unreadable' }
|
|
577
|
+
// Out of it, the verb must move into the pin: a removed worktree or clone would fail only after the destructive /clear.
|
|
578
|
+
if (where === 'out' && !(await isCheckoutRoot($, pin.root))) return { refusal: 'checkout unreadable' }
|
|
579
|
+
return { pin, where }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
|
|
583
|
+
async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
|
|
584
|
+
try {
|
|
585
|
+
const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
|
|
586
|
+
return out.exitCode === 0 && out.stdout.trim() === root
|
|
587
|
+
} catch {
|
|
588
|
+
return false
|
|
589
|
+
}
|
|
590
|
+
}
|
|
378
591
|
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
async function
|
|
384
|
-
if (
|
|
592
|
+
// A press during a running turn is held (row `queued`, with a cancel button) and sent when the main turn ends;
|
|
593
|
+
// with no turn running it is sent at once. With no pin or an unreadable checkout it refuses before anything, as the
|
|
594
|
+
// clear is destructive; outside the pinned repo it goes through and the verb moves into it. The pendingWork claim is
|
|
595
|
+
// taken with the version it read, so two presses never both pass.
|
|
596
|
+
async function startVerb($: Dollar, verb: 'work' | 'resume', number: number) {
|
|
597
|
+
if (held !== null || inFlight !== null) return
|
|
598
|
+
const place = await placeOf($)
|
|
599
|
+
if ('refusal' in place) return refuse($, number, place.refusal)
|
|
385
600
|
const { value, version } = await $.state.get(PENDING)
|
|
386
601
|
if (value != null) return
|
|
602
|
+
if (held !== null || inFlight !== null) return
|
|
387
603
|
if (!(await $.state.set(PENDING, number, { ifVersion: version })).isSet) return
|
|
388
|
-
|
|
604
|
+
const press: HeldPress = { id: (pressCount += 1), verb, number }
|
|
605
|
+
claiming = { number, isCancelled: false }
|
|
606
|
+
try {
|
|
607
|
+
// A press that needs no hold is never shown queued: no turn running, no cancel button.
|
|
608
|
+
if (isMainTurnRunning()) {
|
|
609
|
+
await update($, pendingPhase, () => 'queued')
|
|
610
|
+
// A cancel landed during that write: nothing was sent, so dropping the press is the whole cancel.
|
|
611
|
+
if (claiming.isCancelled) return dropPending($)
|
|
612
|
+
// The turn mark is synchronous with the turn start, so this read is the latest word on it.
|
|
613
|
+
if (isMainTurnRunning()) {
|
|
614
|
+
held = press
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
debug($, `trailhead mod: ${verb} ${number} did not start: ${String(err)}`)
|
|
620
|
+
return dropPending($)
|
|
621
|
+
} finally {
|
|
622
|
+
claiming = null
|
|
623
|
+
}
|
|
624
|
+
await dispatch($, press)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// The toast box is narrow and already names the plugin: the text stays a few words.
|
|
628
|
+
const refuse = ($: Dollar, number: number, refusal: string) => $.ui.toast(`#${number} not started: ${refusal}`)
|
|
629
|
+
|
|
630
|
+
// The pending marks go phase first, the claim last: until pendingWork is clear no new press can take the slot,
|
|
631
|
+
// so a new press never has its phase erased by the old one's cleanup.
|
|
632
|
+
// `dropping` counts the drops in flight, so the stale-claim sweep (clearStale) never starts beside one.
|
|
633
|
+
let dropping = 0
|
|
634
|
+
async function dropPending($: Dollar) {
|
|
635
|
+
dropping += 1
|
|
389
636
|
try {
|
|
390
|
-
|
|
391
|
-
|
|
637
|
+
try {
|
|
638
|
+
await update($, pendingPhase, () => null)
|
|
639
|
+
} finally {
|
|
640
|
+
await update($, pendingWork, () => null).catch(err => debug($, `trailhead mod: pending mark not cleared: ${String(err)}`))
|
|
641
|
+
}
|
|
642
|
+
} finally {
|
|
643
|
+
dropping -= 1
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Sends the press: /clear, then `/trailhead:<verb> <n>` in the new conversation. Every await comes first, then the
|
|
648
|
+
// turn check and the /clear call follow with no await between, so a turn that started at any point before is seen
|
|
649
|
+
// and the press is held again instead of queued behind it in the host, where it could not be cancelled.
|
|
650
|
+
async function dispatch($: Dollar, press: HeldPress) {
|
|
651
|
+
inFlight = press.id
|
|
652
|
+
dispatching = { id: press.id, number: press.number, isCancelled: false, isSent: false }
|
|
653
|
+
let isReheld = false
|
|
654
|
+
try {
|
|
655
|
+
let clearing: Promise<unknown> | null = null
|
|
656
|
+
let before = ''
|
|
657
|
+
let args = String(press.number)
|
|
658
|
+
while (clearing === null) {
|
|
659
|
+
await update($, pendingPhase, () => 'starting')
|
|
660
|
+
before = await $.session.id()
|
|
661
|
+
// Read at the send, not only at the press: a held press may find the session moved or the checkout gone.
|
|
662
|
+
const place = await placeOf($)
|
|
663
|
+
if ('refusal' in place) return refuse($, press.number, place.refusal)
|
|
664
|
+
args = place.where === 'out' ? `${press.number} ${checkoutArg(place.pin)}` : String(press.number)
|
|
665
|
+
// A cancel pressed while the row still read queued: dropped here, the last line before the clear.
|
|
666
|
+
if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
|
|
667
|
+
if (!isMainTurnRunning()) {
|
|
668
|
+
clearing = $.command.run({ command: CLEAR_COMMAND, args: '' })
|
|
669
|
+
dispatching = { ...dispatching!, isSent: true }
|
|
670
|
+
break
|
|
671
|
+
}
|
|
672
|
+
// A turn began meanwhile: back to queued. Ownership returns to `held` only while the turn still runs, else go round.
|
|
673
|
+
await update($, pendingPhase, () => 'queued')
|
|
674
|
+
// A cancel landed during that write: drop the press (isReheld stays false, so the finally clears the marks).
|
|
675
|
+
if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
|
|
676
|
+
if (isMainTurnRunning()) {
|
|
677
|
+
held = press
|
|
678
|
+
isReheld = true
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
await clearing
|
|
392
683
|
// A clear another hook answered without running resolves too: the verb must not start in the old conversation.
|
|
393
|
-
if ((await $.session.id()) === before) return debug($, `trailhead mod:
|
|
394
|
-
// The clear emptied the
|
|
395
|
-
await update($, pendingWork, () => number)
|
|
396
|
-
await
|
|
684
|
+
if ((await $.session.id()) === before) return debug($, `trailhead mod: ${press.verb} ${press.number} skipped, the conversation was not cleared`)
|
|
685
|
+
// The clear emptied the atoms: the row is still starting in the new session.
|
|
686
|
+
await update($, pendingWork, () => press.number)
|
|
687
|
+
await update($, pendingPhase, () => 'starting')
|
|
688
|
+
await $.command.run({ command: press.verb === 'work' ? WORK_COMMAND : RESUME_COMMAND, args })
|
|
689
|
+
} catch (err) {
|
|
690
|
+
debug($, `trailhead mod: ${press.verb} ${press.number} did not start: ${String(err)}`)
|
|
691
|
+
} finally {
|
|
692
|
+
if (dispatching?.id === press.id) dispatching = null
|
|
693
|
+
// A press held again keeps its marks and its place in `held`; only the in-flight guard goes.
|
|
694
|
+
if (isReheld) inFlight = null
|
|
695
|
+
else {
|
|
696
|
+
try {
|
|
697
|
+
await dropPending($)
|
|
698
|
+
} finally {
|
|
699
|
+
if (inFlight === press.id) inFlight = null
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// The zero-delay timer after a main turn ends. It claims the held press in its first statements, with no await,
|
|
706
|
+
// so the press stays cancellable until this instant; a turn running again (a queued prompt) leaves it held.
|
|
707
|
+
function releaseHeld($: Dollar) {
|
|
708
|
+
if (isMainTurnRunning()) return
|
|
709
|
+
const press = held
|
|
710
|
+
if (press === null) return
|
|
711
|
+
held = null
|
|
712
|
+
void dispatch($, press).catch(err => debug($, `trailhead mod: held press failed: ${String(err)}`))
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The cancel button of a queued row: a held press never leaves the panel, so nothing is sent and the conversation
|
|
716
|
+
// stays. A press released but not yet at its /clear is marked and dropped by the dispatch at that line. A queued
|
|
717
|
+
// row with no press behind it (a hot reload reset the module, the atoms stayed) is cleared. Else nothing to cancel.
|
|
718
|
+
async function cancelQueued($: Dollar, number: number) {
|
|
719
|
+
if (held?.number === number) {
|
|
720
|
+
held = null
|
|
721
|
+
return dropPending($)
|
|
722
|
+
}
|
|
723
|
+
if (dispatching?.number === number) {
|
|
724
|
+
if (!dispatching.isSent) dispatching = { ...dispatching, isCancelled: true }
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
if (claiming?.number === number) {
|
|
728
|
+
claiming = { ...claiming, isCancelled: true }
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
|
|
732
|
+
if ((await read($, pendingPhase)) !== 'queued' || (await read($, pendingWork)) !== number) return
|
|
733
|
+
// The reads above awaited: a press made meanwhile owns the atoms.
|
|
734
|
+
if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
|
|
735
|
+
await dropPending($)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// A pending claim the pane draws with no press behind it and no `queued` mark (a hot reload reset the module,
|
|
739
|
+
// the atoms stayed) would block every press for good. Run from a zero-delay timer, as a render cannot write state.
|
|
740
|
+
// The claim is read first, then the module state is checked: a press that took the claim has set its guards by
|
|
741
|
+
// the time that read answers.
|
|
742
|
+
let isClearingStale = false
|
|
743
|
+
async function clearStale($: Dollar) {
|
|
744
|
+
if (isClearingStale) return
|
|
745
|
+
isClearingStale = true
|
|
746
|
+
try {
|
|
747
|
+
const isIdle = () => held === null && claiming === null && dispatching === null && inFlight === null && dropping === 0
|
|
748
|
+
if (!isIdle()) return
|
|
749
|
+
// The claim is read with its version and released only if that version still stands, so a press that took the
|
|
750
|
+
// slot meanwhile is never cleared. The phase goes first (while the stale claim still blocks every new press),
|
|
751
|
+
// then the compare-and-set on the claim.
|
|
752
|
+
const { value, version } = await $.state.get(PENDING)
|
|
753
|
+
if (value == null || !isIdle()) return
|
|
754
|
+
if ((await read($, pendingPhase)) === 'queued' || !isIdle()) return
|
|
755
|
+
debug($, 'trailhead mod: cleared a pending press left with no owner')
|
|
756
|
+
dropping += 1
|
|
757
|
+
try {
|
|
758
|
+
await update($, pendingPhase, () => null)
|
|
759
|
+
await $.state.set(PENDING, null, { ifVersion: version })
|
|
760
|
+
} finally {
|
|
761
|
+
dropping -= 1
|
|
762
|
+
}
|
|
397
763
|
} catch (err) {
|
|
398
|
-
debug($, `trailhead mod:
|
|
764
|
+
debug($, `trailhead mod: stale press not cleared: ${String(err)}`)
|
|
399
765
|
} finally {
|
|
400
|
-
|
|
401
|
-
|
|
766
|
+
isClearingStale = false
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// The whiteboard ticket the panel last switched to. A module variable, not an atom: a /clear empties the atoms,
|
|
771
|
+
// and a ticket the person already left for the map must not pull the panel back.
|
|
772
|
+
let followedTicket: number | null = null
|
|
773
|
+
|
|
774
|
+
// Called by the work state's refresh once it has applied (the state atom is written there): when this session's
|
|
775
|
+
// own whiteboard ticket is in play the panel shows the whiteboard, once per ticket (#306).
|
|
776
|
+
export async function followWhiteboard($: Dollar): Promise<void> {
|
|
777
|
+
try {
|
|
778
|
+
const { follow, memo } = nextFollow(followedTicket, await read($, workState), await read($, ownTicket), await read($, isOrphaned))
|
|
779
|
+
followedTicket = memo
|
|
780
|
+
if (!follow) return
|
|
781
|
+
await update($, isDashboard, () => true)
|
|
782
|
+
void requestRefresh($, { bypassFloor: true })
|
|
783
|
+
} catch (err) {
|
|
784
|
+
debug($, `trailhead mod: whiteboard follow failed: ${String(err)}`)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// A whiteboard ticket created while a map is shown: a hint, never a switch.
|
|
789
|
+
async function hintWhiteboard($: Dollar, command: unknown, call: unknown): Promise<void> {
|
|
790
|
+
try {
|
|
791
|
+
if (typeof command !== 'string' || !isWhiteboardCreate(command)) return
|
|
792
|
+
const number = createdIssueNumber(call)
|
|
793
|
+
if (number === null || (await read($, isDashboard))) return
|
|
794
|
+
$.ui.toast(whiteboardHint(number))
|
|
795
|
+
} catch (err) {
|
|
796
|
+
debug($, `trailhead mod: whiteboard hint failed: ${String(err)}`)
|
|
402
797
|
}
|
|
403
798
|
}
|
|
404
799
|
|
|
@@ -454,8 +849,16 @@ async function autoOpen($: Dollar) {
|
|
|
454
849
|
}
|
|
455
850
|
}
|
|
456
851
|
|
|
852
|
+
// Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
|
|
853
|
+
// una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
|
|
854
|
+
// disegna a qualsiasi larghezza. Se nemmeno così viene disegnato (nessuna superficie), lo chiude.
|
|
457
855
|
async function togglePanel($: Dollar): Promise<string> {
|
|
458
|
-
|
|
856
|
+
const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
|
|
857
|
+
if (pane?.isPlaced === false) {
|
|
858
|
+
const reopened = await openPane($)
|
|
859
|
+
if (reopened.isPlaced) return 'Map panel opened.'
|
|
860
|
+
}
|
|
861
|
+
if (pane !== undefined) {
|
|
459
862
|
await $.ui.close({ id: PANE_ID })
|
|
460
863
|
return 'Map panel closed.'
|
|
461
864
|
}
|
|
@@ -482,6 +885,7 @@ export function registerMapPane(on: On) {
|
|
|
482
885
|
// when neither has one, and register.tsx already hooks these events bare.
|
|
483
886
|
on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
|
|
484
887
|
const result = await next(e)
|
|
888
|
+
await safeSyncPin($, { isStart: true })
|
|
485
889
|
await safeReadActiveMap($)
|
|
486
890
|
void requestRefresh($)
|
|
487
891
|
return result
|
|
@@ -489,14 +893,23 @@ export function registerMapPane(on: On) {
|
|
|
489
893
|
|
|
490
894
|
on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
|
|
491
895
|
const result = await next(e)
|
|
896
|
+
await safeSyncPin($)
|
|
492
897
|
await safeReadActiveMap($)
|
|
493
|
-
void requestRefresh(
|
|
898
|
+
void requestRefresh($, { isTurnEnd: true })
|
|
899
|
+
// The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
|
|
900
|
+
if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
|
|
494
901
|
return result
|
|
495
902
|
})
|
|
496
903
|
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
904
|
+
// Every reason: a /clear empties the atoms, so note what the pane must get back and restore it in the new
|
|
905
|
+
// session; any ending drops a held press (nothing is left to run it in). The mod's own /clear never finds one:
|
|
906
|
+
// only a dispatch issues it, and a held press and a dispatch never coexist.
|
|
907
|
+
on('session.end', { reason: ['clear', 'resume', 'logout', 'prompt_input_exit', 'other'] }, async ($, e, next) => {
|
|
908
|
+
if (e.reason === 'clear') await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
|
|
909
|
+
if (held !== null) {
|
|
910
|
+
held = null
|
|
911
|
+
await dropPending($).catch(err => debug($, `trailhead mod: held press not dropped: ${String(err)}`))
|
|
912
|
+
}
|
|
500
913
|
return next(e)
|
|
501
914
|
})
|
|
502
915
|
|
|
@@ -509,7 +922,9 @@ export function registerMapPane(on: On) {
|
|
|
509
922
|
// After the call ran: a Bash `gh issue` write changed the tracker.
|
|
510
923
|
on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
|
|
511
924
|
const result = await next(e)
|
|
512
|
-
|
|
925
|
+
lastWriteAt = await $.clock.now()
|
|
926
|
+
void requestRefresh($, { isWrite: true })
|
|
927
|
+
await hintWhiteboard($, e.command, result)
|
|
513
928
|
return result
|
|
514
929
|
// This hook never judges the call: if it fails, the call goes on untouched.
|
|
515
930
|
}).catch((_$, e, next) => next(e))
|
|
@@ -546,7 +961,10 @@ export function registerMapPane(on: On) {
|
|
|
546
961
|
const play = await read($, workState)
|
|
547
962
|
const orphaned = await read($, isOrphaned)
|
|
548
963
|
const own = await read($, ownTicket)
|
|
964
|
+
const pin = await read($, repoPin)
|
|
549
965
|
const pending = await read($, pendingWork)
|
|
966
|
+
const phase = await read($, pendingPhase)
|
|
967
|
+
if (pending !== null && phase !== 'queued') $.clock.after(0, () => void clearStale($))
|
|
550
968
|
const now = await $.clock.now()
|
|
551
969
|
const cols = e.props.bodyColumns
|
|
552
970
|
const isDock = e.props.placement === 'dock'
|
|
@@ -615,10 +1033,14 @@ export function registerMapPane(on: On) {
|
|
|
615
1033
|
</Box>
|
|
616
1034
|
)
|
|
617
1035
|
|
|
1036
|
+
// The total of open tickets across every open map and the whiteboard, one dim line; hidden while unknown.
|
|
1037
|
+
const totalText = openTotalLabel(data.openTotal?.total ?? null, data.openTotal?.isMapsCapped ?? false, cols)
|
|
1038
|
+
const totalLine = totalText === null ? null : text('open-total', dim, totalText)
|
|
1039
|
+
|
|
618
1040
|
// This session's own ticket survives a work-state file another session took over; without one, the file decides.
|
|
619
1041
|
const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
|
|
620
1042
|
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
621
|
-
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
|
|
1043
|
+
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
|
|
622
1044
|
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
623
1045
|
const holder = r.isDim ? dim : {}
|
|
624
1046
|
const line = (
|
|
@@ -633,12 +1055,25 @@ export function registerMapPane(on: On) {
|
|
|
633
1055
|
{r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
|
|
634
1056
|
{r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
|
|
635
1057
|
{r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
636
|
-
{r.aside?.kind === '
|
|
1058
|
+
{r.aside?.kind === 'starting' ? text(`starting-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
1059
|
+
{r.aside?.kind === 'queued' ? (
|
|
1060
|
+
<Button
|
|
1061
|
+
key={`cancel-${t.number}`}
|
|
1062
|
+
label={CANCEL_LABEL}
|
|
1063
|
+
onPress={() => {
|
|
1064
|
+
void cancelQueued($, t.number).catch(err => debug($, `trailhead mod: cancel press failed: ${String(err)}`))
|
|
1065
|
+
}}
|
|
1066
|
+
/>
|
|
1067
|
+
) : null}
|
|
1068
|
+
{r.aside?.kind === 'work' || r.aside?.kind === 'resume' ? (
|
|
637
1069
|
<Button
|
|
638
|
-
key={
|
|
639
|
-
label=
|
|
1070
|
+
key={`${r.aside.kind}-${t.number}`}
|
|
1071
|
+
label={r.aside.kind}
|
|
640
1072
|
variant="primary"
|
|
641
|
-
onPress={() =>
|
|
1073
|
+
onPress={() => {
|
|
1074
|
+
const verb = r.aside?.kind === 'resume' ? 'resume' : 'work'
|
|
1075
|
+
void startVerb($, verb, t.number).catch(err => debug($, `trailhead mod: ${verb} press failed: ${String(err)}`))
|
|
1076
|
+
}}
|
|
642
1077
|
/>
|
|
643
1078
|
) : null}
|
|
644
1079
|
</Box>
|
|
@@ -671,6 +1106,7 @@ export function registerMapPane(on: On) {
|
|
|
671
1106
|
const ticketGroups = (tickets: readonly PaneTicket[]) => {
|
|
672
1107
|
const groups = groupTickets(tickets)
|
|
673
1108
|
return [
|
|
1109
|
+
group('needs-you', 'Needs you', groups.needsYou),
|
|
674
1110
|
group('frontier', 'Frontier', groups.frontier),
|
|
675
1111
|
group('in-progress', 'In progress', groups.inProgress),
|
|
676
1112
|
group('uat', 'UAT', groups.uat),
|
|
@@ -728,9 +1164,27 @@ export function registerMapPane(on: On) {
|
|
|
728
1164
|
})
|
|
729
1165
|
}
|
|
730
1166
|
|
|
1167
|
+
// A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
|
|
1168
|
+
// timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
|
|
1169
|
+
// A trailing refresh past its due time by more than the grace was dropped by the host: the render asks for
|
|
1170
|
+
// the read it would have made (the request replaces the dead handle).
|
|
1171
|
+
const isTrailingLate = trailing !== null && isTimerLate(now, trailing.dueAt, TIMER_GRACE_MS)
|
|
1172
|
+
const isOverdue = (d: { fetchedAt: number | null; error: string | null; isLoading: boolean }) =>
|
|
1173
|
+
isTrailingLate ||
|
|
1174
|
+
(d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS))
|
|
1175
|
+
|
|
1176
|
+
// No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
|
|
1177
|
+
if (pin === null) {
|
|
1178
|
+
return (
|
|
1179
|
+
<Box flexDirection="column" width={cols}>
|
|
1180
|
+
{text('no-repo', dim, NO_REPO)}
|
|
1181
|
+
</Box>
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
731
1185
|
if (isBoard) {
|
|
732
1186
|
const isLoaded = board.fetchedAt !== null
|
|
733
|
-
if (!isLoaded && !board.isLoading && board.error === null) askFill()
|
|
1187
|
+
if ((!isLoaded && !board.isLoading && board.error === null) || isOverdue(board)) askFill()
|
|
734
1188
|
const heading = fit('Dashboard', headingRoom(cols, MAP_LABEL))
|
|
735
1189
|
const title = (
|
|
736
1190
|
<Box key="dashboard-title">
|
|
@@ -741,6 +1195,7 @@ export function registerMapPane(on: On) {
|
|
|
741
1195
|
return (
|
|
742
1196
|
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
743
1197
|
{headerRow(title)}
|
|
1198
|
+
{totalLine}
|
|
744
1199
|
{mapsGroup}
|
|
745
1200
|
<Box key="whiteboard" marginTop={gap}>
|
|
746
1201
|
{text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
|
|
@@ -760,13 +1215,14 @@ export function registerMapPane(on: On) {
|
|
|
760
1215
|
<Box flexDirection="column" width={cols}>
|
|
761
1216
|
{text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
|
|
762
1217
|
{whiteboardRow}
|
|
1218
|
+
{totalLine}
|
|
763
1219
|
{mapsGroup}
|
|
764
1220
|
</Box>
|
|
765
1221
|
)
|
|
766
1222
|
}
|
|
767
1223
|
const isCurrent = data.map?.number === target
|
|
768
1224
|
const isLoading = !isCurrent && (data.isLoading || data.error === null)
|
|
769
|
-
if (!isCurrent && !data.isLoading && data.error === null) askFill()
|
|
1225
|
+
if ((!isCurrent && !data.isLoading && data.error === null) || (isCurrent && isOverdue(data))) askFill()
|
|
770
1226
|
|
|
771
1227
|
const map = isCurrent ? data.map : null
|
|
772
1228
|
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
@@ -785,6 +1241,7 @@ export function registerMapPane(on: On) {
|
|
|
785
1241
|
{viewing}
|
|
786
1242
|
{text('loading', dim, 'Loading tickets…')}
|
|
787
1243
|
{whiteboardRow}
|
|
1244
|
+
{totalLine}
|
|
788
1245
|
{mapsGroup}
|
|
789
1246
|
</Box>
|
|
790
1247
|
)
|
|
@@ -815,6 +1272,7 @@ export function registerMapPane(on: On) {
|
|
|
815
1272
|
{header}
|
|
816
1273
|
{viewing}
|
|
817
1274
|
{whiteboardRow}
|
|
1275
|
+
{totalLine}
|
|
818
1276
|
{mapsGroup}
|
|
819
1277
|
{ticketGroups(tickets)}
|
|
820
1278
|
{isCurrent && data.error === null && tickets.length === 0 ? (
|