rvlib-shipkit 1.2.0 → 1.3.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -1,11 +1,12 @@
1
1
  // the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
2
- // nth repo of the project list, ⌘J shows the overview, ⌘P the plan, ⌘K opens
3
- // the picker.
4
- // A digit is read from the physical key (`code`), so an AZERTY keyboard,
5
- // where the key under 1 types `&`, selects the same repo. Ctrl stands in for
2
+ // nth repo of the project list, then the home row under the right hand: ⌘J
3
+ // the overview, ⌘K the picker, ⌘L the activity, ⌘' the plan. ⌘B cycles the
4
+ // dock at the bottom of the page (dock-st.ts): the line, slim, open.
5
+ // A digit and the quote are read from the physical key (`code`), so an
6
+ // AZERTY keyboard, where the key under 1 types `&`, selects the same repo. Ctrl stands in for
6
7
  // ⌘ off a Mac. The macOS app binds none of these to a menu item, so they
7
8
  // reach the page (tests/home-strip.test.tsx reads ShipkitGui.swift).
8
- export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'picker' }
9
+ export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'picker' } | { t: 'dock' }
9
10
 
10
11
  type KeyLike = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
11
12
 
@@ -14,12 +15,46 @@ export function pageKey(e: KeyLike): PageKey | null {
14
15
  const digit = /^Digit([1-9])$/.exec(e.code)
15
16
  if (digit != null) return { t: 'repo', n: Number(digit[1]) }
16
17
  if (e.shiftKey) return null
18
+ if (e.code === 'Quote' || e.key === "'") return { t: 'planned' }
17
19
  const k = e.key.toLowerCase()
18
20
  if (k === 'j') return { t: 'home' }
19
- if (k === 'p') return { t: 'planned' }
20
21
  if (k === 'k') return { t: 'picker' }
22
+ if (k === 'l') return { t: 'activity' }
23
+ if (k === 'b') return { t: 'dock' }
21
24
  return null
22
25
  }
23
26
 
24
27
  /** the shortcut each place of the home carries on its button */
25
- export const PLACE_KEY = { overview: '⌘J', planned: '⌘P' } as const
28
+ export const PLACE_KEY = { overview: '⌘J', planned: "⌘'", activity: '⌘L' } as const
29
+ /** the picker's shortcut, on the second button, after home */
30
+ export const PICKER_KEY = '⌘K'
31
+ /** the dock's shortcut, on the dock's own header */
32
+ export const DOCK_KEY = '⌘B'
33
+
34
+ /** the tree's filter, on a repo page: a plain `/` focuses it, and its
35
+ * badge at the input's right prints this */
36
+ export const SEARCH_KEY = '/'
37
+
38
+ export function isSearchKey(e: Pick<KeyLike, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
39
+ return e.key === SEARCH_KEY && !e.metaKey && !e.ctrlKey && !e.altKey
40
+ }
41
+
42
+ /** a repo page's sections, each on ⌥ and its letter. ⌥ because every other
43
+ * layer is spoken for: the plain letters are the severity filter (a n r)
44
+ * and the chart's follow key, ⌘ with these letters is copy, reload, select
45
+ * all, new tab, minimize and close in the browser and the macOS app, and ⌥
46
+ * plus a letter only types a character inside a text field, where the page
47
+ * leaves the key alone. Read from the physical key (`code`): on a Mac ⌥C
48
+ * types ç, and AZERTY moves the letters */
49
+ export type SectionId = 'checks' | 'runs' | 'asks' | 'tests' | 'media' | 'wikis' | 'admin'
50
+ export const SECTION_LETTER: Record<SectionId, string> = { checks: 'C', runs: 'R', asks: 'A', tests: 'T', media: 'M', wikis: 'W', admin: 'D' }
51
+ /** the badge at the end of a section's line */
52
+ export const sectionKeyLabel = (id: SectionId): string => `⌥${SECTION_LETTER[id]}`
53
+
54
+ export function sectionKey(e: KeyLike): SectionId | null {
55
+ if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return null
56
+ const m = /^Key([A-Z])$/.exec(e.code)
57
+ if (m == null) return null
58
+ const hit = Object.entries(SECTION_LETTER).find(([, l]) => l === m[1])
59
+ return hit == null ? null : (hit[0] as SectionId)
60
+ }
@@ -1,11 +1,10 @@
1
- // The live strip under the header, as the page holds it: `/api/live.json`
2
- // read every second (the runs of the last ten minutes, the ones in flight,
3
- // here and in the other house repos), whether the strip is folded, and the
4
- // chart it draws. Folded, the chart is the thin rolling window of the last
5
- // two minutes; unfolded, the whole record (the recent runs of /api/live.json
6
- // over the older ones of /api/timeline.json), opened on its last ten minutes
7
- // and fit to all of it on demand. It is the hub's one run chart
8
- // (chart-view.ts, timeline-chart.tsx).
1
+ // A repo's live runs, as the page holds them: `/api/live.json` read every
2
+ // second while the repo shows (the runs of the last ten minutes and the ones
3
+ // in flight), which the header's run state reads, and the chart of the runs
4
+ // page over it: the whole record (the recent runs of /api/live.json over the
5
+ // older ones of /api/timeline.json), opened on its last ten minutes and fit
6
+ // to all of it on demand (chart-view.ts, timeline-chart.tsx). What runs in
7
+ // every repo at once is the dock's (dock-st.ts).
9
8
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
10
9
  import type { LiveView } from '../../ledger/live-timeline.ts'
11
10
  import type { TimelineInvocation } from '../../ledger/run-timeline.ts'
@@ -15,16 +14,15 @@ import { chartHeight } from './timeline-geometry.ts'
15
14
 
16
15
  /** how often the page asks. A strip a second behind the runner is still live */
17
16
  export const LIVE_POLL_MS = 1_000
18
- /** the folded strip's window, and the span the unfolded one opens on */
19
- export const FOLDED_WINDOW_MS = 120_000
17
+ /** the span the chart opens on */
20
18
  export const OPEN_WINDOW_MS = 600_000
21
- /** the most the unfolded chart grows to: past it the lanes scroll inside it */
19
+ /** the most the chart grows to: past it the lanes scroll inside it */
22
20
  export const OPEN_MAX_H = 320
23
- /** how long the unfolded chart keeps a height its lanes no longer fill,
21
+ /** how long the chart keeps a height its lanes no longer fill,
24
22
  * so it grows at once and never shrinks under the eye every second */
25
23
  export const HEIGHT_HOLD_MS = 10_000
26
24
 
27
- /** the height the unfolded chart shows: up at once, down only once the
25
+ /** the height the chart shows: up at once, down only once the
28
26
  * lanes have wanted less for HEIGHT_HOLD_MS */
29
27
  export type HeldHeight = { h: number; lowSince: number | null }
30
28
  export function settleHeight(prev: HeldHeight, wanted: number, now: number, holdMs = HEIGHT_HOLD_MS): HeldHeight {
@@ -33,32 +31,20 @@ export function settleHeight(prev: HeldHeight, wanted: number, now: number, hold
33
31
  return now - prev.lowSince >= holdMs ? { h: wanted, lowSince: null } : prev
34
32
  }
35
33
 
36
- /** the whole record the unfolded chart draws: every run of the live view,
34
+ /** the whole record the chart draws: every run of the live view,
37
35
  * then the older runs of the record it does not carry, newest first */
38
36
  export function mergeRuns(recent: TimelineInvocation[], record: TimelineInvocation[]): TimelineInvocation[] {
39
37
  const seen = new Set(recent.map((i) => i.runId))
40
38
  return [...recent, ...record.filter((i) => !seen.has(i.runId))].sort((a, b) => b.started - a.started)
41
39
  }
42
- /** the lanes the folded strip has room for */
43
- export const FOLDED_LANES = 5
44
-
45
- /** where the fold is remembered. localStorage is per origin and every
46
- * repo's hub has its own port, so this is per repo already */
47
- export const OPEN_KEY = 'hub:liveOpen'
48
-
49
- const readOpen = (): boolean => {
50
- try { return globalThis.localStorage?.getItem(OPEN_KEY) === '1' } catch { return false }
51
- }
52
-
53
40
  /** the server clock now: the view's stamp plus what the page clock has
54
41
  * counted since it arrived, since the page may be another machine */
55
- export const serverNow = (view: LiveView, receivedAt: number, clientNow: number): number => view.now + Math.max(0, clientNow - receivedAt)
42
+ export const serverNow = (view: { now: number }, receivedAt: number, clientNow: number): number => view.now + Math.max(0, clientNow - receivedAt)
56
43
 
57
- /** what the chart draws: folded, the rolling two minutes; open, the whole
58
- * record, opening on its last ten */
59
- export function liveSource(view: LiveView | null, now: number, open: boolean, record: TimelineInvocation[] = []): ChartSource | null {
44
+ /** what the runs page's chart draws: the whole record, opening on its last
45
+ * ten minutes */
46
+ export function liveSource(view: LiveView | null, now: number, record: TimelineInvocation[] = []): ChartSource | null {
60
47
  if (view == null) return null
61
- if (!open) return { invocations: view.invocations, now, windowMs: FOLDED_WINDOW_MS, laneCap: Math.max(1, FOLDED_LANES - view.repos.length), zoomable: false, cap: view.cap }
62
48
  return { invocations: mergeRuns(view.invocations, record), now, windowMs: null, zoomable: true, cap: view.cap, homeSpanMs: OPEN_WINDOW_MS }
63
49
  }
64
50
 
@@ -67,10 +53,7 @@ export class LiveSvc {
67
53
  /** the page clock when `view` arrived */
68
54
  receivedAt = 0
69
55
  error = ''
70
- /** a short answer to a click (a repo whose hub is not serving) */
71
- note = ''
72
- open = readOpen()
73
- /** the unfolded chart's height, held so it does not jump every second */
56
+ /** the chart's height, held so it does not jump every second */
74
57
  held: HeldHeight = { h: 0, lowSince: null }
75
58
  chart: ChartView
76
59
  private timer: ReturnType<typeof setInterval> | null = null
@@ -78,7 +61,7 @@ export class LiveSvc {
78
61
  private started = false
79
62
 
80
63
  constructor(private st: RepoSt) {
81
- this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.open, this.st.timeline.data?.invocations ?? []))
64
+ this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.st.timeline.data?.invocations ?? []))
82
65
  makeAutoObservable<this, 'st' | 'timer' | 'inFlight' | 'started'>(this, { st: false, timer: false, inFlight: false, started: false, view: observableRef, chart: false })
83
66
  }
84
67
 
@@ -87,18 +70,9 @@ export class LiveSvc {
87
70
  return this.view == null ? this.st.clock.now : serverNow(this.view, this.receivedAt, this.st.clock.now)
88
71
  }
89
72
 
90
- toggle(): void {
91
- this.open = !this.open
92
- try { globalThis.localStorage?.setItem(OPEN_KEY, this.open ? '1' : '0') } catch { /* private window: the fold lasts the tab */ }
93
- // either fold starts live: the folded strip is a trail, nothing else
94
- this.chart.attach()
95
- this.opened()
96
- }
97
-
98
- /** unfolded: back to the last ten minutes, the older record fetched, and
99
- * a height from the lanes as they are */
73
+ /** back to the last ten minutes, the older record fetched, and a height
74
+ * from the lanes as they are */
100
75
  private opened(): void {
101
- if (!this.open) return
102
76
  this.chart.home()
103
77
  this.chart.attach()
104
78
  this.held = { h: 0, lowSince: null }
@@ -108,8 +82,10 @@ export class LiveSvc {
108
82
 
109
83
  /** the height the lanes want now, as the held rule lets it show */
110
84
  settle(now: number): void {
85
+ // no runs page on screen, no chart to size: the record is not laid out
86
+ if (this.chart.viewW === 0) return
111
87
  const l = this.chart.layout
112
- const lanes = (l?.lanes ?? 0) + (this.view?.repos.length ?? 0)
88
+ const lanes = l?.lanes ?? 0
113
89
  this.held = settleHeight(this.held, Math.min(OPEN_MAX_H, chartHeight(lanes)), now)
114
90
  }
115
91
 
@@ -139,7 +115,7 @@ export class LiveSvc {
139
115
  this.view = v
140
116
  this.receivedAt = Date.now()
141
117
  this.error = ''
142
- if (this.open) this.settle(Date.now())
118
+ this.settle(Date.now())
143
119
  })
144
120
  // a run that ended, from a terminal as much as from a button,
145
121
  // wrote new numbers: the rows and the timeline read them now
@@ -158,20 +134,4 @@ export class LiveSvc {
158
134
  get running(): boolean {
159
135
  return (this.view?.runs.length ?? 0) > 0
160
136
  }
161
-
162
- /** a repo lane was clicked: its hub, when it serves */
163
- async openRepo(repo: string): Promise<void> {
164
- try {
165
- const r = (await (await fetch(this.st.url(`/api/repo-hub?repo=${encodeURIComponent(repo)}`))).json()) as { url: string | null }
166
- if (r.url != null) window.open(r.url, '_blank')
167
- else this.say(`${repo} has no hub serving`)
168
- } catch (e) {
169
- this.say(`${repo}: ${(e as Error).message}`)
170
- }
171
- }
172
-
173
- private say(text: string): void {
174
- runInAction(() => { this.note = text })
175
- setTimeout(() => runInAction(() => { if (this.note === text) this.note = '' }), 4_000)
176
- }
177
137
  }
@@ -10,6 +10,8 @@ import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
10
  import type { WorkerView } from '../../home/hubs.ts'
11
11
  import type { ResourceView } from '../../home/resources.ts'
12
12
  import type { PmAnswer, Role } from '../../home/pm.ts'
13
+ import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
14
+ import { ClockSvc } from './clock-svc.ts'
13
15
  import type { HomeSt } from './home-st.ts'
14
16
  import type { HubDot } from './projects-svc.ts'
15
17
 
@@ -22,6 +24,21 @@ export const MACHINE_TIMEOUT_MS = 8_000
22
24
  * on the next load (the overview) while the daemon and pm are asked again */
23
25
  export const MACHINE_KEY = 'hub:machine'
24
26
 
27
+ /** how long a copied pid says so */
28
+ export const COPIED_MS = 1_200
29
+
30
+ /** how open opens a global process's url, kept per browser */
31
+ export const OPEN_HOW_KEY = 'hub:open-how'
32
+ export type OpenHow = 'browser' | 'app'
33
+
34
+ function loadOpenHow(): OpenHow {
35
+ try {
36
+ return globalThis.localStorage?.getItem(OPEN_HOW_KEY) === 'app' ? 'app' : 'browser'
37
+ } catch {
38
+ return 'browser'
39
+ }
40
+ }
41
+
25
42
  type Kept = { data: HomeAnswer | null; pm: PmAnswer | null; widgets: WidgetsAnswer | null }
26
43
 
27
44
  function loadKept(): Kept | null {
@@ -48,11 +65,19 @@ export class MachineSt {
48
65
  pmDown = ''
49
66
  /** the last action that failed */
50
67
  error = ''
68
+ /** where open sends a global process's url: the default browser, or a
69
+ * shipkit app window */
70
+ openHow: OpenHow = loadOpenHow()
51
71
  /** why the daemon did not answer the last poll, or '' while it does */
52
72
  down = ''
53
73
  /** the daemon answered once since the load: until then data, pm and
54
74
  * widgets are what this browser kept, shown but never acted on */
55
75
  fresh = false
76
+ /** the one second the process ages move on, ticking while the machine polls */
77
+ clock: ClockSvc
78
+ /** the pid a click just copied, which says so for COPIED_MS */
79
+ copiedPid: number | null = null
80
+ private copiedTimer: ReturnType<typeof setTimeout> | null = null
56
81
  /** what was last kept, so an unchanged machine is not written again */
57
82
  private kept = ''
58
83
  private timer: ReturnType<typeof setInterval> | null = null
@@ -63,13 +88,14 @@ export class MachineSt {
63
88
  private pollError = false
64
89
 
65
90
  constructor(private st: HomeSt, private o: MachineOpts = {}) {
91
+ this.clock = new ClockSvc()
66
92
  const k = loadKept()
67
93
  if (k != null) {
68
94
  this.data = k.data
69
95
  this.pm = k.pm
70
96
  this.widgets = k.widgets
71
97
  }
72
- makeAutoObservable<this, 'st' | 'timer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, timer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
98
+ makeAutoObservable<this, 'st' | 'timer' | 'copiedTimer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, clock: false, timer: false, copiedTimer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
73
99
  }
74
100
 
75
101
  /** the repo the page shows, null on the overview and the plan */
@@ -85,6 +111,13 @@ export class MachineSt {
85
111
  return this.worker(this.repo)
86
112
  }
87
113
 
114
+ /** the processes table: shipkit's own first, then one group per repo in
115
+ * the project list's order, one row per process (process-rows.ts, what
116
+ * `shipkit global-processes` prints) */
117
+ get processGroups(): ProcessGroup[] {
118
+ return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null })
119
+ }
120
+
88
121
  resource(id: string): ResourceView | null {
89
122
  return this.pm?.resources.find((r) => r.id === id) ?? null
90
123
  }
@@ -110,10 +143,12 @@ export class MachineSt {
110
143
  if (this.repo != null && this.data != null) void this.hub(this.repo, 'ensure')
111
144
  })
112
145
  this.timer = setInterval(() => void this.load(), MACHINE_POLL_MS)
146
+ this.clock.start()
113
147
  }
114
148
 
115
149
  stop(): void {
116
150
  if (this.timer != null) clearInterval(this.timer)
151
+ this.clock.stop()
117
152
  }
118
153
 
119
154
  /** pm is polled beside the daemon: either one down leaves the other shown */
@@ -249,6 +284,34 @@ export class MachineSt {
249
284
  return this.post('/api/pm/resource', { id, act, action })
250
285
  }
251
286
 
287
+ /** the url of a global process, the way this browser picked (started
288
+ * first when it is down) */
289
+ openProcess(id: string): Promise<boolean> {
290
+ return this.processAct(id, this.openHow === 'app' ? 'app' : 'open')
291
+ }
292
+
293
+ /** a process's pid to the clipboard, and the "copied" flash on it; a
294
+ * refusal (plain http, an unfocused page) is said in the strip */
295
+ async copyPid(pid: number): Promise<void> {
296
+ try {
297
+ if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
298
+ await navigator.clipboard.writeText(String(pid))
299
+ } catch (e) {
300
+ runInAction(() => { this.error = `could not copy pid ${pid}: ${(e as Error).message}` })
301
+ return
302
+ }
303
+ runInAction(() => { this.copiedPid = pid })
304
+ if (this.copiedTimer != null) clearTimeout(this.copiedTimer)
305
+ this.copiedTimer = setTimeout(() => runInAction(() => { this.copiedPid = null }), COPIED_MS)
306
+ }
307
+
308
+ setOpenHow(how: OpenHow): void {
309
+ this.openHow = how
310
+ try {
311
+ globalThis.localStorage?.setItem(OPEN_HOW_KEY, how)
312
+ } catch { /* private window or blocked site data: the pick lasts the tab */ }
313
+ }
314
+
252
315
  /** restart the daemon or the web through pm */
253
316
  restartChild(id: Role): Promise<boolean> {
254
317
  return this.post('/api/pm/child', { id, act: 'restart' })
@@ -8,13 +8,15 @@ import sanitizeHtml from 'sanitize-html'
8
8
 
9
9
  marked.use({ gfm: true, breaks: false, async: false })
10
10
 
11
+ const CARD_TAGS = [
12
+ 'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
13
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
14
+ 'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
15
+ ]
16
+
11
17
  export const renderMd = (src: string): string =>
12
18
  sanitizeHtml(marked.parse(src) as string, {
13
- allowedTags: [
14
- 'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
15
- 'table', 'thead', 'tbody', 'tr', 'th', 'td',
16
- 'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
17
- ],
19
+ allowedTags: CARD_TAGS,
18
20
  allowedAttributes: { a: ['href', 'target', 'rel'] },
19
21
  allowedSchemes: ['http', 'https'],
20
22
  allowProtocolRelative: false,
@@ -37,3 +39,34 @@ export const renderMdInline = (src: string): string =>
37
39
  a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
38
40
  },
39
41
  })
42
+
43
+ /** where a README image loads from: an http(s) url as written, a path
44
+ * relative to the repo root through `fileUrl`, anything else (data:, file:,
45
+ * `//host`) nowhere */
46
+ export const readmeImageSrc = (src: string, fileUrl: (rel: string) => string): string | null => {
47
+ const s = src.trim()
48
+ if (/^https?:\/\//i.test(s)) return s
49
+ if (s === '' || s.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(s)) return null
50
+ const rel = s.replace(/[?#].*$/, '').replace(/^(\.\/|\/)+/, '')
51
+ return rel === '' ? null : fileUrl(rel.split('/').map(encodeURIComponent).join('/'))
52
+ }
53
+
54
+ /** a whole README: the card allowlist plus images and the few tags READMEs
55
+ * lean on. A relative image is rewritten to the repo's file route, an image
56
+ * with no loadable source is dropped */
57
+ export const renderReadme = (src: string, fileUrl: (rel: string) => string): string =>
58
+ sanitizeHtml(marked.parse(src) as string, {
59
+ allowedTags: [...CARD_TAGS, 'h5', 'h6', 'img', 'details', 'summary', 'kbd', 'sup', 'sub', 'del'],
60
+ allowedAttributes: { a: ['href', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height'] },
61
+ allowedSchemes: ['http', 'https'],
62
+ allowProtocolRelative: false,
63
+ transformTags: {
64
+ a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
65
+ img: (tagName, attribs) => {
66
+ const { src: raw, ...rest } = attribs
67
+ const src = readmeImageSrc(raw ?? '', fileUrl)
68
+ return { tagName, attribs: src == null ? rest : { ...rest, src } }
69
+ },
70
+ },
71
+ exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
72
+ })
@@ -44,7 +44,7 @@ export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
44
44
  export function planWords(row: PlanRow, now: number): { words: string; at: number | null } {
45
45
  if (row.state === 'running') return { words: 'running now', at: row.dueAt }
46
46
  if (row.state === 'due') return { words: row.reason === 'asked' ? 'due now, asked' : row.reason === 'changed' ? 'due now, a save moved it' : 'due now', at: row.dueAt }
47
- if (row.state === 'late') return { words: 'late, waits for the repo to be active', at: row.dueAt }
47
+ if (row.state === 'late') return { words: 'waits for the repo to be active', at: row.dueAt }
48
48
  if (row.state === 'next' && row.dueAt != null) return { words: `in ${aheadWords(row.dueAt - now)}`, at: row.dueAt }
49
49
  return { words: 'on the next save that moves what it reads', at: null }
50
50
  }
@@ -1,5 +1,5 @@
1
1
  // the one picker of the page (cmd+k, keys.ts), a node of the root: the
2
- // home's places (home, planned) first, then the shown repo's own jump
2
+ // home's places (home, planned, activity) first, then the shown repo's own jump
3
3
  // targets, then every other house repo as a place to go. Another repo's
4
4
  // resources are never listed: a jump to a repo selects it in place, and its
5
5
  // own targets are one more cmd+k away.
@@ -24,24 +24,28 @@ export class PaletteSt {
24
24
  const st = this.st
25
25
  if (st.machine == null) return []
26
26
  return [
27
- { id: 'place:home', label: 'home', section: 'place', run: () => st.navigate('/') },
28
- { id: 'place:planned', label: 'planned', section: 'place', run: () => st.navigate('/planned') },
27
+ { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
+ { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
29
+ { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
30
+ { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
29
31
  ]
30
32
  }
31
33
 
32
- /** every other house repo, as a place to go */
34
+ /** every house repo but the one on screen, as a place to go: on a
35
+ * global page that is every repo */
33
36
  get others(): Jump[] {
34
37
  const st = this.st
35
38
  if (st.machine == null) return []
36
- return st.projects.list.filter((p) => p.name !== st.selected).map((p) => ({ id: `repo:${p.name}`, label: p.name, section: 'repo', run: () => st.select(p.name) }))
39
+ const on = st.shown?.key
40
+ return st.projects.list.filter((p) => p.name !== on).map((p) => ({ id: `repo:${p.name}`, label: p.name, section: 'repo', face: { repo: p.name }, run: () => st.select(p.name) }))
37
41
  }
38
42
 
39
43
  get jumps(): Jump[] {
40
44
  return [...this.places, ...(this.st.shown?.jumps ?? []), ...this.others]
41
45
  }
42
46
 
43
- /** ranked: prefix matches first, then substring. With no query, home and
44
- * planned, then the shown repo's first targets, then every other repo,
47
+ /** ranked: prefix matches first, then substring. With no query, home,
48
+ * planned and activity, then the shown repo's first targets, then every other repo,
45
49
  * so a jump elsewhere is always in reach */
46
50
  get results(): Jump[] {
47
51
  const q = this.query.trim().toLowerCase()
@@ -0,0 +1,47 @@
1
+ // the one picker, cmd+k (palette-st.ts): home, planned, activity and
2
+ // archived, the shown repo's targets, then every other repo. A place wears
3
+ // its button's icon, a repo its own icon, as the project list shows them.
4
+ import { observer } from 'mobx-react-lite'
5
+ import type { HomeSt } from './home-st.ts'
6
+ import { Icon, ICON } from './icons.tsx'
7
+ import { Face } from './project-list.tsx'
8
+ import type { JumpFace } from './repo-st.ts'
9
+
10
+ /** the icon a picker entry wears; an empty slot keeps the labels aligned */
11
+ const PalFace = observer(({ app, face }: { app: HomeSt; face: JumpFace | undefined }) => {
12
+ if (face != null && 'place' in face) return <span className="pface"><Icon d={ICON[face.place]} /></span>
13
+ const p = face == null ? null : app.projects.list.find((x) => x.name === face.repo) ?? null
14
+ return <span className="pface">{p == null ? null : <Face p={p} px={16} />}</span>
15
+ })
16
+
17
+ export const Palette = observer(({ app }: { app: HomeSt }) => {
18
+ const p = app.palette
19
+ if (!p.open) return null
20
+ return (
21
+ <div className="palov" onClick={() => p.close()}>
22
+ <div className="pal" onClick={(e) => e.stopPropagation()}>
23
+ <input autoFocus placeholder="jump to…" value={p.query}
24
+ onChange={(e) => p.setQuery(e.target.value)}
25
+ onKeyDown={(e) => {
26
+ if (e.key === 'ArrowDown') { e.preventDefault(); p.move(1) }
27
+ else if (e.key === 'ArrowUp') { e.preventDefault(); p.move(-1) }
28
+ else if (e.key === 'Enter') p.run()
29
+ else if (e.key === 'Escape') p.close()
30
+ }} />
31
+ <div className="prows">
32
+ {p.results.map((jp, n) => (
33
+ <div key={jp.id} className={`prow${n === p.idx ? ' on' : ''}`}
34
+ onMouseEnter={() => p.setIdx(n)}
35
+ onClick={() => p.run(jp)}>
36
+ <PalFace app={app} face={jp.face} />
37
+ <span className="plabel">{jp.label}</span>
38
+ <span className="pchip">{jp.section}</span>
39
+ {jp.key == null ? null : <kbd className="pjkey">{jp.key}</kbd>}
40
+ </div>
41
+ ))}
42
+ {p.results.length === 0 ? <div className="prow none">no match</div> : null}
43
+ </div>
44
+ </div>
45
+ </div>
46
+ )
47
+ })
@@ -0,0 +1,65 @@
1
+ // a row of small boxes that stays one row at any width: the ones that do not
2
+ // fit fold into a `+N` box at its end, whose hover lists them. An invisible
3
+ // copy of every box, out of the flow, gives their widths, so what shows is
4
+ // decided before the paint and never wraps. Each box's hover is its title.
5
+ import { observer, useLocalObservable } from 'mobx-react-lite'
6
+ import { useLayoutEffect, useRef, type ReactNode } from 'react'
7
+ import { Tip } from './floating.tsx'
8
+
9
+ export type PillItem = { key: string; node: ReactNode; title: string }
10
+
11
+ /** how many boxes of these widths fit in `avail`, a `+N` box of `plusW`
12
+ * taking its room as soon as one does not */
13
+ export function fitCount(widths: number[], avail: number, plusW: number, gap: number): number {
14
+ const all = widths.reduce((a, w) => a + w, 0) + gap * Math.max(0, widths.length - 1)
15
+ if (all <= avail) return widths.length
16
+ let used = plusW
17
+ for (let i = 0; i < widths.length; i++) {
18
+ used += widths[i]! + gap
19
+ if (used > avail) return i
20
+ }
21
+ return widths.length
22
+ }
23
+
24
+ const lines = (text: string): ReactNode => text.split('\n').map((l, i) => <div key={i}>{l}</div>)
25
+
26
+ export const PillRow = observer(({ items, className }: { items: PillItem[]; className?: string }) => {
27
+ const st = useLocalObservable(() => ({ n: Number.POSITIVE_INFINITY, set(v: number) { this.n = v } }))
28
+ const box = useRef<HTMLDivElement | null>(null)
29
+ const probe = useRef<HTMLDivElement | null>(null)
30
+ const measure = (): void => {
31
+ const b = box.current
32
+ const p = probe.current
33
+ if (b == null || p == null) return
34
+ const kids = [...p.children] as HTMLElement[]
35
+ const plus = kids.pop()
36
+ if (plus == null) return
37
+ const gap = Number.parseFloat(getComputedStyle(b).columnGap) || 0
38
+ st.set(fitCount(kids.map((k) => k.getBoundingClientRect().width), b.clientWidth, plus.getBoundingClientRect().width, gap))
39
+ }
40
+ // after every render: the boxes' words change with the clock
41
+ useLayoutEffect(measure)
42
+ useLayoutEffect(() => {
43
+ const b = box.current
44
+ if (b == null || typeof ResizeObserver === 'undefined') return
45
+ const ro = new ResizeObserver(() => measure())
46
+ ro.observe(b)
47
+ return () => ro.disconnect()
48
+ }, [])
49
+ const n = Math.min(st.n, items.length)
50
+ const hidden = items.slice(n)
51
+ return (
52
+ <div ref={box} className={`pchips${className == null ? '' : ` ${className}`}`}>
53
+ {items.slice(0, n).map((it) => <Tip key={it.key} className="pchipw" content={lines(it.title)}>{it.node}</Tip>)}
54
+ {hidden.length === 0 ? null : (
55
+ <Tip className="pchipw" content={hidden.map((h) => <div key={h.key}>{lines(h.title)}</div>)}>
56
+ <span className="pchip dim">+{hidden.length}</span>
57
+ </Tip>
58
+ )}
59
+ <div ref={probe} className="pchips pchipprobe" aria-hidden="true">
60
+ {items.map((it) => <span key={it.key} className="pchipw">{it.node}</span>)}
61
+ <span className="pchip dim">+{items.length}</span>
62
+ </div>
63
+ </div>
64
+ )
65
+ })