rvlib-shipkit 1.1.1 → 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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  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 +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -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_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -1,14 +1,15 @@
1
- // THE PAGES OF THE TEST GROUPS: `tests` itself, one line per test check, and
1
+ // THE PAGES OF THE TEST GROUPS: `tests` itself, every test file of the repo
2
+ // slowest first then one line per test check, and
2
3
  // each test check (`test`, `test:<unit>`), which answers one question: where
3
4
  // does its time go. The check's own facts (its sentence, its command, its
4
5
  // last wall time and its trend) are the check page's pieces, read from the
5
6
  // resource the tree already holds. The breakdown is the one fetch the page
6
7
  // makes (tests-svc.ts), shaped on the server (collections/test-time.ts).
7
8
  import { observer } from 'mobx-react-lite'
8
- import type { FailingFile, FileTime, LastRun, Phases, Shape, TestCheckRow, TestCheckView, TestsRootView } from '../../collections/test-time.ts'
9
+ import type { FailingFile, FileTime, LastRun, Phases, Shape, TestCheckRow, TestCheckView, TestFileRow, TestsRootView } from '../../collections/test-time.ts'
9
10
  import { CommandLine, LastRunValue, Row, Says, Section, Timings } from './check-view.tsx'
10
11
  import { durationTone, fmtMs } from './dur.ts'
11
- import type { HubSt } from './hub-st.ts'
12
+ import type { RepoSt } from './repo-st.ts'
12
13
  import { TimeAgo } from './time-ago.tsx'
13
14
 
14
15
  const pct = (n: number | null): string => (n == null ? '' : `${Math.round(n * 100)}%`)
@@ -22,13 +23,13 @@ const TREND_TONE = { up: 'bad', down: 'fast', flat: 'dim' } as const
22
23
 
23
24
  export const SHAPE_WORD: Record<Shape, string> = { tail: 'long tail', volume: 'volume', overhead: 'overhead' }
24
25
 
25
- const FileLink = ({ app, file, name }: { app: HubSt; file: string; name: string }) => (
26
+ const FileLink = ({ app, file, name }: { app: RepoSt; file: string; name: string }) => (
26
27
  <a href="#" title={`${file}: its own page`} onClick={(e) => { e.preventDefault(); e.stopPropagation(); app.tests.openFile(file) }}>{name}</a>
27
28
  )
28
29
 
29
30
  // ── failing now ─────────────────────────────────────────────────────────
30
31
 
31
- const Failing = observer(({ app, rows, showCheck }: { app: HubSt; rows: FailingFile[]; showCheck: boolean }) => (
32
+ const Failing = observer(({ app, rows, showCheck }: { app: RepoSt; rows: FailingFile[]; showCheck: boolean }) => (
32
33
  <Section title={`failing now (${rows.length})`} help="the files whose last recorded run failed: the same verdict that colours them red in the tree and lists them on the runs page tab">
33
34
  {rows.length === 0
34
35
  ? <div className="dim">nothing: every file passed its last recorded run</div>
@@ -50,7 +51,7 @@ const Failing = observer(({ app, rows, showCheck }: { app: HubSt; rows: FailingF
50
51
 
51
52
  // ── where the time goes ─────────────────────────────────────────────────
52
53
 
53
- const Summary = observer(({ app, l }: { app: HubSt; l: LastRun }) => (
54
+ const Summary = observer(({ app, l }: { app: RepoSt; l: LastRun }) => (
54
55
  <div className="tsum">
55
56
  <div className="tsline">
56
57
  <span><b>{l.files}</b> files</span>
@@ -121,7 +122,7 @@ const PhaseFacts = ({ p, wallMs }: { p: Phases; wallMs: number | null }) => {
121
122
  )
122
123
  }
123
124
 
124
- const FileRows = observer(({ app, files }: { app: HubSt; files: FileTime[] }) => (
125
+ const FileRows = observer(({ app, files }: { app: RepoSt; files: FileTime[] }) => (
125
126
  <div className="rlist">
126
127
  <div className="rhead rtime">
127
128
  <span className="tleft">file</span>
@@ -152,13 +153,13 @@ const FileRows = observer(({ app, files }: { app: HubSt; files: FileTime[] }) =>
152
153
 
153
154
  // ── the pages ────────────────────────────────────────────────────────────
154
155
 
155
- const Loading = observer(({ app, check }: { app: HubSt; check: string | null }) => {
156
+ const Loading = observer(({ app, check }: { app: RepoSt; check: string | null }) => {
156
157
  const err = app.tests.error(check)
157
158
  if (err !== '') return <div className="warnline">✗ {err}</div>
158
159
  return <div className="dim">{app.tests.isLoading(check) ? 'reading the record…' : 'nothing loaded yet'}</div>
159
160
  })
160
161
 
161
- export const TestCheckPage = observer(({ app, check }: { app: HubSt; check: string }) => {
162
+ export const TestCheckPage = observer(({ app, check }: { app: RepoSt; check: string }) => {
162
163
  const i = app.cols.find('checks', `check:${check}`)
163
164
  const u = i?.check ?? null
164
165
  const v: TestCheckView | null = app.tests.check(check)
@@ -194,7 +195,7 @@ export const TestCheckPage = observer(({ app, check }: { app: HubSt; check: stri
194
195
  )
195
196
  })
196
197
 
197
- const CheckRows = observer(({ app, rows }: { app: HubSt; rows: TestCheckRow[] }) => (
198
+ const CheckRows = observer(({ app, rows }: { app: RepoSt; rows: TestCheckRow[] }) => (
198
199
  <div className="rlist">
199
200
  <div className="rhead rcheckt">
200
201
  <span className="tleft">check</span>
@@ -217,16 +218,42 @@ const CheckRows = observer(({ app, rows }: { app: HubSt; rows: TestCheckRow[] })
217
218
  </div>
218
219
  ))
219
220
 
220
- export const TestsRootPage = observer(({ app }: { app: HubSt }) => {
221
+ const STATUS_WORD = { pass: 'pass', fail: 'fail' } as const
222
+
223
+ /** every file of every test check on one list, slowest first: the answer to
224
+ * "which tests are slow", whatever check runs them */
225
+ const AllFiles = observer(({ app, rows }: { app: RepoSt; rows: TestFileRow[] }) => (
226
+ <div className="rlist">
227
+ <div className="rhead rall">
228
+ <span className="tleft">file</span>
229
+ <span className="tleft">check</span>
230
+ <span>duration</span>
231
+ <span>status</span>
232
+ <span>when</span>
233
+ </div>
234
+ {rows.map((f) => (
235
+ <div key={f.file} className={`rline rall${f.status === 'fail' ? ' red' : ''}`} title={f.file} onClick={() => app.tests.openFile(f.file)}>
236
+ <span className="rwhat">{f.inUnit}</span>
237
+ <span className="rtag" title="the check that runs it">{f.check}</span>
238
+ <span className={`rnum ${fileTone(f)}`}>{f.ms == null ? <span className="dim">never ran</span> : fmtMs(f.ms)}</span>
239
+ <span className={`rnum ${f.status === 'fail' ? 'bad' : 'dim'}`}>{f.status == null ? '' : STATUS_WORD[f.status]}</span>
240
+ {f.at == null ? <span /> : <TimeAgo clock={app.clock} at={f.at} />}
241
+ </div>
242
+ ))}
243
+ </div>
244
+ ))
245
+
246
+ export const TestsRootPage = observer(({ app }: { app: RepoSt }) => {
221
247
  const v: TestsRootView | null = app.tests.root()
222
248
  return (
223
249
  <div className="colcard checkcard">
224
250
  <div className="chead">
225
251
  <span className="tid">tests</span>
226
- <h3 className="ctitle">every test check</h3>
252
+ <h3 className="ctitle">every test</h3>
227
253
  </div>
228
- <div className="usays">each test check's last run: its wall time, its files, how many fail, the slowest file. Click a check for where its time goes</div>
254
+ <div className="usays">every test file by its last recorded duration, then each test check's last run. Click a file for its page, a check for where its time goes</div>
229
255
  {v == null ? <Loading app={app} check={null} /> : <>
256
+ <Section title={`every test file (${v.files.length})`} help="every file of every test check, by its last recorded duration in the shared run, slowest first; the ones that never ran here last. Its tone is against its budget: over it reads red"><AllFiles app={app} rows={v.files} /></Section>
230
257
  <Section title="test checks" help="one line per test check, slowest wall first"><CheckRows app={app} rows={v.checks} /></Section>
231
258
  <Failing app={app} rows={v.failing} showCheck />
232
259
  </>}
@@ -234,5 +261,5 @@ export const TestsRootPage = observer(({ app }: { app: HubSt }) => {
234
261
  )
235
262
  })
236
263
 
237
- export const TestsPage = observer(({ app, check }: { app: HubSt; check: string | null }) =>
264
+ export const TestsPage = observer(({ app, check }: { app: RepoSt; check: string | null }) =>
238
265
  check == null ? <TestsRootPage app={app} /> : <TestCheckPage app={app} check={check} />)
@@ -1,21 +1,22 @@
1
1
  // THE ONE RUN CHART: every check run a bar at its true start and length,
2
2
  // parallel ones stacked in lanes, each invocation a band behind its bars, now
3
- // on the right. The live strip under the header draws it, folded over a
4
- // rolling window and unfolded over the whole record, and nothing else draws a
5
- // run. Geometry is timeline-geometry.ts, view state chart-view.ts; this file
6
- // only draws.
3
+ // on the right. Two views draw it and nothing else draws a run: the dock at
4
+ // the bottom of the page, what runs now in every repo, one group of lanes
5
+ // each; and a repo's runs page, that repo's whole record. Geometry is
6
+ // timeline-geometry.ts, view state chart-view.ts; this file only draws.
7
7
  import { observer } from 'mobx-react-lite'
8
- import { useEffect, useLayoutEffect, useRef } from 'react'
9
- import type { RepoLane } from '../../ledger/live-timeline.ts'
8
+ import { autoUpdate, FloatingPortal, useFloating } from '@floating-ui/react'
9
+ import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
10
+ import { middleware } from './floating.tsx'
10
11
  import type { TimelineBar } from '../../ledger/run-timeline.ts'
11
12
  import type { ChartTip, ChartView } from './chart-view.ts'
12
13
  import { durationTone, fmtMs } from './dur.ts'
13
- import { bandLabels, gapSlices, ROWS, ticks, xOf, type GapSlice, type PlacedBar } from './timeline-geometry.ts'
14
+ import { bandLabels, gapSlices, ROWS, ticks, type GapSlice, type PlacedBar, type PlacedGroup, type RowSize } from './timeline-geometry.ts'
14
15
  import { CACHED } from '../../core/outcome-words.ts'
15
16
 
16
17
  /** who started a run, in words. A starter this build does not name reads as
17
18
  * the word the record holds */
18
- export const BY_WORD: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily pass', save: 'save trigger', unknown: 'before the record' }
19
+ export const BY_WORD: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'the plan', save: 'a save', unknown: 'before the record' }
19
20
  export const byWords = (by: string): string => BY_WORD[by] ?? by
20
21
 
21
22
  /** the one tone a bar wears: running in the accent, then the verdict, a
@@ -35,48 +36,59 @@ export const clockWords = (t: number): string => new Date(t).toLocaleTimeString(
35
36
  * when it ended or that it still grows */
36
37
  export const gapTipWords = (g: GapSlice): string => (g.live ? `since ${clockWords(g.t0)}, nothing ran` : `${clockWords(g.t0)} to ${clockWords(g.t1)}, nothing ran`)
37
38
 
38
- /** a point on the viewport: the tooltip is fixed there, out of every box
39
- * of the strip that clips */
39
+ /** a point on the viewport the tooltip floats from, out of every box of
40
+ * the strip that clips */
40
41
  export type TipAt = { x: number; y: number }
41
42
 
42
43
  /** what hovering a bar says: its check, its verdict with its duration or its
43
44
  * elapsed time, when it started, the run and who started it */
44
45
  export const barTip = (b: PlacedBar, now: number, at: TipAt): ChartTip => ({
45
46
  ...at,
46
- head: b.id,
47
+ head: b.group == null ? b.id : `${b.group} · ${b.id}`,
47
48
  tone: b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : 'idle',
48
49
  lines: [verdictWords(b, now), `${clockWords(b.start)} · ${b.label} · ${byWords(b.by)}`],
49
50
  })
50
51
 
51
- /** right of the pointer, or left of it past the middle of the window, so a
52
- * tooltip over the running bars at the right edge stays on screen */
53
- export const tipStyle = (t: TipAt, viewportW: number): { left: number; top: number } | { right: number; top: number } =>
54
- t.x > viewportW / 2 ? { right: viewportW - t.x, top: t.y } : { left: t.x, top: t.y }
55
-
56
52
  /** under the element the pointer entered, at the pointer's x */
57
53
  const tipAtEvent = (e: React.MouseEvent<Element>): TipAt => ({ x: e.clientX, y: e.currentTarget.getBoundingClientRect().bottom + 4 })
58
54
 
55
+ /** what a tooltip says: its head in its tone, then its lines */
56
+ export const TipBody = ({ t }: { t: ChartTip }) => (
57
+ <>
58
+ <b className={`tl ${t.tone}`}>{t.head}</b>
59
+ {t.lines.map((l) => <span key={l}>{l}</span>)}
60
+ </>
61
+ )
62
+
63
+ /** the hovered mark's words, floated from the point the pointer entered at
64
+ * (floating.tsx middleware: it flips and shifts to stay in the window) */
59
65
  const Tip = observer(({ view }: { view: ChartView }) => {
60
66
  const t = view.hover
67
+ const f = useFloating({ open: t != null, placement: 'bottom-start', strategy: 'fixed', middleware: middleware(0), whileElementsMounted: autoUpdate })
68
+ const x = t?.x ?? 0
69
+ const y = t?.y ?? 0
70
+ useLayoutEffect(() => {
71
+ f.refs.setPositionReference({ getBoundingClientRect: () => ({ x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0 }) })
72
+ }, [f.refs, x, y])
61
73
  if (t == null) return null
62
74
  return (
63
- <div className="tlaxtip" style={tipStyle(t, globalThis.innerWidth ?? Number.POSITIVE_INFINITY)}>
64
- <b className={`tl ${t.tone}`}>{t.head}</b>
65
- {t.lines.map((l) => <span key={l}>{l}</span>)}
66
- </div>
75
+ <FloatingPortal>
76
+ <div ref={f.refs.setFloating} className="tlaxtip" style={f.floatingStyles}><TipBody t={t} /></div>
77
+ </FloatingPortal>
67
78
  )
68
79
  })
69
80
 
70
81
  export type ChartProps = {
71
82
  view: ChartView
72
83
  compact?: boolean
73
- /** other repos with something in flight, one collapsed lane each */
74
- repos?: RepoLane[]
84
+ /** the lane sizes; compact draws `compact` and anything else `full` */
85
+ rows?: RowSize
75
86
  /** keep now in sight: while the reader has not scrolled away, the view
76
87
  * follows the right edge as the window slides */
77
88
  follow?: boolean
78
89
  onBar: (b: PlacedBar) => void
79
- onRepo?: (r: RepoLane) => void
90
+ /** what stands left of a group's lanes, when the chart has groups */
91
+ groupLabel?: (g: PlacedGroup) => ReactNode
80
92
  /** the zoom control, floating over the top right corner: shown while the
81
93
  * pointer is on the chart and while it is zoomed away from home */
82
94
  zoomable?: boolean
@@ -91,7 +103,7 @@ export const DRAG_PX = 4
91
103
  /** a press that moved this far is a pan, and never also a click */
92
104
  export const isPan = (x0: number, x: number): boolean => Math.abs(x - x0) >= DRAG_PX
93
105
 
94
- export const TimelineChart = observer(({ view, compact = false, repos = [], follow = false, onBar, onRepo, zoomable = false }: ChartProps) => {
106
+ export const TimelineChart = observer(({ view, compact = false, rows, follow = false, onBar, groupLabel, zoomable = false }: ChartProps) => {
95
107
  const cap = view.cap
96
108
  const ref = useRef<HTMLDivElement>(null)
97
109
  /** where to scroll once the next zoom has rendered */
@@ -176,8 +188,8 @@ export const TimelineChart = observer(({ view, compact = false, repos = [], foll
176
188
  placed.current = true
177
189
  }
178
190
  })
179
- const r = compact ? ROWS.compact : ROWS.full
180
- const lanes = (l?.lanes ?? 0) + repos.length
191
+ const r = ROWS[rows ?? (compact ? 'compact' : 'full')]
192
+ const lanes = l?.lanes ?? 0
181
193
  const lanesH = lanes * (r.lane + r.gap)
182
194
  const top = r.band + r.conc
183
195
  const h = top + Math.max(r.lane, lanesH) + r.axis
@@ -196,7 +208,8 @@ export const TimelineChart = observer(({ view, compact = false, repos = [], foll
196
208
  <div className="tlaxin" style={{ width: l.axis.width }}>
197
209
  <svg className="tlsvg" width={l.axis.width} height={h}>
198
210
  {l.bands.map((b) => (
199
- <rect key={b.runId} className={`tlband${b.reds > 0 ? ' red' : ''}${b.running ? ' run' : ''}`} x={b.x} y={0} width={Math.max(1, b.w)} height={top + lanesH} />
211
+ <rect key={b.runId} className={`tlband${b.reds > 0 ? ' red' : ''}${b.running ? ' run' : ''}`} x={b.x} width={Math.max(1, b.w)}
212
+ y={b.lane0 == null ? 0 : laneY(b.lane0)} height={b.lane0 == null ? top + lanesH : (b.lanes ?? 1) * (r.lane + r.gap) - r.gap} />
200
213
  ))}
201
214
  {compact ? null : bandLabels(l.bands).map((b) => (
202
215
  <text key={b.runId} className="tlbandlbl" x={b.x} y={11}
@@ -238,16 +251,9 @@ export const TimelineChart = observer(({ view, compact = false, repos = [], foll
238
251
  onMouseLeave={() => hover(null)}
239
252
  onClick={() => onBar(b)} />
240
253
  ))}
241
- {repos.map((rl, k) => {
242
- const x = xOf(l.axis, Math.max(rl.start, l.axis.segs[0]?.t0 ?? rl.start))
243
- const lane = l.lanes + k
244
- return (
245
- <rect key={rl.repo} className="tlb repo" x={x} y={laneY(lane)} width={Math.max(2, l.axis.nowX - x)} height={r.lane}
246
- onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: rl.repo, tone: 'idle', lines: [`${rl.running} running: ${rl.checks.slice(0, 4).join(', ')}${rl.checks.length > 4 ? ', …' : ''}`, `${rl.label} · ${byWords(rl.by)}`, onRepo == null ? '' : 'click opens its hub when it serves'].filter((x) => x !== '') })}
247
- onMouseLeave={() => hover(null)}
248
- onClick={() => onRepo?.(rl)} />
249
- )
250
- })}
254
+ {(l.groups ?? []).slice(1).map((g) => (
255
+ <line key={g.key} className="tlgrp" x1={0} x2={l.axis.width} y1={laneY(g.lane0) - r.gap / 2} y2={laneY(g.lane0) - r.gap / 2} />
256
+ ))}
251
257
  <line className="tlnow" x1={l.axis.nowX} x2={l.axis.nowX} y1={0} y2={top + lanesH} />
252
258
  </svg>
253
259
  <Tip view={view} />
@@ -255,10 +261,20 @@ export const TimelineChart = observer(({ view, compact = false, repos = [], foll
255
261
  )}
256
262
  </div>
257
263
  )
258
- if (!zoomable && !follow) return chart
264
+ // a group's label stands in a gutter of its own, level with its lanes,
265
+ // so it stays in sight however far the chart is panned
266
+ const gutter = groupLabel == null || l?.groups == null ? null : (
267
+ <div className="tlgutter" style={{ height: h }}>
268
+ {l.groups.map((g) => (
269
+ <div key={g.key} className="tlglbl" style={{ top: laneY(g.lane0), height: g.lanes * (r.lane + r.gap) - r.gap }}>{groupLabel(g)}</div>
270
+ ))}
271
+ </div>
272
+ )
273
+ const body = gutter == null ? chart : <div className="tlgroups">{gutter}{chart}</div>
274
+ if (!zoomable && !follow) return body
259
275
  return (
260
276
  <div className={`tlchart${view.zoomed ? ' zoomed' : ''}`}>
261
- {chart}
277
+ {body}
262
278
  <span className="tlfloat">
263
279
  {follow && !view.follow
264
280
  ? <button className="tllive" onClick={() => view.attach()} title={`the view stays where it is; back to now and follow it (${FOLLOW_KEY})`}>● live</button>
@@ -1,8 +1,9 @@
1
1
  // The horizontal run timeline, as geometry: one time axis with now on the
2
2
  // right, every bar at its true start and length, parallel bars stacked in
3
3
  // lanes, and the zoom math. Pure, so a test pins every position and the view
4
- // only draws what this returns. The live strip is its one user, with two
5
- // windows: folded, a rolling window that ends now; unfolded, the whole record.
4
+ // only draws what this returns. Two users: the dock at the bottom of the
5
+ // page, a rolling window that ends now with one group of lanes per repo, and
6
+ // a repo's runs page, its whole record.
6
7
  //
7
8
  // ONE rule bends a plain linear axis over the record: an idle stretch longer
8
9
  // than GAP_MS is folded to a fixed GAP_PX slice that says how long it was,
@@ -30,11 +31,14 @@ export const TICK_MIN_PX = 72
30
31
  export type Span = { start: number; end: number }
31
32
 
32
33
  /** the rows of a chart, in px: band labels, the concurrency band, the lanes,
33
- * the time axis. Compact is the folded strip: thin lanes and nothing else */
34
+ * the time axis. Compact is thin lanes and nothing else */
34
35
  export const ROWS = {
35
36
  full: { band: 16, conc: 20, lane: 8, gap: 2, axis: 18 },
36
37
  compact: { band: 0, conc: 0, lane: 3, gap: 1, axis: 0 },
38
+ /** the slim dock: compact, with lanes a repo's label fits beside */
39
+ slim: { band: 0, conc: 0, lane: 4, gap: 1, axis: 0 },
37
40
  } as const
41
+ export type RowSize = keyof typeof ROWS
38
42
 
39
43
  /** how tall a chart with `lanes` lanes draws */
40
44
  export const chartHeight = (lanes: number, compact = false): number => {
@@ -209,8 +213,15 @@ export function gapSlices(a: Axis, charPx = 6, padPx = 3): GapSlice[] {
209
213
  })
210
214
  }
211
215
 
212
- export type PlacedBar = TimelineBar & { x: number; w: number; lane: number; runId: string; label: string; by: string }
213
- export type PlacedBand = { runId: string; label: string; x: number; w: number; reds: number; running: boolean }
216
+ export type PlacedBar = TimelineBar & { x: number; w: number; lane: number; runId: string; label: string; by: string; group?: string }
217
+ /** `lane0` and `lanes`: the lanes of its group, when the chart has groups */
218
+ export type PlacedBand = { runId: string; label: string; x: number; w: number; reds: number; running: boolean; lane0?: number; lanes?: number }
219
+
220
+ /** one repo's runs, a group of lanes of its own */
221
+ export type ChartGroup = { key: string; invocations: TimelineInvocation[] }
222
+ /** a group as drawn: its first lane, how many it takes, how many of its bars
223
+ * did not fit */
224
+ export type PlacedGroup = { key: string; lane0: number; lanes: number; hidden: number }
214
225
 
215
226
  /** one step of the concurrency band: `n` checks in flight from x for w px */
216
227
  export type ConcStep = { x: number; w: number; n: number; over: boolean }
@@ -225,6 +236,8 @@ export type Layout = {
225
236
  conc: ConcStep[]
226
237
  /** the most in flight at once in view */
227
238
  concMax: number
239
+ /** the groups, top to bottom, when the chart has groups */
240
+ groups?: PlacedGroup[]
228
241
  }
229
242
 
230
243
  /** how many bars are in flight at each moment, as a step function: the
@@ -312,3 +325,25 @@ export function layoutTimeline(invs: TimelineInvocation[], now: number, pxPerMs:
312
325
  })
313
326
  return { axis, bars, bands, lanes: count, hidden, conc, concMax: Math.max(0, ...series.map((p) => p.n)) }
314
327
  }
328
+
329
+ /** every group on one rolling window, stacked top to bottom: each packs its
330
+ * own lanes, at least `minLanes` so its label fits beside them and at most
331
+ * `laneCap`, and the concurrency band counts every group's bars together. A
332
+ * window only: over the whole record each group would fold its own idle
333
+ * stretches, and the groups would not share one axis */
334
+ export function layoutGroups(groups: ChartGroup[], now: number, pxPerMs: number, o: { from: number; laneCap?: number; minLanes?: number; cap?: number }): Layout {
335
+ const all = layoutTimeline(groups.flatMap((g) => g.invocations), now, pxPerMs, { from: o.from, cap: o.cap, laneCap: 0 })
336
+ const bars: PlacedBar[] = []
337
+ const bands: PlacedBand[] = []
338
+ const placed: PlacedGroup[] = []
339
+ let lane0 = 0
340
+ for (const g of groups) {
341
+ const l = layoutTimeline(g.invocations, now, pxPerMs, { from: o.from, laneCap: o.laneCap })
342
+ const lanes = Math.max(o.minLanes ?? 1, l.lanes)
343
+ for (const b of l.bars) bars.push({ ...b, lane: b.lane + lane0, group: g.key })
344
+ for (const b of l.bands) bands.push({ ...b, lane0, lanes })
345
+ placed.push({ key: g.key, lane0, lanes, hidden: l.hidden })
346
+ lane0 += lanes
347
+ }
348
+ return { axis: all.axis, bars, bands, lanes: lane0, hidden: placed.reduce((a, g) => a + g.hidden, 0), conc: all.conc, concMax: all.concMax, groups: placed }
349
+ }
@@ -1,12 +1,12 @@
1
1
  // The runs page, as it holds the record: what the server shaped from the run
2
2
  // record (ledger/run-timeline.ts), and which invocations the reader opened.
3
3
  // Loaded when the page opens and again after a run ends, so it is never a
4
- // poll the rest of the page pays for. The live strip's unfolded chart reads
5
- // the same record from here for the runs older than its own window.
4
+ // poll the rest of the page pays for. The chart over the runs page reads the
5
+ // same record from here for the runs older than the live window.
6
6
  import { testId } from '../../collections/collection-types.ts'
7
7
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
8
8
  import type { Timeline } from '../../ledger/run-timeline.ts'
9
- import type { HubSt } from './hub-st.ts'
9
+ import type { RepoSt } from './repo-st.ts'
10
10
 
11
11
  export type { Timeline }
12
12
 
@@ -18,7 +18,7 @@ export class TimelineSvc {
18
18
  * the reader closes it, every other one is shut until opened */
19
19
  opened = new Set<string>()
20
20
  closed = new Set<string>()
21
- constructor(private st: HubSt) {
21
+ constructor(private st: RepoSt) {
22
22
  makeAutoObservable<this, 'st'>(this, { st: false, data: observableRef })
23
23
  }
24
24
 
@@ -46,10 +46,11 @@ export class TimelineSvc {
46
46
  this.st.cols.select('tests', testId(file))
47
47
  }
48
48
 
49
- async load(): Promise<void> {
49
+ /** `fresh` after a run: the record it just wrote, not the warm answer */
50
+ async load(fresh = false): Promise<void> {
50
51
  runInAction(() => { this.loading = true })
51
52
  try {
52
- const r = await fetch('/api/timeline.json')
53
+ const r = await fetch(this.st.url(fresh ? '/api/timeline.json?fresh=1' : '/api/timeline.json'))
53
54
  if (!r.ok) throw new Error(await r.text())
54
55
  const t = (await r.json()) as Timeline
55
56
  runInAction(() => { this.data = t; this.error = '' })
@@ -5,16 +5,17 @@
5
5
  // Every line sits on a grid of fixed columns (the .rline grids in hub.css):
6
6
  // numbers right aligned in tabular figures, ran and cached each a column of
7
7
  // their own told apart by colour, and no separator glyph on any line. The
8
- // shaping is the server's (ledger/run-timeline.ts). The chart of every run in
9
- // time is the live strip under the header, not this page.
8
+ // shaping is the server's (ledger/run-timeline.ts). Over the tabs, the one run
9
+ // chart (timeline-chart.tsx) draws this repo's whole record in time; what
10
+ // runs now in every repo is the dock at the bottom of the page.
10
11
  import { observer } from 'mobx-react-lite'
11
12
  import { useEffect } from 'react'
12
13
  import { CACHED, RAN } from '../../core/outcome-words.ts'
13
14
  import type { InvocationSummary, SlowCheck, SuiteToFix, Timeline, TimelineBar, TimelineInvocation } from '../../ledger/run-timeline.ts'
14
- import { byWords, verdictWords } from './timeline-chart.tsx'
15
+ import { byWords, TimelineChart, verdictWords } from './timeline-chart.tsx'
15
16
  import { durationTone, fmtMs } from './dur.ts'
16
17
  import { Help } from './help.tsx'
17
- import type { HubSt } from './hub-st.ts'
18
+ import type { RepoSt } from './repo-st.ts'
18
19
  import { TimeAgo, stampWords } from './time-ago.tsx'
19
20
  import { checkName, editorHref } from './check-words.ts'
20
21
  import { RUNS_TABS, runsTab, runsTabId, type RunsTab } from './runs-tabs.ts'
@@ -40,7 +41,7 @@ export function tabBadge(tab: RunsTab, d: Timeline): TabBadge | null {
40
41
 
41
42
  /** who started a run, as a tag short enough for one fixed column. A starter
42
43
  * this build does not name reads as the word the record holds */
43
- export const BY_TAG: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily', save: 'save', unknown: 'unknown' }
44
+ export const BY_TAG: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'plan', save: 'save', unknown: 'unknown' }
44
45
  export const byTag = (by: string): string => BY_TAG[by] ?? by
45
46
 
46
47
  /** a check's verdict, as the label's colour class: the tree's `tl` tones */
@@ -90,7 +91,7 @@ export function childOrder(bars: readonly TimelineBar[]): TimelineBar[] {
90
91
 
91
92
  /** one check of a run, a child line on the run's grid: its word sits in the
92
93
  * column its run counts it in */
93
- const CheckLine = ({ app, b, name }: { app: HubSt; b: TimelineBar; name: ChildName }) => (
94
+ const CheckLine = ({ app, b, name }: { app: RepoSt; b: TimelineBar; name: ChildName }) => (
94
95
  <div className={`rline rchild${b.status === 'fail' ? ' red' : ''}`} onClick={() => app.timeline.openCheck(b.id)}
95
96
  title={[name.description ?? '', `${b.id}: ${verdictWords(b)}`, stampWords(b.start)].filter((x) => x !== '').join('\n')}>
96
97
  <span />
@@ -102,7 +103,7 @@ const CheckLine = ({ app, b, name }: { app: HubSt; b: TimelineBar; name: ChildNa
102
103
  </div>
103
104
  )
104
105
 
105
- const RunLine = observer(({ app, inv, n }: { app: HubSt; inv: TimelineInvocation; n: number }) => {
106
+ const RunLine = observer(({ app, inv, n }: { app: RepoSt; inv: TimelineInvocation; n: number }) => {
106
107
  const open = app.timeline.isOpen(inv.runId, n)
107
108
  return (
108
109
  <>
@@ -122,12 +123,12 @@ const RunLine = observer(({ app, inv, n }: { app: HubSt; inv: TimelineInvocation
122
123
 
123
124
  /** the checks of an unfolded run, in a box of their own that scrolls: a full
124
125
  * gate is fifty checks, which would push every other run off the page */
125
- const RunChecks = observer(({ app, bars }: { app: HubSt; bars: TimelineBar[] }) => {
126
+ const RunChecks = observer(({ app, bars }: { app: RepoSt; bars: TimelineBar[] }) => {
126
127
  const names = checkNames(app.cols.resources)
127
128
  return <div className="rkids">{childOrder(bars).map((b) => <CheckLine key={`${b.id}:${b.start}`} app={app} b={b} name={nameOf(names, b.id)} />)}</div>
128
129
  })
129
130
 
130
- const Runs = ({ app, rows }: { app: HubSt; rows: TimelineInvocation[] }) => (
131
+ const Runs = ({ app, rows }: { app: RepoSt; rows: TimelineInvocation[] }) => (
131
132
  rows.length === 0 ? <div className="rempty">no run on record yet</div> : <>{rows.map((inv, n) => <RunLine key={inv.runId} app={app} inv={inv} n={n} />)}</>
132
133
  )
133
134
 
@@ -142,7 +143,7 @@ const inUnit = (r: SuiteToFix): string => {
142
143
  return at < 0 ? r.file : r.file.slice(at + r.unit.length + 2)
143
144
  }
144
145
 
145
- const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; recorded: boolean }) => {
146
+ const TestFiles = ({ app, rows, recorded }: { app: RepoSt; rows: SuiteToFix[]; recorded: boolean }) => {
146
147
  if (!recorded) return <div className="rempty">no test file run recorded yet: the next run of a test check records one</div>
147
148
  if (rows.length === 0) return <div className="rempty">every test file passed and fits the budget</div>
148
149
  return (
@@ -166,7 +167,7 @@ const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; re
166
167
  const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling', budget: 'over its budget' } as const
167
168
 
168
169
  /** two numbers side by side need their names once, above the column */
169
- const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
170
+ const Slowest = ({ app, rows }: { app: RepoSt; rows: SlowCheck[] }) => {
170
171
  if (rows.length === 0) return <div className="rempty">nothing ran yet</div>
171
172
  return (
172
173
  <>
@@ -183,7 +184,7 @@ const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
183
184
  )
184
185
  }
185
186
 
186
- const Longest = ({ app, rows }: { app: HubSt; rows: InvocationSummary[] }) => {
187
+ const Longest = ({ app, rows }: { app: RepoSt; rows: InvocationSummary[] }) => {
187
188
  if (rows.length === 0) return <div className="rempty">no run on record yet</div>
188
189
  return (
189
190
  <>
@@ -202,7 +203,7 @@ const Longest = ({ app, rows }: { app: HubSt; rows: InvocationSummary[] }) => {
202
203
  )
203
204
  }
204
205
 
205
- const Tabs = observer(({ app, on }: { app: HubSt; on: RunsTab }) => {
206
+ const Tabs = observer(({ app, on }: { app: RepoSt; on: RunsTab }) => {
206
207
  const t = app.timeline
207
208
  const d = t.data
208
209
  return (
@@ -218,21 +219,37 @@ const Tabs = observer(({ app, on }: { app: HubSt; on: RunsTab }) => {
218
219
  })}
219
220
  <Help text={RUNS_TABS.find((x) => x.key === on)!.help} />
220
221
  <span className="sp" />
221
- <button disabled={t.loading} onClick={() => void t.load()}>{t.loading ? 'reading…' : 'reload'}</button>
222
+ <button disabled={t.loading} onClick={() => void t.load(true)}>{t.loading ? 'reading…' : 'reload'}</button>
222
223
  </div>
223
224
  )
224
225
  })
225
226
 
226
- export const TimelineView = observer(({ app }: { app: HubSt }) => {
227
+ /** what the chart over the tabs is, behind its (?) */
228
+ export const RECORD_HELP = 'every check this repo ran, on record: the run bands, the time axis, and over the lanes how many checks ran at once, with the cap dashed and the stretches past it in orange. It opens on the last ten minutes and follows now: the right edge is the present. The wheel zooms and a drag pans; leaving the right edge stops the view where it is so a bar can be read, and ● live, the k key or panning back to the right edge follows now again. While a tooltip is open the view stands still. fit shows every run on record, and a click on a bar opens its check. What runs in every repo right now is the dock at the bottom of the page'
229
+
230
+ /** this repo's whole record in time, its height held so it never jumps
231
+ * under the eye as a run adds lanes and ends */
232
+ const RecordChart = observer(({ app }: { app: RepoSt }) => {
233
+ const l = app.live
234
+ return (
235
+ <div className="rchart" style={{ height: l.held.h }}>
236
+ <span className="rchelp"><Help text={RECORD_HELP} /></span>
237
+ <TimelineChart view={l.chart} follow zoomable onBar={(b) => app.cols.select('checks', `check:${b.id}`)} />
238
+ </div>
239
+ )
240
+ })
241
+
242
+ export const TimelineView = observer(({ app }: { app: RepoSt }) => {
227
243
  const t = app.timeline
228
244
  useEffect(() => { void t.load() }, [t])
229
245
  const d = t.data
230
246
  const on = runsTab(app.sel?.t === 'item' ? app.sel.id : null)
231
247
  return (
232
248
  <div className="colcard tlcard">
249
+ <RecordChart app={app} />
233
250
  <Tabs app={app} on={on} />
234
251
  {t.error !== '' ? <div className="warnline">{t.error}</div> : null}
235
- {d == null ? <div className="dim">reading the run record…</div> : (
252
+ {d == null ? <div className="dim twait"><span className="spin" />reading the run record…</div> : (
236
253
  <div className={`rlist ${on}`}>
237
254
  {on === 'runs' ? <Runs app={app} rows={d.invocations} />
238
255
  : on === 'files' ? <TestFiles app={app} rows={d.testFiles} recorded={d.suitesRecorded} />
@@ -4,7 +4,7 @@
4
4
  // around. No row carries prose: the words are the tooltip and the page it opens.
5
5
  import { observer } from 'mobx-react-lite'
6
6
  import type { CSSProperties } from 'react'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
  import { GLYPH, markOf, rowName, rowSlots, type TreeRow } from './tree-build.ts'
9
9
 
10
10
  /** the fold marker: a chevron, right when the branch is shut and down when
@@ -15,7 +15,7 @@ export const Caret = ({ open }: { open: boolean }): React.ReactElement => (
15
15
  </svg>
16
16
  )
17
17
 
18
- export const TreeRowView = observer(({ app, row }: { app: HubSt; row: TreeRow }) => {
18
+ export const TreeRowView = observer(({ app, row }: { app: RepoSt; row: TreeRow }) => {
19
19
  const t = app.tree
20
20
  const u = app.checks
21
21
  const running = row.checks.some((id) => u.isRunning(id))
@@ -4,7 +4,7 @@
4
4
  // newline separated paths, text encodable and hand editable, and comes back
5
5
  // on reopen.
6
6
  import { makeAutoObservable } from 'mobx'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
  import type { Resource } from './hub-types.ts'
9
9
  import { buildPathTree, buildTypeTree, dirKeys, flattenTree, whenCounts, type TreeNode, type TreeRow } from './tree-build.ts'
10
10
 
@@ -37,7 +37,7 @@ export class TreeSvc {
37
37
  /** the row key under the keyboard cursor */
38
38
  cursor: string | null = null
39
39
 
40
- constructor(private st: HubSt) {
40
+ constructor(private st: RepoSt) {
41
41
  this.tab = readStore(TAB_KEY) === 'type' ? 'type' : 'path'
42
42
  for (const p of readStore(EXPANDED_KEY).split('\n')) if (p !== '') this.expanded.add(p)
43
43
  makeAutoObservable<this, 'st'>(this, { st: false })