rvlib-shipkit 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -1,14 +1,73 @@
1
- // the project gutter's state: every repo on this disk that shares the house
2
- // wiki, polled from /api/projects.json while the gutter is mounted. The
1
+ // the project list's state: every repo on this disk that shares the house
2
+ // wiki, polled from /api/projects.json while the list is mounted. The
3
3
  // server measures at most once per 5s whatever the number of tabs, so this
4
4
  // poll is cheap.
5
5
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
- import type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
- import type { HubSt } from './hub-st.ts'
6
+ import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
+ import { identityOfName, type Identity } from '../../core/identity-color.ts'
8
8
 
9
- export type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
9
+ export type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
10
10
 
11
11
  export const PROJECTS_POLL_MS = 5_000
12
+ /** how long the colour picker rests before its colour is written */
13
+ export const IDENTITY_SAVE_MS = 400
14
+
15
+ /** where this browser keeps the last project list, painted at once on the
16
+ * next load while the daemon is asked again */
17
+ export const LIST_KEY = 'hub:projects'
18
+
19
+ /** where this browser keeps whether the rail's archived rows show */
20
+ export const ARCHIVED_OPEN_KEY = 'hub:archived-open'
21
+
22
+ const keptOpen = (): boolean => {
23
+ try { return globalThis.localStorage?.getItem(ARCHIVED_OPEN_KEY) === '1' } catch { return false }
24
+ }
25
+
26
+ /** where this browser keeps the last sort it picked */
27
+ export const SORT_KEY = 'hub:sort'
28
+
29
+ /** a sort the foot's menu offers: each one reorders the list once, and the
30
+ * result is the new manual order */
31
+ export type SortMode = 'activity' | 'commit' | 'name' | 'asks'
32
+
33
+ /** the menu's lines, in their order: a label naming the order, a title
34
+ * saying how it breaks ties */
35
+ export const SORTS: { id: SortMode; label: string; title: string }[] = [
36
+ { id: 'activity', label: 'last activity', title: 'newest first: the last commit, shipkit run or Claude session' },
37
+ { id: 'commit', label: 'last commit', title: 'newest commit on the checked out branch first' },
38
+ { id: 'name', label: 'name', title: 'A to Z' },
39
+ { id: 'asks', label: 'open asks', title: 'the repo waiting on the most asks first' },
40
+ ]
41
+
42
+ const keptSort = (): SortMode | null => {
43
+ try {
44
+ const v = globalThis.localStorage?.getItem(SORT_KEY)
45
+ return SORTS.some((m) => m.id === v) ? (v as SortMode) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** the project list this browser last saw, or [] when storage is absent,
52
+ * blocked or holds anything else */
53
+ export function loadList(): Project[] {
54
+ try {
55
+ const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
56
+ return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]) : []
57
+ } catch {
58
+ return []
59
+ }
60
+ }
61
+
62
+ /** keeps the list for the next load; false when storage refused it */
63
+ export function saveList(list: Project[]): boolean {
64
+ try {
65
+ globalThis.localStorage?.setItem(LIST_KEY, JSON.stringify(list))
66
+ return true
67
+ } catch {
68
+ return false
69
+ }
70
+ }
12
71
 
13
72
  /** one of six kind hues, hashed from the name, so an initial keeps its
14
73
  * colour from one load to the next */
@@ -51,6 +110,20 @@ export const byActivity = (list: Project[]): string[] =>
51
110
  .sort((a, b) => (b.p.activity ?? -Infinity) - (a.p.activity ?? -Infinity) || a.i - b.i)
52
111
  .map(({ p }) => p.name)
53
112
 
113
+ /** the names in a sort's order, ties in their current order */
114
+ export function sortNames(list: Project[], mode: SortMode): string[] {
115
+ if (mode === 'activity') return byActivity(list)
116
+ const cmp: (a: Project, b: Project) => number = mode === 'commit'
117
+ ? (a, b) => (b.commit ?? -Infinity) - (a.commit ?? -Infinity)
118
+ : mode === 'asks' ? (a, b) => b.asks - a.asks
119
+ : (a, b) => a.name.localeCompare(b.name)
120
+ return list.map((p, i) => ({ p, i })).sort((a, b) => cmp(a.p, b.p) || a.i - b.i).map(({ p }) => p.name)
121
+ }
122
+
123
+ /** the hub state dot the home draws on a tile, only when it needs a look:
124
+ * its hub starts or crashed. A hub that is up is the normal case and draws none */
125
+ export type HubDot = 'starting' | 'crashed' | null
126
+
54
127
  /** what the icon face shows, and nothing more: at most ONE badge (the open
55
128
  * asks, the only fact that needs a human's hands), dim while VS Code does
56
129
  * not have the folder open, and a pulsing edge while Claude sessions run */
@@ -99,22 +172,149 @@ export function cardModel(p: Project, now: number): CardModel {
99
172
  }
100
173
  }
101
174
 
175
+ /** a page of the home that is no repo's */
176
+ export type Place = 'overview' | 'planned' | 'activity'
177
+
102
178
  export class ProjectsSvc {
103
- list: Project[] = []
104
- /** why the last poll or open failed, shown in the gutter's tooltip */
179
+ /** the last list this browser saw until the first poll answers, so the
180
+ * list paints at once and nothing moves when the daemon answers */
181
+ list: Project[] = loadList()
182
+ /** why the last poll or open failed, shown on the list's head */
105
183
  error = ''
106
- /** the icon under the pointer and the middle of its row, for the card */
107
- hover: { name: string; y: number } | null = null
184
+ /** the repos whose repo.config.ts says `archived: true`: never in `list`,
185
+ * only on the archived page */
186
+ archived: ArchivedRow[] = []
187
+ /** the archived button at the foot of the rail is on, its rows shown, per browser */
188
+ archivedOpen = keptOpen()
189
+ /** the sort menu at the foot of the rail is open */
190
+ sortMenuOpen = false
191
+ /** the last sort picked in this browser; `sortMode` says whether the list
192
+ * is still in its order */
193
+ sortPicked: SortMode | null = keptSort()
194
+ /** the daemon answered once: until then the list is the kept one */
195
+ fresh = false
196
+ /** the row under the pointer, whose hover card shows */
197
+ hover: string | null = null
108
198
  /** the tile being dragged, and the gap it would land in */
109
199
  drag: string | null = null
110
200
  gap: number | null = null
201
+ /** the repo the home page shows, or showed last while a global page is
202
+ * up; null until one is picked, and on a hub of its own, whose repo is
203
+ * `current`. The list's api is always the
204
+ * page origin's own: the home's machine wide one, or this hub's */
205
+ selected: string | null = null
206
+ /** what a single click does: the home shows that repo in place, a hub
207
+ * of its own opens the home on it. Default: the folder in VS Code */
208
+ onSelect: (name: string) => void = (name) => void this.open(name)
209
+ /** the two places at the top of the list, the overview and the plan:
210
+ * the home sets it, a hub of its own has none */
211
+ onPlace: ((p: Place) => void) | null = null
212
+ /** the archived page, where an archived row of the rail leads: the home sets it */
213
+ onArchived: (() => void) | null = null
214
+ /** the picker's line after the overview: the home sets it */
215
+ onPicker: (() => void) | null = null
216
+ /** the picker is open, so its line is lit: the home sets it */
217
+ pickerOpen: () => boolean = () => false
218
+ /** the place shown, marked at the top of the list; null on a repo page */
219
+ place: Place | null = null
220
+ /** a global page is up: the selected repo's row stays marked, dimmer */
221
+ away = false
222
+ /** the home's hub state per repo; a hub draws none */
223
+ dotOf: (name: string) => HubDot = () => null
224
+ /** one line when a machine resource the repo uses is off */
225
+ warnOf: (name: string) => string | null = () => null
111
226
  private gen = 0
227
+ /** a colour write waiting for the picker to rest, per repo: a poll keeps
228
+ * the colour shown for that repo meanwhile */
229
+ private identitySaves = new Map<string, ReturnType<typeof setTimeout>>()
112
230
  /** bumped by every reorder, so a poll that left before it cannot put
113
231
  * the old order back */
114
232
  private orderGen = 0
115
233
 
116
- constructor(private st: HubSt) {
117
- makeAutoObservable<this, 'st' | 'gen' | 'orderGen'>(this, { st: false, gen: false, orderGen: false, list: observableRef })
234
+ constructor() {
235
+ makeAutoObservable<this, 'gen' | 'orderGen' | 'identitySaves' | 'saveIdentity' | 'kept' | 'keep'>(this, { gen: false, orderGen: false, identitySaves: false, saveIdentity: false, kept: false, keep: false, list: observableRef, onSelect: false, onPlace: false, onPicker: false, pickerOpen: false, onArchived: false, dotOf: false, warnOf: false })
236
+ }
237
+
238
+ toggleArchivedOpen(): void {
239
+ this.archivedOpen = !this.archivedOpen
240
+ try { globalThis.localStorage?.setItem(ARCHIVED_OPEN_KEY, this.archivedOpen ? '1' : '0') } catch { /* private window: the choice lasts the tab */ }
241
+ }
242
+
243
+ isArchived(name: string): boolean {
244
+ return this.archived.some((p) => p.name === name)
245
+ }
246
+
247
+ /** archive a repo or bring it back: written into its repo.config.ts
248
+ * (`archived`, only that key), then the list is read again, so the repo
249
+ * moves between the rail and the archived page, and the daemon stops or
250
+ * loads its hub on its next tick */
251
+ async setArchived(name: string, on: boolean): Promise<void> {
252
+ try {
253
+ const r = await fetch('/api/projects/archived', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, archived: on }) })
254
+ if (!r.ok) throw new Error(await r.text())
255
+ runInAction(() => { this.error = '' })
256
+ await this.load()
257
+ } catch (e) {
258
+ runInAction(() => { this.error = `${on ? 'archive' : 'unarchive'} ${name}: ${(e as Error).message}` })
259
+ }
260
+ }
261
+
262
+ /** the tile drawn as the current one. The home marks only a repo that
263
+ * was picked (it sets `onPlace`); a hub of its own marks its own repo */
264
+ isCurrent(p: Project): boolean {
265
+ if (this.selected != null) return p.name === this.selected
266
+ return this.onPlace == null && p.current
267
+ }
268
+
269
+ select(name: string): void {
270
+ this.selected = name
271
+ this.onSelect(name)
272
+ }
273
+
274
+
275
+ /** a click selects once it is sure no second click follows */
276
+ click(name: string): void {
277
+ this.select(name)
278
+ }
279
+
280
+ /** a double click also opens the folder in VS Code (its first click already selected) */
281
+ doubleClick(name: string): void {
282
+ void this.open(name)
283
+ }
284
+
285
+ /** a repo's colours: what its config declares, else the ones SK024 would
286
+ * derive from its name, so every repo reads in a colour of its own */
287
+ identityOf(name: string): Identity {
288
+ return this.list.find((p) => p.name === name)?.identity ?? identityOfName(name)
289
+ }
290
+
291
+ /** one colour set by hand: shown at once, written into the repo's
292
+ * repo.config.ts once the picker rests, so a drag across the colour
293
+ * field is one write, not one per frame */
294
+ setIdentity(name: string, patch: Partial<Identity>): void {
295
+ const next = { ...this.identityOf(name), ...patch }
296
+ this.list = this.list.map((p) => (p.name === name ? { ...p, identity: next } : p))
297
+ const t = this.identitySaves.get(name)
298
+ if (t != null) clearTimeout(t)
299
+ const h: ReturnType<typeof setTimeout> = setTimeout(() => void this.saveIdentity(name, next, h), IDENTITY_SAVE_MS)
300
+ this.identitySaves.set(name, h)
301
+ }
302
+
303
+ private async saveIdentity(name: string, id: Identity, h: ReturnType<typeof setTimeout>): Promise<void> {
304
+ try {
305
+ const r = await fetch('/api/projects/identity', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...id }) })
306
+ if (!r.ok) throw new Error(await r.text())
307
+ const j = (await r.json()) as { identity: Identity }
308
+ runInAction(() => {
309
+ if (this.identitySaves.get(name) === h) this.list = this.list.map((p) => (p.name === name ? { ...p, identity: j.identity } : p))
310
+ this.error = ''
311
+ })
312
+ } catch (e) {
313
+ runInAction(() => { this.error = `colours of ${name}: ${(e as Error).message}` })
314
+ } finally {
315
+ // a later pick rests on its own timer, which keeps the guard
316
+ if (this.identitySaves.get(name) === h) this.identitySaves.delete(name)
317
+ }
118
318
  }
119
319
 
120
320
  get dragFrom(): number {
@@ -127,16 +327,12 @@ export class ProjectsSvc {
127
327
  return this.gap
128
328
  }
129
329
 
130
- hoverIn(name: string, y: number): void {
131
- if (this.drag == null) this.hover = { name, y }
330
+ hoverIn(name: string): void {
331
+ if (this.drag == null) this.hover = name
132
332
  }
133
333
 
134
334
  hoverOut(name: string): void {
135
- if (this.hover?.name === name) this.hover = null
136
- }
137
-
138
- get hovered(): Project | null {
139
- return this.hover == null ? null : this.list.find((p) => p.name === this.hover!.name) ?? null
335
+ if (this.hover === name) this.hover = null
140
336
  }
141
337
 
142
338
  dragStart(name: string): void {
@@ -162,9 +358,30 @@ export class ProjectsSvc {
162
358
  void this.reorder(moveTo(this.list.map((p) => p.name), from, gap))
163
359
  }
164
360
 
165
- /** one reorder by last activity; from then on it is the manual order */
166
- sortByActivity(): void {
167
- void this.reorder(byActivity(this.list))
361
+ /** the sort the list is in now: the last one picked while the order
362
+ * still matches it, else the manual order a drag made */
363
+ get sortMode(): SortMode | 'manual' {
364
+ const m = this.sortPicked
365
+ if (m == null) return 'manual'
366
+ const now = this.list.map((p) => p.name)
367
+ return sortNames(this.list, m).every((n, i) => n === now[i]) ? m : 'manual'
368
+ }
369
+
370
+ /** the button opens or closes the menu, and sorts nothing */
371
+ toggleSortMenu(): void {
372
+ this.sortMenuOpen = !this.sortMenuOpen
373
+ }
374
+ setSortMenu(open: boolean): void {
375
+ this.sortMenuOpen = open
376
+ }
377
+
378
+ /** a pick: one reorder in that sort's order, then the menu closes; from
379
+ * then on it is the manual order */
380
+ sortBy(mode: SortMode): void {
381
+ this.sortMenuOpen = false
382
+ this.sortPicked = mode
383
+ try { globalThis.localStorage?.setItem(SORT_KEY, mode) } catch { /* private window: the pick lasts the tab */ }
384
+ void this.reorder(sortNames(this.list, mode))
168
385
  }
169
386
 
170
387
  /** shows the new order at once, then saves it for every hub */
@@ -187,6 +404,7 @@ export class ProjectsSvc {
187
404
  const gen = ++this.gen
188
405
  const tick = async (): Promise<void> => {
189
406
  await this.load()
407
+ this.keep()
190
408
  if (gen === this.gen) timer = setTimeout(() => void tick(), PROJECTS_POLL_MS)
191
409
  }
192
410
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -197,17 +415,29 @@ export class ProjectsSvc {
197
415
  }
198
416
  }
199
417
 
418
+ /** the list as it is now, kept for the next load. Only a change is
419
+ * written: the poll repeats the same rows every five seconds */
420
+ private kept = ''
421
+ private keep(): void {
422
+ if (!this.fresh) return
423
+ const s = JSON.stringify(this.list)
424
+ if (s !== this.kept && saveList(this.list)) this.kept = s
425
+ }
426
+
200
427
  async load(): Promise<void> {
201
428
  const order = this.orderGen
202
429
  try {
203
430
  const r = await fetch('/api/projects.json')
204
431
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
205
- const j = (await r.json()) as { projects: Project[] }
432
+ const j = (await r.json()) as { projects: Project[]; archived?: ArchivedRow[] }
206
433
  runInAction(() => {
207
434
  // a reorder since this left, or a drag in flight: the next poll answers
208
435
  if (order !== this.orderGen || this.drag != null) return
209
- this.list = j.projects
436
+ const mine = new Map(this.list.filter((p) => this.identitySaves.has(p.name)).map((p) => [p.name, p.identity]))
437
+ this.list = mine.size === 0 ? j.projects : j.projects.map((p) => (mine.has(p.name) ? { ...p, identity: mine.get(p.name)! } : p))
438
+ this.archived = j.archived ?? []
210
439
  this.error = ''
440
+ this.fresh = true
211
441
  })
212
442
  } catch (e) {
213
443
  runInAction(() => { this.error = `projects: ${(e as Error).message}` })
@@ -5,7 +5,7 @@
5
5
  // the same reasons a switch is.
6
6
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
7
  import type { ProseGroup, ProseView } from '../../prose/prose-view.ts'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
 
10
10
  export type TierFilter = 'all' | 'blocking' | 'soft'
11
11
  export type ListKind = 'allow' | 'ban'
@@ -29,7 +29,7 @@ export class ProseHitsSt {
29
29
  full = new Set<string>()
30
30
  form: { kind: ListKind; word: string; to: string; reason: string } = { kind: 'allow', word: '', to: '', reason: '' }
31
31
 
32
- constructor(private st: HubSt) {
32
+ constructor(private st: RepoSt) {
33
33
  // .ref: the view is swapped whole on every load, never edited in place
34
34
  makeAutoObservable<this, 'st'>(this, { st: false, view: observableRef })
35
35
  }
@@ -37,7 +37,7 @@ export class ProseHitsSt {
37
37
  async load(): Promise<void> {
38
38
  runInAction(() => { this.loading = true })
39
39
  try {
40
- const r = await fetch('/api/prose.json')
40
+ const r = await fetch(this.st.url('/api/prose.json'))
41
41
  if (!r.ok) throw new Error(await r.text())
42
42
  const v = (await r.json()) as ProseView
43
43
  runInAction(() => { this.view = v; this.error = '' })
@@ -9,7 +9,7 @@ import type { ProseGroup, ProseViewHit } from '../../prose/prose-view.ts'
9
9
  import type { ProseWords } from '../../prose/ste.ts'
10
10
  import { editorHref } from './check-words.ts'
11
11
  import { Help } from './help.tsx'
12
- import type { HubSt } from './hub-st.ts'
12
+ import type { RepoSt } from './repo-st.ts'
13
13
  import { GROUP_CAP, HIT_CAP, ProseHitsSt, type ListKind, type TierFilter } from './prose-hits-st.ts'
14
14
 
15
15
  /** blocking tiers read red, the soft tier reads as a warning */
@@ -47,7 +47,7 @@ const HitLine = ({ root, h, shape }: { root: string; h: ProseViewHit; shape: boo
47
47
  )
48
48
  }
49
49
 
50
- const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGroup }) => {
50
+ const Group = observer(({ app, s, g }: { app: RepoSt; s: ProseHitsSt; g: ProseGroup }) => {
51
51
  const open = s.open.has(g.key)
52
52
  const t = TIER_WORD[g.tier] ?? { word: g.tier, tone: '' }
53
53
  const shown = s.full.has(g.key) ? g.hits : g.hits.slice(0, HIT_CAP)
@@ -77,7 +77,7 @@ const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGro
77
77
  )
78
78
  })
79
79
 
80
- const Hits = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
80
+ const Hits = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
81
81
  const v = s.view
82
82
  if (v == null) return <div className="dim">{s.error !== '' ? s.error : 'reading the hits…'}</div>
83
83
  if (!v.scoped) return <div className="dim">this repo declares no prose scope, so the check reads no file: add `prose: {'{'} include: [...] {'}'}` to repo.config.ts</div>
@@ -130,7 +130,7 @@ const ListRows = ({ s, words }: { s: ProseHitsSt; words: ProseWords }) => {
130
130
  )
131
131
  }
132
132
 
133
- const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
133
+ const Lists = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
134
134
  const f = s.form
135
135
  const g = app.checks.gate
136
136
  return (
@@ -154,7 +154,7 @@ const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
154
154
  })
155
155
 
156
156
  /** the two sections over one state, the part a test renders headless */
157
- export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => (
157
+ export const ProseSections = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => (
158
158
  <>
159
159
  <section className="csec">
160
160
  <div className="cstitle">hits<Help text="every hit of the last read, grouped by the word or the rule that fired, the noisiest first. A file:line opens the editor on that line" /></div>
@@ -169,7 +169,7 @@ export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt
169
169
 
170
170
  /** mounted by the check page for SK014 only: the state lives while the page
171
171
  * is open, and reads the hits once on the way in */
172
- export const ProseHits = observer(({ app }: { app: HubSt }) => {
172
+ export const ProseHits = observer(({ app }: { app: RepoSt }) => {
173
173
  const s = useMemo(() => new ProseHitsSt(app), [app])
174
174
  useEffect(() => { void s.load() }, [s])
175
175
  return <ProseSections app={app} s={s} />
@@ -0,0 +1,38 @@
1
+ // the repo's README, shown when no row is picked. Read again each time the
2
+ // empty page shows, so an edited README appears without a reload
3
+ import { makeAutoObservable, runInAction } from 'mobx'
4
+ import type { RepoSt } from './repo-st.ts'
5
+
6
+ export class ReadmeSvc {
7
+ /** the file name at the root, null when the repo has none */
8
+ file: string | null = null
9
+ md: string | null = null
10
+ error: string | null = null
11
+ loaded = false
12
+
13
+ constructor(private st: RepoSt) {
14
+ makeAutoObservable<this, 'st'>(this, { st: false })
15
+ }
16
+
17
+ /** where a README image loads from: the hub's file route, under this repo */
18
+ fileUrl = (rel: string): string => this.st.url(`/api/readme-file/${rel}`)
19
+
20
+ async load(): Promise<void> {
21
+ try {
22
+ const r = await fetch(this.st.url('/api/readme.json'))
23
+ if (!r.ok) throw new Error(`http ${r.status}: ${await r.text()}`)
24
+ const j = (await r.json()) as { file: string | null; md: string | null }
25
+ runInAction(() => {
26
+ this.file = j.file
27
+ this.md = j.md
28
+ this.error = null
29
+ this.loaded = true
30
+ })
31
+ } catch (e) {
32
+ runInAction(() => {
33
+ this.error = `README fetch broke: ${(e as Error).message}`
34
+ this.loaded = true
35
+ })
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,19 @@
1
+ // the page with no row picked: the repo's README, images included, or the
2
+ // hint when the repo has none
3
+ import { observer } from 'mobx-react-lite'
4
+ import { useEffect } from 'react'
5
+ import { renderReadme } from './md.ts'
6
+ import type { RepoSt } from './repo-st.ts'
7
+
8
+ export const ReadmeView = observer(({ app }: { app: RepoSt }) => {
9
+ const r = app.readme
10
+ useEffect(() => { void r.load() }, [r])
11
+ if (r.error != null) return <div className="ph"><div className="errs">{r.error}</div>Pick a row on the left.</div>
12
+ if (r.md == null) return <div className="ph">{r.loaded ? 'Pick a row on the left.' : ''}</div>
13
+ return (
14
+ <div className="readme">
15
+ <div className="readme-head">{r.file} · pick a row on the left for the rest</div>
16
+ <div className="md readme-body" dangerouslySetInnerHTML={{ __html: renderReadme(r.md, r.fileUrl) }} />
17
+ </div>
18
+ )
19
+ })
@@ -0,0 +1,11 @@
1
+ /* the README on a repo page with no row picked: a readable column held to the left
2
+ (the pane centers its child, the auto right margin takes the slack), images at their width */
3
+ .readme{width:100%;max-width:860px;margin:0 auto 0 var(--s3);padding:var(--s3) var(--s4)}
4
+ .readme-head{color:var(--dim);font-size:11px;margin-bottom:var(--s2)}
5
+ .readme-body{font-size:13.5px;line-height:1.6}
6
+ .readme .readme-body h1{font-size:22px;margin:var(--s3) 0 var(--s2)}
7
+ .readme .readme-body h2{font-size:17px;margin:var(--s3) 0 var(--s2);border-bottom:1px solid var(--line);padding-bottom:var(--s1)}
8
+ .readme .readme-body h3{font-size:14px}
9
+ .readme-body img{max-width:100%;height:auto;border-radius:4px}
10
+ .readme-body pre{overflow-x:auto;padding:var(--s2);background:var(--hi3);border-radius:4px}
11
+ .readme-body a{color:var(--blue)}
@@ -0,0 +1,61 @@
1
+ // A repo page's sections: the pages reached for most, one line each at the top
2
+ // of the left pane, over the tree, drawn the way the project list draws its
3
+ // lines (an icon, a label, a count where it says something, its ⌥ shortcut at
4
+ // the end, keys.ts), the current one lit. A page this repo does not have is
5
+ // not a line. The lines come from what the page already holds (the kept
6
+ // screen on a reload), so none arrives late and moves the tree.
7
+ import { observer } from 'mobx-react-lite'
8
+ import { RUNS_ID } from '../../collections/collection-types.ts'
9
+ import { Icon, ICON } from './icons.tsx'
10
+ import { sectionKeyLabel, type SectionId } from './keys.ts'
11
+ import type { RepoSt } from './repo-st.ts'
12
+ import { openWikis, WIKIS } from './wikis-view.tsx'
13
+
14
+ export type RepoSection = {
15
+ id: SectionId
16
+ label: string
17
+ title: string
18
+ /** null when a number would say nothing */
19
+ count: number | null
20
+ tone: 'red' | 'warn' | null
21
+ on: boolean
22
+ go: () => void
23
+ }
24
+
25
+ const counted = (n: number, tone: 'red' | 'warn'): Pick<RepoSection, 'count' | 'tone'> => (n === 0 ? { count: null, tone: null } : { count: n, tone })
26
+
27
+ export function repoSections(app: RepoSt): RepoSection[] {
28
+ const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
29
+ const sel = app.sel
30
+ const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
31
+ const res = app.cols.resources
32
+ const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
33
+ const none = { count: null, tone: null }
34
+ const out: RepoSection[] = []
35
+ if (has('checks')) out.push({ id: 'checks', label: 'checks', title: 'every check the gate and the health pass run, one page each', ...counted(reds('checks'), 'red'), on: onCol('checks'), go: () => app.showCol('checks') })
36
+ // a page, never a resource: every hub keeps a run record
37
+ out.push({ id: 'runs', label: 'runs', title: 'every run on record, the slowest checks and the longest runs', ...none, on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
38
+ if (has('asks')) {
39
+ const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
40
+ out.push({ id: 'asks', label: 'asks', title: 'what waits on a human', ...counted(open, 'warn'), on: onCol('asks'), go: () => app.showCol('asks') })
41
+ }
42
+ if (has('tests')) out.push({ id: 'tests', label: 'tests', title: 'every test file, slowest first, then each test check', ...counted(reds('tests'), 'red'), on: sel?.t === 'tests' || onCol('tests'), go: () => app.showTests(null) })
43
+ const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
44
+ if (firstCat != null) out.push({ id: 'media', label: 'media', title: 'the assets, in the atelier', ...none, on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
45
+ if (has(WIKIS)) out.push({ id: 'wikis', label: 'wikis', title: 'every wiki this repo mounts, as a link graph', ...none, on: onCol(WIKIS), go: () => openWikis(app) })
46
+ if (app.home.machine != null) out.push({ id: 'admin', label: 'admin', title: 'reload the hub, keep the repo active, archive it', ...none, on: sel?.t === 'admin', go: () => app.showAdmin() })
47
+ return out
48
+ }
49
+
50
+ export const Sections = observer(({ app }: { app: RepoSt }) => (
51
+ <nav className="rsecs" aria-label="sections">
52
+ {repoSections(app).map((s) => (
53
+ <button key={s.id} className={`rsec${s.on ? ' cur' : ''}`} aria-current={s.on ? 'page' : undefined} title={`${s.title} (${sectionKeyLabel(s.id)})`} onClick={s.go}>
54
+ <Icon d={ICON[s.id]} />
55
+ <span className="rsecl">{s.label}</span>
56
+ {s.count == null ? null : <b className={`rsecn n${s.tone}`}>{s.count}</b>}
57
+ <kbd className="pjkey">{sectionKeyLabel(s.id)}</kbd>
58
+ </button>
59
+ ))}
60
+ </nav>
61
+ ))