rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -22,6 +22,7 @@ import { serverNow } from './live-svc.ts'
22
22
  import { ROWS, type RowSize } from './timeline-geometry.ts'
23
23
  import { ageWords } from './time-ago.tsx'
24
24
  import type { HomeSt } from './home-st.ts'
25
+ import { ownTrouble, reach } from './reach.ts'
25
26
 
26
27
  export type DockSize = 'line' | 'slim' | 'open'
27
28
 
@@ -296,7 +297,7 @@ export class DockSt {
296
297
  if (this.inFlight) return
297
298
  this.inFlight = true
298
299
  try {
299
- const r = await fetch('/api/house-runs.json')
300
+ const r = await reach('/api/house-runs.json')
300
301
  if (!r.ok) throw new Error(await r.text())
301
302
  const v = (await r.json()) as HouseView
302
303
  runInAction(() => {
@@ -306,7 +307,7 @@ export class DockSt {
306
307
  this.error = ''
307
308
  })
308
309
  } catch (e) {
309
- runInAction(() => { this.error = `the runs could not be read: ${(e as Error).message}` })
310
+ runInAction(() => { this.error = ownTrouble(e, this.home.machine, (m) => `the runs could not be read: ${m}`) })
310
311
  } finally {
311
312
  this.inFlight = false
312
313
  }
@@ -4,6 +4,9 @@
4
4
  // then the status as small pills that fold into +N rather than wrap. The
5
5
  // state is dock-st.ts; the height is the page's panel (hub-app.tsx).
6
6
  import { observer } from 'mobx-react-lite'
7
+ import { useEffect, useState } from 'react'
8
+ import { Tip } from './floating.tsx'
9
+ import { Icon, ICON } from './icons.tsx'
7
10
  import type { HomeSt } from './home-st.ts'
8
11
  import { CycleButton } from './cycle-button.tsx'
9
12
  import { DOCK_KEY } from './keys.ts'
@@ -15,6 +18,38 @@ import type { PlacedGroup } from './timeline-geometry.ts'
15
18
  /** what the dock is, on its size button's hover */
16
19
  export const DOCK_HELP = `every check shipkit runs in every repo, whoever started it: a page, an agent in a terminal, the plan. The line, the pills alone; slim, the last two minutes, one group of lanes per repo; open, the last ten, zoomed by the wheel and panned by a drag, its height dragged at the top edge`
17
20
 
21
+ const COPIED_MS = 1500
22
+
23
+ /** the page's own address, to paste somewhere: the app window has no url bar */
24
+ export const CopyUrl = () => {
25
+ const [href, setHref] = useState(() => globalThis.location?.href ?? '')
26
+ const [said, setSaid] = useState<string | null>(null)
27
+ // the router moves the address with pushState and replaceState, which no
28
+ // event reports: read it when the pointer or the focus arrives
29
+ const sync = () => setHref(location.href)
30
+ useEffect(() => {
31
+ if (said == null) return
32
+ const t = setTimeout(() => setSaid(null), COPIED_MS)
33
+ return () => clearTimeout(t)
34
+ }, [said])
35
+ const click = async () => {
36
+ try {
37
+ if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
38
+ await navigator.clipboard.writeText(location.href)
39
+ setSaid('copied')
40
+ } catch (e) {
41
+ setSaid(`could not copy: ${(e as Error).message}`)
42
+ }
43
+ }
44
+ return (
45
+ <Tip className="dcopy" instant placement="top-end" content={<><div>{said ?? 'click to copy this page\'s url'}</div><code>{href}</code></>}>
46
+ <button className={`ghost dcopyb${said === 'copied' ? ' ok' : ''}`} aria-label="copy this page's url" onMouseEnter={sync} onFocus={sync} onClick={() => void click()}>
47
+ <Icon d={ICON.link} />
48
+ </button>
49
+ </Tip>
50
+ )
51
+ }
52
+
18
53
  /** a group's label: the repo's icon and its name in its colour */
19
54
  const GroupLabel = observer(({ app, g }: { app: HomeSt; g: PlacedGroup }) => {
20
55
  const p = app.projects.list.find((x) => x.name === g.key)
@@ -55,6 +90,7 @@ export const Dock = observer(({ app }: { app: HomeSt }) => {
55
90
  <CycleButton options={DOCK_SIZES} value={d.size} onChange={(s) => d.setSize(s)} shortcut={DOCK_KEY} title={DOCK_HELP} />
56
91
  <PillRow items={d.pills.map((p) => ({ key: p.key, title: p.title, node: <PillFace app={app} p={p} /> }))} />
57
92
  {d.error !== '' ? <span className="warnline">{d.error}</span> : null}
93
+ <CopyUrl />
58
94
  </div>
59
95
  {d.size === 'line' ? null : <div className="dbody">
60
96
  {v == null ? null : groups.length === 0
@@ -6,6 +6,12 @@
6
6
  .dhead{display:flex;gap:var(--s2);align-items:center;height:22px;flex:none;padding:0 var(--s3)}
7
7
  /* the status pills take the room left, and fold rather than wrap */
8
8
  .dhead .pchips{flex:1}
9
+ /* the page's url to the clipboard, at the bottom right corner */
10
+ .dcopy{flex:none;display:flex;margin-left:auto}
11
+ .dcopyb{display:flex;align-items:center;padding:0 var(--s0);color:var(--dim)}
12
+ .dcopyb:hover{color:var(--ink)}
13
+ .dcopyb.ok{color:var(--ok)}
14
+ .dcopyb .hico{width:13px;height:13px;color:inherit}
9
15
  .dbody{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 var(--s3) var(--s1);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
10
16
  .dempty{padding:var(--s1) 0;color:var(--dim)}
11
17
  .dock .tlax.compact{height:auto;padding-top:0}
@@ -31,5 +37,5 @@
31
37
  .dockanim>[data-panel]{transition:flex-grow 180ms ease-out}
32
38
  @media (prefers-reduced-motion:reduce){.dockanim>[data-panel]{transition:none}}
33
39
  /* the runs page's record chart: its (?) in its top left corner */
34
- .rchart{position:relative;flex:none;overflow:hidden;margin-bottom:var(--s2)}
40
+ .rchart{position:relative;flex:none;overflow:hidden;margin-top:var(--s3)}
35
41
  .rchelp{position:absolute;top:0;left:0;z-index:4}
@@ -0,0 +1,30 @@
1
+ // THE way the page says trouble: cards floating at the top right, just under
2
+ // the bar, taking no layout room, so one coming or going moves nothing, and
3
+ // nothing drawn while all is well. A bar never carries trouble text. Styles:
4
+ // home-strip.css .htoasts.
5
+ import type { ReactNode } from 'react'
6
+
7
+ export type Card = {
8
+ key: string
9
+ tone: 'red' | 'warn'
10
+ /** a bold lead word or two ("web crashed"), before the words */
11
+ head?: ReactNode
12
+ body?: ReactNode
13
+ }
14
+
15
+ /** the cards, in a zero height box placed right after the bar, so they
16
+ * float just under it whatever the bar's height */
17
+ export const FloatCards = ({ cards }: { cards: Card[] }) => {
18
+ if (cards.length === 0) return null
19
+ return (
20
+ <div className="hanchor">
21
+ <div className="htoasts">
22
+ {cards.map((c) => (
23
+ <div key={c.key} className={c.tone === 'warn' ? 'htoast warn' : 'htoast'} role="alert">
24
+ {c.head == null ? null : <><b>{c.head}</b> </>}{c.body}
25
+ </div>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ )
30
+ }
@@ -3,8 +3,9 @@
3
3
  // (flip to stay in the window, shift off its edges, an offset from the
4
4
  // trigger), a portal lifts it out of every box that clips, and hover opens
5
5
  // it after a short delay, so a pointer passing by never flickers one open.
6
- // Nothing here is positioned by hand.
7
- import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
6
+ // A popover (Pop) opens on a click instead, and Escape or a click outside
7
+ // closes it. Nothing here is positioned by hand.
8
+ import { autoUpdate, flip, FloatingFocusManager, FloatingPortal, offset, safePolygon, shift, useClick, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
8
9
  import { observer, useLocalObservable } from 'mobx-react-lite'
9
10
  import type { HTMLAttributes, ReactNode } from 'react'
10
11
 
@@ -25,13 +26,15 @@ type TipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'content'> & {
25
26
  fallbacks?: Placement[]
26
27
  /** the pointer can move into it (a log to scroll): it stays open on the way */
27
28
  interactive?: boolean
29
+ /** opens the moment the pointer arrives, for a trigger whose only words are its tip */
30
+ instant?: boolean
28
31
  }
29
32
 
30
33
  /** a trigger and what floats over it on hover or focus */
31
- export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, ...rest }: TipProps) => {
34
+ export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, instant = false, ...rest }: TipProps) => {
32
35
  const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
33
36
  const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(6, fallbacks), whileElementsMounted: autoUpdate })
34
- const hover = useHover(f.context, { delay: { open: OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
37
+ const hover = useHover(f.context, { delay: { open: instant ? 0 : OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
35
38
  const focus = useFocus(f.context)
36
39
  const dismiss = useDismiss(f.context)
37
40
  const role = useRole(f.context, { role: 'tooltip' })
@@ -47,3 +50,37 @@ export const Tip = observer(({ content, children, tipClass = 'ftip', placement =
47
50
  </>
48
51
  )
49
52
  })
53
+
54
+ type PopProps = {
55
+ /** what the trigger button shows */
56
+ label: ReactNode
57
+ className?: string
58
+ 'aria-label'?: string
59
+ popClass?: string
60
+ placement?: Placement
61
+ /** what floats, given the way to close it (an entry that acts closes it) */
62
+ children: (close: () => void) => ReactNode
63
+ }
64
+
65
+ /** a button that opens what floats under it on a click; focus moves into
66
+ * it, and Escape or a click outside closes it */
67
+ export const Pop = observer(({ label, className, popClass = 'fpop', placement = 'bottom-start', children, ...rest }: PopProps) => {
68
+ const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
69
+ const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(4), whileElementsMounted: autoUpdate })
70
+ const click = useClick(f.context)
71
+ const dismiss = useDismiss(f.context)
72
+ const role = useRole(f.context, { role: 'dialog' })
73
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role])
74
+ return (
75
+ <>
76
+ <button type="button" ref={f.refs.setReference} className={className} {...getReferenceProps(rest)}>{label}</button>
77
+ {st.open ? (
78
+ <FloatingPortal>
79
+ <FloatingFocusManager context={f.context} modal={false}>
80
+ <div ref={f.refs.setFloating} className={popClass} style={f.floatingStyles} {...getFloatingProps()}>{children(() => st.set(false))}</div>
81
+ </FloatingFocusManager>
82
+ </FloatingPortal>
83
+ ) : null}
84
+ </>
85
+ )
86
+ })
@@ -0,0 +1,42 @@
1
+ // A repo's git page state: one repo's git, read from the daemon while the
2
+ // page is shown (`/api/home/git.json`), a week of commits and HEAD's last
3
+ // one past that. The activity page reads every repo for a window; this reads
4
+ // one, whatever that window is.
5
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
+ import type { ActivityRow } from '../../home/activity-shape.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
+ import { ownTrouble, reach } from './reach.ts'
9
+
10
+ export const GIT_POLL_MS = 30_000
11
+
12
+ export class GitSt {
13
+ row: ActivityRow | null = null
14
+ error = ''
15
+ private timer: ReturnType<typeof setInterval> | null = null
16
+
17
+ constructor(private st: RepoSt) {
18
+ makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false, row: observableRef })
19
+ }
20
+
21
+ async load(): Promise<void> {
22
+ try {
23
+ const r = await reach(`/api/home/git.json?repo=${encodeURIComponent(this.st.key)}`, { signal: AbortSignal.timeout(30_000) })
24
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
25
+ const j = (await r.json()) as ActivityRow
26
+ runInAction(() => { this.row = j; this.error = '' })
27
+ } catch (e) {
28
+ runInAction(() => { this.error = ownTrouble(e, this.st.home.machine, (m) => `read git: ${m}`) })
29
+ }
30
+ }
31
+
32
+ /** reads while the page is shown */
33
+ start(): void {
34
+ if (this.timer != null) return
35
+ void this.load()
36
+ this.timer = setInterval(() => void this.load(), GIT_POLL_MS)
37
+ }
38
+ stop(): void {
39
+ if (this.timer != null) clearInterval(this.timer)
40
+ this.timer = null
41
+ }
42
+ }
@@ -0,0 +1,61 @@
1
+ // A repo's git page: its branch against its upstream, what is not committed,
2
+ // and its commits of the last week (HEAD's last one when the week holds
3
+ // none), with the ways out to VS Code and Fork. Every repo at once: the
4
+ // activity page (⌘L).
5
+ import { observer } from 'mobx-react-lite'
6
+ import { useEffect } from 'react'
7
+ import { isMainBranch, upstreamWords } from '../../home/activity-shape.ts'
8
+ import { Commit } from './activity-view.tsx'
9
+ import { Icon, ICON } from './icons.tsx'
10
+ import type { RepoSt } from './repo-st.ts'
11
+
12
+ const KIND_WORD = { staged: 'staged', modified: 'modified', untracked: 'new' } as const
13
+
14
+ export const GitPage = observer(({ app }: { app: RepoSt }) => {
15
+ const g = app.git
16
+ useEffect(() => {
17
+ g.start()
18
+ return () => g.stop()
19
+ }, [g])
20
+ const r = g.row
21
+ const m = app.home.machine
22
+ const now = app.clock.now
23
+ const branch = r == null ? '' : r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
24
+ return (
25
+ <div className="colcard checkcard">
26
+ <div className="chead">
27
+ <span className="tid">git</span>
28
+ <h3 className="ctitle">{app.name}</h3>
29
+ <span className="sp" />
30
+ {m == null ? null : <button className="hicon" title="open in VS Code" aria-label={`open ${app.key} in VS Code`} onClick={() => m.openInCode(app.key)}><Icon d={ICON.vscode} /></button>}
31
+ <button className="hicon" title="open in Fork" aria-label={`open ${app.key} in Fork`} onClick={() => void app.home.activity.openFork(app.key)}><Icon d={ICON.fork} /></button>
32
+ </div>
33
+ <div className="usays">its branch, what is not committed, and its commits of the last week. Every repo at once: the activity page (⌘L)</div>
34
+ {g.error !== '' ? <div className="warnline">{g.error}</div> : null}
35
+ {r == null ? (g.error === '' ? <div className="dim twait"><span className="spin" />reading git…</div> : null) : r.error != null ? <div className="tl red">git failed: {r.error}</div> : (
36
+ <>
37
+ <div className="cfacts">
38
+ <div className="cf"><div className="ck">branch</div><div className={`cv tl ${isMainBranch(r.branch) ? 'ok' : 'warn'}`}>{branch}</div></div>
39
+ <div className="cf"><div className="ck">upstream</div><div className="cv">{upstreamWords(r)}</div></div>
40
+ </div>
41
+ <section className="csec">
42
+ <div className="cstitle">not committed ({r.files.length})</div>
43
+ {r.files.length === 0 ? <div className="dim">nothing: the tree is clean</div> : (
44
+ <table className="gitfiles"><tbody>{r.files.map((f) => <tr key={f.path}><td className={`gitkind ${f.kind}`}>{KIND_WORD[f.kind]}</td><td className="gitpath">{f.path}</td></tr>)}</tbody></table>
45
+ )}
46
+ </section>
47
+ <section className="csec">
48
+ <div className="cstitle">commits, last week ({r.commits.length})</div>
49
+ <table className="gitcommits">
50
+ <tbody>
51
+ {r.commits.map((c) => <tr key={c.sha}><Commit c={c} now={now} old={false} /></tr>)}
52
+ {r.last != null ? <tr><Commit c={r.last} now={now} old /></tr> : null}
53
+ {r.commits.length === 0 && r.last == null ? <tr><td className="dim">no commit yet</td></tr> : null}
54
+ </tbody>
55
+ </table>
56
+ </section>
57
+ </>
58
+ )}
59
+ </div>
60
+ )
61
+ })
@@ -22,9 +22,25 @@ export type HashSel =
22
22
  | { t: 'tests'; check: string | null }
23
23
  /** what administers the repo: archive, reload its hub, keep it active */
24
24
  | { t: 'admin' }
25
+ /** the repo's README, what a repo shows with nothing else named */
26
+ | { t: 'readme' }
27
+ /** the global processes the repo declares, one card each, `id` the one
28
+ * a tree row opened (scrolled to and lit), null for none */
29
+ | { t: 'processes'; id: string | null }
30
+ /** what the machine plans to run in this repo, and when */
31
+ | { t: 'plan' }
32
+ /** the repo's git: branch, upstream, uncommitted files, recent commits */
33
+ | { t: 'git' }
34
+ /** what runs when: every check on every context, with the legend */
35
+ | { t: 'when' }
36
+ /** the checks one owner ships: shipkit's, or the repo's own */
37
+ | { t: 'checks'; from: CheckOwner }
38
+
39
+ /** whose a gate check is */
40
+ export type CheckOwner = 'shipkit' | 'repo'
25
41
 
26
42
  export type ParsedHash = {
27
- kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'none'
43
+ kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'readme' | 'processes' | 'plan' | 'git' | 'when' | 'checks' | 'none'
28
44
  /** the collection, for an item. null for everything else */
29
45
  col: string | null
30
46
  id: string
@@ -40,6 +56,12 @@ const CAT = 'cat-'
40
56
  const STATUS = 'page:status'
41
57
  const TESTS = 'page:tests'
42
58
  const ADMIN = 'page:admin'
59
+ const README = 'page:readme'
60
+ const PROCESSES = 'page:processes'
61
+ const PLAN = 'page:plan'
62
+ const GIT = 'page:git'
63
+ const WHEN = 'page:when'
64
+ const CHECKS = 'page:checks:'
43
65
 
44
66
  /** what this selection puts in the url. null only for no selection at all,
45
67
  * and then the hash is left alone rather than wiped */
@@ -53,6 +75,12 @@ export function hashFor(sel: HashSel | null): string | null {
53
75
  case 'status': return STATUS
54
76
  case 'tests': return sel.check == null ? TESTS : `${TESTS}:${sel.check}`
55
77
  case 'admin': return ADMIN
78
+ case 'readme': return README
79
+ case 'processes': return sel.id == null ? PROCESSES : `${PROCESSES}:${sel.id}`
80
+ case 'plan': return PLAN
81
+ case 'git': return GIT
82
+ case 'when': return WHEN
83
+ case 'checks': return `${CHECKS}${sel.from}`
56
84
  }
57
85
  }
58
86
 
@@ -62,6 +90,16 @@ export function parseHash(raw: string): ParsedHash {
62
90
  if (raw === STATUS) return { kind: 'status', col: null, id: '', tools: null }
63
91
  if (raw === TESTS) return { kind: 'tests', col: null, id: '', tools: null }
64
92
  if (raw === ADMIN) return { kind: 'admin', col: null, id: '', tools: null }
93
+ if (raw === README) return { kind: 'readme', col: null, id: '', tools: null }
94
+ if (raw === PROCESSES) return { kind: 'processes', col: null, id: '', tools: null }
95
+ if (raw === PLAN) return { kind: 'plan', col: null, id: '', tools: null }
96
+ if (raw === GIT) return { kind: 'git', col: null, id: '', tools: null }
97
+ if (raw === WHEN) return { kind: 'when', col: null, id: '', tools: null }
98
+ if (raw === `${CHECKS}shipkit` || raw === `${CHECKS}repo`) return { kind: 'checks', col: null, id: raw.slice(CHECKS.length), tools: null }
99
+ if (raw.startsWith(`${PROCESSES}:`)) {
100
+ const id = raw.slice(PROCESSES.length + 1)
101
+ return id === '' ? none : { kind: 'processes', col: null, id, tools: null }
102
+ }
65
103
  // a check id carries colons of its own (`test:rvlib-shipkit`)
66
104
  if (raw.startsWith(`${TESTS}:`)) {
67
105
  const check = raw.slice(TESTS.length + 1)
@@ -104,6 +142,12 @@ export function selOf(p: ParsedHash): HashSel | null {
104
142
  case 'status': return { t: 'status' }
105
143
  case 'tests': return { t: 'tests', check: p.id === '' ? null : p.id }
106
144
  case 'admin': return { t: 'admin' }
145
+ case 'readme': return { t: 'readme' }
146
+ case 'processes': return { t: 'processes', id: p.id === '' ? null : p.id }
147
+ case 'plan': return { t: 'plan' }
148
+ case 'git': return { t: 'git' }
149
+ case 'when': return { t: 'when' }
150
+ case 'checks': return { t: 'checks', from: p.id as CheckOwner }
107
151
  }
108
152
  }
109
153
 
@@ -1,13 +1,18 @@
1
- // A repo's pieces of the top bar, beside its icon and its name: what runs now
2
- // and the two runs that are not one check (RepoRun), then on the right its
3
- // processes, the hub's own trouble and modernize while it is behind
4
- // (RepoTools), and at the far right end the editor icons (RepoOpeners). On
5
- // the home they ride in the home strip (home-strip.tsx); a hub of its own
1
+ // A repo's pieces of the top bar. On the right: modernize while it is behind
2
+ // and the hub's own processes (RepoTools), then a spinner while something
3
+ // runs, its words on hover, and one check… button that opens the two runs
4
+ // that are not one check (RepoRun), then at the far right end the editor
5
+ // icons (RepoOpeners). The home strip puts the repo's global processes right
6
+ // after its name (home-strip.tsx). The bar carries no trouble text:
7
+ // the hub's own trouble is a card floating under it (hubCards, float-cards.tsx).
8
+ // On the home they ride in the home strip (home-strip.tsx); a hub of its own
6
9
  // draws them in its own header, with its name. The repo's sections are the
7
- // left pane's (repo-sections.tsx). What runs, drawn, is the dock at the
8
- // bottom of the page (dock-view.tsx).
10
+ // top rows of the left pane's tree (repo-sections.ts). What runs, drawn, is
11
+ // the dock at the bottom of the page (dock-view.tsx).
9
12
  import { observer } from 'mobx-react-lite'
10
13
  import { Icon, ICON } from './icons.tsx'
14
+ import { Pop, Tip } from './floating.tsx'
15
+ import { FloatCards, type Card } from './float-cards.tsx'
11
16
  import type { RepoSt } from './repo-st.ts'
12
17
 
13
18
  /** the repo's hub has not answered on the home: its reason and its fix show
@@ -17,56 +22,64 @@ export const notUp = (app: RepoSt): boolean => {
17
22
  const m = app.home.machine
18
23
  if (m == null || app.started) return false
19
24
  if (!app.cols.cached) return true
20
- return m.fresh && m.worker(app.key)?.status !== 'up'
25
+ // the daemon silent: the hub's state is unknown, not down, so its last screen stays
26
+ return m.fresh && m.down === '' && m.worker(app.key)?.status !== 'up'
21
27
  }
22
28
 
23
- /** what is running, in one line: the hub's own run first, since it says the
24
- * most, else the newest run the record has in flight. Cut to its width, the
25
- * whole line on hover */
26
- const RunState = observer(({ app }: { app: RepoSt }) => {
29
+ /** what is running, in words: the hub's own run first, since it says the
30
+ * most, else the newest run the record has in flight. Null while idle */
31
+ export const runWords = (app: RepoSt): string | null => {
27
32
  const own = app.checks.strip
33
+ if (own !== '') return own
28
34
  const run = app.live.view?.runs[0]
29
- const text = own !== '' ? own : run != null ? `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}` : 'idle'
30
- const going = app.checks.busy || run != null
35
+ if (run != null) return `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}`
36
+ return app.checks.busy ? 'running' : null
37
+ }
38
+
39
+ /** a spinner while something runs, its words in a tooltip that opens at
40
+ * once; nothing at all while idle */
41
+ const RunState = observer(({ app }: { app: RepoSt }) => {
42
+ const text = runWords(app)
43
+ if (text == null) return null
31
44
  return (
32
- <span className={`hstate${going ? ' on' : ''}`} title={text}>
33
- {/* the spinner's slot is always there, so the text never moves */}
34
- <span className={`spin${going ? '' : ' idle'}`} />
35
- <span className="htext">{text}</span>
36
- </span>
45
+ <Tip className="hbusy" instant placement="bottom" tabIndex={0} role="status" aria-label={`running: ${text}`} content={text}>
46
+ <span className="spin" />
47
+ </Tip>
37
48
  )
38
49
  })
39
50
 
40
51
  export const HEALTH_TITLE = 'run the health pass: every check but the build steps, as shipkit check runs them (creds, probes, rules, contracts)'
41
52
  export const GATE_TITLE = 'run the gate: every check shipkit ci runs, the build steps included (lint, typecheck, tests)'
42
53
 
43
- /** the run state, then the two runs, compact: below a width their labels
44
- * give way to their icons, so the bar never wraps */
45
- export const RepoRun = observer(({ app }: { app: RepoSt }) => {
54
+ /** the two runs, each with what it does; an entry closes the menu as it starts */
55
+ export const CheckMenu = observer(({ app, close }: { app: RepoSt; close: () => void }) => {
46
56
  const u = app.checks
47
57
  return (
48
- <span className="hrun">
49
- <RunState app={app} />
50
- <button className="primary hbtn" disabled={u.busy} title={HEALTH_TITLE} aria-label="health pass" onClick={() => void u.run()}>
51
- <Icon d={ICON.health} /><span className="lbl">health pass</span>
58
+ <div className="hmenu">
59
+ <button className="hmitem" disabled={u.busy} onClick={() => { close(); void u.run() }}>
60
+ <Icon d={ICON.health} /><span><b>health pass</b><small>{HEALTH_TITLE}</small></span>
52
61
  </button>
53
- <button className="hbtn" disabled={u.busy} title={GATE_TITLE} aria-label="gate" onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>
54
- <Icon d={ICON.gate} /><span className="lbl">gate</span>
62
+ <button className="hmitem" disabled={u.busy} onClick={() => { close(); void u.run(undefined, { gate: true, label: 'the gate' }) }}>
63
+ <Icon d={ICON.gate} /><span><b>gate</b><small>{GATE_TITLE}</small></span>
55
64
  </button>
56
- </span>
65
+ </div>
57
66
  )
58
67
  })
59
68
 
60
- /** what is wrong with the hub itself, one chip per problem. Nothing when all
61
- * is well: the bar states trouble, never health */
62
- const HubStatus = observer(({ app }: { app: RepoSt }) => (
63
- <>
64
- {app.cols.hubStatus.map((p) => (
65
- <span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
66
- ))}
67
- </>
69
+ /** the spinner while a run goes, then one small check… button with the two
70
+ * runs behind it. The bar ends on the right, so the spinner comes and goes
71
+ * to the button's left and the button never moves */
72
+ export const RepoRun = observer(({ app }: { app: RepoSt }) => (
73
+ <span className="hrun">
74
+ <RunState app={app} />
75
+ <Pop className="ghost hcheck" aria-label="run a check" label="check…" placement="bottom-end">{(close) => <CheckMenu app={app} close={close} />}</Pop>
76
+ </span>
68
77
  ))
69
78
 
79
+ /** what is wrong with the hub itself, one card per problem */
80
+ export const hubCards = (app: RepoSt): Card[] =>
81
+ app.cols.hubStatus.map((p) => ({ key: `hub:${p.id}`, tone: p.severity === 'red' ? 'red' : 'warn', body: p.text }))
82
+
70
83
  /** modernize, while the repo runs an older shipkit than the home */
71
84
  const Modernize = observer(({ app }: { app: RepoSt }) => {
72
85
  const m = app.home.machine
@@ -88,48 +101,25 @@ export const RepoOpeners = observer(({ app }: { app: RepoSt }) => {
88
101
  )
89
102
  })
90
103
 
91
- /** the right side of the bar for a repo, before the machine's trouble and
92
- * the openers, which close it */
104
+ /** the right side of the bar for a repo, before the openers, which close it */
93
105
  export const RepoTools = observer(({ app }: { app: RepoSt }) => (
94
106
  <>
95
- <ProcessChips app={app} />
96
- <HubStatus app={app} />
97
107
  <Modernize app={app} />
98
108
  </>
99
109
  ))
100
110
 
101
111
  /** the bar of a hub of its own: its name, then the same pieces the home
102
- * strip carries for a repo */
112
+ * strip carries for a repo, and its trouble floating under it */
103
113
  export const HeaderBar = observer(({ app }: { app: RepoSt }) => (
104
- <header className="hdr">
105
- <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>
106
- <RepoRun app={app} />
107
- <span className="sp" />
108
- <RepoTools app={app} />
109
- <RepoOpeners app={app} />
110
- </header>
114
+ <>
115
+ <header className="hdr">
116
+ <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>
117
+ <span className="sp" />
118
+ <RepoTools app={app} />
119
+ <RepoRun app={app} />
120
+ <RepoOpeners app={app} />
121
+ </header>
122
+ <FloatCards cards={hubCards(app)} />
123
+ </>
111
124
  ))
112
125
 
113
- /** one switch per process the repo declares (serve.processes): its dot, its
114
- * id, a click starts or stops it, and a link once it serves */
115
- export const ProcessChips = observer(({ app }: { app: RepoSt }) => {
116
- const s = app.processes
117
- if (s.list.length === 0) return null
118
- return (
119
- <span className="hprocs">
120
- {s.list.map((p) => {
121
- const on = p.status === 'up' || p.status === 'starting'
122
- const title = [p.cmd, p.note, p.external ? 'started outside the hub' : null, p.tail.at(-1)].filter((x) => x != null && x !== '').join('\n')
123
- return (
124
- <span key={p.id} className="hproc">
125
- <button className={`ghost ${p.status}`} title={title} disabled={p.status === 'stopping' || (on && p.external)}
126
- onClick={() => void s.act(p.id, on ? 'stop' : 'start')}>
127
- <i className={`pdot ${p.status}`} />{p.id}
128
- </button>
129
- {p.url != null && p.status === 'up' ? <a href={p.url} target="_blank" rel="noreferrer" title={p.url}>↗</a> : null}
130
- </span>
131
- )
132
- })}
133
- </span>
134
- )
135
- })
@@ -6,6 +6,7 @@
6
6
  // exists so the whole text of a run stays reachable.
7
7
  import { makeAutoObservable, runInAction } from 'mobx'
8
8
  import type { RepoSt } from './repo-st.ts'
9
+ import { ownTrouble, reach } from './reach.ts'
9
10
 
10
11
  export class HealthSvc {
11
12
  statusMd: string | null = null
@@ -25,10 +26,10 @@ export class HealthSvc {
25
26
 
26
27
  async load(): Promise<void> {
27
28
  try {
28
- const s = (await (await fetch(this.st.url('/api/status.json'))).json()) as { statusMd: string | null }
29
+ const s = (await (await reach(this.st.url('/api/status.json'))).json()) as { statusMd: string | null }
29
30
  runInAction(() => { this.statusMd = s.statusMd })
30
31
  } catch (e) {
31
- runInAction(() => { this.statusMd = `FAIL status fetch broke: ${(e as Error).message}` })
32
+ runInAction(() => { this.statusMd = ownTrouble(e, this.st.home.machine, (m) => `FAIL status fetch broke: ${m}`) || this.statusMd })
32
33
  }
33
34
  }
34
35