@marcomigozzi/trailhead 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +158 -34
- package/README.md +147 -33
- 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/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- 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 +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -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/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -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 +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- 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 +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- 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 +38 -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-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- 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-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- 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/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- 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/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- 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-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- 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 +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- 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 +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- 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 +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -1,34 +1,56 @@
|
|
|
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, MapPaneData, MapSummary, PaneTicket, 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,
|
|
19
|
+
headingRoom,
|
|
17
20
|
HISTORY_LIMIT,
|
|
21
|
+
isTimerLate,
|
|
22
|
+
keepOpenTotal,
|
|
18
23
|
LIST_LIMIT,
|
|
24
|
+
MAP_LABEL,
|
|
25
|
+
MAPS_READ_LIMIT,
|
|
19
26
|
mapRowLayout,
|
|
20
27
|
nextViewed,
|
|
28
|
+
openTotalLabel,
|
|
29
|
+
openTotalQuery,
|
|
21
30
|
parseActiveMap,
|
|
31
|
+
parseDashboard,
|
|
22
32
|
parseHistory,
|
|
33
|
+
parseIssuesPage,
|
|
23
34
|
parseMap,
|
|
24
35
|
parseMaps,
|
|
36
|
+
parseMapsRead,
|
|
37
|
+
parseOpenTotal,
|
|
25
38
|
parseTickets,
|
|
39
|
+
parseUatParents,
|
|
26
40
|
rasterCells,
|
|
27
41
|
refreshDecision,
|
|
28
42
|
rowLayout,
|
|
29
43
|
sparkline,
|
|
44
|
+
uatParentLine,
|
|
30
45
|
viewTarget,
|
|
46
|
+
withUatParents,
|
|
47
|
+
whiteboardHeading,
|
|
48
|
+
whiteboardRowLayout,
|
|
49
|
+
withLabel,
|
|
31
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'
|
|
32
54
|
import { fit, isMissing, sameValue } from './work-state-view'
|
|
33
55
|
|
|
34
56
|
export const PANE_ID = 'trailhead-map'
|
|
@@ -37,14 +59,27 @@ export const PANEL_COMMAND = 'trailhead:panel'
|
|
|
37
59
|
export const MIN_REFRESH_MS = 15_000
|
|
38
60
|
export const TIMER_MS = 180_000
|
|
39
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
|
|
40
64
|
// A list unchanged for this long is still written, so its age label moves on.
|
|
41
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'
|
|
42
68
|
const ACTIVE_MAP_PATH = '.trailhead/active-map'
|
|
43
69
|
const TICKET_FIELDS = 'number,title,labels,assignees,url'
|
|
44
70
|
const MAP_FIELDS = 'title,body,url'
|
|
45
71
|
const HISTORY_FIELDS = 'number,labels,createdAt,closedAt'
|
|
46
72
|
const MAPS_FIELDS = 'number,title,url'
|
|
47
|
-
const MAPS_LIMIT =
|
|
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
|
|
82
|
+
const DASHBOARD_FIELDS = 'number,url'
|
|
48
83
|
// Same colours as the band.
|
|
49
84
|
const ACCENT = '#7aa2f7'
|
|
50
85
|
// The accent as a Raster colour (0x00RRGGBB), and the chart height in terminal rows.
|
|
@@ -58,6 +93,8 @@ const EMPTY: MapPaneData = {
|
|
|
58
93
|
map: null,
|
|
59
94
|
tickets: [],
|
|
60
95
|
maps: [],
|
|
96
|
+
whiteboard: null,
|
|
97
|
+
openTotal: null,
|
|
61
98
|
unverifiedCount: 0,
|
|
62
99
|
isCapped: false,
|
|
63
100
|
history: null,
|
|
@@ -67,27 +104,50 @@ const EMPTY: MapPaneData = {
|
|
|
67
104
|
isLoading: false,
|
|
68
105
|
}
|
|
69
106
|
|
|
107
|
+
const EMPTY_DASHBOARD: DashboardPaneData = {
|
|
108
|
+
dashboard: null,
|
|
109
|
+
tickets: [],
|
|
110
|
+
unverifiedCount: 0,
|
|
111
|
+
isCapped: false,
|
|
112
|
+
fetchedAt: null,
|
|
113
|
+
error: null,
|
|
114
|
+
isLoading: false,
|
|
115
|
+
}
|
|
116
|
+
|
|
70
117
|
export const mapPane = atom({ plugin: 'trailhead', key: 'mapPane' } as const, EMPTY)
|
|
71
118
|
export const activeMap = atom({ plugin: 'trailhead', key: 'activeMap' } as const, null as number | null)
|
|
72
119
|
// The map shown when it is not the active one (a click on the maps list); null follows the active map.
|
|
73
120
|
// The pane only reads `.trailhead/active-map`; the work verb is what changes it.
|
|
74
121
|
export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const, null as number | null)
|
|
75
|
-
// The
|
|
122
|
+
// The dashboard view (maps index + whiteboard tickets) in place of a map; a map press leaves it.
|
|
123
|
+
export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
|
|
124
|
+
export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
|
|
125
|
+
// The ticket whose `work` or `resume` press is queued; one at a time.
|
|
76
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)
|
|
77
129
|
// Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
|
|
78
130
|
export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
|
|
79
131
|
const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
|
|
80
132
|
const WORK_COMMAND = 'trailhead:work'
|
|
133
|
+
const RESUME_COMMAND = 'trailhead:resume'
|
|
81
134
|
// The built-in that empties the conversation; the work verb starts in a clean one.
|
|
82
135
|
const CLEAR_COMMAND = 'clear'
|
|
83
136
|
// The same refs the band's atoms are written under; read here to mark the ticket in play.
|
|
84
137
|
const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
85
138
|
const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
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)
|
|
86
143
|
|
|
87
144
|
type Dollar = EngineInterface
|
|
88
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 }
|
|
89
149
|
|
|
90
|
-
|
|
150
|
+
function debug($: Dollar, message: string): void {
|
|
91
151
|
try {
|
|
92
152
|
$.ui.log(message, { to: 'debug' })
|
|
93
153
|
} catch {
|
|
@@ -101,17 +161,28 @@ let queue: Promise<void> = Promise.resolve()
|
|
|
101
161
|
let generation = 0
|
|
102
162
|
let lastStartedAt: number | null = null
|
|
103
163
|
let trailing: Timer | null = null
|
|
104
|
-
let ticker:
|
|
164
|
+
let ticker: Tracked | null = null
|
|
105
165
|
// What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
|
|
106
|
-
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null }
|
|
166
|
+
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
|
|
107
167
|
let carried: Carried | null = null
|
|
108
|
-
//
|
|
109
|
-
|
|
168
|
+
// A `work` or `resume` press made during a running turn: the panel keeps it, and sends nothing, until the
|
|
169
|
+
// main turn ends. One slot, shared with `inFlight` (the pendingWork claim): never both at once.
|
|
170
|
+
let held: HeldPress | null = null
|
|
171
|
+
// The id of the press whose /clear and verb are being sent: /clear empties the pendingWork atom meanwhile.
|
|
172
|
+
let inFlight: number | null = null
|
|
173
|
+
// The press being sent, until its /clear is called: a cancel marks it and the dispatch drops it at that line.
|
|
174
|
+
let dispatching: { id: number; number: number; isCancelled: boolean; isSent: boolean } | null = null
|
|
175
|
+
// The number of a press between its claim and the choice to hold it or send it.
|
|
176
|
+
let claiming: { number: number; isCancelled: boolean } | null = null
|
|
177
|
+
let pressCount = 0
|
|
110
178
|
|
|
111
179
|
async function readActiveMap($: Dollar): Promise<number | null> {
|
|
180
|
+
const pin = await read($, repoPin)
|
|
181
|
+
// No repo pinned: there is nothing to read the marker from.
|
|
182
|
+
if (pin === null) return read($, activeMap)
|
|
112
183
|
let next: number | null
|
|
113
184
|
try {
|
|
114
|
-
next = parseActiveMap((await $.fs.read(ACTIVE_MAP_PATH)) as string)
|
|
185
|
+
next = parseActiveMap((await $.fs.read(pinPath(pin, ACTIVE_MAP_PATH))) as string)
|
|
115
186
|
} catch (err) {
|
|
116
187
|
// A missing marker means no active map; any other failure is transient: keep the last one.
|
|
117
188
|
if (!isMissing(err)) return read($, activeMap)
|
|
@@ -131,27 +202,93 @@ async function setPane($: Dollar, next: MapPaneData) {
|
|
|
131
202
|
|
|
132
203
|
type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
133
204
|
|
|
134
|
-
|
|
205
|
+
// Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
|
|
206
|
+
async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
|
|
135
207
|
try {
|
|
136
|
-
const out = await $.process.run(['gh', ...args], { timeoutMs: GH_TIMEOUT_MS })
|
|
208
|
+
const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
|
|
137
209
|
return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
|
|
138
210
|
} catch (error) {
|
|
139
211
|
return { ok: false, reason: failureReason({ error }) }
|
|
140
212
|
}
|
|
141
213
|
}
|
|
142
214
|
|
|
143
|
-
const readList = ($: Dollar, target: number) =>
|
|
215
|
+
const readList = ($: Dollar, pin: RepoPin, target: number, kept: OpenTotal | null) =>
|
|
144
216
|
Promise.all([
|
|
145
|
-
gh($, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
|
|
146
|
-
|
|
147
|
-
gh($, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
148
|
-
readMaps(
|
|
217
|
+
gh($, pin, ['issue', 'view', String(target), '--json', MAP_FIELDS]),
|
|
218
|
+
readOpen($, pin, `trailhead:map-${target}`),
|
|
219
|
+
gh($, pin, ['issue', 'list', '--label', `trailhead:map-${target}`, '--state', 'all', '--limit', String(HISTORY_LIMIT), '--json', HISTORY_FIELDS]),
|
|
220
|
+
readMaps($, pin, kept),
|
|
221
|
+
readUat($, pin, `trailhead:map-${target}`),
|
|
149
222
|
])
|
|
150
223
|
|
|
151
|
-
// The open
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
224
|
+
// The open tickets of one label as the JSON array `gh issue list --json` prints, so one parser reads both.
|
|
225
|
+
// `gh issue list --label` goes through the search API, which lags a write by seconds; the GraphQL
|
|
226
|
+
// `repository.issues` read does not, so a ticket just created or closed shows at once. Paged up to LIST_LIMIT
|
|
227
|
+
// rows (`also`: a second label the rows must carry, GraphQL's `labels` being any-of). A page that fails or is
|
|
228
|
+
// unreadable discards the whole GraphQL read (never a partial list) and the `gh issue list` runs once instead.
|
|
229
|
+
const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string): Promise<GhResult> => {
|
|
230
|
+
const rows: unknown[] = []
|
|
231
|
+
let cursor: string | null = null
|
|
232
|
+
while (rows.length < LIST_LIMIT) {
|
|
233
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
|
|
234
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${TICKETS_QUERY}`, '-f', `label=${label}`]
|
|
235
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
236
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
237
|
+
if (page === null) return readOpenFallback($, pin, label, also)
|
|
238
|
+
rows.push(...page.rows)
|
|
239
|
+
if (!page.hasNextPage) break
|
|
240
|
+
cursor = page.endCursor
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, stdout: JSON.stringify(also === undefined ? rows : withLabel(rows, also)) }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
|
|
246
|
+
gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
|
|
247
|
+
|
|
248
|
+
// The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
|
|
249
|
+
// one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
|
|
250
|
+
// show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
|
|
251
|
+
// that fails or is unreadable, or pages left at UAT_MAX_PAGES, make the read null (never a partial map, no search
|
|
252
|
+
// fallback). Null is the failure value: the rows still group by label, only the parent line goes.
|
|
253
|
+
const readUat = async ($: Dollar, pin: RepoPin, scope: string): Promise<ReadonlyMap<number, UatParent> | null> => {
|
|
254
|
+
const rows: unknown[] = []
|
|
255
|
+
let cursor: string | null = null
|
|
256
|
+
for (let n = 0; n < UAT_MAX_PAGES; n++) {
|
|
257
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, as in readMaps.
|
|
258
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${UAT_QUERY}`]
|
|
259
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
260
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
261
|
+
if (page === null) return null
|
|
262
|
+
rows.push(...page.rows)
|
|
263
|
+
if (!page.hasNextPage) return parseUatParents(JSON.stringify(withLabel(rows, scope)))
|
|
264
|
+
cursor = page.endCursor
|
|
265
|
+
}
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The open maps of the repo, the whiteboard's counts and the open-ticket total across them. A failed or
|
|
270
|
+
// unreadable maps read is null: the caller keeps the last good list. One GraphQL call gives every map with its
|
|
271
|
+
// sub-issue counts and the whiteboard's; if it fails or is unreadable the name-only `gh issue list` runs instead
|
|
272
|
+
// (counts null, the whiteboard null: the row is drawn name-only, never stale; the total null too, as a sum over a
|
|
273
|
+
// list of unknown completeness is never shown). Both bounded at MAPS_LIMIT open maps.
|
|
274
|
+
type MapsResult = { maps: readonly MapSummary[] | null; whiteboard: MapCounts | null; openTotal: OpenTotal | null }
|
|
275
|
+
|
|
276
|
+
// The total over the open maps just read, one aliased `totalCount` query. A failed or unreadable count keeps
|
|
277
|
+
// `kept` only while it was computed over exactly these maps (and the same cut), else it is hidden, never stale.
|
|
278
|
+
const readOpenTotal = async ($: Dollar, pin: RepoPin, read: MapsRead, kept: OpenTotal | null): Promise<OpenTotal | null> => {
|
|
279
|
+
const maps = read.maps.map(m => m.number)
|
|
280
|
+
const out = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${openTotalQuery(maps)}`])
|
|
281
|
+
const total = out.ok ? parseOpenTotal(out.stdout, maps) : null
|
|
282
|
+
return total === null ? keepOpenTotal(kept, maps, read.isMapsCapped) : { total, maps, isMapsCapped: read.isMapsCapped }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const readMaps = async ($: Dollar, pin: RepoPin, kept: OpenTotal | null): Promise<MapsResult> => {
|
|
286
|
+
// The {owner}/{repo} placeholders come from the pinned root's remote, which gh resolves with its host too.
|
|
287
|
+
const counted = await gh($, pin, ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`])
|
|
288
|
+
const withCounts = counted.ok ? parseMapsRead(counted.stdout) : null
|
|
289
|
+
if (withCounts !== null) return { maps: withCounts.maps, whiteboard: withCounts.whiteboard, openTotal: await readOpenTotal($, pin, withCounts, kept) }
|
|
290
|
+
const out = await gh($, pin, ['issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', String(MAPS_LIMIT), '--json', MAPS_FIELDS])
|
|
291
|
+
return { maps: out.ok ? parseMaps(out.stdout) : null, whiteboard: null, openTotal: null }
|
|
155
292
|
}
|
|
156
293
|
|
|
157
294
|
// What the pane compares to skip a write: times aside, but whether the history is as fresh as the list.
|
|
@@ -171,12 +308,17 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
|
|
|
171
308
|
|
|
172
309
|
// One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
|
|
173
310
|
// (generation) or the target moved on, checked before every write; a moved target is read again.
|
|
174
|
-
async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
311
|
+
async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
|
|
175
312
|
const target = await readTarget($)
|
|
176
313
|
if (token !== generation) return
|
|
314
|
+
// Checked before every write: the last test is the token, with no await after it. A pin that moved to
|
|
315
|
+
// another repo (even with the same map number there) makes the read stale; the target is not read
|
|
316
|
+
// under the new pin for it.
|
|
177
317
|
const isStale = async () => {
|
|
178
318
|
if (token !== generation) return true
|
|
179
|
-
|
|
319
|
+
const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
|
|
320
|
+
if (token !== generation) return true
|
|
321
|
+
if (!isMoved) return false
|
|
180
322
|
wantsAnother()
|
|
181
323
|
return true
|
|
182
324
|
}
|
|
@@ -184,38 +326,107 @@ async function fetchMap($: Dollar, token: number, wantsAnother: () => void) {
|
|
|
184
326
|
|
|
185
327
|
if (target === null) {
|
|
186
328
|
// No map to show: the open maps alone, so one can be picked.
|
|
187
|
-
const
|
|
329
|
+
const mapsRead = await readMaps($, pin, before.openTotal)
|
|
330
|
+
const maps = mapsRead.maps ?? before.maps
|
|
188
331
|
if (await isStale()) return
|
|
189
|
-
return setPane($, { ...EMPTY, maps })
|
|
332
|
+
return setPane($, { ...EMPTY, maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
190
333
|
}
|
|
191
334
|
const isSameMap = before.map?.number === target
|
|
192
335
|
if (!isSameMap) {
|
|
193
336
|
if (await isStale()) return
|
|
194
|
-
await setPane($, { ...EMPTY, maps: before.maps, isLoading: true })
|
|
337
|
+
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
|
|
195
338
|
}
|
|
196
339
|
|
|
197
|
-
const [view, list, historyRead, mapsRead] = await readList($, target)
|
|
198
|
-
const maps = mapsRead ?? before.maps
|
|
199
|
-
|
|
340
|
+
const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
|
|
341
|
+
const maps = mapsRead.maps ?? before.maps
|
|
342
|
+
const { whiteboard, openTotal } = mapsRead
|
|
343
|
+
await dropClosedView($, mapsRead.maps)
|
|
200
344
|
const now = await $.clock.now()
|
|
201
345
|
if (await isStale()) return
|
|
202
346
|
|
|
203
347
|
const kept = isSameMap ? before : EMPTY
|
|
204
348
|
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
205
|
-
if (failure !== null) return setPane($, { ...kept, maps, error: failure, isLoading: false })
|
|
349
|
+
if (failure !== null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: failure, isLoading: false })
|
|
206
350
|
const map = view.ok ? parseMap(view.stdout, target) : null
|
|
207
351
|
const tickets = list.ok ? parseTickets(list.stdout) : null
|
|
208
|
-
if (map === null || tickets === null) return setPane($, { ...kept, maps, error: 'gh returned unreadable output', isLoading: false })
|
|
352
|
+
if (map === null || tickets === null) return setPane($, { ...kept, maps, whiteboard, openTotal, error: 'gh returned unreadable output', isLoading: false })
|
|
209
353
|
|
|
210
354
|
// A history that failed alone keeps the last good one of this map (with its own time), never another map's.
|
|
211
355
|
const history = historyRead.ok ? parseHistory(historyRead.stdout) : null
|
|
212
356
|
const historyPart = history !== null ? { history, historyAt: now } : { history: kept.history, historyAt: kept.historyAt }
|
|
213
|
-
const next: MapPaneData = { map, ...tickets, maps, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
357
|
+
const next: MapPaneData = { map, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), maps, whiteboard, openTotal, ...historyPart, fetchedAt: now, error: null, isLoading: false }
|
|
214
358
|
const isFresh = before.fetchedAt !== null && now - before.fetchedAt < AGE_REFRESH_MS
|
|
215
359
|
if (isFresh && isSameMap && sameValue(shape(before), shape(next))) return
|
|
216
360
|
await setPane($, next)
|
|
217
361
|
}
|
|
218
362
|
|
|
363
|
+
// The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
|
|
364
|
+
// as a map read: a result is not committed once the pane closed or the dashboard was left.
|
|
365
|
+
async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
|
|
366
|
+
const isStale = async () => {
|
|
367
|
+
if (token !== generation) return true
|
|
368
|
+
const isOther = !isSamePin(await read($, repoPin), pin)
|
|
369
|
+
const isLeft = !(await read($, isDashboard))
|
|
370
|
+
if (token !== generation) return true
|
|
371
|
+
if (!isOther && !isLeft) return false
|
|
372
|
+
wantsAnother()
|
|
373
|
+
return true
|
|
374
|
+
}
|
|
375
|
+
const before = await read($, dashboardPane)
|
|
376
|
+
const keptTotal = (await read($, mapPane)).openTotal
|
|
377
|
+
if (before.fetchedAt === null && !before.isLoading && before.error === null) {
|
|
378
|
+
await update($, dashboardPane, () => ({ ...before, isLoading: true }))
|
|
379
|
+
}
|
|
380
|
+
const [list, index, mapsRead, uatParents] = await Promise.all([
|
|
381
|
+
readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
|
|
382
|
+
gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
383
|
+
readMaps($, pin, keptTotal),
|
|
384
|
+
readUat($, pin, 'trailhead:whiteboard'),
|
|
385
|
+
])
|
|
386
|
+
const now = await $.clock.now()
|
|
387
|
+
if (await isStale()) return
|
|
388
|
+
const shown = await read($, mapPane)
|
|
389
|
+
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
390
|
+
await dropClosedView($, mapsRead.maps)
|
|
391
|
+
|
|
392
|
+
const kept = await read($, dashboardPane)
|
|
393
|
+
const tickets = list.ok ? parseTickets(list.stdout, { isWhiteboard: true }) : null
|
|
394
|
+
const error = !list.ok ? list.reason : tickets === null ? 'gh returned unreadable output' : null
|
|
395
|
+
if (tickets === null) return update($, dashboardPane, () => ({ ...kept, error, isLoading: false }))
|
|
396
|
+
// A failed index read alone keeps the last link: the list is what the view is about.
|
|
397
|
+
const parsed = index.ok ? parseDashboard(index.stdout) : null
|
|
398
|
+
const dashboard = parsed === null ? kept.dashboard : parsed.ref
|
|
399
|
+
const next: DashboardPaneData = { dashboard, ...tickets, tickets: withUatParents(tickets.tickets, uatParents), fetchedAt: now, error: null, isLoading: false }
|
|
400
|
+
if (!sameValue(kept, next)) await update($, dashboardPane, () => next)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Checks the repo the session is in and pins it. A move to another GitHub repo empties what the pane
|
|
404
|
+
// holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
|
|
405
|
+
let paneRepo: string | null = null
|
|
406
|
+
// Called by refreshPin right before the pin atom changes, from the pane's refresh and the band's alike. A move
|
|
407
|
+
// to another repo drops the reads in flight and empties what the pane holds for the old one, then asks for a
|
|
408
|
+
// fresh read (a closed pane makes that a no-op), so its tickets never show under the new repo.
|
|
409
|
+
export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
|
|
410
|
+
generation += 1
|
|
411
|
+
const key = pin === null ? null : pinKey(pin)
|
|
412
|
+
const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
|
|
413
|
+
paneRepo = key ?? paneRepo
|
|
414
|
+
if (!isOtherRepo) return
|
|
415
|
+
await update($, mapPane, () => EMPTY)
|
|
416
|
+
await update($, dashboardPane, () => EMPTY_DASHBOARD)
|
|
417
|
+
await update($, activeMap, () => null)
|
|
418
|
+
await update($, viewedMap, () => null)
|
|
419
|
+
await update($, isDashboard, () => false)
|
|
420
|
+
void requestRefresh($, { bypassFloor: true })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function syncPin($: Dollar, init: { isStart?: boolean } = {}): Promise<RepoPin | null> {
|
|
424
|
+
return refreshPin($, { ...init, onMove: pin => onPinMove($, pin) })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const safeSyncPin = ($: Dollar, init: { isStart?: boolean } = {}) =>
|
|
428
|
+
syncPin($, init).catch(err => debug($, `trailhead mod: repo pin failed: ${String(err)}`))
|
|
429
|
+
|
|
219
430
|
async function isPaneUp($: Dollar): Promise<boolean> {
|
|
220
431
|
return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
|
|
221
432
|
}
|
|
@@ -226,33 +437,59 @@ function stopTimers() {
|
|
|
226
437
|
isFilling = false
|
|
227
438
|
trailing?.cancel()
|
|
228
439
|
trailing = null
|
|
229
|
-
ticker?.cancel()
|
|
440
|
+
ticker?.timer.cancel()
|
|
230
441
|
ticker = null
|
|
231
442
|
}
|
|
232
443
|
|
|
444
|
+
// The slow timer past its due time by more than the grace never fired: drop it, so the caller starts another.
|
|
445
|
+
// A dropped trailing needs no such check: the next read outside the floor clears it.
|
|
446
|
+
const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
|
|
447
|
+
if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
|
|
448
|
+
t.timer.cancel()
|
|
449
|
+
return null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const startTrailing = ($: Dollar, waitMs: number): Timer =>
|
|
453
|
+
$.clock.after(waitMs, () => {
|
|
454
|
+
trailing = null
|
|
455
|
+
void requestRefresh($)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const startTicker = ($: Dollar, now: number): Tracked => ({
|
|
459
|
+
timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
|
|
460
|
+
dueAt: now + TIMER_MS,
|
|
461
|
+
})
|
|
462
|
+
|
|
233
463
|
// `bypassFloor`: the caller's last result was dropped unseen, so reading again is not a repeat.
|
|
234
|
-
|
|
464
|
+
// `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
|
|
465
|
+
// by when the search index (history, the list fallback) has caught up.
|
|
466
|
+
// `isTick`: the slow timer fired, so its next due time moves on.
|
|
467
|
+
type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean }
|
|
235
468
|
|
|
236
469
|
// Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
|
|
237
470
|
// just after a refresh still shows.
|
|
238
471
|
async function decide($: Dollar, request: Request) {
|
|
239
472
|
if (!(await isPaneUp($))) return
|
|
240
|
-
|
|
473
|
+
// Nothing pinned: no repo to read, so no call.
|
|
474
|
+
const pin = await syncPin($)
|
|
475
|
+
if (pin === null) return
|
|
241
476
|
const now = await $.clock.now()
|
|
477
|
+
if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
|
|
478
|
+
// A timer the host dropped is started again here, else the pane would wait on it for good.
|
|
479
|
+
ticker = liveOrNull(ticker, now) ?? startTicker($, now)
|
|
242
480
|
const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
|
|
243
481
|
if (!decision.run) {
|
|
244
|
-
|
|
245
|
-
trailing = $.clock.after(decision.waitMs, () => {
|
|
246
|
-
trailing = null
|
|
247
|
-
void requestRefresh($)
|
|
248
|
-
})
|
|
249
|
-
}
|
|
482
|
+
trailing ??= startTrailing($, decision.waitMs)
|
|
250
483
|
return
|
|
251
484
|
}
|
|
252
485
|
lastStartedAt = now
|
|
253
486
|
trailing?.cancel()
|
|
254
487
|
trailing = null
|
|
255
|
-
|
|
488
|
+
// Never bypassFloor: a burst of writes still costs one read per floor plus this one trailing.
|
|
489
|
+
if (request.isWrite === true) trailing = startTrailing($, MIN_REFRESH_MS)
|
|
490
|
+
const again = () => void requestRefresh($, { bypassFloor: true })
|
|
491
|
+
if (await read($, isDashboard)) await fetchDashboard($, pin, generation, again)
|
|
492
|
+
else await fetchMap($, pin, generation, again)
|
|
256
493
|
}
|
|
257
494
|
|
|
258
495
|
// Does nothing while the pane is closed: the data exists only for the pane.
|
|
@@ -270,6 +507,7 @@ async function carryOverClear($: Dollar) {
|
|
|
270
507
|
isAutoOpened: await read($, paneAutoOpened),
|
|
271
508
|
viewed: await read($, viewedMap),
|
|
272
509
|
active: await read($, activeMap),
|
|
510
|
+
isDashboard: await read($, isDashboard),
|
|
273
511
|
}
|
|
274
512
|
}
|
|
275
513
|
|
|
@@ -280,7 +518,9 @@ async function restoreAfterClear($: Dollar) {
|
|
|
280
518
|
const kept = carried
|
|
281
519
|
carried = null
|
|
282
520
|
try {
|
|
521
|
+
await syncPin($)
|
|
283
522
|
if (kept?.isAutoOpened === true) await update($, paneAutoOpened, () => true)
|
|
523
|
+
if (kept?.isDashboard === true) await update($, isDashboard, () => true)
|
|
284
524
|
const active = await readActiveMap($)
|
|
285
525
|
const viewed = kept?.viewed ?? null
|
|
286
526
|
if (viewed !== null && viewed !== active && active === kept?.active) await update($, viewedMap, () => viewed)
|
|
@@ -290,32 +530,251 @@ async function restoreAfterClear($: Dollar) {
|
|
|
290
530
|
void requestRefresh($, { bypassFloor: true })
|
|
291
531
|
}
|
|
292
532
|
|
|
293
|
-
// Edits of the tracker
|
|
294
|
-
|
|
533
|
+
// Edits of the tracker from a Bash call, one regex over one shell segment (`[^\n;&|]*`) so a read elsewhere
|
|
534
|
+
// in the line never counts. Three kinds: `gh issue <verb>`; a `gh api` call on a `repos/.../issues` path that
|
|
535
|
+
// writes (an explicit POST/PATCH/PUT/DELETE, or a -f/-F/--field/--raw-field/--input, which makes gh default to
|
|
536
|
+
// POST) and does not say `-X GET`; and the body helper, which rewrites a map's body through the API.
|
|
537
|
+
export const GH_ISSUE_WRITE =
|
|
538
|
+
/\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/
|
|
295
539
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
540
|
+
// The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
|
|
541
|
+
// move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
|
|
542
|
+
// be read is `unknown`, refused, but not told it is elsewhere.
|
|
543
|
+
async function whereIsSession($: Dollar, pin: RepoPin): Promise<'in' | 'out' | 'unknown'> {
|
|
544
|
+
try {
|
|
545
|
+
const { repo, toplevel } = await currentRepo($)
|
|
546
|
+
return isPinCheckout(pin, repo, toplevel ?? (await toplevelOf($))) ? 'in' : 'out'
|
|
547
|
+
} catch {
|
|
548
|
+
return 'unknown'
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Where the press stands, read at the press and again at the send: the pin to hand over, or the refusal.
|
|
553
|
+
async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' } | { refusal: string }> {
|
|
554
|
+
const pin = await read($, repoPin)
|
|
555
|
+
if (pin === null) return { refusal: 'no repo pinned' }
|
|
556
|
+
const where = await whereIsSession($, pin)
|
|
557
|
+
if (where === 'unknown') return { refusal: 'checkout unreadable' }
|
|
558
|
+
// Out of it, the verb must move into the pin: a removed worktree or clone would fail only after the destructive /clear.
|
|
559
|
+
if (where === 'out' && !(await isCheckoutRoot($, pin.root))) return { refusal: 'checkout unreadable' }
|
|
560
|
+
return { pin, where }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
|
|
564
|
+
async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
|
|
565
|
+
try {
|
|
566
|
+
const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
|
|
567
|
+
return out.exitCode === 0 && out.stdout.trim() === root
|
|
568
|
+
} catch {
|
|
569
|
+
return false
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// A press during a running turn is held (row `queued`, with a cancel button) and sent when the main turn ends;
|
|
574
|
+
// with no turn running it is sent at once. With no pin or an unreadable checkout it refuses before anything, as the
|
|
575
|
+
// clear is destructive; outside the pinned repo it goes through and the verb moves into it. The pendingWork claim is
|
|
576
|
+
// taken with the version it read, so two presses never both pass.
|
|
577
|
+
async function startVerb($: Dollar, verb: 'work' | 'resume', number: number) {
|
|
578
|
+
if (held !== null || inFlight !== null) return
|
|
579
|
+
const place = await placeOf($)
|
|
580
|
+
if ('refusal' in place) return refuse($, number, place.refusal)
|
|
302
581
|
const { value, version } = await $.state.get(PENDING)
|
|
303
582
|
if (value != null) return
|
|
583
|
+
if (held !== null || inFlight !== null) return
|
|
304
584
|
if (!(await $.state.set(PENDING, number, { ifVersion: version })).isSet) return
|
|
305
|
-
|
|
585
|
+
const press: HeldPress = { id: (pressCount += 1), verb, number }
|
|
586
|
+
claiming = { number, isCancelled: false }
|
|
306
587
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
588
|
+
// A press that needs no hold is never shown queued: no turn running, no cancel button.
|
|
589
|
+
if (isMainTurnRunning()) {
|
|
590
|
+
await update($, pendingPhase, () => 'queued')
|
|
591
|
+
// A cancel landed during that write: nothing was sent, so dropping the press is the whole cancel.
|
|
592
|
+
if (claiming.isCancelled) return dropPending($)
|
|
593
|
+
// The turn mark is synchronous with the turn start, so this read is the latest word on it.
|
|
594
|
+
if (isMainTurnRunning()) {
|
|
595
|
+
held = press
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
debug($, `trailhead mod: ${verb} ${number} did not start: ${String(err)}`)
|
|
601
|
+
return dropPending($)
|
|
602
|
+
} finally {
|
|
603
|
+
claiming = null
|
|
604
|
+
}
|
|
605
|
+
await dispatch($, press)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The toast box is narrow and already names the plugin: the text stays a few words.
|
|
609
|
+
const refuse = ($: Dollar, number: number, refusal: string) => $.ui.toast(`#${number} not started: ${refusal}`)
|
|
610
|
+
|
|
611
|
+
// The pending marks go phase first, the claim last: until pendingWork is clear no new press can take the slot,
|
|
612
|
+
// so a new press never has its phase erased by the old one's cleanup.
|
|
613
|
+
// `dropping` counts the drops in flight, so the stale-claim sweep (clearStale) never starts beside one.
|
|
614
|
+
let dropping = 0
|
|
615
|
+
async function dropPending($: Dollar) {
|
|
616
|
+
dropping += 1
|
|
617
|
+
try {
|
|
618
|
+
try {
|
|
619
|
+
await update($, pendingPhase, () => null)
|
|
620
|
+
} finally {
|
|
621
|
+
await update($, pendingWork, () => null).catch(err => debug($, `trailhead mod: pending mark not cleared: ${String(err)}`))
|
|
622
|
+
}
|
|
623
|
+
} finally {
|
|
624
|
+
dropping -= 1
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Sends the press: /clear, then `/trailhead:<verb> <n>` in the new conversation. Every await comes first, then the
|
|
629
|
+
// turn check and the /clear call follow with no await between, so a turn that started at any point before is seen
|
|
630
|
+
// and the press is held again instead of queued behind it in the host, where it could not be cancelled.
|
|
631
|
+
async function dispatch($: Dollar, press: HeldPress) {
|
|
632
|
+
inFlight = press.id
|
|
633
|
+
dispatching = { id: press.id, number: press.number, isCancelled: false, isSent: false }
|
|
634
|
+
let isReheld = false
|
|
635
|
+
try {
|
|
636
|
+
let clearing: Promise<unknown> | null = null
|
|
637
|
+
let before = ''
|
|
638
|
+
let args = String(press.number)
|
|
639
|
+
while (clearing === null) {
|
|
640
|
+
await update($, pendingPhase, () => 'starting')
|
|
641
|
+
before = await $.session.id()
|
|
642
|
+
// Read at the send, not only at the press: a held press may find the session moved or the checkout gone.
|
|
643
|
+
const place = await placeOf($)
|
|
644
|
+
if ('refusal' in place) return refuse($, press.number, place.refusal)
|
|
645
|
+
args = place.where === 'out' ? `${press.number} ${checkoutArg(place.pin)}` : String(press.number)
|
|
646
|
+
// A cancel pressed while the row still read queued: dropped here, the last line before the clear.
|
|
647
|
+
if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
|
|
648
|
+
if (!isMainTurnRunning()) {
|
|
649
|
+
clearing = $.command.run({ command: CLEAR_COMMAND, args: '' })
|
|
650
|
+
dispatching = { ...dispatching!, isSent: true }
|
|
651
|
+
break
|
|
652
|
+
}
|
|
653
|
+
// A turn began meanwhile: back to queued. Ownership returns to `held` only while the turn still runs, else go round.
|
|
654
|
+
await update($, pendingPhase, () => 'queued')
|
|
655
|
+
// A cancel landed during that write: drop the press (isReheld stays false, so the finally clears the marks).
|
|
656
|
+
if (dispatching?.isCancelled === true) return debug($, `trailhead mod: ${press.verb} ${press.number} cancelled before the clear`)
|
|
657
|
+
if (isMainTurnRunning()) {
|
|
658
|
+
held = press
|
|
659
|
+
isReheld = true
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
await clearing
|
|
309
664
|
// A clear another hook answered without running resolves too: the verb must not start in the old conversation.
|
|
310
|
-
if ((await $.session.id()) === before) return debug($, `trailhead mod:
|
|
311
|
-
// The clear emptied the
|
|
312
|
-
await update($, pendingWork, () => number)
|
|
313
|
-
await
|
|
665
|
+
if ((await $.session.id()) === before) return debug($, `trailhead mod: ${press.verb} ${press.number} skipped, the conversation was not cleared`)
|
|
666
|
+
// The clear emptied the atoms: the row is still starting in the new session.
|
|
667
|
+
await update($, pendingWork, () => press.number)
|
|
668
|
+
await update($, pendingPhase, () => 'starting')
|
|
669
|
+
await $.command.run({ command: press.verb === 'work' ? WORK_COMMAND : RESUME_COMMAND, args })
|
|
670
|
+
} catch (err) {
|
|
671
|
+
debug($, `trailhead mod: ${press.verb} ${press.number} did not start: ${String(err)}`)
|
|
672
|
+
} finally {
|
|
673
|
+
if (dispatching?.id === press.id) dispatching = null
|
|
674
|
+
// A press held again keeps its marks and its place in `held`; only the in-flight guard goes.
|
|
675
|
+
if (isReheld) inFlight = null
|
|
676
|
+
else {
|
|
677
|
+
try {
|
|
678
|
+
await dropPending($)
|
|
679
|
+
} finally {
|
|
680
|
+
if (inFlight === press.id) inFlight = null
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The zero-delay timer after a main turn ends. It claims the held press in its first statements, with no await,
|
|
687
|
+
// so the press stays cancellable until this instant; a turn running again (a queued prompt) leaves it held.
|
|
688
|
+
function releaseHeld($: Dollar) {
|
|
689
|
+
if (isMainTurnRunning()) return
|
|
690
|
+
const press = held
|
|
691
|
+
if (press === null) return
|
|
692
|
+
held = null
|
|
693
|
+
void dispatch($, press).catch(err => debug($, `trailhead mod: held press failed: ${String(err)}`))
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The cancel button of a queued row: a held press never leaves the panel, so nothing is sent and the conversation
|
|
697
|
+
// stays. A press released but not yet at its /clear is marked and dropped by the dispatch at that line. A queued
|
|
698
|
+
// row with no press behind it (a hot reload reset the module, the atoms stayed) is cleared. Else nothing to cancel.
|
|
699
|
+
async function cancelQueued($: Dollar, number: number) {
|
|
700
|
+
if (held?.number === number) {
|
|
701
|
+
held = null
|
|
702
|
+
return dropPending($)
|
|
703
|
+
}
|
|
704
|
+
if (dispatching?.number === number) {
|
|
705
|
+
if (!dispatching.isSent) dispatching = { ...dispatching, isCancelled: true }
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
if (claiming?.number === number) {
|
|
709
|
+
claiming = { ...claiming, isCancelled: true }
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
|
|
713
|
+
if ((await read($, pendingPhase)) !== 'queued' || (await read($, pendingWork)) !== number) return
|
|
714
|
+
// The reads above awaited: a press made meanwhile owns the atoms.
|
|
715
|
+
if (claiming !== null || dispatching !== null || inFlight !== null || held !== null) return
|
|
716
|
+
await dropPending($)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// A pending claim the pane draws with no press behind it and no `queued` mark (a hot reload reset the module,
|
|
720
|
+
// the atoms stayed) would block every press for good. Run from a zero-delay timer, as a render cannot write state.
|
|
721
|
+
// The claim is read first, then the module state is checked: a press that took the claim has set its guards by
|
|
722
|
+
// the time that read answers.
|
|
723
|
+
let isClearingStale = false
|
|
724
|
+
async function clearStale($: Dollar) {
|
|
725
|
+
if (isClearingStale) return
|
|
726
|
+
isClearingStale = true
|
|
727
|
+
try {
|
|
728
|
+
const isIdle = () => held === null && claiming === null && dispatching === null && inFlight === null && dropping === 0
|
|
729
|
+
if (!isIdle()) return
|
|
730
|
+
// The claim is read with its version and released only if that version still stands, so a press that took the
|
|
731
|
+
// slot meanwhile is never cleared. The phase goes first (while the stale claim still blocks every new press),
|
|
732
|
+
// then the compare-and-set on the claim.
|
|
733
|
+
const { value, version } = await $.state.get(PENDING)
|
|
734
|
+
if (value == null || !isIdle()) return
|
|
735
|
+
if ((await read($, pendingPhase)) === 'queued' || !isIdle()) return
|
|
736
|
+
debug($, 'trailhead mod: cleared a pending press left with no owner')
|
|
737
|
+
dropping += 1
|
|
738
|
+
try {
|
|
739
|
+
await update($, pendingPhase, () => null)
|
|
740
|
+
await $.state.set(PENDING, null, { ifVersion: version })
|
|
741
|
+
} finally {
|
|
742
|
+
dropping -= 1
|
|
743
|
+
}
|
|
314
744
|
} catch (err) {
|
|
315
|
-
debug($, `trailhead mod:
|
|
745
|
+
debug($, `trailhead mod: stale press not cleared: ${String(err)}`)
|
|
316
746
|
} finally {
|
|
317
|
-
|
|
318
|
-
|
|
747
|
+
isClearingStale = false
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The whiteboard ticket the panel last switched to. A module variable, not an atom: a /clear empties the atoms,
|
|
752
|
+
// and a ticket the person already left for the map must not pull the panel back.
|
|
753
|
+
let followedTicket: number | null = null
|
|
754
|
+
|
|
755
|
+
// Called by the work state's refresh once it has applied (the state atom is written there): when this session's
|
|
756
|
+
// own whiteboard ticket is in play the panel shows the whiteboard, once per ticket (#306).
|
|
757
|
+
export async function followWhiteboard($: Dollar): Promise<void> {
|
|
758
|
+
try {
|
|
759
|
+
const { follow, memo } = nextFollow(followedTicket, await read($, workState), await read($, ownTicket), await read($, isOrphaned))
|
|
760
|
+
followedTicket = memo
|
|
761
|
+
if (!follow) return
|
|
762
|
+
await update($, isDashboard, () => true)
|
|
763
|
+
void requestRefresh($, { bypassFloor: true })
|
|
764
|
+
} catch (err) {
|
|
765
|
+
debug($, `trailhead mod: whiteboard follow failed: ${String(err)}`)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// A whiteboard ticket created while a map is shown: a hint, never a switch.
|
|
770
|
+
async function hintWhiteboard($: Dollar, command: unknown, call: unknown): Promise<void> {
|
|
771
|
+
try {
|
|
772
|
+
if (typeof command !== 'string' || !isWhiteboardCreate(command)) return
|
|
773
|
+
const number = createdIssueNumber(call)
|
|
774
|
+
if (number === null || (await read($, isDashboard))) return
|
|
775
|
+
$.ui.toast(whiteboardHint(number))
|
|
776
|
+
} catch (err) {
|
|
777
|
+
debug($, `trailhead mod: whiteboard hint failed: ${String(err)}`)
|
|
319
778
|
}
|
|
320
779
|
}
|
|
321
780
|
|
|
@@ -323,6 +782,7 @@ async function startWork($: Dollar, number: number) {
|
|
|
323
782
|
// A view change is not a repeat read, so the floor does not apply.
|
|
324
783
|
async function viewMap($: Dollar, number: number | null) {
|
|
325
784
|
const active = await read($, activeMap)
|
|
785
|
+
await update($, isDashboard, () => false)
|
|
326
786
|
await update($, viewedMap, () => (number === active ? null : number))
|
|
327
787
|
void requestRefresh($, { bypassFloor: true })
|
|
328
788
|
}
|
|
@@ -332,6 +792,16 @@ const pressView = ($: Dollar, number: number | null): void => {
|
|
|
332
792
|
void viewMap($, number).catch(err => debug($, `trailhead mod: map view failed: ${String(err)}`))
|
|
333
793
|
}
|
|
334
794
|
|
|
795
|
+
// The switch to the dashboard (the Whiteboard row), or back to the map shown before it (the view is kept).
|
|
796
|
+
async function setDashboard($: Dollar, isOn: boolean) {
|
|
797
|
+
await update($, isDashboard, () => isOn)
|
|
798
|
+
void requestRefresh($, { bypassFloor: true })
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const pressDashboard = ($: Dollar, isOn: boolean): void => {
|
|
802
|
+
void setDashboard($, isOn).catch(err => debug($, `trailhead mod: dashboard switch failed: ${String(err)}`))
|
|
803
|
+
}
|
|
804
|
+
|
|
335
805
|
// Opening always refreshes. A render cannot write state, not even from work it left running,
|
|
336
806
|
// so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
|
|
337
807
|
// renders from opening twice before the flag is written.
|
|
@@ -360,8 +830,16 @@ async function autoOpen($: Dollar) {
|
|
|
360
830
|
}
|
|
361
831
|
}
|
|
362
832
|
|
|
833
|
+
// Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
|
|
834
|
+
// una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
|
|
835
|
+
// disegna a qualsiasi larghezza. Se nemmeno così viene disegnato (nessuna superficie), lo chiude.
|
|
363
836
|
async function togglePanel($: Dollar): Promise<string> {
|
|
364
|
-
|
|
837
|
+
const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
|
|
838
|
+
if (pane?.isPlaced === false) {
|
|
839
|
+
const reopened = await openPane($)
|
|
840
|
+
if (reopened.isPlaced) return 'Map panel opened.'
|
|
841
|
+
}
|
|
842
|
+
if (pane !== undefined) {
|
|
365
843
|
await $.ui.close({ id: PANE_ID })
|
|
366
844
|
return 'Map panel closed.'
|
|
367
845
|
}
|
|
@@ -388,6 +866,7 @@ export function registerMapPane(on: On) {
|
|
|
388
866
|
// when neither has one, and register.tsx already hooks these events bare.
|
|
389
867
|
on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
|
|
390
868
|
const result = await next(e)
|
|
869
|
+
await safeSyncPin($, { isStart: true })
|
|
391
870
|
await safeReadActiveMap($)
|
|
392
871
|
void requestRefresh($)
|
|
393
872
|
return result
|
|
@@ -395,14 +874,23 @@ export function registerMapPane(on: On) {
|
|
|
395
874
|
|
|
396
875
|
on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
|
|
397
876
|
const result = await next(e)
|
|
877
|
+
await safeSyncPin($)
|
|
398
878
|
await safeReadActiveMap($)
|
|
399
879
|
void requestRefresh($)
|
|
880
|
+
// The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
|
|
881
|
+
if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
|
|
400
882
|
return result
|
|
401
883
|
})
|
|
402
884
|
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
885
|
+
// Every reason: a /clear empties the atoms, so note what the pane must get back and restore it in the new
|
|
886
|
+
// session; any ending drops a held press (nothing is left to run it in). The mod's own /clear never finds one:
|
|
887
|
+
// only a dispatch issues it, and a held press and a dispatch never coexist.
|
|
888
|
+
on('session.end', { reason: ['clear', 'resume', 'logout', 'prompt_input_exit', 'other'] }, async ($, e, next) => {
|
|
889
|
+
if (e.reason === 'clear') await carryOverClear($).catch(err => debug($, `trailhead mod: map pane carry-over failed: ${String(err)}`))
|
|
890
|
+
if (held !== null) {
|
|
891
|
+
held = null
|
|
892
|
+
await dropPending($).catch(err => debug($, `trailhead mod: held press not dropped: ${String(err)}`))
|
|
893
|
+
}
|
|
406
894
|
return next(e)
|
|
407
895
|
})
|
|
408
896
|
|
|
@@ -415,7 +903,8 @@ export function registerMapPane(on: On) {
|
|
|
415
903
|
// After the call ran: a Bash `gh issue` write changed the tracker.
|
|
416
904
|
on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
|
|
417
905
|
const result = await next(e)
|
|
418
|
-
void requestRefresh(
|
|
906
|
+
void requestRefresh($, { isWrite: true })
|
|
907
|
+
await hintWhiteboard($, e.command, result)
|
|
419
908
|
return result
|
|
420
909
|
// This hook never judges the call: if it fails, the call goes on untouched.
|
|
421
910
|
}).catch((_$, e, next) => next(e))
|
|
@@ -447,9 +936,15 @@ export function registerMapPane(on: On) {
|
|
|
447
936
|
const active = await read($, activeMap)
|
|
448
937
|
const viewed = await read($, viewedMap)
|
|
449
938
|
const target = viewTarget(active, viewed)
|
|
939
|
+
const isBoard = await read($, isDashboard)
|
|
940
|
+
const board = await read($, dashboardPane)
|
|
450
941
|
const play = await read($, workState)
|
|
451
942
|
const orphaned = await read($, isOrphaned)
|
|
943
|
+
const own = await read($, ownTicket)
|
|
944
|
+
const pin = await read($, repoPin)
|
|
452
945
|
const pending = await read($, pendingWork)
|
|
946
|
+
const phase = await read($, pendingPhase)
|
|
947
|
+
if (pending !== null && phase !== 'queued') $.clock.after(0, () => void clearStale($))
|
|
453
948
|
const now = await $.clock.now()
|
|
454
949
|
const cols = e.props.bodyColumns
|
|
455
950
|
const isDock = e.props.placement === 'dock'
|
|
@@ -463,8 +958,9 @@ export function registerMapPane(on: On) {
|
|
|
463
958
|
const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
|
|
464
959
|
|
|
465
960
|
// The open maps, one row each; shown with several maps open (or with no active map, to pick one).
|
|
961
|
+
// On the dashboard every map is listed and each row opens it.
|
|
466
962
|
const mapRow = (m: MapSummary) => {
|
|
467
|
-
const r = mapRowLayout(m, { active, viewed, columns: cols })
|
|
963
|
+
const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
|
|
468
964
|
return (
|
|
469
965
|
<Box key={`map-row-${m.number}`}>
|
|
470
966
|
{text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
|
|
@@ -481,13 +977,13 @@ export function registerMapPane(on: On) {
|
|
|
481
977
|
</Box>
|
|
482
978
|
)
|
|
483
979
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
980
|
+
const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
|
|
981
|
+
const mapsGroup = isMapsShown ? (
|
|
982
|
+
<Box key="group-maps" flexDirection="column" marginTop={gap}>
|
|
983
|
+
{text('head-maps', bold, `Maps · ${data.maps.length}`)}
|
|
984
|
+
{data.maps.map(mapRow)}
|
|
985
|
+
</Box>
|
|
986
|
+
) : null
|
|
491
987
|
// Viewing a map other than the active one: say so, with the way back.
|
|
492
988
|
const viewing =
|
|
493
989
|
viewed === null || viewed === active ? null : (
|
|
@@ -500,58 +996,34 @@ export function registerMapPane(on: On) {
|
|
|
500
996
|
/>
|
|
501
997
|
</Box>
|
|
502
998
|
)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!isCurrent && !data.isLoading && data.error === null && !isFilling) {
|
|
518
|
-
isFilling = true
|
|
519
|
-
$.clock.after(0, () => {
|
|
520
|
-
isFilling = false
|
|
521
|
-
void requestRefresh($)
|
|
522
|
-
})
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const map = isCurrent ? data.map : null
|
|
526
|
-
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
527
|
-
const header = (
|
|
528
|
-
<Box key="header" flexDirection="column">
|
|
529
|
-
<Box key="map">
|
|
530
|
-
<Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
|
|
531
|
-
</Box>
|
|
532
|
-
{map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
|
|
999
|
+
// The dashboard's header: its title, and the `map` button back to the map shown before it.
|
|
1000
|
+
const headerRow = (title: ReturnType<typeof text>) => (
|
|
1001
|
+
<Box key="header-row" width={cols} justifyContent="space-between">
|
|
1002
|
+
{title}
|
|
1003
|
+
<Button key="to-map" label={MAP_LABEL} onPress={() => pressDashboard($, false)} />
|
|
1004
|
+
</Box>
|
|
1005
|
+
)
|
|
1006
|
+
// The way into the whiteboard: a row above the maps list, its counts beside the name like a map row.
|
|
1007
|
+
const wb = whiteboardRowLayout(data.whiteboard, cols)
|
|
1008
|
+
const whiteboardRow = (
|
|
1009
|
+
<Box key="whiteboard-row" marginTop={gap}>
|
|
1010
|
+
{text('whiteboard-mark', {}, wb.mark)}
|
|
1011
|
+
<Button key="open-whiteboard" label={wb.label} onPress={() => pressDashboard($, true)} />
|
|
1012
|
+
{wb.aside === null ? null : text('whiteboard-aside', dim, wb.aside)}
|
|
533
1013
|
</Box>
|
|
534
1014
|
)
|
|
535
|
-
if (isLoading) {
|
|
536
|
-
return (
|
|
537
|
-
<Box flexDirection="column">
|
|
538
|
-
{header}
|
|
539
|
-
{viewing}
|
|
540
|
-
{text('loading', dim, 'Loading tickets…')}
|
|
541
|
-
{mapsGroup}
|
|
542
|
-
</Box>
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
1015
|
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
const
|
|
1016
|
+
// The total of open tickets across every open map and the whiteboard, one dim line; hidden while unknown.
|
|
1017
|
+
const totalText = openTotalLabel(data.openTotal?.total ?? null, data.openTotal?.isMapsCapped ?? false, cols)
|
|
1018
|
+
const totalLine = totalText === null ? null : text('open-total', dim, totalText)
|
|
549
1019
|
|
|
1020
|
+
// This session's own ticket survives a work-state file another session took over; without one, the file decides.
|
|
1021
|
+
const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
|
|
550
1022
|
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
551
|
-
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, columns: cols })
|
|
1023
|
+
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
|
|
552
1024
|
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
553
|
-
const holder = r.
|
|
554
|
-
|
|
1025
|
+
const holder = r.isDim ? dim : {}
|
|
1026
|
+
const line = (
|
|
555
1027
|
<Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
|
|
556
1028
|
<Box>
|
|
557
1029
|
{r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
|
|
@@ -561,19 +1033,41 @@ export function registerMapPane(on: On) {
|
|
|
561
1033
|
{r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
|
|
562
1034
|
</Box>
|
|
563
1035
|
{r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
|
|
1036
|
+
{r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
|
|
564
1037
|
{r.aside?.kind === 'queued' ? text(`queued-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
565
|
-
{r.aside?.kind === '
|
|
1038
|
+
{r.aside?.kind === 'starting' ? text(`starting-${t.number}`, { color: AMBER }, r.aside.text) : null}
|
|
1039
|
+
{r.aside?.kind === 'queued' ? (
|
|
1040
|
+
<Button
|
|
1041
|
+
key={`cancel-${t.number}`}
|
|
1042
|
+
label={CANCEL_LABEL}
|
|
1043
|
+
onPress={() => {
|
|
1044
|
+
void cancelQueued($, t.number).catch(err => debug($, `trailhead mod: cancel press failed: ${String(err)}`))
|
|
1045
|
+
}}
|
|
1046
|
+
/>
|
|
1047
|
+
) : null}
|
|
1048
|
+
{r.aside?.kind === 'work' || r.aside?.kind === 'resume' ? (
|
|
566
1049
|
<Button
|
|
567
|
-
key={
|
|
568
|
-
label=
|
|
1050
|
+
key={`${r.aside.kind}-${t.number}`}
|
|
1051
|
+
label={r.aside.kind}
|
|
569
1052
|
variant="primary"
|
|
570
|
-
onPress={() =>
|
|
1053
|
+
onPress={() => {
|
|
1054
|
+
const verb = r.aside?.kind === 'resume' ? 'resume' : 'work'
|
|
1055
|
+
void startVerb($, verb, t.number).catch(err => debug($, `trailhead mod: ${verb} press failed: ${String(err)}`))
|
|
1056
|
+
}}
|
|
571
1057
|
/>
|
|
572
1058
|
) : null}
|
|
573
1059
|
</Box>
|
|
574
1060
|
)
|
|
1061
|
+
const parent = uatParentLine(t, cols)
|
|
1062
|
+
return parent === null ? (
|
|
1063
|
+
line
|
|
1064
|
+
) : (
|
|
1065
|
+
<Box key={`entry-${t.number}`} flexDirection="column">
|
|
1066
|
+
{line}
|
|
1067
|
+
{text(`uat-of-${t.number}`, dim, parent)}
|
|
1068
|
+
</Box>
|
|
1069
|
+
)
|
|
575
1070
|
}
|
|
576
|
-
|
|
577
1071
|
const group = (key: string, name: string, rows: readonly PaneTicket[]) => {
|
|
578
1072
|
if (rows.length === 0) return null
|
|
579
1073
|
const hasMarkColumn = rows.some(isInPlay)
|
|
@@ -589,15 +1083,147 @@ export function registerMapPane(on: On) {
|
|
|
589
1083
|
</Box>
|
|
590
1084
|
)
|
|
591
1085
|
}
|
|
1086
|
+
const ticketGroups = (tickets: readonly PaneTicket[]) => {
|
|
1087
|
+
const groups = groupTickets(tickets)
|
|
1088
|
+
return [
|
|
1089
|
+
group('needs-you', 'Needs you', groups.needsYou),
|
|
1090
|
+
group('frontier', 'Frontier', groups.frontier),
|
|
1091
|
+
group('in-progress', 'In progress', groups.inProgress),
|
|
1092
|
+
group('uat', 'UAT', groups.uat),
|
|
1093
|
+
group('paused', 'Paused', groups.paused),
|
|
1094
|
+
group('blocked', 'Blocked', groups.blocked),
|
|
1095
|
+
]
|
|
1096
|
+
}
|
|
1097
|
+
const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
|
|
1098
|
+
d.unverifiedCount === 0 ? null : (
|
|
1099
|
+
<Box key="unverified" marginTop={gap}>
|
|
1100
|
+
<Text {...dim}>{unverifiedLine(d.unverifiedCount)}</Text>
|
|
1101
|
+
</Box>
|
|
1102
|
+
),
|
|
1103
|
+
!d.isCapped ? null : (
|
|
1104
|
+
<Box key="capped" marginTop={gap}>
|
|
1105
|
+
<Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
|
|
1106
|
+
</Box>
|
|
1107
|
+
),
|
|
1108
|
+
]
|
|
1109
|
+
const footerOf = (d: { fetchedAt: number | null; error: string | null }) => {
|
|
1110
|
+
const status =
|
|
1111
|
+
d.error === null
|
|
1112
|
+
? `updated ${ageLabel(now - (d.fetchedAt ?? now))}`
|
|
1113
|
+
: d.fetchedAt === null
|
|
1114
|
+
? `⚠ ${d.error}`
|
|
1115
|
+
: `⚠ ${d.error} · list from ${ageLabel(now - d.fetchedAt)}`
|
|
1116
|
+
const footer = footerStatus(status, cols)
|
|
1117
|
+
return (
|
|
1118
|
+
<Box key="footer" flexDirection="column">
|
|
1119
|
+
{text('rule', dim, '─'.repeat(cols))}
|
|
1120
|
+
<Box key="footer-row" width={cols} justifyContent="space-between">
|
|
1121
|
+
<Box gap={1}>
|
|
1122
|
+
<Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
|
|
1123
|
+
{footer.isInline ? text('age', dim, footer.text) : null}
|
|
1124
|
+
</Box>
|
|
1125
|
+
<Button
|
|
1126
|
+
key="close"
|
|
1127
|
+
label="close"
|
|
1128
|
+
onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
|
|
1129
|
+
/>
|
|
1130
|
+
</Box>
|
|
1131
|
+
{footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
|
|
1132
|
+
</Box>
|
|
1133
|
+
)
|
|
1134
|
+
}
|
|
1135
|
+
// An empty view (a pane the person opened, or one that waited undrawn) asks for its first read,
|
|
1136
|
+
// once. Like the auto-open it leaves the render through a zero-delay timer; inside the floor it
|
|
1137
|
+
// waits for the trailing refresh rather than the slow timer.
|
|
1138
|
+
const askFill = () => {
|
|
1139
|
+
if (isFilling) return
|
|
1140
|
+
isFilling = true
|
|
1141
|
+
$.clock.after(0, () => {
|
|
1142
|
+
isFilling = false
|
|
1143
|
+
void requestRefresh($)
|
|
1144
|
+
})
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
|
|
1148
|
+
// timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
|
|
1149
|
+
const isOverdue = (d: { fetchedAt: number | null; error: string | null; isLoading: boolean }) =>
|
|
1150
|
+
d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS)
|
|
1151
|
+
|
|
1152
|
+
// No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
|
|
1153
|
+
if (pin === null) {
|
|
1154
|
+
return (
|
|
1155
|
+
<Box flexDirection="column" width={cols}>
|
|
1156
|
+
{text('no-repo', dim, NO_REPO)}
|
|
1157
|
+
</Box>
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (isBoard) {
|
|
1162
|
+
const isLoaded = board.fetchedAt !== null
|
|
1163
|
+
if ((!isLoaded && !board.isLoading && board.error === null) || isOverdue(board)) askFill()
|
|
1164
|
+
const heading = fit('Dashboard', headingRoom(cols, MAP_LABEL))
|
|
1165
|
+
const title = (
|
|
1166
|
+
<Box key="dashboard-title">
|
|
1167
|
+
<Text {...accent}>{board.dashboard === null ? heading : link(board.dashboard.url, heading)}</Text>
|
|
1168
|
+
</Box>
|
|
1169
|
+
)
|
|
1170
|
+
const isEmpty = isLoaded && board.error === null && board.tickets.length === 0
|
|
1171
|
+
return (
|
|
1172
|
+
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
1173
|
+
{headerRow(title)}
|
|
1174
|
+
{totalLine}
|
|
1175
|
+
{mapsGroup}
|
|
1176
|
+
<Box key="whiteboard" marginTop={gap}>
|
|
1177
|
+
{text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
|
|
1178
|
+
</Box>
|
|
1179
|
+
{!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
|
|
1180
|
+
{ticketGroups(board.tickets)}
|
|
1181
|
+
{isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
|
|
1182
|
+
{notes(board)}
|
|
1183
|
+
<Box key="spacer" flexGrow={1} />
|
|
1184
|
+
{footerOf(board)}
|
|
1185
|
+
</Box>
|
|
1186
|
+
)
|
|
1187
|
+
}
|
|
592
1188
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1189
|
+
if (target === null) {
|
|
1190
|
+
return (
|
|
1191
|
+
<Box flexDirection="column" width={cols}>
|
|
1192
|
+
{text('empty', dim, 'No active map. Run /trailhead:map to pick one.')}
|
|
1193
|
+
{whiteboardRow}
|
|
1194
|
+
{totalLine}
|
|
1195
|
+
{mapsGroup}
|
|
1196
|
+
</Box>
|
|
1197
|
+
)
|
|
1198
|
+
}
|
|
1199
|
+
const isCurrent = data.map?.number === target
|
|
1200
|
+
const isLoading = !isCurrent && (data.isLoading || data.error === null)
|
|
1201
|
+
if ((!isCurrent && !data.isLoading && data.error === null) || (isCurrent && isOverdue(data))) askFill()
|
|
600
1202
|
|
|
1203
|
+
const map = isCurrent ? data.map : null
|
|
1204
|
+
const heading = map === null ? `map #${target}` : fit(`#${map.number} ${map.title}`, cols)
|
|
1205
|
+
const header = (
|
|
1206
|
+
<Box key="header" flexDirection="column">
|
|
1207
|
+
<Box key="map">
|
|
1208
|
+
<Text {...accent}>{map === null ? heading : link(map.url, heading)}</Text>
|
|
1209
|
+
</Box>
|
|
1210
|
+
{map?.destination == null ? null : text('destination', dim, fit(map.destination, cols))}
|
|
1211
|
+
</Box>
|
|
1212
|
+
)
|
|
1213
|
+
if (isLoading) {
|
|
1214
|
+
return (
|
|
1215
|
+
<Box flexDirection="column">
|
|
1216
|
+
{header}
|
|
1217
|
+
{viewing}
|
|
1218
|
+
{text('loading', dim, 'Loading tickets…')}
|
|
1219
|
+
{whiteboardRow}
|
|
1220
|
+
{totalLine}
|
|
1221
|
+
{mapsGroup}
|
|
1222
|
+
</Box>
|
|
1223
|
+
)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const tickets = isCurrent ? data.tickets : []
|
|
601
1227
|
const end = burndownEnd(data, now)
|
|
602
1228
|
const series = isCurrent && data.history !== null ? burndownSeries(data.history, end.at, cols) : null
|
|
603
1229
|
const burndown = (s: BurndownSeries, history: BurndownHistory) => (
|
|
@@ -621,42 +1247,19 @@ export function registerMapPane(on: On) {
|
|
|
621
1247
|
<Box flexDirection="column" width={cols} {...(isDock ? { minHeight: e.props.scroll.bodyRows } : {})}>
|
|
622
1248
|
{header}
|
|
623
1249
|
{viewing}
|
|
1250
|
+
{whiteboardRow}
|
|
1251
|
+
{totalLine}
|
|
624
1252
|
{mapsGroup}
|
|
625
|
-
{
|
|
626
|
-
{group('in-progress', 'In progress', groups.inProgress)}
|
|
627
|
-
{group('blocked', 'Blocked', groups.blocked)}
|
|
1253
|
+
{ticketGroups(tickets)}
|
|
628
1254
|
{isCurrent && data.error === null && tickets.length === 0 ? (
|
|
629
1255
|
<Box key="no-tickets" marginTop={gap}>
|
|
630
1256
|
<Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
|
|
631
1257
|
</Box>
|
|
632
1258
|
) : null}
|
|
633
|
-
{
|
|
634
|
-
<Box key="unverified" marginTop={gap}>
|
|
635
|
-
<Text {...dim}>{unverifiedLine(data.unverifiedCount)}</Text>
|
|
636
|
-
</Box>
|
|
637
|
-
)}
|
|
638
|
-
{!isCurrent || !data.isCapped ? null : (
|
|
639
|
-
<Box key="capped" marginTop={gap}>
|
|
640
|
-
<Text {...dim}>{`list capped at ${LIST_LIMIT}`}</Text>
|
|
641
|
-
</Box>
|
|
642
|
-
)}
|
|
1259
|
+
{isCurrent ? notes(data) : null}
|
|
643
1260
|
{series === null || data.history === null ? null : burndown(series, data.history)}
|
|
644
1261
|
<Box key="spacer" flexGrow={1} />
|
|
645
|
-
|
|
646
|
-
{text('rule', dim, '─'.repeat(cols))}
|
|
647
|
-
<Box key="footer-row" width={cols} justifyContent="space-between">
|
|
648
|
-
<Box gap={1}>
|
|
649
|
-
<Button key="refresh" label="refresh" onPress={() => void requestRefresh($)} />
|
|
650
|
-
{footer.isInline ? text('age', dim, footer.text) : null}
|
|
651
|
-
</Box>
|
|
652
|
-
<Button
|
|
653
|
-
key="close"
|
|
654
|
-
label="close"
|
|
655
|
-
onPress={() => void closePane($).catch(err => debug($, `trailhead mod: close press failed: ${String(err)}`))}
|
|
656
|
-
/>
|
|
657
|
-
</Box>
|
|
658
|
-
{footer.isInline ? null : <Box key="age-row">{text('age', dim, footer.text)}</Box>}
|
|
659
|
-
</Box>
|
|
1262
|
+
{footerOf(data)}
|
|
660
1263
|
</Box>
|
|
661
1264
|
)
|
|
662
1265
|
})
|