jackherd 1.10.0 → 1.11.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/CHANGELOG.md +19 -0
- package/README.md +17 -2
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
- package/package.json +1 -1
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +41 -1
package/src/plans/live.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { pathToFileURL } from 'node:url'
|
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
5
|
import { runHerdr } from '../terminal/herdr'
|
|
6
6
|
import { openInBrowser } from '../terminal/open'
|
|
7
|
+
import { stripAnsi } from '../terminal/text'
|
|
8
|
+
import { closeWaiting } from '../waiting/command'
|
|
7
9
|
import { appointmentKeys } from './appointment-rows'
|
|
8
10
|
import { askCoordinator } from './ask-coordinator'
|
|
9
11
|
import type { LaneMemory } from './current-phase'
|
|
@@ -21,16 +23,22 @@ import {
|
|
|
21
23
|
import { fetchHeadPath } from './git'
|
|
22
24
|
import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
|
|
23
25
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
24
|
-
import type
|
|
26
|
+
import { type Screen, settledSelection } from './layout'
|
|
27
|
+
import { nextKeys } from './next-rows'
|
|
25
28
|
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
26
29
|
import { planPullRequests } from './plan-heads'
|
|
27
30
|
import { herdLabel } from './plan-panes'
|
|
28
31
|
import { pullRequestUrl } from './pull-requests'
|
|
29
32
|
import { newReadCache, type ReadCache } from './read-cache'
|
|
30
33
|
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
34
|
+
import { closeRow } from './row-actions'
|
|
35
|
+
import { blockRowByKey, bracketAt, hoveredKeyAt, lineIndexAt, withHovered } from './row-focus'
|
|
31
36
|
import {
|
|
32
37
|
inputOutcome,
|
|
38
|
+
isActivateKey,
|
|
39
|
+
isMotion,
|
|
33
40
|
isSprintPageKey,
|
|
41
|
+
type MouseEvent,
|
|
34
42
|
overviewSelection,
|
|
35
43
|
parseMouse,
|
|
36
44
|
settledBlockFolds,
|
|
@@ -39,7 +47,6 @@ import {
|
|
|
39
47
|
syncLaneFolds,
|
|
40
48
|
TOP_BLOCK,
|
|
41
49
|
type TopBlockId,
|
|
42
|
-
targetKeys,
|
|
43
50
|
toggledBlockFold,
|
|
44
51
|
toggledExpansion,
|
|
45
52
|
type View,
|
|
@@ -48,6 +55,7 @@ import {
|
|
|
48
55
|
import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
|
|
49
56
|
import { singleFlight } from './single-flight'
|
|
50
57
|
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
58
|
+
import { sourceKeys } from './source-rows'
|
|
51
59
|
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
52
60
|
import { boardTexts } from './texts'
|
|
53
61
|
import {
|
|
@@ -61,7 +69,7 @@ import {
|
|
|
61
69
|
TERMINAL_OFF,
|
|
62
70
|
TERMINAL_ON,
|
|
63
71
|
} from './view'
|
|
64
|
-
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, waitingKeys } from './waiting-click'
|
|
72
|
+
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf, waitingKeys } from './waiting-click'
|
|
65
73
|
|
|
66
74
|
/** The state of the running view; only the functions below `runLive` change it. */
|
|
67
75
|
type Live = {
|
|
@@ -240,7 +248,9 @@ const readOnce = async (live: Live, onChange: () => void) => {
|
|
|
240
248
|
)
|
|
241
249
|
const settled = settledBlockFolds(folded, {
|
|
242
250
|
waiting: waitingKeys(next.waiting ?? []),
|
|
251
|
+
next: nextKeys(next.next?.rows ?? []),
|
|
243
252
|
appointments: appointmentKeys(next.appointments?.rows ?? []),
|
|
253
|
+
sources: sourceKeys(next.sourceBlock?.sources ?? []),
|
|
244
254
|
})
|
|
245
255
|
live.view = { ...live.view, selection: settled }
|
|
246
256
|
live.snapshot = next
|
|
@@ -308,6 +318,15 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
308
318
|
})
|
|
309
319
|
return
|
|
310
320
|
}
|
|
321
|
+
if ('done' in outcome) {
|
|
322
|
+
live.notice = closeRow(outcome.done, {
|
|
323
|
+
sprintFile: live.source.sprintFile,
|
|
324
|
+
language: live.source.settings.planLanguage,
|
|
325
|
+
now: new Date(),
|
|
326
|
+
closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
|
|
327
|
+
})
|
|
328
|
+
return
|
|
329
|
+
}
|
|
311
330
|
const reason = openInBrowser(outcome.open)
|
|
312
331
|
if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
|
|
313
332
|
}
|
|
@@ -333,23 +352,72 @@ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input:
|
|
|
333
352
|
return true
|
|
334
353
|
}
|
|
335
354
|
|
|
355
|
+
/** The mouse moved: the row under it is drawn like the selected one, and the screen is redrawn only when that row changes. */
|
|
356
|
+
const hover = (live: Live, screen: Screen, mouse: MouseEvent): void => {
|
|
357
|
+
const frame = { headerRows: screen.header.length, bodyRows: bodyRows(screen), top: live.view.top }
|
|
358
|
+
const key = hoveredKeyAt(screen.focusHits, mouse, frame)
|
|
359
|
+
const line = lineIndexAt(mouse, frame)
|
|
360
|
+
const text = line === null ? '' : (screen.header[line] ?? screen.body.lines[line - frame.headerRows] ?? '')
|
|
361
|
+
const selection = withHovered(
|
|
362
|
+
live.view.selection,
|
|
363
|
+
key,
|
|
364
|
+
key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1),
|
|
365
|
+
)
|
|
366
|
+
if (selection === live.view.selection) return
|
|
367
|
+
live.view = { ...live.view, selection }
|
|
368
|
+
redraw(live)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Enter or space on the selected row of a block on top does its main action (`mainClickOf`: ask, open, expand); true
|
|
373
|
+
* when the input was for such a row, whether or not it had anything to do.
|
|
374
|
+
*/
|
|
375
|
+
const activateBlockRow = (live: Live, screen: Screen, input: string): boolean => {
|
|
376
|
+
if (!isActivateKey(input)) return false
|
|
377
|
+
const key = live.view.selection.selected
|
|
378
|
+
const row = key === null ? null : blockRowByKey(blocksOf(screen), key)
|
|
379
|
+
if (!row) return false
|
|
380
|
+
const click = mainClickOf(row)
|
|
381
|
+
if (click) applyBlockClick(live, click)
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const blocksOf = ({ waitingBlock, nextBlock, appointmentBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
386
|
+
waitingBlock,
|
|
387
|
+
nextBlock,
|
|
388
|
+
appointmentBlock,
|
|
389
|
+
sourceBlock,
|
|
390
|
+
]
|
|
391
|
+
|
|
336
392
|
const handleInput = (live: Live, input: string) => {
|
|
337
393
|
const { snapshot, screen } = live
|
|
338
394
|
if (!snapshot || !screen) return
|
|
395
|
+
const mouse = parseMouse(input)
|
|
396
|
+
if (mouse && isMotion(mouse)) {
|
|
397
|
+
hover(live, screen, mouse)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
339
400
|
live.notice = null
|
|
401
|
+
live.view = { ...live.view, selection: settledSelection(live.view.selection, screen) }
|
|
402
|
+
if (activateBlockRow(live, screen, input)) {
|
|
403
|
+
redraw(live)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
340
406
|
if (isSprintPageKey(input)) {
|
|
341
407
|
void writeSprintPage(live, snapshot)
|
|
342
408
|
return
|
|
343
409
|
}
|
|
344
410
|
if (
|
|
345
411
|
clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
|
|
346
|
-
clickBlock(live, TOP_BLOCK.
|
|
412
|
+
clickBlock(live, TOP_BLOCK.next, screen.nextBlock, input) ||
|
|
413
|
+
clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input) ||
|
|
414
|
+
clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
|
|
347
415
|
) {
|
|
348
416
|
redraw(live)
|
|
349
417
|
return
|
|
350
418
|
}
|
|
351
419
|
const context = {
|
|
352
|
-
keys:
|
|
420
|
+
keys: screen.rowKeys,
|
|
353
421
|
targets: screen.body.targets,
|
|
354
422
|
headerRows: screen.header.length,
|
|
355
423
|
bodyRows: bodyRows(screen),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { sectionLines } from '../plan-format/markdown'
|
|
2
|
+
import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { shortTitle } from './task-name'
|
|
4
|
+
import type { BlockItem } from './waiting-click'
|
|
5
|
+
|
|
6
|
+
/** A line of the board's next section as the herd shows it. */
|
|
7
|
+
export type NextRow = { text: string }
|
|
8
|
+
|
|
9
|
+
/** The next block: the section's lines without the ones the for-you block already shows. */
|
|
10
|
+
export type NextBlock = { rows: NextRow[] }
|
|
11
|
+
|
|
12
|
+
const MAX_NEXT_ROWS = 3
|
|
13
|
+
|
|
14
|
+
const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
|
|
15
|
+
|
|
16
|
+
const sameText = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The next section of the board, up to three list lines in the order written; the parser cuts after three, as the board
|
|
20
|
+
* says. A line equal to one of `shown` (the texts of the for-you block) is left out: it stands above already.
|
|
21
|
+
*/
|
|
22
|
+
export const nextBlock = (board: string, language: PlanLanguage, shown: readonly string[]): NextBlock => ({
|
|
23
|
+
rows: sectionLines(board, NEXT_VOCABULARY[language].heading)
|
|
24
|
+
.flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
|
|
25
|
+
.slice(0, MAX_NEXT_ROWS)
|
|
26
|
+
.filter((text) => !shown.some((known) => sameText(known, text)))
|
|
27
|
+
.map((text) => ({ text })),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const nextKey = ({ text }: NextRow): string => `next>${text}`
|
|
31
|
+
|
|
32
|
+
/** The rows as the block draws them, numbered; a click on one only expands it. */
|
|
33
|
+
export const nextItems = (rows: readonly NextRow[]): BlockItem[] =>
|
|
34
|
+
rows.map((row, index) => ({
|
|
35
|
+
key: nextKey(row),
|
|
36
|
+
mark: String(index + 1),
|
|
37
|
+
text: row.text,
|
|
38
|
+
title: shortTitle(row.text),
|
|
39
|
+
url: null,
|
|
40
|
+
ask: null,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
/** The keys of all rows: what the fold of the block remembers. */
|
|
44
|
+
export const nextKeys = (rows: readonly NextRow[]): string[] => rows.map(nextKey)
|
package/src/plans/planned.ts
CHANGED
|
@@ -19,8 +19,15 @@ export type PlannedPlan = {
|
|
|
19
19
|
phaseIds: string[]
|
|
20
20
|
done: number
|
|
21
21
|
total: number
|
|
22
|
+
/** Set when the plan file was last changed `STALE_PLAN_DAYS` or more days ago; absent otherwise or when git does not know. */
|
|
23
|
+
stale?: true
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/** Days without a change to its plan file from which a planned phase reads `stale?`. */
|
|
27
|
+
export const STALE_PLAN_DAYS = 14
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 86_400_000
|
|
30
|
+
|
|
24
31
|
/** A lane of the sprint and what its agent reports, null when it has none. */
|
|
25
32
|
export type PlannedLane = { lane: SprintLane; agent: string | null }
|
|
26
33
|
|
|
@@ -76,9 +83,24 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
|
|
|
76
83
|
})
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/** The plans with `stale` set on those whose file `touchedAt` says was last changed `STALE_PLAN_DAYS` or more days before `now`. */
|
|
87
|
+
export const markStale = (
|
|
88
|
+
plans: readonly PlannedPlan[],
|
|
89
|
+
touchedAt: (path: string) => number | null,
|
|
90
|
+
now: number,
|
|
91
|
+
): PlannedPlan[] =>
|
|
92
|
+
plans.map((plan) => {
|
|
93
|
+
const touched = touchedAt(plan.path)
|
|
94
|
+
return touched !== null && now - touched >= STALE_PLAN_DAYS * DAY_MS ? { ...plan, stale: true } : plan
|
|
95
|
+
})
|
|
96
|
+
|
|
79
97
|
/** `P3 P4 P5` */
|
|
80
98
|
export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
|
|
81
99
|
|
|
100
|
+
/** `P3`: the phase of the plan that comes next, the first open one in the order of the plan. */
|
|
101
|
+
export const plannedNextLabel = (plan: Pick<PlannedPlan, 'phaseIds' | 'stale'>): string =>
|
|
102
|
+
`${phaseLabels(plan.phaseIds.slice(0, 1), ' ')}${plan.stale ? ' stale?' : ''}`
|
|
103
|
+
|
|
82
104
|
/** How many phases the plans list together. */
|
|
83
105
|
export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
|
|
84
106
|
planned.reduce((count, plan) => count + plan.phaseIds.length, 0)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { boardRowKey } from './ask-target'
|
|
4
|
+
import { closeAppointmentRow, closeWaitingRow } from './board-close'
|
|
5
|
+
import { errorText } from './error-text'
|
|
6
|
+
import { isQuestionId } from './question-records'
|
|
7
|
+
import { DONE_KIND, type DoneTarget } from './row-targets'
|
|
8
|
+
import { TEXTS } from './texts'
|
|
9
|
+
|
|
10
|
+
/** What `closeRow` needs from the outside: the board file, its language, the clock, and the closing of log records. */
|
|
11
|
+
export type CloseDependencies = {
|
|
12
|
+
sprintFile: string
|
|
13
|
+
language: PlanLanguage
|
|
14
|
+
now: Date
|
|
15
|
+
/** Closes the records of the files and questions log by id; how many it closed. */
|
|
16
|
+
closeRecords: (ids: readonly string[]) => number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
|
|
20
|
+
if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
|
|
21
|
+
return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
|
|
25
|
+
if (target.kind === DONE_KIND.boardRow) return closeWaitingRow(board, language, target, now)
|
|
26
|
+
return target.kind === DONE_KIND.appointment ? closeAppointmentRow(board, language, target, now) : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const subjectOfRow = (target: DoneTarget): string => {
|
|
30
|
+
if (target.kind === DONE_KIND.boardRow) return `board row ${boardRowKey(target.since, target.item)}`
|
|
31
|
+
return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
|
|
35
|
+
try {
|
|
36
|
+
const board = readFileSync(dependencies.sprintFile, 'utf8')
|
|
37
|
+
const closed = closedBoard(target, board, dependencies)
|
|
38
|
+
if (closed === null) return TEXTS.notClosed('row not found in the board')
|
|
39
|
+
writeFileSync(dependencies.sprintFile, closed)
|
|
40
|
+
return TEXTS.closed(subjectOfRow(target))
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return TEXTS.notClosed(errorText(error))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
|
|
48
|
+
* status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
|
|
49
|
+
* changed since the herd drew it is found by its text or not at all.
|
|
50
|
+
*/
|
|
51
|
+
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
|
|
52
|
+
target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ESC, RESET, stripAnsi, visibleLength } from '../terminal/text'
|
|
2
|
+
import { textWidth } from '../terminal/width'
|
|
3
|
+
import { isChipKey } from './link-chips'
|
|
4
|
+
import type { ClickTarget, MouseEvent, Selection } from './selection'
|
|
5
|
+
import type { BlockHit, BlockRow } from './waiting-click'
|
|
6
|
+
|
|
7
|
+
/** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
|
|
8
|
+
export type FocusHit = { key: string; line: number; lines: number }
|
|
9
|
+
|
|
10
|
+
export const FOCUS_MARK = '❯'
|
|
11
|
+
export const FOCUS_ON = `${ESC}[48;5;238m`
|
|
12
|
+
|
|
13
|
+
const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
|
|
14
|
+
const LEADING_STYLES = new RegExp(`^((?:${ESC}\\[[0-9;]*m)*)(.)`, 'u')
|
|
15
|
+
/** The cells a row's lead may give up for the mark: a blank, the bar of a block, the closed chevron; an open chevron or a tree branch says more. */
|
|
16
|
+
const MARKABLE_LEADS = new Set([' ', '▌', '▸'])
|
|
17
|
+
|
|
18
|
+
const markedLead = (line: string): string =>
|
|
19
|
+
line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
|
|
20
|
+
MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A row under the mouse or the selection: a light background across `width` cells, and, on the first line of the row
|
|
25
|
+
* (`marked`), the `❯` in place of the bar, blank or closed chevron that leads it. The background is laid again after
|
|
26
|
+
* every reset inside the line, so styled text and buttons keep it.
|
|
27
|
+
*/
|
|
28
|
+
export const focusLine = (line: string, width: number, marked: boolean): string => {
|
|
29
|
+
const shown = marked ? markedLead(line) : line
|
|
30
|
+
const padded = `${shown}${' '.repeat(Math.max(width - visibleLength(shown), 0))}`
|
|
31
|
+
return `${FOCUS_ON}${padded.replace(BACKGROUND_LOST, (reset) => `${reset}${FOCUS_ON}`)}${RESET}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The row on screen line `line`, null where none is. */
|
|
35
|
+
export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit | null =>
|
|
36
|
+
hits.find((hit) => line >= hit.line && line < hit.line + hit.lines) ?? null
|
|
37
|
+
|
|
38
|
+
/** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
|
|
39
|
+
export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
|
|
40
|
+
|
|
41
|
+
/** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
|
|
42
|
+
export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
|
|
43
|
+
const line = row - 1
|
|
44
|
+
if (line < headerRows) return line
|
|
45
|
+
const bodyLine = line - headerRows
|
|
46
|
+
return bodyLine < bodyRows ? headerRows + top + bodyLine : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The key of the row under the mouse, null over none. */
|
|
50
|
+
export const hoveredKeyAt = (hits: readonly FocusHit[], mouse: MouseEvent, frame: HoverFrame): string | null => {
|
|
51
|
+
const line = lineIndexAt(mouse, frame)
|
|
52
|
+
return line === null ? null : (focusHitAt(hits, line)?.key ?? null)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BRACKET_BUTTON = /\[[^[\]]+\]/g
|
|
56
|
+
|
|
57
|
+
/** The columns `[from, to)` of the square-bracket buttons in the visible text of a line. */
|
|
58
|
+
export const bracketSpans = (visible: string): { from: number; to: number }[] =>
|
|
59
|
+
[...visible.matchAll(BRACKET_BUTTON)].map((match) => {
|
|
60
|
+
const from = textWidth(visible.slice(0, match.index))
|
|
61
|
+
return { from, to: from + textWidth(match[0]) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** The column a square-bracket button under `column` starts in, null where the mouse is over none. */
|
|
65
|
+
export const bracketAt = (visible: string, column: number): number | null =>
|
|
66
|
+
bracketSpans(visible).find((span) => column >= span.from && column < span.to)?.from ?? null
|
|
67
|
+
|
|
68
|
+
const STYLE_OR_LINK = new RegExp(`(${ESC}\\[[0-9;]*m|${ESC}\\]8;[^;${ESC}\\x07]*;[^${ESC}\\x07]*(?:${ESC}\\\\|\\x07))`)
|
|
69
|
+
const BOLD_ON = `${ESC}[1m`
|
|
70
|
+
const BOLD_OFF = `${ESC}[22m`
|
|
71
|
+
|
|
72
|
+
/** The line with the cells `[from, to)` bold, counted by what shows; styles and links in the line stay where they are. */
|
|
73
|
+
export const boldCells = (line: string, from: number, to: number): string => {
|
|
74
|
+
let column = 0
|
|
75
|
+
return line
|
|
76
|
+
.split(STYLE_OR_LINK)
|
|
77
|
+
.map((token, index) => {
|
|
78
|
+
if (index % 2 === 1) return token
|
|
79
|
+
return [...token]
|
|
80
|
+
.map((character) => {
|
|
81
|
+
const start = column
|
|
82
|
+
column += textWidth(character)
|
|
83
|
+
const opens = start === from ? BOLD_ON : ''
|
|
84
|
+
const closes = column >= to && start < to ? BOLD_OFF : ''
|
|
85
|
+
return `${opens}${character}${closes}`
|
|
86
|
+
})
|
|
87
|
+
.join('')
|
|
88
|
+
})
|
|
89
|
+
.join('')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The line with the square-bracket button that starts in column `from` bold; the line itself where none does. */
|
|
93
|
+
export const boldBracketAt = (line: string, from: number): string => {
|
|
94
|
+
const span = bracketSpans(stripAnsi(line)).find((each) => each.from === from)
|
|
95
|
+
return span ? boldCells(line, span.from, span.to) : line
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The key of the hit of a line that holds links but is no row of its own: it is marked by the mouse only. */
|
|
99
|
+
export const LINKS_KEY_PREFIX = 'links@'
|
|
100
|
+
|
|
101
|
+
/** The selection with the row under the mouse and the button in it set; the selection itself while both are the same. */
|
|
102
|
+
export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection =>
|
|
103
|
+
(selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from
|
|
104
|
+
? selection
|
|
105
|
+
: { ...selection, hovered: key, hoveredFrom: from }
|
|
106
|
+
|
|
107
|
+
/** Whether a row is the selected one or the one under the mouse. */
|
|
108
|
+
export const isFocused = ({ selected, hovered }: Selection, key: string): boolean => key === selected || key === hovered
|
|
109
|
+
|
|
110
|
+
/** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
|
|
111
|
+
export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
|
|
112
|
+
key === selected && key !== hovered
|
|
113
|
+
|
|
114
|
+
/** The row of a drawn block that has `key`, null for a key of the body. */
|
|
115
|
+
export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
|
|
116
|
+
blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
|
|
117
|
+
|
|
118
|
+
/** The rows of a block on top of the herd, from the screen line of its first row; none while the block is not drawn or folded. */
|
|
119
|
+
export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
|
|
120
|
+
let line = block?.firstLine ?? 0
|
|
121
|
+
return (block?.rows ?? []).map((row) => {
|
|
122
|
+
const hit = { key: row.key, line, lines: row.lines.length }
|
|
123
|
+
line += row.lines.length
|
|
124
|
+
return hit
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The rows of a body from the lines its whole-line targets span, `offset` screen lines below the top; a chip is part of
|
|
130
|
+
* its row. A line with chips and no row of its own (the links under a lane) is a hit under a key made of its line.
|
|
131
|
+
*/
|
|
132
|
+
export const focusHitsOfTargets = (targets: readonly ClickTarget[], offset: number): FocusHit[] => {
|
|
133
|
+
const hits = new Map<string, FocusHit>()
|
|
134
|
+
for (const { key, line, wholeLine } of targets) {
|
|
135
|
+
if (!wholeLine || isChipKey(key)) continue
|
|
136
|
+
const known = hits.get(key)
|
|
137
|
+
hits.set(
|
|
138
|
+
key,
|
|
139
|
+
known ? { ...known, lines: line + 1 - (known.line - offset) } : { key, line: offset + line, lines: 1 },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
const rowLines = new Set(targets.filter(({ wholeLine }) => wholeLine).map(({ line }) => line))
|
|
143
|
+
const linkLines = [...new Set(targets.filter(({ key }) => isChipKey(key)).map(({ line }) => line))]
|
|
144
|
+
const links = linkLines
|
|
145
|
+
.filter((line) => !rowLines.has(line))
|
|
146
|
+
.map((line) => ({ key: `${LINKS_KEY_PREFIX}${offset + line}`, line: offset + line, lines: 1 }))
|
|
147
|
+
return [...hits.values(), ...links]
|
|
148
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** What a `[done]` button closes: a record of the files and questions log, a row of the waiting section, an appointment row. */
|
|
2
|
+
export const DONE_KIND = { record: 'record', boardRow: 'boardRow', appointment: 'appointment' } as const
|
|
3
|
+
|
|
4
|
+
export type DoneTarget =
|
|
5
|
+
| { kind: typeof DONE_KIND.record; id: string }
|
|
6
|
+
| { kind: typeof DONE_KIND.boardRow; since: string; item: string }
|
|
7
|
+
| { kind: typeof DONE_KIND.appointment; when: string; action: string }
|
package/src/plans/selection.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { TEXTS } from './texts'
|
|
|
10
10
|
export type LaneFold = { open: boolean }
|
|
11
11
|
|
|
12
12
|
/** The blocks on top of the herd, by the name their fold is remembered under. */
|
|
13
|
-
export const TOP_BLOCK = { waiting: 'waiting', appointments: 'appointments' } as const
|
|
13
|
+
export const TOP_BLOCK = { waiting: 'waiting', next: 'next', appointments: 'appointments', sources: 'sources' } as const
|
|
14
14
|
export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -20,6 +20,10 @@ export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
|
|
|
20
20
|
*/
|
|
21
21
|
export type Selection = {
|
|
22
22
|
selected: string | null
|
|
23
|
+
/** The row under the mouse, null or absent while it is over none; drawn like the selected one. */
|
|
24
|
+
hovered?: string | null
|
|
25
|
+
/** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
|
|
26
|
+
hoveredFrom?: number | null
|
|
23
27
|
expanded: ReadonlySet<string>
|
|
24
28
|
showDone: boolean
|
|
25
29
|
showLegend: boolean
|
|
@@ -313,6 +317,9 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
|
|
|
313
317
|
return selection
|
|
314
318
|
}
|
|
315
319
|
|
|
320
|
+
/** Whether an input is enter or space, which acts on the selected row. */
|
|
321
|
+
export const isActivateKey = (input: string): boolean => isInput(INPUT.toggle, input)
|
|
322
|
+
|
|
316
323
|
/** The key that writes the whole sprint as an HTML page and opens it. */
|
|
317
324
|
export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
|
|
318
325
|
|
|
@@ -322,6 +329,12 @@ export const parseMouse = (key: string): MouseEvent | null => {
|
|
|
322
329
|
return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
|
|
323
330
|
}
|
|
324
331
|
|
|
332
|
+
const MOTION_BIT = 32
|
|
333
|
+
|
|
334
|
+
/** Whether a report is the mouse moving (with or without a button held), not a click, a release or the wheel. */
|
|
335
|
+
export const isMotion = ({ button, pressed }: MouseEvent): boolean =>
|
|
336
|
+
pressed && button >= MOTION_BIT && button < MOUSE_BUTTON.wheelUp
|
|
337
|
+
|
|
325
338
|
export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
|
|
326
339
|
targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
|
|
327
340
|
|
package/src/plans/settings.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join, relative } from 'node:path'
|
|
|
3
3
|
import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
|
+
import type { ToneName } from '../config/tones'
|
|
6
7
|
import type { TicketSource } from '../watch/plan-badges'
|
|
7
8
|
|
|
8
9
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
@@ -17,6 +18,12 @@ export type PlansSettings = Pick<
|
|
|
17
18
|
context: JackherdConfig['plans']['context']
|
|
18
19
|
/** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
|
|
19
20
|
ticketSources: TicketSource[]
|
|
21
|
+
/** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
|
|
22
|
+
herdSources: { name: string; tone: ToneName | null }[]
|
|
23
|
+
/** By source name: what the herd puts before a row of "Waiting for you" that came from the source, its `label` or else its name. */
|
|
24
|
+
sourceLabels: Record<string, string>
|
|
25
|
+
/** The tone of each role in the herd. */
|
|
26
|
+
tones: JackherdConfig['plans']['tones']
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
|
|
@@ -38,6 +45,9 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
38
45
|
ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
|
|
39
46
|
ticket ? [{ name, lane: lane ?? null }] : [],
|
|
40
47
|
),
|
|
48
|
+
herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
|
|
49
|
+
sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
|
|
50
|
+
tones: config.plans.tones,
|
|
41
51
|
})
|
|
42
52
|
|
|
43
53
|
/** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
|