jackherd 1.12.2 → 1.12.3
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/CHANGELOG.md +11 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +6 -0
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- package/src/plans/next-rows.ts +0 -12
package/src/plans/live.ts
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
import { existsSync, type FSWatcher,
|
|
1
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
2
|
import { basename, dirname } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
5
|
import { runHerdr } from '../terminal/herdr'
|
|
6
|
+
import { enterTerminal, reportCrash, terminalRestore } from '../terminal/lifetime'
|
|
6
7
|
import { openInBrowser } from '../terminal/open'
|
|
7
|
-
import { crashEnd
|
|
8
|
+
import { crashEnd } from '../terminal/restore'
|
|
8
9
|
import { closeWaiting } from '../waiting/command'
|
|
10
|
+
import { isWatcherRunning } from '../watch/watcher-lock'
|
|
9
11
|
import { askCoordinator } from './ask-coordinator'
|
|
10
|
-
import type { LaneMemory } from './current-phase'
|
|
11
12
|
import { errorText } from './error-text'
|
|
13
|
+
import { focusedScreen } from './focus-overlay'
|
|
14
|
+
import { noPullRequests } from './github'
|
|
12
15
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import { recallLanes, rememberLanes } from './herd-memory'
|
|
16
|
+
HERD_FILES,
|
|
17
|
+
type HerdPlans,
|
|
18
|
+
type HerdRead,
|
|
19
|
+
herdFilesOf,
|
|
20
|
+
isPublishedLive,
|
|
21
|
+
laneRead,
|
|
22
|
+
publishedRead,
|
|
23
|
+
readHerdBeat,
|
|
24
|
+
readHerdPlans,
|
|
25
|
+
readHerdRead,
|
|
26
|
+
} from './herd-state'
|
|
25
27
|
import { type Screen, settledSelection } from './layout'
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
+
import { initialPageState, type PageState, syncSprintPage } from './page-sync'
|
|
29
|
+
import { pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
28
30
|
import { herdLabel } from './plan-panes'
|
|
29
31
|
import { pullRequestUrl } from './pull-requests'
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
+
import { type Reader, type ReadOutcome, startReader } from './reader'
|
|
33
|
+
import { REFRESH_PACE } from './refresh-pace'
|
|
32
34
|
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
33
35
|
import { closeRow } from './row-actions'
|
|
34
36
|
import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
|
|
@@ -51,64 +53,56 @@ import {
|
|
|
51
53
|
windowTop,
|
|
52
54
|
withoutStaleMotions,
|
|
53
55
|
} from './selection'
|
|
54
|
-
import {
|
|
56
|
+
import { boardRepoPathOf, CLOCK_LOCALE } from './settings'
|
|
55
57
|
import { SHIMMER, shimmerEnabled, shimmerRows } from './shimmer'
|
|
56
|
-
import { singleFlight } from './single-flight'
|
|
57
58
|
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
58
|
-
import { exportSprintPage
|
|
59
|
+
import { exportSprintPage } from './sprint-export'
|
|
59
60
|
import { boardTexts } from './texts'
|
|
60
61
|
import {
|
|
61
62
|
agentOfSnapshot,
|
|
62
63
|
bodyRows,
|
|
63
64
|
changedLinesOutput,
|
|
64
65
|
clockOf,
|
|
65
|
-
focusedScreen,
|
|
66
66
|
frameLinesOf,
|
|
67
67
|
nextFrameDelay,
|
|
68
68
|
type PreparedShimmer,
|
|
69
69
|
paneWidth,
|
|
70
70
|
preparedShimmer,
|
|
71
|
+
type RenderContext,
|
|
71
72
|
renderSnapshot,
|
|
72
73
|
renderUnfocused,
|
|
73
74
|
shimmerOutput,
|
|
74
75
|
startSelection,
|
|
75
|
-
TERMINAL_OFF,
|
|
76
|
-
TERMINAL_ON,
|
|
77
76
|
} from './view'
|
|
78
77
|
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
|
|
79
|
-
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
|
|
81
|
+
* (`herd-state.ts`), with the watcher on the directory it writes to and what was taken from it last.
|
|
82
|
+
*/
|
|
83
|
+
type Feed =
|
|
84
|
+
| { kind: 'own'; reader: Reader }
|
|
85
|
+
| { kind: 'published'; directory: FSWatcher | null; taken: { read: HerdRead; plans: HerdPlans } | null }
|
|
80
86
|
|
|
81
87
|
/** The state of the running view; only the functions below `runLive` change it. */
|
|
82
88
|
type Live = {
|
|
83
89
|
source: Source
|
|
90
|
+
/** What the herd shares with the watcher to take its read (`publishedKey`); null when it never does. */
|
|
91
|
+
key: string | null
|
|
92
|
+
feed: Feed | null
|
|
84
93
|
snapshot: Snapshot | null
|
|
85
94
|
screen: Screen | null
|
|
86
95
|
view: View
|
|
87
96
|
errors: string[]
|
|
88
|
-
pullRequests: PullRequestState
|
|
89
|
-
fetch: FetchState
|
|
90
97
|
/** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
|
|
91
98
|
notice: string | null
|
|
92
|
-
watchers: Map<string, FSWatcher>
|
|
93
|
-
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
94
|
-
fetchHead: string | null
|
|
95
99
|
reported: Map<string, string | null>
|
|
96
|
-
/**
|
|
97
|
-
memories: Map<string, LaneMemory>
|
|
98
|
-
/** Session logs and plans parsed at the last read, kept while they did not change. */
|
|
99
|
-
cache: ReadCache
|
|
100
|
-
pending: Timer | null
|
|
101
|
-
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
100
|
+
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
|
|
102
101
|
read: Snapshot | null
|
|
102
|
+
/** The whole board as the watcher published it, while the herd shows one lane of it; null otherwise. */
|
|
103
|
+
whole: Snapshot | null
|
|
103
104
|
/** The sprint page last written, and the key of the plans it was built from; a click on a plan chip opens it. */
|
|
104
|
-
|
|
105
|
-
pageWriting: boolean
|
|
106
|
-
flight: (() => Promise<void>) | null
|
|
107
|
-
/** The refreshes in a row that wrote nothing to the terminal; the next one waits `refreshDelay` of it. */
|
|
108
|
-
quietRounds: number
|
|
109
|
-
/** When the last refresh started (epoch milliseconds), null before the first; a refresh outside the pace waits `watchRefreshDelay` of it. */
|
|
110
|
-
lastRefreshAt: number | null
|
|
111
|
-
pageError: string | null
|
|
105
|
+
pageState: PageState
|
|
112
106
|
/**
|
|
113
107
|
* The lines last written to the terminal, with their shimmer marks, so a stripe that moved on is no change (the
|
|
114
108
|
* shimmer clock draws that); null while the terminal content is unknown, such as after a resize.
|
|
@@ -126,18 +120,8 @@ type Live = {
|
|
|
126
120
|
shimmer: { prepared: PreparedShimmer; clock: Timer | null }
|
|
127
121
|
}
|
|
128
122
|
|
|
129
|
-
const PULL_REQUEST_REFRESH_MS = 60_000
|
|
130
123
|
const QUIT_KEYS = new Set(['\x03', 'q'])
|
|
131
124
|
|
|
132
|
-
/** When `file` was last written (epoch milliseconds); 0 when it cannot be stat-ed, which no fetch of the herd claims. */
|
|
133
|
-
const modifiedAt = (file: string): number => {
|
|
134
|
-
try {
|
|
135
|
-
return statSync(file).mtimeMs
|
|
136
|
-
} catch {
|
|
137
|
-
return 0
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
125
|
/** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
|
|
142
126
|
export const changedTitles = (
|
|
143
127
|
titles: ReadonlyMap<string, string | null>,
|
|
@@ -161,76 +145,6 @@ const clearTitles = (source: Source, reported: Map<string, string | null>) => {
|
|
|
161
145
|
}
|
|
162
146
|
}
|
|
163
147
|
|
|
164
|
-
const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: (file: string) => void) => {
|
|
165
|
-
for (const [file, watcher] of watchers) {
|
|
166
|
-
if (files.includes(file)) continue
|
|
167
|
-
watcher.close()
|
|
168
|
-
watchers.delete(file)
|
|
169
|
-
}
|
|
170
|
-
for (const file of files.filter((candidate) => !watchers.has(candidate))) {
|
|
171
|
-
try {
|
|
172
|
-
const watcher = watch(file, (event) => {
|
|
173
|
-
if (event === 'rename') {
|
|
174
|
-
watcher.close()
|
|
175
|
-
watchers.delete(file)
|
|
176
|
-
}
|
|
177
|
-
onChange(file)
|
|
178
|
-
})
|
|
179
|
-
watchers.set(file, watcher)
|
|
180
|
-
} catch {
|
|
181
|
-
// A file that cannot be watched right now is retried on the next refresh, which also covers it meanwhile.
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const STDOUT_FD = 1
|
|
187
|
-
const STDERR_FD = 2
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* The terminal given back on every end, written synchronously because `exit` waits for nothing: mouse reports and
|
|
191
|
-
* alternate screen off, cursor shown, raw mode released, once (`onceRestore`).
|
|
192
|
-
*/
|
|
193
|
-
const terminalRestore = (): (() => void) =>
|
|
194
|
-
onceRestore(TERMINAL_OFF, {
|
|
195
|
-
write: (text) => writeSync(STDOUT_FD, text),
|
|
196
|
-
releaseRaw: () => {
|
|
197
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
198
|
-
},
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
/** How the view ends: `quit` and `crash` leave, `restore` gives the terminal back and runs on `exit` as well. */
|
|
202
|
-
type TerminalEnds = { quit: () => void; crash: (error: unknown) => void; restore: () => void }
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Alternate screen, hidden cursor, mouse reports and raw keys. A signal quits, an error nobody caught crashes (a
|
|
206
|
-
* closed terminal's `EIO` on stdin among them), and `exit` restores the terminal whatever ended the process.
|
|
207
|
-
*/
|
|
208
|
-
const enterTerminal = (onInput: (input: string) => void, ends: TerminalEnds, onResize: () => void) => {
|
|
209
|
-
process.stdout.write(TERMINAL_ON)
|
|
210
|
-
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, ends.quit)
|
|
211
|
-
process.on('exit', ends.restore)
|
|
212
|
-
process.on('uncaughtException', ends.crash)
|
|
213
|
-
process.on('unhandledRejection', ends.crash)
|
|
214
|
-
process.stdout.on('resize', onResize)
|
|
215
|
-
if (!process.stdin.isTTY) return
|
|
216
|
-
process.stdin.setRawMode(true)
|
|
217
|
-
process.stdin.on('error', ends.crash)
|
|
218
|
-
process.stdin.on('data', (chunk) => {
|
|
219
|
-
for (const input of withoutStaleMotions(splitKeys(chunk.toString()))) onInput(input)
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** The terminal restored first, so the report lands in the shell and not on the alternate screen; null reports nothing. */
|
|
224
|
-
const reportCrash = (report: string | null, restore: () => void) => {
|
|
225
|
-
restore()
|
|
226
|
-
if (report === null) return
|
|
227
|
-
try {
|
|
228
|
-
writeSync(STDERR_FD, `${report}\n`)
|
|
229
|
-
} catch {
|
|
230
|
-
// stderr went away with the terminal; the exit code still tells.
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
148
|
const hoverScreenOf = (screen: Screen, top: number): HoverScreen => ({
|
|
235
149
|
header: screen.header,
|
|
236
150
|
bodyLines: screen.body.lines,
|
|
@@ -241,15 +155,22 @@ const hoverScreenOf = (screen: Screen, top: number): HoverScreen => ({
|
|
|
241
155
|
/** The lines over the footer: refresh errors, a failed page, the notice of the last action. */
|
|
242
156
|
const liveErrors = (live: Live): string[] => [
|
|
243
157
|
...live.errors,
|
|
244
|
-
...(live.
|
|
158
|
+
...(live.pageState.error ? [live.pageState.error] : []),
|
|
245
159
|
...(live.notice ? [live.notice] : []),
|
|
246
160
|
]
|
|
247
161
|
|
|
248
162
|
const shimmerOn = (live: Live): boolean => shimmerEnabled(live.source.settings.shimmer, process.env)
|
|
249
163
|
|
|
164
|
+
const renderContextOf = (live: Live): RenderContext => ({
|
|
165
|
+
selection: live.view.selection,
|
|
166
|
+
errors: liveErrors(live),
|
|
167
|
+
width: paneWidth(),
|
|
168
|
+
shimmer: shimmerOn(live),
|
|
169
|
+
})
|
|
170
|
+
|
|
250
171
|
/** The screen for the current state, with the window scrolled to keep the cursor in view. */
|
|
251
172
|
const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
|
|
252
|
-
const screen = renderSnapshot(snapshot,
|
|
173
|
+
const screen = renderSnapshot(snapshot, renderContextOf(live))
|
|
253
174
|
return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
|
|
254
175
|
}
|
|
255
176
|
|
|
@@ -314,7 +235,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
|
|
|
314
235
|
const drawHover = (live: Live) => {
|
|
315
236
|
live.frame = null
|
|
316
237
|
if (!live.snapshot) return
|
|
317
|
-
live.plain ??= renderUnfocused(live.snapshot,
|
|
238
|
+
live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
|
|
318
239
|
const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
|
|
319
240
|
if (laid === null) redraw(live)
|
|
320
241
|
else write(live, laid, live.view.top)
|
|
@@ -326,11 +247,16 @@ const requestFrame = (live: Live) => {
|
|
|
326
247
|
live.frame = setTimeout(() => drawHover(live), nextFrameDelay(live.lastFrameAt, Date.now()))
|
|
327
248
|
}
|
|
328
249
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
250
|
+
/**
|
|
251
|
+
* The whole sprint, also while the view shows one lane: the watcher's read holds it, the herd's own reader reads the
|
|
252
|
+
* snapshot again without the lane filter.
|
|
253
|
+
*/
|
|
254
|
+
const wholeSprint = async (live: Live, snapshot: Snapshot): Promise<Snapshot> => {
|
|
255
|
+
if (!live.source.options.lane) return snapshot
|
|
256
|
+
if (live.whole) return live.whole
|
|
257
|
+
const pullRequests = live.feed?.kind === 'own' ? live.feed.reader.pullRequests() : noPullRequests()
|
|
258
|
+
return loadSnapshot({ ...live.source, options: { ...live.source.options, lane: null } }, pullRequests)
|
|
259
|
+
}
|
|
334
260
|
|
|
335
261
|
const boardTextsOf = (live: Live) => boardTexts(live.source.settings.board.term)
|
|
336
262
|
|
|
@@ -338,74 +264,150 @@ const sprintDirectoryOf = ({ root, sprintFile, settings }: Source): string =>
|
|
|
338
264
|
dirname(boardRepoPathOf(root, settings, sprintFile))
|
|
339
265
|
|
|
340
266
|
/** The snapshot as read with the plan chips of the page written so far, none before the first one. */
|
|
341
|
-
const withKnownPage = (live: Live, read: Snapshot): Snapshot =>
|
|
342
|
-
|
|
267
|
+
const withKnownPage = (live: Live, read: Snapshot): Snapshot => {
|
|
268
|
+
const { page } = live.pageState
|
|
269
|
+
return page ? withPlanChips(read, page, sprintDirectoryOf(live.source)) : read
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const isCurrent = (live: Live, read: Snapshot): boolean => {
|
|
273
|
+
const { page } = live.pageState
|
|
274
|
+
return page !== null && !pageNeedsWriting(page, pageKey(read), existsSync(page.path))
|
|
275
|
+
}
|
|
343
276
|
|
|
344
|
-
|
|
345
|
-
|
|
277
|
+
/** Writes the sprint page from the herd's own read when its plans changed (`syncSprintPage`), then draws its chips. */
|
|
278
|
+
const syncPage = async (live: Live, read: Snapshot, feed: Feed) => {
|
|
279
|
+
if (live.feed !== feed) return
|
|
280
|
+
if (!(await syncSprintPage(live.pageState, live.source, read, () => wholeSprint(live, read)))) return
|
|
281
|
+
if (live.feed !== feed) return
|
|
282
|
+
if (live.read) live.snapshot = withKnownPage(live, live.read)
|
|
283
|
+
redraw(live)
|
|
284
|
+
}
|
|
346
285
|
|
|
347
286
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
287
|
+
* Takes a read into the view, from the herd's own reader or from the watcher: the selection follows the lanes, the
|
|
288
|
+
* errors (`notes` after the read's own) and the pane titles are set, and the screen is drawn. True when it wrote
|
|
289
|
+
* anything.
|
|
350
290
|
*/
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const written = await writeSprintPageInSteps(live.source, await wholeSprint(live, read))
|
|
357
|
-
live.pageError = 'error' in written ? boardTextsOf(live).pageNotWritten(written.error) : null
|
|
358
|
-
live.page = pageAfterWrite(live.page, written, key)
|
|
359
|
-
} catch (error) {
|
|
360
|
-
live.pageError = boardTextsOf(live).pageNotWritten(errorText(error))
|
|
361
|
-
} finally {
|
|
362
|
-
live.pageWriting = false
|
|
291
|
+
const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): boolean => {
|
|
292
|
+
if ('error' in outcome) {
|
|
293
|
+
const at = clockOf(CLOCK_LOCALE)
|
|
294
|
+
live.errors = [`refresh failed at ${at}, showing the last good state: ${outcome.error}`]
|
|
295
|
+
return redraw(live)
|
|
363
296
|
}
|
|
364
|
-
|
|
365
|
-
|
|
297
|
+
const next = withKnownPage(live, outcome.read)
|
|
298
|
+
live.read = outcome.read
|
|
299
|
+
if (!live.snapshot)
|
|
300
|
+
live.view = {
|
|
301
|
+
...live.view,
|
|
302
|
+
selection: startSelection(next, live.source.options),
|
|
303
|
+
}
|
|
304
|
+
const folded = syncLaneFolds(
|
|
305
|
+
live.view.selection,
|
|
306
|
+
next.views.map(({ progress, status }) => ({
|
|
307
|
+
lane: progress.lane.name,
|
|
308
|
+
status: status.agent,
|
|
309
|
+
})),
|
|
310
|
+
)
|
|
311
|
+
live.view = { ...live.view, selection: folded }
|
|
312
|
+
live.snapshot = next
|
|
313
|
+
live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
|
|
314
|
+
return redraw(live)
|
|
366
315
|
}
|
|
367
316
|
|
|
368
|
-
/**
|
|
369
|
-
const
|
|
370
|
-
live.
|
|
371
|
-
|
|
317
|
+
/** The herd's own reader: it takes each read and writes the sprint page from it. */
|
|
318
|
+
const ownFeed = (live: Live): Feed => {
|
|
319
|
+
const reader = startReader(live.source, (outcome) => {
|
|
320
|
+
const fetchLine = reader.fetchLine()
|
|
321
|
+
const changed = takeRead(live, outcome, fetchLine ? [fetchLine] : [])
|
|
322
|
+
const { read } = live
|
|
323
|
+
if (read) setTimeout(() => void syncPage(live, read, feed), 0)
|
|
324
|
+
return changed
|
|
325
|
+
})
|
|
326
|
+
const feed: Feed = { kind: 'own', reader }
|
|
327
|
+
return feed
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Shows what was taken from the watcher, at the herd's own time: the whole board, or the herd's lane of it. */
|
|
331
|
+
const showPublished = (live: Live, taken: { read: HerdRead; plans: HerdPlans }): boolean => {
|
|
332
|
+
const published = publishedRead(taken.read, taken.plans.plans, Date.now())
|
|
333
|
+
const { lane } = live.source.options
|
|
334
|
+
live.whole = lane ? published.snapshot : null
|
|
335
|
+
live.pageState.page = published.page
|
|
336
|
+
const shown = lane ? laneRead(published.snapshot, lane, boardTextsOf(live).laneNotListed) : published.snapshot
|
|
337
|
+
return takeRead(live, { read: shown }, published.fetchLine ? [published.fetchLine] : [])
|
|
372
338
|
}
|
|
373
339
|
|
|
374
|
-
|
|
375
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Takes the watcher's read when it is for this herd's key; the plan texts are read again only when their key moved.
|
|
342
|
+
* A read the watcher is still writing is never seen half: it replaces the file whole.
|
|
343
|
+
*/
|
|
344
|
+
const takePublished = (live: Live, feed: Extract<Feed, { kind: 'published' }>): void => {
|
|
345
|
+
const files = herdFilesOf(live.source.root)
|
|
346
|
+
const read = readHerdRead(files.read)
|
|
347
|
+
const kept = feed.taken?.plans
|
|
348
|
+
const plans = read && (kept?.plansKey === read.plansKey ? kept : readHerdPlans(files.plans))
|
|
349
|
+
if (!read || read.key !== live.key || !plans || plans.plansKey !== read.plansKey) {
|
|
350
|
+
if (feed.taken) showPublished(live, feed.taken)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
feed.taken = { read, plans }
|
|
354
|
+
showPublished(live, feed.taken)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Follows the watcher's read: woken by its file, without a timer of its own. */
|
|
358
|
+
const publishedFeed = (live: Live): Feed => {
|
|
359
|
+
const feed: Extract<Feed, { kind: 'published' }> = { kind: 'published', directory: null, taken: null }
|
|
360
|
+
const files = herdFilesOf(live.source.root)
|
|
361
|
+
let pending = false
|
|
362
|
+
const onChange = (_event: string, name: string | null) => {
|
|
363
|
+
if (name !== HERD_FILES.read || pending) return
|
|
364
|
+
pending = true
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
pending = false
|
|
367
|
+
if (live.feed === feed) takePublished(live, feed)
|
|
368
|
+
}, 0)
|
|
369
|
+
}
|
|
376
370
|
try {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
live.read = read
|
|
381
|
-
if (!live.snapshot)
|
|
382
|
-
live.view = {
|
|
383
|
-
...live.view,
|
|
384
|
-
selection: startSelection(next, live.source.options),
|
|
385
|
-
}
|
|
386
|
-
const folded = syncLaneFolds(
|
|
387
|
-
live.view.selection,
|
|
388
|
-
next.views.map(({ progress, status }) => ({
|
|
389
|
-
lane: progress.lane.name,
|
|
390
|
-
status: status.agent,
|
|
391
|
-
})),
|
|
392
|
-
)
|
|
393
|
-
live.view = { ...live.view, selection: folded }
|
|
394
|
-
live.snapshot = next
|
|
395
|
-
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
|
|
396
|
-
live.errors = [
|
|
397
|
-
...next.errors,
|
|
398
|
-
...(fetchLine ? [fetchLine] : []),
|
|
399
|
-
...reportTitles(live.source, next.titles, live.reported),
|
|
400
|
-
]
|
|
401
|
-
syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
|
|
402
|
-
} catch (error) {
|
|
403
|
-
const at = clockOf(CLOCK_LOCALE)
|
|
404
|
-
live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
|
|
371
|
+
feed.directory = watch(dirname(files.read), onChange)
|
|
372
|
+
} catch {
|
|
373
|
+
feed.directory = null
|
|
405
374
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
375
|
+
takePublished(live, feed)
|
|
376
|
+
return feed
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Whether the watcher publishes a young read for this herd's key (`isPublishedLive`). */
|
|
380
|
+
const isPublishedForHerd = (live: Live): boolean => {
|
|
381
|
+
const files = herdFilesOf(live.source.root)
|
|
382
|
+
return isPublishedLive({
|
|
383
|
+
beat: readHerdBeat(files.beat),
|
|
384
|
+
key: live.key,
|
|
385
|
+
watcherRunning: isWatcherRunning(files.lock),
|
|
386
|
+
now: Date.now(),
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const stopFeed = (feed: Feed | null): void => {
|
|
391
|
+
if (feed?.kind === 'own') feed.reader.stop()
|
|
392
|
+
else feed?.directory?.close()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Switches to the watcher's read while it publishes one for this herd, and back to reading itself once it stops or
|
|
397
|
+
* grows old; while it follows the watcher, the sources' ages are drawn again at the herd's time.
|
|
398
|
+
*/
|
|
399
|
+
const followFeed = (live: Live): void => {
|
|
400
|
+
const published = isPublishedForHerd(live)
|
|
401
|
+
const { feed } = live
|
|
402
|
+
if (feed?.kind === 'published' && published) {
|
|
403
|
+
takePublished(live, feed)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if (feed?.kind === 'own' && !published) return
|
|
407
|
+
stopFeed(feed)
|
|
408
|
+
live.whole = null
|
|
409
|
+
live.pageState.error = null
|
|
410
|
+
live.feed = published ? publishedFeed(live) : ownFeed(live)
|
|
409
411
|
}
|
|
410
412
|
|
|
411
413
|
const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string | null = null) => {
|
|
@@ -423,15 +425,17 @@ const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string |
|
|
|
423
425
|
* first. Any other `file:` address is refused.
|
|
424
426
|
*/
|
|
425
427
|
const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
if (!
|
|
428
|
+
const { page } = live.pageState
|
|
429
|
+
const anchor = planAnchorOn(page?.path ?? null, address)
|
|
430
|
+
if (!anchor || !page) return
|
|
431
|
+
const read = live.whole ?? live.read
|
|
432
|
+
if (!read || !isCurrent(live, read)) {
|
|
429
433
|
void writeSprintPage(live, snapshot, anchor)
|
|
430
434
|
return
|
|
431
435
|
}
|
|
432
|
-
const reason = openInBrowser(`${pathToFileURL(
|
|
436
|
+
const reason = openInBrowser(`${pathToFileURL(page.path).href}#${anchor}`)
|
|
433
437
|
const texts = boardTextsOf(live)
|
|
434
|
-
live.notice = reason === null ? texts.pageOpened(
|
|
438
|
+
live.notice = reason === null ? texts.pageOpened(page.path) : texts.pageNotOpenedReason(reason)
|
|
435
439
|
}
|
|
436
440
|
|
|
437
441
|
/**
|
|
@@ -563,29 +567,24 @@ const handleInput = (live: Live, input: string) => {
|
|
|
563
567
|
redraw(live)
|
|
564
568
|
}
|
|
565
569
|
|
|
566
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Runs the live view of `source`. With a `key` (`publishedKey`) it takes the read the watcher publishes for it while
|
|
572
|
+
* there is one, and reads for itself otherwise; without one it always reads for itself.
|
|
573
|
+
*/
|
|
574
|
+
export const runLive = (source: Source, key: string | null = null): void => {
|
|
567
575
|
const live: Live = {
|
|
568
576
|
source,
|
|
577
|
+
key,
|
|
578
|
+
feed: null,
|
|
569
579
|
snapshot: null,
|
|
570
580
|
screen: null,
|
|
571
581
|
view: { selection: overviewSelection(), top: 0, follow: true },
|
|
572
582
|
errors: [],
|
|
573
|
-
pullRequests: noPullRequests(),
|
|
574
|
-
fetch: initialFetchState(),
|
|
575
583
|
notice: null,
|
|
576
|
-
watchers: new Map(),
|
|
577
|
-
fetchHead: fetchHeadPath(source.root),
|
|
578
584
|
reported: new Map(),
|
|
579
|
-
memories: recallLanes(source.root),
|
|
580
|
-
cache: newReadCache(),
|
|
581
|
-
pending: null,
|
|
582
585
|
read: null,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
flight: null,
|
|
586
|
-
quietRounds: 0,
|
|
587
|
-
lastRefreshAt: null,
|
|
588
|
-
pageError: null,
|
|
586
|
+
whole: null,
|
|
587
|
+
pageState: initialPageState(),
|
|
589
588
|
written: null,
|
|
590
589
|
plain: null,
|
|
591
590
|
mouse: null,
|
|
@@ -593,60 +592,12 @@ export const runLive = (source: Source): void => {
|
|
|
593
592
|
frame: null,
|
|
594
593
|
shimmer: { prepared: preparedShimmer([], 0), clock: null },
|
|
595
594
|
}
|
|
596
|
-
/** A refresh asked for outside the pace, bundled by `watchRefreshDelay`: one already pending covers the ask. */
|
|
597
|
-
const schedule = () => {
|
|
598
|
-
if (live.pending) return
|
|
599
|
-
live.pending = setTimeout(
|
|
600
|
-
() => {
|
|
601
|
-
live.pending = null
|
|
602
|
-
void refresh(live, onFileChange)
|
|
603
|
-
},
|
|
604
|
-
watchRefreshDelay(live.lastRefreshAt, Date.now()),
|
|
605
|
-
)
|
|
606
|
-
}
|
|
607
|
-
/** A watched file changed; `FETCH_HEAD` written by the herd's own fetch asks nothing, its end refreshes once. */
|
|
608
|
-
const onFileChange = (file: string) => {
|
|
609
|
-
if (file === live.fetchHead && isOwnFetchWrite(modifiedAt(file), live.fetch)) return
|
|
610
|
-
schedule()
|
|
611
|
-
}
|
|
612
|
-
const fetchBaseBranch = async () => {
|
|
613
|
-
live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
|
|
614
|
-
const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
|
|
615
|
-
const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
616
|
-
if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
|
|
617
|
-
live.fetch = {
|
|
618
|
-
...live.fetch,
|
|
619
|
-
running: false,
|
|
620
|
-
failure,
|
|
621
|
-
lastSuccess: failure === null ? new Date() : live.fetch.lastSuccess,
|
|
622
|
-
}
|
|
623
|
-
schedule()
|
|
624
|
-
}
|
|
625
|
-
/** Starts a fetch when one is due; true when it did, whose end then refreshes. */
|
|
626
|
-
const fetchWhenDue = (pullRequestGone: boolean): boolean => {
|
|
627
|
-
const due = shouldFetch({
|
|
628
|
-
now: Date.now(),
|
|
629
|
-
state: live.fetch,
|
|
630
|
-
intervalMinutes: source.settings.fetchIntervalMinutes,
|
|
631
|
-
pullRequestGone,
|
|
632
|
-
})
|
|
633
|
-
if (due) void fetchBaseBranch()
|
|
634
|
-
return due
|
|
635
|
-
}
|
|
636
|
-
const refreshPullRequests = async () => {
|
|
637
|
-
const previous = live.pullRequests
|
|
638
|
-
live.pullRequests = await fetchPullRequests(source.root)
|
|
639
|
-
const readable = previous.errors.length === 0 && live.pullRequests.errors.length === 0
|
|
640
|
-
const laneBranches = new Set(live.snapshot?.views.flatMap(({ status }) => (status.branch ? [status.branch] : [])))
|
|
641
|
-
const gone = goneLanePullRequests(previous.byBranch, live.pullRequests.byBranch, laneBranches)
|
|
642
|
-
if (!fetchWhenDue(readable && gone.length > 0)) schedule()
|
|
643
|
-
}
|
|
644
595
|
const restore = terminalRestore()
|
|
645
596
|
const leave = (code: number) => {
|
|
646
597
|
restore()
|
|
647
598
|
clearTitles(source, live.reported)
|
|
648
599
|
source.terminal.labelOwnPane(null)
|
|
649
|
-
|
|
600
|
+
stopFeed(live.feed)
|
|
650
601
|
process.exit(code)
|
|
651
602
|
}
|
|
652
603
|
const quit = () => leave(0)
|
|
@@ -661,16 +612,13 @@ export const runLive = (source: Source): void => {
|
|
|
661
612
|
source.terminal.labelOwnPane(herdLabel(source.options.lane))
|
|
662
613
|
process.removeAllListeners(TAKEOVER_SIGNAL)
|
|
663
614
|
process.on(TAKEOVER_SIGNAL, takenOver)
|
|
664
|
-
|
|
615
|
+
const onData = (chunk: string) => {
|
|
616
|
+
for (const input of withoutStaleMotions(splitKeys(chunk))) onInput(input)
|
|
617
|
+
}
|
|
618
|
+
enterTerminal(onData, { quit, crash, restore }, () => {
|
|
665
619
|
live.written = null
|
|
666
620
|
redraw(live)
|
|
667
621
|
})
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
void refresh(live, onFileChange).finally(() => setTimeout(poll, refreshDelay(live.quietRounds)))
|
|
671
|
-
}
|
|
672
|
-
setTimeout(poll, REFRESH_PACE.baseMs)
|
|
673
|
-
setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
|
|
674
|
-
void refresh(live, onFileChange)
|
|
675
|
-
void refreshPullRequests()
|
|
622
|
+
followFeed(live)
|
|
623
|
+
setInterval(() => followFeed(live), REFRESH_PACE.maxMs)
|
|
676
624
|
}
|