rvlib-shipkit 1.1.0 → 1.2.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 (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -1,6 +1,7 @@
1
1
  // The header over every page, and the live strip under it. The header is the
2
- // repo name, one link per core page, and on the right the two runs that are
3
- // not one check with what is running now. The strip is the one run chart
2
+ // repo name (on the home the strip above names it), one link per core page,
3
+ // and on the right the repo's own controls and the two runs that are not one
4
+ // check with what is running now. The strip is the one run chart
4
5
  // (timeline-chart.tsx) over a rolling window that ends now: folded, a thin
5
6
  // bar of the last two minutes; unfolded, the full chart over ten, zoomable.
6
7
  // Each fold has ONE height whatever runs, so a run never moves the page.
@@ -8,14 +9,15 @@ import { observer } from 'mobx-react-lite'
8
9
  import { RUNS_ID } from '../../collections/collection-types.ts'
9
10
  import { Help } from './help.tsx'
10
11
  import { openWikis, WIKIS } from './wikis-view.tsx'
11
- import type { HubSt } from './hub-st.ts'
12
+ import type { RepoSt } from './repo-st.ts'
12
13
  import { byWords, TimelineChart } from './timeline-chart.tsx'
14
+ import { RepoControls } from './home-strip.tsx'
13
15
 
14
16
  export type CorePage = { id: string; label: string; on: boolean; go: () => void }
15
17
 
16
18
  /** the pages reached for most, in the order they are reached for. A page this
17
19
  * repo does not have is not a link */
18
- export function corePages(app: HubSt): CorePage[] {
20
+ export function corePages(app: RepoSt): CorePage[] {
19
21
  const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
20
22
  const sel = app.sel
21
23
  const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
@@ -25,7 +27,7 @@ export function corePages(app: HubSt): CorePage[] {
25
27
  // always here
26
28
  out.push({ id: 'runs', label: 'runs', on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
27
29
  if (has('asks')) out.push({ id: 'asks', label: 'asks', on: onCol('asks'), go: () => app.showCol('asks') })
28
- const firstCat = app.media.assets[0]?.cat
30
+ const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
29
31
  if (firstCat != null) out.push({ id: 'media', label: 'media', on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
30
32
  if (has(WIKIS)) out.push({ id: WIKIS, label: 'wikis', on: onCol(WIKIS), go: () => openWikis(app) })
31
33
  return out
@@ -33,14 +35,15 @@ export function corePages(app: HubSt): CorePage[] {
33
35
 
34
36
  /** what is running, in one line: the hub's own run first, since it says the
35
37
  * most, else the newest run the record has in flight */
36
- const RunState = observer(({ app }: { app: HubSt }) => {
38
+ const RunState = observer(({ app }: { app: RepoSt }) => {
37
39
  const own = app.checks.strip
38
40
  const run = app.live.view?.runs[0]
39
41
  const text = own !== '' ? own : run != null ? `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}` : 'idle'
40
42
  const going = app.checks.busy || run != null
41
43
  return (
42
44
  <span className={`hstate${going ? ' on' : ''}`} title={text}>
43
- {going ? <span className="spin" /> : null}
45
+ {/* the spinner's slot is always there, so the text never moves */}
46
+ <span className={`spin${going ? '' : ' idle'}`} />
44
47
  <span className="htext">{text}</span>
45
48
  </span>
46
49
  )
@@ -48,7 +51,7 @@ const RunState = observer(({ app }: { app: HubSt }) => {
48
51
 
49
52
  /** what is wrong with the hub itself, one chip per problem. Nothing when all
50
53
  * is well: the header states trouble, never health */
51
- const HubStatus = observer(({ app }: { app: HubSt }) => (
54
+ const HubStatus = observer(({ app }: { app: RepoSt }) => (
52
55
  <>
53
56
  {app.cols.hubStatus.map((p) => (
54
57
  <span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
@@ -56,17 +59,22 @@ const HubStatus = observer(({ app }: { app: HubSt }) => (
56
59
  </>
57
60
  ))
58
61
 
59
- export const HeaderBar = observer(({ app }: { app: HubSt }) => {
62
+ export const HeaderBar = observer(({ app }: { app: RepoSt }) => {
60
63
  const u = app.checks
61
64
  return (
62
65
  <header className="hdr">
63
- <span className="hname">{app.name !== '' ? app.name : 'hub'}</span>
66
+ {/* on the home the strip names the repo, in its colour */}
67
+ {app.home.machine != null ? null : <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>}
64
68
  <nav className="hnav">
69
+ {/* the spinner's slot stays once the repo is read, so the links never move */}
70
+ <span className={`spin${app.cols.loaded ? ' idle' : ''}`} title={app.cols.loaded ? undefined : app.cols.cached ? 'showing what this browser kept, reading the repo' : 'reading the repo'} />
65
71
  {corePages(app).map((p) => (
66
72
  <button key={p.id} className={p.on ? 'on' : ''} onClick={p.go}>{p.label}</button>
67
73
  ))}
68
74
  </nav>
69
75
  <span className="sp" />
76
+ <RepoControls app={app.home} name={app.key} />
77
+ <ProcessChips app={app} />
70
78
  <HubStatus app={app} />
71
79
  <RunState app={app} />
72
80
  <button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
@@ -75,6 +83,30 @@ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
75
83
  )
76
84
  })
77
85
 
86
+ /** one switch per process the repo declares (serve.processes): its dot, its
87
+ * id, a click starts or stops it, and a link once it serves */
88
+ export const ProcessChips = observer(({ app }: { app: RepoSt }) => {
89
+ const s = app.processes
90
+ if (s.list.length === 0) return null
91
+ return (
92
+ <span className="hprocs">
93
+ {s.list.map((p) => {
94
+ const on = p.status === 'up' || p.status === 'starting'
95
+ const title = [p.cmd, p.note, p.external ? 'started outside the hub' : null, p.tail.at(-1)].filter((x) => x != null && x !== '').join('\n')
96
+ return (
97
+ <span key={p.id} className="hproc">
98
+ <button className={`ghost ${p.status}`} title={title} disabled={p.status === 'stopping' || (on && p.external)}
99
+ onClick={() => void s.act(p.id, on ? 'stop' : 'start')}>
100
+ <i className={`pdot ${p.status}`} />{p.id}
101
+ </button>
102
+ {p.url != null && p.status === 'up' ? <a href={p.url} target="_blank" rel="noreferrer" title={p.url}>↗</a> : null}
103
+ </span>
104
+ )
105
+ })}
106
+ </span>
107
+ )
108
+ })
109
+
78
110
  /** the key that folds and unfolds the strip, printed on its chevron */
79
111
  export const LIVE_KEY = 'l'
80
112
 
@@ -82,7 +114,7 @@ export const LIVE_KEY = 'l'
82
114
  export const LIVE_ROW_H = 30
83
115
 
84
116
  /** what the strip is, in the (?) beside its chevron */
85
- export const LIVE_HELP = "every check shipkit runs, whoever started it: this hub, an agent in a terminal, the daily pass, here and in the other repos. Folded, the last two minutes: a bar grows at the right while its check runs and slides left once it ends, in its verdict colour. Unfolded, the whole record, opened on the last ten minutes: 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 follows now: the right edge is the present and the view slides with it. 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. A lane at the bottom is another repo with something running; a click opens its hub when it serves. The count is the checks in flight against the runner's cap"
117
+ export const LIVE_HELP = "every check shipkit runs, whoever started it: this hub, an agent in a terminal, the plan, here and in the other repos. Folded, the last two minutes: a bar grows at the right while its check runs and slides left once it ends, in its verdict colour. Unfolded, the whole record, opened on the last ten minutes: 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 follows now: the right edge is the present and the view slides with it. 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. A lane at the bottom is another repo with something running; a click opens its hub when it serves. The count is the checks in flight against the runner's cap"
86
118
 
87
119
  const Chevron = ({ open }: { open: boolean }) => (
88
120
  <svg className={open ? 'cvt open' : 'cvt'} viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
@@ -90,7 +122,7 @@ const Chevron = ({ open }: { open: boolean }) => (
90
122
  </svg>
91
123
  )
92
124
 
93
- export const LiveStrip = observer(({ app }: { app: HubSt }) => {
125
+ export const LiveStrip = observer(({ app }: { app: RepoSt }) => {
94
126
  const l = app.live
95
127
  const v = l.view
96
128
  const run = v?.runs[0]
@@ -5,14 +5,14 @@
5
5
  // every check has its own page (checks-svc.ts owns the run itself). This card
6
6
  // exists so the whole text of a run stays reachable.
7
7
  import { makeAutoObservable, runInAction } from 'mobx'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
 
10
10
  export class HealthSvc {
11
11
  statusMd: string | null = null
12
12
  /** the lines of the run in flight, newest last */
13
13
  live: string[] = []
14
14
 
15
- constructor(private st: HubSt) {
15
+ constructor(private st: RepoSt) {
16
16
  makeAutoObservable<this, 'st'>(this, { st: false })
17
17
  }
18
18
 
@@ -25,7 +25,7 @@ export class HealthSvc {
25
25
 
26
26
  async load(): Promise<void> {
27
27
  try {
28
- const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
28
+ const s = (await (await fetch(this.st.url('/api/status.json'))).json()) as { statusMd: string | null }
29
29
  runInAction(() => { this.statusMd = s.statusMd })
30
30
  } catch (e) {
31
31
  runInAction(() => { this.statusMd = `FAIL status fetch broke: ${(e as Error).message}` })
@@ -1,10 +1,12 @@
1
1
  // THE help affordance, once, for every surface of the hub: a small `(?)` that
2
- // shows one sentence the moment it is hovered or focused. CSS does the
3
- // showing (.hlp in hub.css), so there is no library, no delay and no state.
2
+ // shows one sentence when it is hovered or focused, placed by floating-ui
3
+ // (floating.tsx) above it, or wherever it fits.
4
4
  //
5
5
  // A term lives in TERMS rather than in the component that shows it, so the
6
6
  // same word is explained the same way wherever it appears.
7
7
 
8
+ import { Tip } from './floating.tsx'
9
+
8
10
  export const TERMS: Record<string, string> = {
9
11
  always: 'it runs on every gate and every health pass',
10
12
  release: 'it runs on the release path only: the full gate and the health pass run it, the quick gate does not',
@@ -23,8 +25,9 @@ export const TERMS: Record<string, string> = {
23
25
 
24
26
  /** one sentence, either given here or named by its term. `text` takes a node
25
27
  * so a legend can ride in the same bubble as a sentence */
26
- export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement => (
27
- <span className="hlp" tabIndex={0} role="note">
28
- ?<span className="hbub">{text ?? (term != null ? TERMS[term] ?? term : '')}</span>
29
- </span>
30
- )
28
+ export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement => {
29
+ const c = text ?? (term != null ? TERMS[term] ?? term : '')
30
+ // the sentence also rides inside the glyph, out of sight, so a screen
31
+ // reader and a search of the page find it without a hover
32
+ return <Tip className="hlp" tabIndex={0} tipClass="hbub" content={c}>?<span className="vh">{c}</span></Tip>
33
+ }
@@ -0,0 +1,241 @@
1
+ // HomeSt: THE root of the page (build/app-state-tree), exposed as window.app.
2
+ // One tree for every page the home serves, '/', '/planned' and '/r/<repo>/':
3
+ // the machine, the project list, the plan, and one RepoSt per repo visited, built on
4
+ // the first visit and kept for the life of the page (same name, same
5
+ // instance), so a repo you come back to shows what it showed. Only the shown
6
+ // repo polls. Every other repo whose hub is up is read ahead in the
7
+ // background, one at a time, so its first visit has its data at once. A hub serving on its own is the same tree with no machine and
8
+ // one repo at base ''.
9
+ //
10
+ // the one constraint a reader breaks first: the url and the tree move
11
+ // together, and only `show` moves the page. A list click, a link, back,
12
+ // forward and a hand edited address all land there.
13
+ import { makeAutoObservable, observableShallow, reaction, type IReactionDisposer } from 'mobx'
14
+ import { currentHash } from './hash-route.ts'
15
+ import { MachineSt, type MachineOpts } from './machine-st.ts'
16
+ import { LayoutSt } from './layout-st.ts'
17
+ import { PaletteSt } from './palette-st.ts'
18
+ import { PlannedSt } from './planned-st.ts'
19
+ import { ProjectsSvc } from './projects-svc.ts'
20
+ import { parseFilter, RepoSt } from './repo-st.ts'
21
+
22
+ /** the longest one repo's read ahead holds the next one back */
23
+ export const PREFETCH_WAIT_MS = 10_000
24
+
25
+ /** what the page shows: the machine overview, the plan, or one repo */
26
+ export type Page = 'overview' | 'planned' | 'repo'
27
+
28
+ /** where a repo's hub answers on the home */
29
+ export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
30
+ /** the page of a repo on the home */
31
+ export const repoPage = (name: string): string => `${repoBase(name)}/`
32
+
33
+ /** the page an address on the home names */
34
+ export function routeOf(pathname: string): { page: Page; repo: string | null } {
35
+ const m = /^\/r\/([^/]+)/.exec(pathname)
36
+ if (m != null) return { page: 'repo', repo: decodeURIComponent(m[1]!) }
37
+ if (/^\/planned\/?$/.test(pathname)) return { page: 'planned', repo: null }
38
+ return { page: 'overview', repo: null }
39
+ }
40
+
41
+ /** a plain left click: anything else keeps the browser's own behaviour
42
+ * (a new tab, a new window, a download) */
43
+ export const plainClick = (e: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean =>
44
+ e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
45
+
46
+ export type HomeOpts = MachineOpts & {
47
+ /** true on the home's web; false on a hub serving on its own */
48
+ home: boolean
49
+ }
50
+
51
+ export class HomeSt {
52
+ /** the machine, on the home; null on a hub of its own */
53
+ machine: MachineSt | null
54
+ /** the list of every repo on this disk */
55
+ projects: ProjectsSvc
56
+ /** the plan, polled while it is the page */
57
+ planned: PlannedSt
58
+ /** the one picker, cmd+k */
59
+ palette: PaletteSt
60
+ /** the widths of the project list and a repo's sidebar */
61
+ layout: LayoutSt
62
+ page: Page
63
+ /** the key of the shown repo: its name on the home, '' for the one repo
64
+ * of a hub of its own. null on the overview and the plan */
65
+ selected: string | null = null
66
+ /** every repo visited, by key, kept for the life of the page */
67
+ repos = new Map<string, RepoSt>()
68
+ private disposers: IReactionDisposer[] = []
69
+ /** a read ahead is going: one repo at a time, never two loops */
70
+ private prefetching = false
71
+ private readonly onUrl = (): void => this.fromUrl()
72
+
73
+ constructor(o: HomeOpts) {
74
+ this.projects = new ProjectsSvc()
75
+ this.planned = new PlannedSt(this)
76
+ this.palette = new PaletteSt(this)
77
+ this.layout = new LayoutSt()
78
+ this.machine = o.home ? new MachineSt(this, o) : null
79
+ this.page = o.home ? 'overview' : 'repo'
80
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, palette: false, layout: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
81
+ const m = this.machine
82
+ if (m != null) {
83
+ this.projects.onSelect = (name) => this.select(name)
84
+ this.projects.onPlace = (p) => this.navigate(p === 'overview' ? '/' : '/planned')
85
+ this.projects.dotOf = (name) => m.dotOf(name)
86
+ this.projects.warnOf = (name) => m.warnOf(name)
87
+ } else {
88
+ this.projects.onSelect = (name) => void this.openInHome(name)
89
+ const own = this.repo('')
90
+ own.markSeen()
91
+ this.selected = own.key
92
+ }
93
+ }
94
+
95
+ /** the repo on screen, or null */
96
+ get shown(): RepoSt | null {
97
+ return this.page !== 'repo' || this.selected == null ? null : this.repos.get(this.selected) ?? null
98
+ }
99
+
100
+ /** every repo shown once, in visit order: each keeps its own mounted
101
+ * region. A repo only read ahead has no region yet */
102
+ get visited(): RepoSt[] {
103
+ return [...this.repos.values()].filter((r) => r.seen)
104
+ }
105
+
106
+ get title(): string {
107
+ if (this.page === 'overview') return 'home'
108
+ if (this.page === 'planned') return 'planned'
109
+ const r = this.shown
110
+ return r == null || r.name === '' ? 'hub' : `${r.name} · hub`
111
+ }
112
+
113
+ /** the repo of this key, built on its first visit and never again */
114
+ repo(key: string): RepoSt {
115
+ let r = this.repos.get(key)
116
+ if (r == null) {
117
+ r = new RepoSt(this, key, this.machine == null ? '' : repoBase(key))
118
+ this.repos.set(key, r)
119
+ // what this browser kept of it paints before its hub answers
120
+ void r.cols.restore()
121
+ }
122
+ return r
123
+ }
124
+
125
+ boot(): void {
126
+ this.machine?.boot()
127
+ this.disposers.push(reaction(() => this.title, (t) => { document.title = t }, { fireImmediately: true }))
128
+ // back, forward, a pasted link and a hand edited hash all arrive
129
+ // here; fromUrl is idempotent, so the two events firing together
130
+ // for one move cost nothing
131
+ window.addEventListener('popstate', this.onUrl)
132
+ window.addEventListener('hashchange', this.onUrl)
133
+ this.fromUrl()
134
+ }
135
+
136
+ dispose(): void {
137
+ for (const d of this.disposers) d()
138
+ this.disposers = []
139
+ window.removeEventListener('popstate', this.onUrl)
140
+ window.removeEventListener('hashchange', this.onUrl)
141
+ this.machine?.stop()
142
+ this.planned.stop()
143
+ for (const r of this.repos.values()) r.dispose()
144
+ }
145
+
146
+ /** a list click, a repo row: that repo, in place, as a history entry */
147
+ select(name: string): void {
148
+ this.show('repo', name, 'push')
149
+ }
150
+
151
+ /** a link to a page of the home: the same page, in place */
152
+ navigate(path: string): void {
153
+ const u = new URL(path, 'http://home.invalid')
154
+ const r = routeOf(u.pathname)
155
+ if (r.page === 'planned') this.planned.query = u.searchParams.get('q') ?? ''
156
+ this.show(r.page, r.repo, 'push')
157
+ }
158
+
159
+ /** the url decides the page: the first load, back, forward and a hand
160
+ * edited address all land here */
161
+ fromUrl(): void {
162
+ if (this.machine == null) {
163
+ this.shown?.applyHash()
164
+ return
165
+ }
166
+ const r = routeOf(location.pathname)
167
+ // an address from before repo pages had their own path (`/#<repo>`)
168
+ const legacy = r.page === 'overview' ? currentHash() : ''
169
+ if (legacy !== '') return this.show('repo', legacy, 'replace')
170
+ if (r.page === 'planned') this.planned.query = new URLSearchParams(location.search).get('q') ?? ''
171
+ if (r.page === 'repo') this.repo(r.repo!).filter = parseFilter(location.search)
172
+ this.show(r.page, r.repo, 'none')
173
+ this.shown?.applyHash()
174
+ }
175
+
176
+ /** the one place the page changes. A repo left behind pauses and keeps
177
+ * everything; the one shown resumes once its hub answers */
178
+ private show(page: Page, repo: string | null, how: 'push' | 'replace' | 'none'): void {
179
+ const prev = this.shown
180
+ const next = page === 'repo' && repo != null ? this.repo(repo) : null
181
+ // the page already shown is never a second history entry
182
+ if (how === 'push' && page === this.page && next === prev && page !== 'planned') how = 'none'
183
+ if (prev != null && prev !== next) prev.pause()
184
+ next?.markSeen()
185
+ this.page = page
186
+ this.selected = next?.key ?? null
187
+ this.projects.selected = this.selected
188
+ this.projects.place = page === 'repo' ? null : page
189
+ if (how !== 'none') {
190
+ const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : '/'
191
+ if (how === 'push') history.pushState(null, '', to)
192
+ else history.replaceState(null, '', to)
193
+ }
194
+ if (page === 'planned') this.planned.start()
195
+ else this.planned.stop()
196
+ if (next != null && next !== prev && this.machine?.data != null) void this.machine.hub(next.key, 'ensure')
197
+ this.wake()
198
+ }
199
+
200
+ /** the shown repo polls once its hub answers, or at once on a hub of its
201
+ * own. One that started keeps going when its hub goes away, and says so */
202
+ wake(): void {
203
+ const r = this.shown
204
+ if (r == null) return
205
+ if (this.machine == null || r.started || (this.machine.fresh && this.machine.worker(r.key)?.status === 'up')) r.resume()
206
+ }
207
+
208
+ /** read ahead the first screen of every repo whose hub is up, in list
209
+ * order, one at a time, once the repo shown has its own. A repo read
210
+ * once is not read again: it polls when it shows */
211
+ async prefetch(): Promise<void> {
212
+ const m = this.machine
213
+ if (m?.data == null || !m.fresh || this.prefetching) return
214
+ const shown = this.shown
215
+ if (shown != null && !shown.cols.loaded) return
216
+ this.prefetching = true
217
+ try {
218
+ for (const p of this.projects.list) {
219
+ // a repo shown meanwhile has its own first screen to read:
220
+ // it goes first, and the next machine poll resumes this loop
221
+ const now = this.shown
222
+ if (now != null && !now.cols.loaded) return
223
+ if (m.worker(p.name)?.status !== 'up') continue
224
+ const r = this.repo(p.name)
225
+ if (r.started) continue
226
+ // a hub that hangs holds the loop back this long, never forever
227
+ await Promise.race([r.prefetch(), new Promise((ok) => setTimeout(ok, PREFETCH_WAIT_MS))])
228
+ }
229
+ } finally {
230
+ this.prefetching = false
231
+ }
232
+ }
233
+
234
+ /** a list click on a hub serving on its own: that repo's page on the
235
+ * home when one serves, else the folder in VS Code */
236
+ async openInHome(name: string): Promise<void> {
237
+ const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
238
+ if (home?.up !== true) return this.projects.open(name)
239
+ window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
240
+ }
241
+ }
@@ -0,0 +1,70 @@
1
+ /* the home's strip over a hub page, and the overview. hub.css owns every
2
+ token and the base controls: no colour is written here. */
3
+ .hstrip{display:flex;align-items:center;flex-wrap:nowrap;gap:var(--s2);height:32px;flex:none;padding:0 var(--s3);border-bottom:1px solid var(--line);overflow:hidden;white-space:nowrap}
4
+ .hstrip .sp{flex:1}
5
+ .hplace{font-size:12px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;color:var(--dim)}
6
+ .hrepo{display:inline-flex;align-items:center;gap:var(--s2);min-width:0}
7
+ .hrepo img,.hrico{flex:none;width:18px;height:18px;border-radius:4px;object-fit:contain}
8
+ .hrname{font-size:13px;font-weight:650;letter-spacing:.04em}
9
+ .hswatch{display:inline-flex;gap:2px}
10
+ .hswatch input{width:16px;height:16px;padding:0;border:1px solid var(--line);border-radius:3px;background:none;cursor:pointer}
11
+ .hswatch input::-webkit-color-swatch-wrapper{padding:0}
12
+ .hswatch input::-webkit-color-swatch{border:none;border-radius:2px}
13
+ .halert{display:inline-flex;align-items:center;font-size:12px;color:var(--errink)}
14
+ .hctl{display:inline-flex;align-items:center;gap:var(--s2)}
15
+ .hstat,.hflag{display:inline-flex;align-items:center;gap:var(--s1);font-size:12px;color:var(--dim)}
16
+ .herr{font-size:12px;color:var(--errink);max-width:40ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
17
+ .hdown{padding:var(--s2) var(--s3);font-size:13px;color:var(--errink);border-bottom:1px solid var(--red)}
18
+ .hdown b{color:var(--red);margin-right:var(--s2)}
19
+ .hdot{display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:var(--s1);background:var(--line)}
20
+ .hdot.up{background:var(--ok)}.hdot.starting,.hdot.stopping{background:var(--warn)}.hdot.crashed{background:var(--red)}
21
+ .hres{display:inline-flex;align-items:center;padding:var(--s0) var(--s2);font-size:12px}
22
+ .hres.down{color:var(--dim)}
23
+ .hproc{display:inline-flex}
24
+ /* a process log on hover, placed by floating-ui (floating.tsx); the pointer
25
+ can move into it to scroll */
26
+ .hpop{z-index:20;margin:0;padding:var(--s2);width:min(80ch,80vw);max-height:40vh;overflow:auto;
27
+ background:var(--panel);border:1px solid var(--line);border-radius:3px;box-shadow:0 6px 20px var(--shadow);font:11px/1.45 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
28
+ .hmon{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(340px,100%),1fr));gap:var(--s3)}
29
+ .hwidget{padding:var(--s2) var(--s3);border:1px solid var(--line);border-radius:3px;font-size:12px;overflow:auto}
30
+ .hwidget h3{margin:0 0 var(--s2);font-size:12px;font-weight:650}
31
+ .hwidget table{border-collapse:collapse}
32
+ .hwidget td{padding:0 var(--s3) 0 0;white-space:nowrap;vertical-align:middle}
33
+ .hbad{color:var(--errink)}
34
+ .hwait{flex:1;min-height:0;padding:var(--s4);color:var(--dim);font-size:13px;overflow:auto}
35
+ .hwait p{margin:0 0 var(--s2)}
36
+ .hlog{margin:var(--s2) 0 0;padding:var(--s2);background:var(--panel);border:1px solid var(--line);border-radius:3px;
37
+ font:11px/1.45 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap;max-height:60vh;overflow:auto}
38
+ .hall{flex:1;min-height:0;overflow:auto;padding:var(--s4)}
39
+ /* the sections one under the other: shipkit, the process cards, the monitor */
40
+ .hstack{display:flex;flex-direction:column;gap:var(--s4)}
41
+ /* one card per global process, as many columns as fit, never a horizontal scroll */
42
+ .hcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:var(--s3)}
43
+ .hcard{display:flex;flex-direction:column;gap:var(--s1);min-width:0;padding:var(--s2) var(--s3);background:var(--panel);border:1px solid var(--line);border-radius:4px;font-size:12px}
44
+ .hchead{display:flex;align-items:center;gap:var(--s2)}
45
+ .hcstate{font-size:12px;color:var(--dim)}
46
+ .hcstate.up{color:var(--ok)}.hcstate.crashed{color:var(--red)}.hcstate.starting,.hcstate.stopping{color:var(--warn)}
47
+ .hcfile{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
48
+ .hcurl{color:var(--accent);text-decoration:none;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
49
+ .hcurl:hover{text-decoration:underline}
50
+ .hcbtns{display:flex;flex-wrap:wrap;gap:var(--s1);margin-top:var(--s1)}
51
+ .hsec{min-width:0}
52
+ .hsec h2{display:flex;align-items:center;gap:var(--s2);height:28px;margin:0 0 var(--s2);padding-bottom:var(--s1);border-bottom:1px solid var(--line);
53
+ font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600}
54
+ .hsec h2 .sp{flex:1}
55
+ .hsec h2 button{letter-spacing:0;text-transform:none}
56
+ .hico{flex:none;color:var(--accent)}
57
+ .hrow .sp{flex:1}
58
+ .hrow{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2);padding:var(--s1) 0;border-bottom:1px solid var(--line)}
59
+ .hrow .hlog{flex-basis:100%}
60
+ .hlink{display:inline-flex;align-items:center;color:inherit;text-decoration:none;font-size:13px}
61
+ .hlink:hover{text-decoration:underline}
62
+ .hdim{font-size:12px;color:var(--dim)}
63
+ .hnote{font-size:12px;color:var(--warn)}
64
+ /* a filled bar any widget can draw: <span class="wbar" style="--p:51%"><i></i></span>,
65
+ green below 70%, `mid` from 70%, `hi` from 90% (the widget picks the class) */
66
+ .wbar{display:inline-block;vertical-align:middle;width:84px;height:7px;margin-right:var(--s2);border-radius:4px;background:var(--line);overflow:hidden}
67
+ .wbar > i{display:block;height:100%;width:var(--p,0%);background:var(--ok)}
68
+ .wbar.mid > i{background:var(--warn)}
69
+ .wbar.hi > i{background:var(--red)}
70
+ .hcard .hrow{border-bottom:0;padding:var(--s0) 0}