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
@@ -0,0 +1,340 @@
1
+ // RepoSt: one repo's hub page as a node of the page tree (home-st.ts owns
2
+ // the root, build/app-state-tree). Owns cross-service state: selection, the
3
+ // severity filter (an ADDRESS: ?f=needs · ?f=red), and the hash router: every
4
+ // page and every tab is a selection, every selection has an address
5
+ // (hash-route.ts), and setSel is the one place both move together. A repo
6
+ // that is not shown keeps all of it and polls nothing (resume, pause), and
7
+ // only the shown one writes the url.
8
+ import { makeAutoObservable } from 'mobx'
9
+ import { ClockSvc } from './clock-svc.ts'
10
+ import { ColsSvc } from './cols-svc.ts'
11
+ import { canonicalSel, currentHash, hashFor, parseHash, selOf, type HashSel } from './hash-route.ts'
12
+ import { ComfySvc } from './comfy-svc.ts'
13
+ import { HealthSvc } from './health-svc.ts'
14
+ import { ReadmeSvc } from './readme-svc.ts'
15
+ import { LiveSvc } from './live-svc.ts'
16
+ import { MediaSvc } from './media-svc.ts'
17
+ import { TimelineSvc } from './timeline-svc.ts'
18
+ import { TreeSvc } from './tree-svc.ts'
19
+ import { ChecksSvc } from './checks-svc.ts'
20
+ import { ProcessesSvc } from './processes-svc.ts'
21
+ import { TestsSvc } from './tests-svc.ts'
22
+ import { AdminSvc } from './admin-svc.ts'
23
+ import { repoSections } from './repo-sections.tsx'
24
+ import { sectionKeyLabel } from './keys.ts'
25
+ import type { Resource } from './hub-types.ts'
26
+ import type { HomeSt } from './home-st.ts'
27
+
28
+ export type Sel = HashSel | null
29
+
30
+ /** one picker target (cmd+k): run() fires the SAME action clicking would */
31
+ /** what a picker entry wears: a repo's own icon, or a place's button icon */
32
+ export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
33
+ /** `key`: the shortcut that does the same, printed at the entry's end */
34
+ export type Jump = { id: string; label: string; section: string; run: () => void; face?: JumpFace; key?: string }
35
+
36
+ /** all · needs (yellow+red) · red — one severity ladder for every section */
37
+ export type Filter = 'all' | 'needs' | 'red'
38
+ export const parseFilter = (search: string): Filter => {
39
+ const f = new URLSearchParams(search).get('f')
40
+ return f === 'needs' || f === 'red' ? f : 'all'
41
+ }
42
+
43
+ export class RepoSt {
44
+ /** the repo's name, from /hub.json; its key until that answers */
45
+ name: string
46
+ /** where this repo sits on disk, from /hub.json: what makes a source
47
+ * path on a page a link that opens his editor */
48
+ root = ''
49
+ /** the one second tick every age of this repo reads (clock-svc.ts). It
50
+ * ticks only while the repo is shown, so a hidden page repaints nothing */
51
+ clock: ClockSvc
52
+ media: MediaSvc
53
+ cols: ColsSvc
54
+ tree: TreeSvc
55
+ health: HealthSvc
56
+ readme: ReadmeSvc
57
+ checks: ChecksSvc
58
+ comfy: ComfySvc
59
+ timeline: TimelineSvc
60
+ /** the runs in flight, whoever started them (live-svc.ts) */
61
+ live: LiveSvc
62
+ processes: ProcessesSvc
63
+ /** the pages behind the test groups of the tree (tests-svc.ts) */
64
+ tests: TestsSvc
65
+ /** the admin page: archive behind its confirmation, reload, keep active */
66
+ admin: AdminSvc
67
+ sel: Sel = null
68
+ filter: Filter = 'all'
69
+ /** sidebar search — live substring filter over asset + collection rows */
70
+ query = ''
71
+ /** the first resume or prefetch ran: on the home, only once this repo's
72
+ * hub answered */
73
+ started = false
74
+ /** the page showed it once: only a repo shown has a region mounted, so a
75
+ * repo read ahead in the background costs no render */
76
+ seen = false
77
+ /** the sidebar search box, so `/` focuses this repo's own */
78
+ searchEl: HTMLInputElement | null = null
79
+ /** its timers run: only the shown repo's do */
80
+ polling = false
81
+ private beat = false
82
+ /** the hub name, the gate block and STATUS.md were asked for */
83
+ private onceRead = false
84
+
85
+ /** `key` names the repo in the root's map, `base` is where its hub
86
+ * answers: '/r/<repo>' on the home, '' on a hub of its own */
87
+ constructor(readonly home: HomeSt, readonly key: string, readonly base: string) {
88
+ this.name = key
89
+ // children built in the ctor body: a field initializer would run
90
+ // before `home` is assigned
91
+ this.clock = new ClockSvc()
92
+ this.media = new MediaSvc(this)
93
+ this.cols = new ColsSvc(this)
94
+ this.tree = new TreeSvc(this)
95
+ this.health = new HealthSvc(this)
96
+ this.readme = new ReadmeSvc(this)
97
+ this.checks = new ChecksSvc(this)
98
+ this.comfy = new ComfySvc(this)
99
+ this.timeline = new TimelineSvc(this)
100
+ this.live = new LiveSvc(this)
101
+ this.processes = new ProcessesSvc(this)
102
+ this.tests = new TestsSvc(this)
103
+ this.admin = new AdminSvc(this)
104
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, processes: false, tests: false, admin: false, searchEl: false, beat: false })
105
+ }
106
+
107
+ /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
108
+ * repo's hub answers it. Every request of the repo goes through here */
109
+ url(path: string): string {
110
+ return path.startsWith('/') ? `${this.base}${path}` : path
111
+ }
112
+
113
+ /** the repo the page shows now: only it writes the url */
114
+ get isShown(): boolean {
115
+ return this.home.shown === this
116
+ }
117
+
118
+ /** what the url carries for this repo: its filter, then its selection */
119
+ get search(): string {
120
+ return this.filter === 'all' ? '' : `?f=${this.filter}`
121
+ }
122
+ get hash(): string {
123
+ const h = this.sel?.t === 'asset' ? this.media.hash : hashFor(this.sel)
124
+ return h == null ? '' : `#${h}`
125
+ }
126
+
127
+ /** what is read once, the first time the repo starts or is read ahead */
128
+ private readOnce(): Promise<unknown> {
129
+ this.started = true
130
+ this.onceRead = true
131
+ return Promise.all([this.readHub(), this.checks.loadGate(), this.health.load()])
132
+ }
133
+
134
+ /** the repo's name and where it sits on disk, from /hub.json */
135
+ private readHub(): Promise<void> {
136
+ return fetch(this.url('/hub.json'))
137
+ .then((r) => r.json() as Promise<{ name: string; root?: string }>)
138
+ .then((h) => { this.setHub(h.name, h.root ?? '') })
139
+ .catch(() => {})
140
+ }
141
+
142
+ /** the first screen of the repo, read in the background while another
143
+ * one shows, so its first visit has its data at once. One step at a
144
+ * time: the browser opens six connections to the home, and the repo on
145
+ * screen keeps most of them. Nothing polls: a resume takes over when it
146
+ * shows */
147
+ async prefetch(): Promise<void> {
148
+ if (this.started) return
149
+ this.started = true
150
+ const steps: (() => Promise<unknown>)[] = [
151
+ () => this.cols.poll(),
152
+ () => this.readOnce(),
153
+ () => this.processes.load(),
154
+ () => this.media.heartbeat(),
155
+ () => this.live.poll(),
156
+ () => this.timeline.load(),
157
+ ]
158
+ for (const step of steps) {
159
+ // shown meanwhile: its resume polls everything, so stop here
160
+ if (this.polling) return
161
+ await step()
162
+ }
163
+ }
164
+
165
+ /** start polling. The first one also reads what is read once */
166
+ resume(): void {
167
+ if (this.polling) return
168
+ this.polling = true
169
+ if (!this.onceRead) {
170
+ void this.readOnce()
171
+ this.applyHash()
172
+ }
173
+ this.clock.start()
174
+ this.processes.start()
175
+ this.media.start()
176
+ this.cols.start()
177
+ this.live.start()
178
+ }
179
+
180
+ /** stop polling, keep every state: a resume shows the same page */
181
+ pause(): void {
182
+ if (!this.polling) return
183
+ this.polling = false
184
+ this.clock.stop()
185
+ this.processes.stop()
186
+ this.media.stop()
187
+ this.cols.stop()
188
+ this.live.stop()
189
+ }
190
+
191
+ dispose(): void {
192
+ this.pause()
193
+ this.media.dispose()
194
+ this.checks.dispose()
195
+ this.comfy.dispose()
196
+ }
197
+
198
+ markSeen(): void {
199
+ this.seen = true
200
+ }
201
+
202
+ private setHub(n: string, root: string): void {
203
+ this.name = n
204
+ this.root = root
205
+ }
206
+
207
+ setFilter(v: Filter): void {
208
+ this.filter = v
209
+ if (!this.isShown) return
210
+ const u = new URL(location.href)
211
+ v === 'all' ? u.searchParams.delete('f') : u.searchParams.set('f', v)
212
+ history.replaceState(null, '', u)
213
+ }
214
+ /** the atelier rewrites its tool set in place: same page, no entry */
215
+ replaceHash(h: string): void {
216
+ if (this.isShown && location.hash.slice(1) !== h) history.replaceState(null, '', `#${h}`)
217
+ }
218
+ setQuery(v: string): void {
219
+ this.query = v
220
+ }
221
+
222
+ /** every jump target: section titles, media assets, collection instances */
223
+ get jumps(): Jump[] {
224
+ const out: Jump[] = []
225
+ for (const cat of new Set(this.media.assets.map((a) => a.cat)))
226
+ out.push({ id: `cat:${cat}`, label: cat, section: 'media', run: () => this.showCat(cat) })
227
+ for (const a of this.media.assets)
228
+ out.push({ id: `asset:${a.id}`, label: a.label, section: a.cat, run: () => this.selectAsset(a.id) })
229
+ // the section lines, with their keys: the same list the left pane draws
230
+ const secs = repoSections(this)
231
+ for (const s of secs) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
232
+ for (const col of this.cols.sidebarCols) {
233
+ if (!secs.some((s) => s.id === col.id)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
234
+ for (const i of col.instances)
235
+ out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
236
+ }
237
+ return out
238
+ }
239
+ // ── selection + hash deep-links ───────────────────────────────────────
240
+ selectAsset(id: string): void {
241
+ this.setSel({ t: 'asset', id })
242
+ void this.media.select(id)
243
+ }
244
+ showCat(cat: string): void {
245
+ this.setSel({ t: 'cat', cat })
246
+ this.media.openCats.add(cat)
247
+ void this.media.fillCat()
248
+ }
249
+ showStatus(): void {
250
+ this.setSel({ t: 'status' })
251
+ void this.health.load()
252
+ }
253
+ /** where the test checks' time goes: the `tests` group (null) or one test
254
+ * check's group. Fetched as the page opens, never on the poll */
255
+ showTests(check: string | null): void {
256
+ this.setSel({ t: 'tests', check })
257
+ void this.tests.load(check)
258
+ }
259
+
260
+ /** the ONE place a selection is written, so the url always describes what
261
+ * is on screen. A new page is a history entry, so back returns to the
262
+ * page before it. The same address is never pushed twice: applyHash
263
+ * lands here too, with the hash already naming this selection */
264
+ setSel(sel: Sel): void {
265
+ this.sel = sel
266
+ const h = hashFor(sel)
267
+ if (h == null || !this.isShown) return
268
+ const cur = parseHash(currentHash())
269
+ const next = parseHash(h)
270
+ // the tool set rides after the asset id (media-svc syncHash), so an
271
+ // address is the same page whatever tools it carries
272
+ if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) return
273
+ history.pushState(null, '', `#${h}`)
274
+ }
275
+ /** one tree row, opened: an asset goes to the atelier, everything else to
276
+ * its own item card. A gate check has a page of its own now, so nothing
277
+ * lands on the raw wall of text unless it is asked for by name */
278
+ openResource(r: Resource): void {
279
+ const assetId = r.id.replace(/^media:/, '')
280
+ if (r.collection === 'media' && this.media.assets.some((a) => a.id === assetId)) {
281
+ this.selectAsset(assetId)
282
+ return
283
+ }
284
+ this.cols.select(r.collection, r.id)
285
+ }
286
+
287
+ /** what administers the repo: reload its hub, keep it active, archive it */
288
+ showAdmin(): void {
289
+ this.admin.cancel()
290
+ this.setSel({ t: 'admin' })
291
+ }
292
+
293
+ /** collection view — one card per instance */
294
+ showCol(id: string): void {
295
+ this.setSel({ t: 'col', id })
296
+ }
297
+ /** open whatever a selection names, through the same calls a click makes */
298
+ go(sel: HashSel): void {
299
+ switch (sel.t) {
300
+ case 'asset': return this.selectAsset(sel.id)
301
+ case 'cat': return this.showCat(sel.cat)
302
+ case 'col': return this.showCol(sel.id)
303
+ case 'item': return this.cols.select(sel.col, sel.id)
304
+ case 'status': return this.showStatus()
305
+ case 'tests': return this.showTests(sel.check)
306
+ case 'admin': return this.showAdmin()
307
+ }
308
+ }
309
+ /** the url decides the page: a reload, back, forward and a pasted link
310
+ * all land here, and only a hash naming something else moves the view */
311
+ applyHash(): void {
312
+ if (!this.isShown) return
313
+ const p = parseHash(currentHash())
314
+ const named = selOf(p)
315
+ if (named == null) return
316
+ // an address naming what this build opens under another address is
317
+ // rewritten in place: a replace, so back never lands on the stale one
318
+ const sel = canonicalSel(named)
319
+ if (sel !== named) history.replaceState(null, '', `#${hashFor(sel)}`)
320
+ // media addresses resolve only once the first heartbeat listed the
321
+ // assets (afterFirstBeat calls back)
322
+ if (sel.t === 'asset' && !this.media.assets.some((a) => a.id === sel.id)) return
323
+ if (sel.t === 'cat' && !this.media.assets.some((a) => a.cat === sel.cat)) return
324
+ if (sel.t === 'asset' && this.media.curId === sel.id) {
325
+ // same asset, new tool set (a pasted share link): apply NOW, a
326
+ // parked pendingTools would hijack the next selection instead
327
+ if (p.tools != null) this.media.applyTab(p.tools)
328
+ return
329
+ }
330
+ if (hashFor(sel) === hashFor(this.sel)) return
331
+ if (sel.t === 'asset') this.media.pendingTools = p.tools
332
+ this.go(sel)
333
+ }
334
+ /** first heartbeat has the asset list — only then can a hash resolve */
335
+ afterFirstBeat(): void {
336
+ if (this.beat || !this.isShown) return
337
+ this.beat = true
338
+ this.applyHash()
339
+ }
340
+ }
@@ -7,7 +7,7 @@ export type RunsTab = 'runs' | 'files' | 'slowest' | 'longest'
7
7
 
8
8
  /** the four lists, in tab order. `key` is what the address carries */
9
9
  export const RUNS_TABS: { key: RunsTab; label: string; help: string }[] = [
10
- { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, its save trigger and its daily health pass. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the live strip under the header: unfold it and press fit` },
10
+ { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the chart over the tabs: press fit for the whole record` },
11
11
  { key: 'files', label: 'test files to fix', help: "bun test files, not checks: each file's last recorded run inside the check that ran it (test, test:<unit>), kept when a test in it failed or when it broke the quick tier budget per file. The same verdict colours the file in the tree under tests. Click a file for its page, the check tag for the check" },
12
12
  { key: 'slowest', label: 'slowest checks', help: 'the checks that cost the most when they really run: the mean and the worst of the runs that ran. A check whose worst run passed half its ceiling is flagged, because the next slow machine trips it' },
13
13
  { key: 'longest', label: 'longest runs', help: 'the invocations on record that took the longest, start to end, whatever ran them' },
@@ -0,0 +1,74 @@
1
+ // each repo's last first screen, kept per browser in IndexedDB (a repo's rows
2
+ // run to near a megabyte, past what localStorage holds for twenty repos): the
3
+ // rows, the collections, the plan rows, the hub problems and the first media
4
+ // category. A load paints it at once, then the hub's answer replaces it.
5
+ // Anything that fails (no IndexedDB, a private window, a blocked store) reads
6
+ // as nothing kept: the page then waits on the hub as it always did.
7
+ import type { ColMeta, HubProblem, Resource } from './hub-types.ts'
8
+ import type { PlanRow } from '../../plan/planner.ts'
9
+
10
+ export type Screen = {
11
+ at: number
12
+ collections: ColMeta[]
13
+ resources: Resource[]
14
+ planned: PlanRow[] | null
15
+ hub: HubProblem[]
16
+ /** the first media category, so the header's media link is there too */
17
+ mediaCat: string | null
18
+ }
19
+
20
+ const DB = 'shipkit-hub'
21
+ const STORE = 'screens'
22
+ /** the longest a load waits on the store before it paints without it */
23
+ export const SCREEN_WAIT_MS = 150
24
+ /** a repo's screen is written at most this often */
25
+ export const SCREEN_WRITE_MS = 60_000
26
+
27
+ let opened: Promise<IDBDatabase | null> | null = null
28
+
29
+ function db(): Promise<IDBDatabase | null> {
30
+ opened ??= new Promise((ok) => {
31
+ try {
32
+ const idb = globalThis.indexedDB
33
+ if (idb == null) return ok(null)
34
+ const r = idb.open(DB, 1)
35
+ r.onupgradeneeded = () => { r.result.createObjectStore(STORE) }
36
+ r.onsuccess = () => ok(r.result)
37
+ r.onerror = () => ok(null)
38
+ r.onblocked = () => ok(null)
39
+ } catch {
40
+ ok(null)
41
+ }
42
+ })
43
+ return opened
44
+ }
45
+
46
+ /** the screen kept for a repo, or null */
47
+ export async function readScreen(key: string): Promise<Screen | null> {
48
+ const d = await db()
49
+ if (d == null) return null
50
+ return new Promise((ok) => {
51
+ try {
52
+ const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key)
53
+ r.onsuccess = () => {
54
+ const v = r.result as Screen | undefined
55
+ ok(v != null && Array.isArray(v.resources) && Array.isArray(v.collections) ? v : null)
56
+ }
57
+ r.onerror = () => ok(null)
58
+ } catch {
59
+ ok(null)
60
+ }
61
+ })
62
+ }
63
+
64
+ /** keeps a repo's screen; a failure only means the next load waits */
65
+ export async function writeScreen(key: string, s: Screen): Promise<void> {
66
+ const d = await db()
67
+ if (d == null) return
68
+ try {
69
+ d.transaction(STORE, 'readwrite').objectStore(STORE).put(s, key)
70
+ } catch (e) {
71
+ // a full or blocked store only means the next load waits on the hub
72
+ console.warn(`shipkit: could not keep the screen of ${key}: ${(e as Error).message}`)
73
+ }
74
+ }
@@ -0,0 +1,84 @@
1
+ // A repo page's left pane: its sections (repo-sections.tsx), then the tree of
2
+ // every resource of the repo, one row deep in its path, under its filter, its
3
+ // two tabs and its severity filter. Only the tree scrolls, so the sections and
4
+ // the filter stay in reach. The row itself is tree-row.tsx.
5
+ import { observer } from 'mobx-react-lite'
6
+ import { Help } from './help.tsx'
7
+ import { SEARCH_KEY } from './keys.ts'
8
+ import { Sections } from './repo-sections.tsx'
9
+ import type { RepoSt } from './repo-st.ts'
10
+ import { TYPE_MARK } from './tree-build.ts'
11
+ import { TreeRowView } from './tree-row.tsx'
12
+
13
+ const Tree = observer(({ app }: { app: RepoSt }) => {
14
+ const t = app.tree
15
+ return (
16
+ // roving focus: the arrows belong to the tree only while it HAS the
17
+ // focus, so the atelier keeps its own keys
18
+ <div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
19
+ {t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
20
+ {!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
21
+ {app.cols.loaded && t.rows.length === 0 ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : app.filter === 'needs' ? 'nothing needs you ✨' : 'no resources yet'}</div> : null}
22
+ </div>
23
+ )
24
+ })
25
+
26
+ /** what every glyph in the type slot means, behind one `(?)` */
27
+ const TypeLegend = () => (
28
+ <Help text={
29
+ <span className="legend">
30
+ {Object.entries(TYPE_MARK).map(([kind, m]) => (
31
+ <span key={kind} className="lrow"><i className={`ty ${m.hue}`}>{m.glyph}</i>{m.word}</span>
32
+ ))}
33
+ </span>
34
+ } />
35
+ )
36
+
37
+ const TreeTabs = observer(({ app }: { app: RepoSt }) => (
38
+ <span className="seg">
39
+ {(['path', 'type'] as const).map((t) => (
40
+ <button key={t} className={app.tree.tab === t ? 'on' : ''} onClick={() => app.tree.setTab(t)}>by {t}</button>
41
+ ))}
42
+ </span>
43
+ ))
44
+
45
+ // segmented severity filter — all · needs (yellow+red) · red, the address
46
+ // rides at /?f=… so a filtered view is shareable. The shortcut letter rides
47
+ // INSIDE its own button, so the binding is discoverable without a legend
48
+ const FILTER_KEY = { all: 'a', needs: 'n', red: 'r' } as const
49
+ const SegFilter = observer(({ app }: { app: RepoSt }) => (
50
+ <span className="seg">
51
+ {(['all', 'needs', 'red'] as const).map((f) => (
52
+ <button key={f} className={app.filter === f ? 'on' : ''} onClick={() => app.setFilter(f)}>
53
+ {f}<span className="sk">{FILTER_KEY[f]}</span>
54
+ </button>
55
+ ))}
56
+ </span>
57
+ ))
58
+
59
+ export const Sidebar = observer(({ app }: { app: RepoSt }) => (
60
+ <aside>
61
+ <Sections app={app} />
62
+ <div className="ssearch">
63
+ <span className="sfield">
64
+ <input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree" value={app.query}
65
+ onChange={(e) => app.setQuery(e.target.value)}
66
+ onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
67
+ <kbd className="skey">{SEARCH_KEY}</kbd>
68
+ </span>
69
+ <SegFilter app={app} />
70
+ </div>
71
+ <div className="stabs">
72
+ <TreeTabs app={app} />
73
+ <span className="sp" />
74
+ <TypeLegend />
75
+ <span className="cnt">{app.tree.visible.length}</span>
76
+ </div>
77
+ {/* its line is held while the repo is read, so the tree never drops under it */}
78
+ {app.tree.whenLine === '' && app.cols.loaded ? null : <div className="swhen">{app.tree.whenLine === '' ? ' ' : app.tree.whenLine}</div>}
79
+ {app.media.dead != null ? <div className="tempty"><span className="warnline">media API down: {app.media.dead}</span></div> : null}
80
+ <div className="tscroll">
81
+ <Tree app={app} />
82
+ </div>
83
+ </aside>
84
+ ))
@@ -5,7 +5,7 @@
5
5
  // tier.
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { durationTone, fmtMs } from './dur.ts'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
  import type { Resource, SuiteFacts, TestFacts, TestRun } from './hub-types.ts'
10
10
  import { copy, PathCode, Row, Section } from './check-view.tsx'
11
11
  import { editorHref, repoPath } from './check-words.ts'
@@ -34,7 +34,7 @@ const overBudget = (r: TestRun, slow: boolean): boolean => !slow && r.tier === '
34
34
 
35
35
  const dot = (r: TestRun, slow: boolean): string => (r.status === 'fail' ? 'red' : overBudget(r, slow) ? 'warn' : 'ok')
36
36
 
37
- const RunLine = ({ app, r, slow }: { app: HubSt; r: TestRun; slow: boolean }) => (
37
+ const RunLine = ({ app, r, slow }: { app: RepoSt; r: TestRun; slow: boolean }) => (
38
38
  <div className={`rline rtrun${r.status === 'fail' ? ' red' : ''}`} title={stampWords(r.at)}>
39
39
  <TimeAgo clock={app.clock} at={r.at} />
40
40
  <span className="rid"><i className={`rdot ${dot(r, slow)}`} />{r.status === 'fail' ? 'failed' : 'passed'}</span>
@@ -43,7 +43,7 @@ const RunLine = ({ app, r, slow }: { app: HubSt; r: TestRun; slow: boolean }) =>
43
43
  </div>
44
44
  )
45
45
 
46
- const Declaration = observer(({ app, s }: { app: HubSt; s: SuiteFacts }) => {
46
+ const Declaration = observer(({ app, s }: { app: RepoSt; s: SuiteFacts }) => {
47
47
  const where = s.unit == null ? 'tests.slow' : `units['${s.unit}'].tests.slow`
48
48
  return (
49
49
  <Section title="declared slow" help="the quick tier leaves it out: its declaration says what it catches and why it cannot be fast">
@@ -67,7 +67,7 @@ const Declaration = observer(({ app, s }: { app: HubSt; s: SuiteFacts }) => {
67
67
  )
68
68
  })
69
69
 
70
- export const TestCard = observer(({ app, id }: { app: HubSt; id: string }) => {
70
+ export const TestCard = observer(({ app, id }: { app: RepoSt; id: string }) => {
71
71
  const i: Resource | null = app.cols.find('tests', id)
72
72
  if (i == null || i.test == null) return <div className="ph">this test file is gone: the repo moved on</div>
73
73
  const t = i.test
@@ -5,7 +5,7 @@
5
5
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
6
  import type { TestCheckView, TestsRootView } from '../../collections/test-time.ts'
7
7
  import { testId } from '../../collections/collection-types.ts'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
 
10
10
  export type { TestCheckView, TestsRootView }
11
11
 
@@ -17,7 +17,7 @@ export class TestsSvc {
17
17
  errors = new Map<string, string>()
18
18
  loading = new Set<string>()
19
19
 
20
- constructor(private st: HubSt) {
20
+ constructor(private st: RepoSt) {
21
21
  makeAutoObservable<this, 'st'>(this, { st: false, views: observableRef })
22
22
  }
23
23
 
@@ -45,7 +45,7 @@ export class TestsSvc {
45
45
  const k = keyOf(check)
46
46
  runInAction(() => { this.loading.add(k) })
47
47
  try {
48
- const r = await fetch(check == null ? '/api/tests.json' : `/api/tests.json?check=${encodeURIComponent(check)}`)
48
+ const r = await fetch(this.st.url(check == null ? '/api/tests.json' : `/api/tests.json?check=${encodeURIComponent(check)}`))
49
49
  if (!r.ok) throw new Error(await r.text())
50
50
  const v = (await r.json()) as TestsRootView | TestCheckView
51
51
  runInAction(() => {