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.
- package/CHANGELOG.md +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +235 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -165,9 +165,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
|
|
|
165
165
|
tagTemplate: rc.tag ?? ROOT_TAG,
|
|
166
166
|
changelogRel: rc.changelog ?? 'CHANGELOG.md',
|
|
167
167
|
npm: rc.npm,
|
|
168
|
-
|
|
169
|
-
// declaration, and a public root package publishes by default
|
|
170
|
-
npmWhenUndeclared: 'from-private-flag',
|
|
168
|
+
undeclaredFix: 'add `release: { npm: true }` to repo.config.ts to publish it',
|
|
171
169
|
githubRelease: rc.githubRelease ?? true,
|
|
172
170
|
tsconfigRel: rc.tsconfig,
|
|
173
171
|
checkFolderName: false,
|
|
@@ -204,7 +202,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
|
|
|
204
202
|
tagTemplate: decl.tag ?? SUB_TAG,
|
|
205
203
|
changelogRel: decl.changelog ?? `${decl.dir}/CHANGELOG.md`,
|
|
206
204
|
npm: decl.npm,
|
|
207
|
-
|
|
205
|
+
undeclaredFix: 'add `npm: true` to this units[] entry to publish it',
|
|
208
206
|
githubRelease: decl.githubRelease ?? false,
|
|
209
207
|
tsconfigRel: decl.tsconfig,
|
|
210
208
|
// only the DEFAULT tag renders the package name into a git tag, so
|
|
@@ -226,11 +224,9 @@ function build(p: {
|
|
|
226
224
|
tagTemplate: string
|
|
227
225
|
changelogRel: string
|
|
228
226
|
npm: boolean | undefined
|
|
229
|
-
/**
|
|
230
|
-
* package
|
|
231
|
-
|
|
232
|
-
* ambiguous and REFUSES. */
|
|
233
|
-
npmWhenUndeclared: 'from-private-flag' | 'refuse'
|
|
227
|
+
/** where this unit's npm declaration goes, for the refusal a non-private
|
|
228
|
+
* package with no `npm` key gets */
|
|
229
|
+
undeclaredFix: string
|
|
234
230
|
githubRelease: boolean
|
|
235
231
|
tsconfigRel: string | undefined
|
|
236
232
|
checkFolderName: boolean
|
|
@@ -256,10 +252,10 @@ function build(p: {
|
|
|
256
252
|
// about must refuse, not quietly resolve to no-publish. A silent false
|
|
257
253
|
// here is a lib that bumps, tags and cuts a release while publishing
|
|
258
254
|
// nothing, and the only trace is one note line in a long log.
|
|
259
|
-
if (p.npm == null && !isPrivate
|
|
260
|
-
errors.push(at(
|
|
255
|
+
if (p.npm == null && !isPrivate)
|
|
256
|
+
errors.push(at(`package.json is not private and no npm target is declared: ${p.undeclaredFix}, or \`private: true\` to its package.json to keep it internal`))
|
|
261
257
|
if (errors.length > 0) return { error: errors }
|
|
262
|
-
const declaredNpm = p.npm
|
|
258
|
+
const declaredNpm = p.npm === true
|
|
263
259
|
const tsconfig = p.tsconfigRel != null ? join(p.root, p.tsconfigRel) : join(p.dir, 'tsconfig.json')
|
|
264
260
|
return {
|
|
265
261
|
unit: {
|
package/src/repo-config.ts
CHANGED
|
@@ -59,6 +59,27 @@ export type ServeConfig = {
|
|
|
59
59
|
* prints beside localhost and rides on every ping message, because a
|
|
60
60
|
* phone cannot resolve localhost */
|
|
61
61
|
publicHost?: string
|
|
62
|
+
/** the long running processes this repo needs (a dev server, a watcher).
|
|
63
|
+
* The hub supervises them: a switch each on its page, a crash restarts,
|
|
64
|
+
* and they outlive a hub restart */
|
|
65
|
+
processes?: ProcessDecl[]
|
|
66
|
+
/** the ids of the global processes this repo needs (any house repo's
|
|
67
|
+
* `globalProcess` unit files). The home rail marks the repo while one is off */
|
|
68
|
+
uses?: string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** one long running process a repo needs, supervised by its hub */
|
|
72
|
+
export type ProcessDecl = {
|
|
73
|
+
/** kebab-case, unique in this repo */
|
|
74
|
+
id: string
|
|
75
|
+
/** the command as a list of words, run from the repo root */
|
|
76
|
+
cmd: string[]
|
|
77
|
+
/** where it runs, repo-relative (default the repo root) */
|
|
78
|
+
cwd?: string
|
|
79
|
+
/** the url it serves, probed for up and shown as a link */
|
|
80
|
+
url?: string
|
|
81
|
+
/** starts with the hub. Default false: it starts from its switch */
|
|
82
|
+
auto?: boolean
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
export type RepoConfig = {
|
|
@@ -164,8 +185,16 @@ export type RepoConfig = {
|
|
|
164
185
|
* lint and prints a skip line carrying the reason. `defineRepo` throws on
|
|
165
186
|
* an unknown id or a blank reason */
|
|
166
187
|
ruleExempt?: Record<string, string>
|
|
167
|
-
/**
|
|
168
|
-
*
|
|
188
|
+
/** the repo is done for now: nothing processes it. The house discovery
|
|
189
|
+
* leaves it out, so it has no rail row, no hub (no file watch, no plan
|
|
190
|
+
* rows, no automatic gates), no processes or widgets, no identity fill,
|
|
191
|
+
* no activity row, and fleet skips it. Only the archived page lists it.
|
|
192
|
+
* The button in the repo header and the page's unarchive toggle it, and
|
|
193
|
+
* only this key moves (core/archived.ts) */
|
|
194
|
+
archived?: boolean
|
|
195
|
+
/** declared github visibility INTENT (absent = 'private'): public means
|
|
196
|
+
* strangers read the tracked tree. What each check does with it:
|
|
197
|
+
* wiki/build/shipkit/built-in-checks.md */
|
|
169
198
|
visibility?: 'private' | 'public'
|
|
170
199
|
/** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
|
|
171
200
|
* whose system prompt points at `brief` (an md file holding the standing
|
|
@@ -250,13 +279,34 @@ export type RepoConfig = {
|
|
|
250
279
|
}
|
|
251
280
|
/** the `shipkit serve` hub: port + optional graph hooks */
|
|
252
281
|
serve?: ServeConfig
|
|
282
|
+
/** the repo's own colours, one #rrggbb hex each: colorLight reads on a
|
|
283
|
+
* light background, colorDark on a dark one. Every page that shows the
|
|
284
|
+
* repo draws its name and its rail tile in them. Rule SK024 warns when
|
|
285
|
+
* they are missing, and its fix derives them from the repo icon */
|
|
286
|
+
identity?: {
|
|
287
|
+
/** #rrggbb that reads on a light background */
|
|
288
|
+
colorLight: string
|
|
289
|
+
/** #rrggbb that reads on a dark background: the home page draws this one */
|
|
290
|
+
colorDark: string
|
|
291
|
+
}
|
|
253
292
|
/** where a gate run writes its per task logs, repo-relative (default
|
|
254
293
|
* '.shipkit/tmp/shipkit-ci'). Each run owns a subdir of it, so several agents in
|
|
255
294
|
* one checkout never overwrite each other. Keep it gitignored. */
|
|
256
295
|
logDir?: string
|
|
257
296
|
}
|
|
258
297
|
|
|
259
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
298
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
|
|
299
|
+
|
|
300
|
+
const KEBAB = /^[a-z0-9][a-z0-9-]*$/
|
|
301
|
+
const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
|
|
302
|
+
|
|
303
|
+
/** why a process declaration cannot run, or null */
|
|
304
|
+
export function processProblem(d: ProcessDecl): string | null {
|
|
305
|
+
if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
|
|
306
|
+
if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
|
|
307
|
+
if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
260
310
|
|
|
261
311
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
262
312
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -266,11 +316,18 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
266
316
|
// a half-declared reviewer would lint a path like `.claude/agents/.md`
|
|
267
317
|
if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
|
|
268
318
|
throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
|
|
319
|
+
const pids = new Set<string>()
|
|
320
|
+
for (const d of c.serve?.processes ?? []) {
|
|
321
|
+
const why = processProblem(d)
|
|
322
|
+
if (why != null) throw new Error(`defineRepo: ${why}`)
|
|
323
|
+
if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
|
|
324
|
+
pids.add(d.id)
|
|
325
|
+
}
|
|
269
326
|
// unknown keys die loud — a typo'd or removed key silently changing the
|
|
270
327
|
// gate is exactly the never-silence failure mode (A23)
|
|
271
328
|
for (const k of Object.keys(c))
|
|
272
329
|
if (!KNOWN_KEYS.has(k))
|
|
273
|
-
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
|
|
330
|
+
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
|
|
274
331
|
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
275
332
|
for (const u of c.units ?? []) {
|
|
276
333
|
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The dock's data on the server: every house repo's record, read for the
|
|
2
|
+
// live window and shaped into one view (ledger/house-runs.ts). One instance
|
|
3
|
+
// serves every page that asks, the home's daemon and a hub serving on its own
|
|
4
|
+
// alike.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: this is asked every few seconds
|
|
7
|
+
// while a dock is open, so nothing here may cost a record read when nothing
|
|
8
|
+
// moved. A repo's record is opened only when its db or its write ahead log
|
|
9
|
+
// changed since the last read (the window only slides, and the shaping drops
|
|
10
|
+
// what slid out), and one shaped answer serves every request inside SHARE_MS.
|
|
11
|
+
import { existsSync } from 'fs'
|
|
12
|
+
import { parallelCap } from '../core/parallel.ts'
|
|
13
|
+
import { shapeHouse, type HouseRecord, type HouseView } from '../ledger/house-runs.ts'
|
|
14
|
+
import { LIVE_HISTORY_MS } from '../ledger/live-timeline.ts'
|
|
15
|
+
import { readWindowReadonly } from '../ledger/run-log.ts'
|
|
16
|
+
import { recordOf, recordStamp } from './project-gutter.ts'
|
|
17
|
+
|
|
18
|
+
/** how long one shaped answer serves every page that asks */
|
|
19
|
+
export const SHARE_MS = 1_000
|
|
20
|
+
|
|
21
|
+
export type HouseRepo = { name: string; path: string }
|
|
22
|
+
|
|
23
|
+
export class HouseRuns {
|
|
24
|
+
private records = new Map<string, { stamp: string; rec: HouseRecord }>()
|
|
25
|
+
private shaped: { at: number; view: HouseView } | null = null
|
|
26
|
+
/** records opened since this instance was built: what a test measures */
|
|
27
|
+
reads = 0
|
|
28
|
+
|
|
29
|
+
constructor(private list: () => HouseRepo[], private now: () => number = Date.now, private cap: () => number = parallelCap) {}
|
|
30
|
+
|
|
31
|
+
view(): HouseView {
|
|
32
|
+
const now = this.now()
|
|
33
|
+
if (this.shaped != null && now - this.shaped.at < SHARE_MS) return this.shaped.view
|
|
34
|
+
const since = now - LIVE_HISTORY_MS
|
|
35
|
+
const next = new Map<string, { stamp: string; rec: HouseRecord }>()
|
|
36
|
+
for (const r of this.list()) {
|
|
37
|
+
const path = recordOf(r.path)
|
|
38
|
+
if (path == null || !existsSync(path)) continue
|
|
39
|
+
const stamp = recordStamp(path)
|
|
40
|
+
let hit = this.records.get(r.path)
|
|
41
|
+
if (hit == null || hit.stamp !== stamp) {
|
|
42
|
+
this.reads++
|
|
43
|
+
hit = { stamp, rec: { repo: r.name, ...readWindowReadonly(path, since) } }
|
|
44
|
+
} else if (hit.rec.repo !== r.name) hit = { stamp, rec: { ...hit.rec, repo: r.name } }
|
|
45
|
+
next.set(r.path, hit)
|
|
46
|
+
}
|
|
47
|
+
// a repo gone from the list takes its rows with it
|
|
48
|
+
this.records = next
|
|
49
|
+
const view = shapeHouse({ now, cap: this.cap(), repos: [...next.values()].map((x) => x.rec) })
|
|
50
|
+
this.shaped = { at: now, view }
|
|
51
|
+
return view
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// the activity page's state: what every house repo committed in a window and
|
|
2
|
+
// what it holds uncommitted, as the daemon reads it from local git. It asks
|
|
3
|
+
// again every ACTIVITY_POLL_MS, only while the page is shown AND the tab is
|
|
4
|
+
// visible, so a page left in the background costs the daemon nothing. The
|
|
5
|
+
// window and the commit cap are remembered per viewer.
|
|
6
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
7
|
+
import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type ActivityAnswer, type ActivityRow } from '../../home/activity-shape.ts'
|
|
8
|
+
|
|
9
|
+
// the words the page and `shipkit cross-repo-activity` share live beside the shapes
|
|
10
|
+
export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
|
|
11
|
+
import type { HomeSt } from './home-st.ts'
|
|
12
|
+
|
|
13
|
+
export const ACTIVITY_POLL_MS = 60_000
|
|
14
|
+
const MINUTES_KEY = 'home:activity:minutes'
|
|
15
|
+
const CAP_KEY = 'home:activity:cap'
|
|
16
|
+
|
|
17
|
+
/** busy rows in the daemon's (rail) order, then the quiet ones below them */
|
|
18
|
+
export function splitRows(rows: ActivityRow[]): { busy: ActivityRow[]; idle: ActivityRow[] } {
|
|
19
|
+
return { busy: rows.filter((r) => !idleRow(r)), idle: rows.filter(idleRow) }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function kept(key: string): string | null {
|
|
23
|
+
try {
|
|
24
|
+
return globalThis.localStorage?.getItem(key) ?? null
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function keptMinutes(): number {
|
|
30
|
+
const n = Number(kept(MINUTES_KEY))
|
|
31
|
+
return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
|
|
32
|
+
}
|
|
33
|
+
function keep(key: string, n: number): void {
|
|
34
|
+
try { globalThis.localStorage?.setItem(key, String(n)) } catch { /* private window: the pick lasts the tab */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ActivitySt {
|
|
38
|
+
data: ActivityAnswer | null = null
|
|
39
|
+
error = ''
|
|
40
|
+
minutes: number = keptMinutes()
|
|
41
|
+
/** the most commits a repo shows before a click shows the rest */
|
|
42
|
+
cap: number = clampCap(kept(CAP_KEY) ?? DEFAULT_CAP)
|
|
43
|
+
/** repos whose commits all show, past the cap */
|
|
44
|
+
expanded = new Set<string>()
|
|
45
|
+
/** repos whose failing and warning checks show under their row */
|
|
46
|
+
checksOpen = new Set<string>()
|
|
47
|
+
/** a read is in flight */
|
|
48
|
+
loading = false
|
|
49
|
+
/** when the next read goes, epoch ms */
|
|
50
|
+
nextAt = 0
|
|
51
|
+
/** the page's clock, one tick a second while shown */
|
|
52
|
+
now = Date.now()
|
|
53
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
54
|
+
private gen = 0
|
|
55
|
+
/** a tab coming back reads only when its countdown is already due */
|
|
56
|
+
private readonly onVisible = (): void => {
|
|
57
|
+
if (this.timer != null) this.tick()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
constructor(private st: HomeSt) {
|
|
61
|
+
makeAutoObservable<this, 'st' | 'timer' | 'gen' | 'onVisible'>(this, { st: false, timer: false, gen: false, onVisible: false, data: observableRef })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get rows() {
|
|
65
|
+
return splitRows(this.data?.rows ?? [])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** the answer shown is for another window than the one picked: it dims until the new one lands */
|
|
69
|
+
get stale(): boolean {
|
|
70
|
+
return this.data != null && this.data.minutes !== this.minutes
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** whole seconds to the next read */
|
|
74
|
+
get countdown(): number {
|
|
75
|
+
return Math.max(0, Math.ceil((this.nextAt - this.now) / 1000))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setMinutes(m: number): void {
|
|
79
|
+
this.minutes = m
|
|
80
|
+
keep(MINUTES_KEY, m)
|
|
81
|
+
void this.poll()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** a typed cap, clamped to CAP_MIN..CAP_MAX */
|
|
85
|
+
setCap(v: unknown): void {
|
|
86
|
+
this.cap = clampCap(v)
|
|
87
|
+
keep(CAP_KEY, this.cap)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toggle(name: string): void {
|
|
91
|
+
if (this.expanded.has(name)) this.expanded.delete(name)
|
|
92
|
+
else this.expanded.add(name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** a click on the status dot: its checks show under the row, or hide */
|
|
96
|
+
toggleChecks(name: string): void {
|
|
97
|
+
if (this.checksOpen.has(name)) this.checksOpen.delete(name)
|
|
98
|
+
else this.checksOpen.add(name)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
openCheck(repo: string, check: string): void {
|
|
102
|
+
this.st.openCheck(repo, check)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** the repo in Fork, the git client: the daemon opens it by name */
|
|
106
|
+
async openFork(name: string): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
const r = await fetch('/api/projects/fork', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
|
|
109
|
+
if (!r.ok) throw new Error(await r.text())
|
|
110
|
+
runInAction(() => { this.error = '' })
|
|
111
|
+
} catch (e) {
|
|
112
|
+
runInAction(() => { this.error = `open ${name} in Fork: ${(e as Error).message}` })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
openRepo(name: string): void {
|
|
117
|
+
this.st.select(name)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** the refresh button: a new git pass now, whatever the countdown says */
|
|
121
|
+
refresh(): void {
|
|
122
|
+
if (!this.loading) void this.poll(true)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** one read. The daemon shares an answer younger than its ttl between
|
|
126
|
+
* readers and never serves an older one; only `fresh` forces a pass */
|
|
127
|
+
async poll(fresh = false): Promise<void> {
|
|
128
|
+
const id = ++this.gen
|
|
129
|
+
runInAction(() => { this.loading = true; this.nextAt = Date.now() + ACTIVITY_POLL_MS })
|
|
130
|
+
try {
|
|
131
|
+
const r = await fetch(`/api/home/activity.json?m=${this.minutes}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(30_000) })
|
|
132
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
133
|
+
const j = (await r.json()) as ActivityAnswer
|
|
134
|
+
runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
|
|
135
|
+
} catch (e) {
|
|
136
|
+
runInAction(() => { if (id === this.gen) this.error = (e as Error).message })
|
|
137
|
+
} finally {
|
|
138
|
+
// the countdown starts when the answer lands, so reads never overlap
|
|
139
|
+
runInAction(() => { if (id === this.gen) { this.loading = false; this.nextAt = Date.now() + ACTIVITY_POLL_MS } })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** one tick a second: the countdown moves, and a due read goes, unless
|
|
144
|
+
* the tab is hidden (the daemon reads git only for a viewer) */
|
|
145
|
+
private tick(): void {
|
|
146
|
+
this.now = Date.now()
|
|
147
|
+
if (this.loading || this.now < this.nextAt) return
|
|
148
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
149
|
+
void this.poll()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** reads while the page is shown */
|
|
153
|
+
start(): void {
|
|
154
|
+
if (this.timer != null) return
|
|
155
|
+
void this.poll()
|
|
156
|
+
this.timer = setInterval(() => this.tick(), 1_000)
|
|
157
|
+
document.addEventListener('visibilitychange', this.onVisible)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
stop(): void {
|
|
161
|
+
if (this.timer == null) return
|
|
162
|
+
clearInterval(this.timer)
|
|
163
|
+
this.timer = null
|
|
164
|
+
this.gen++
|
|
165
|
+
this.loading = false
|
|
166
|
+
document.removeEventListener('visibilitychange', this.onVisible)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `/activity`: what every house repo committed in the window and what it holds
|
|
2
|
+
// uncommitted, as ONE table with a row group per repo, so a glance says which
|
|
3
|
+
// agents did what. Each commit carries its own state (↑ orange unpushed, ✓
|
|
4
|
+
// green pushed), uncommitted work is a red line above the commits, the branch
|
|
5
|
+
// is green on main and orange elsewhere. On top: the window, the commit cap
|
|
6
|
+
// (a number, 1 to 15), then when the next read goes. Repos with nothing in the
|
|
7
|
+
// window sit below the busy ones, each with its last commit in grey.
|
|
8
|
+
import { observer } from 'mobx-react-lite'
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { idleRow, isMainBranch, WINDOW_MINUTES, type ActivityCommit, type ActivityRow, type StatusCheck } from '../../home/activity-shape.ts'
|
|
11
|
+
import { ageShort, CAP_MAX, CAP_MIN, dirtWords, filesWords, upstreamWords, windowWords } from './activity-st.ts'
|
|
12
|
+
import { Tip } from './floating.tsx'
|
|
13
|
+
import { Icon, ICON } from './icons.tsx'
|
|
14
|
+
import type { HomeSt } from './home-st.ts'
|
|
15
|
+
import { checkAddress, plainClick, repoPage } from './home-st.ts'
|
|
16
|
+
import { stampWords } from './time-ago.tsx'
|
|
17
|
+
|
|
18
|
+
/** the repo's icon and name; a plain click selects it in place */
|
|
19
|
+
const RepoName = observer(({ app, name }: { app: HomeSt; name: string }) => {
|
|
20
|
+
const icon = app.projects.list.find((p) => p.name === name)?.icon ?? null
|
|
21
|
+
return (
|
|
22
|
+
<a className="acrepo" href={repoPage(name)} onClick={(e) => {
|
|
23
|
+
if (!plainClick(e)) return
|
|
24
|
+
e.preventDefault()
|
|
25
|
+
app.activity.openRepo(name)
|
|
26
|
+
}}>
|
|
27
|
+
{icon == null ? null : <img src={icon} alt="" width={14} height={14} draggable={false} />}
|
|
28
|
+
<span>{name}</span>
|
|
29
|
+
</a>
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** a bar of exclusive buttons */
|
|
34
|
+
const Bar = <T extends number>({ label, values, value, words, set }: { label: string; values: readonly T[]; value: number; words: (v: T) => string; set: (v: T) => void }) => (
|
|
35
|
+
<div className="acwin" role="radiogroup" aria-label={label}>
|
|
36
|
+
{values.map((v) => (
|
|
37
|
+
<button key={v} role="radio" aria-checked={value === v} className={value === v ? 'on' : ''} onClick={() => set(v)}>{words(v)}</button>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/** when the next read goes; it spins while one runs */
|
|
43
|
+
const Next = observer(({ app }: { app: HomeSt }) => {
|
|
44
|
+
const st = app.activity
|
|
45
|
+
return (
|
|
46
|
+
<button type="button" className="acnext acpill" disabled={st.loading} onClick={() => st.refresh()}
|
|
47
|
+
title="read every repo now · the page reads them again every minute while it is shown">
|
|
48
|
+
<span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
|
|
49
|
+
{st.loading ? '' : ` ${st.countdown}s`}
|
|
50
|
+
</button>
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** one commit's cells; `old` is the last one, from before the window */
|
|
55
|
+
const Commit = ({ c, now, old }: { c: ActivityCommit; now: number; old: boolean }) => (
|
|
56
|
+
<>
|
|
57
|
+
<td className="acsha">{c.sha}</td>
|
|
58
|
+
<td className="acwhen" title={stampWords(c.at)}>{ageShort(c.at, now)}</td>
|
|
59
|
+
<td className={`actitle ${c.pushed ? 'pushed' : 'unpushed'}${old ? ' old' : ''}`} title={`${old ? 'the last commit, before the window. ' : ''}${c.pushed ? 'pushed' : 'not pushed'}: ${c.title}`}>
|
|
60
|
+
<span className="acmark">{c.pushed ? '✓' : '↑'}</span>
|
|
61
|
+
{c.branch == null ? null : <span className="acside">{c.branch}</span>}
|
|
62
|
+
{c.title}
|
|
63
|
+
</td>
|
|
64
|
+
<td className="acwho">{c.author}</td>
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** the checks behind a red or orange dot, failing first. An answer from
|
|
69
|
+
* a daemon that sends no `failing` still names them, by id */
|
|
70
|
+
export const failingOf = (r: ActivityRow): StatusCheck[] => {
|
|
71
|
+
if (r.failing != null) return r.failing
|
|
72
|
+
const c = r.status
|
|
73
|
+
if (c == null) return []
|
|
74
|
+
const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
|
|
75
|
+
return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** each failing and warning check: its id (a link to its page), its title,
|
|
79
|
+
* how old that outcome is, and the first line it printed about it */
|
|
80
|
+
export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
|
|
81
|
+
const now = app.activity.now
|
|
82
|
+
return (
|
|
83
|
+
<ul className="acchecks">
|
|
84
|
+
{failingOf(r).map((c) => (
|
|
85
|
+
<li key={c.id} className={c.status === 'fail' ? 'fail' : 'warn'}>
|
|
86
|
+
<span className="acchkhead">
|
|
87
|
+
<span className="acchkmark">{c.status === 'fail' ? '✗' : '!'}</span>
|
|
88
|
+
<a className="acchkid" href={checkAddress(r.name, c.id)} onClick={(e) => {
|
|
89
|
+
if (!plainClick(e)) return
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
app.activity.openCheck(r.name, c.id)
|
|
92
|
+
}}>{c.id}</a>
|
|
93
|
+
{c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
|
|
94
|
+
{c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
|
|
95
|
+
</span>
|
|
96
|
+
{c.line === '' ? null : <span className="acchkline">{c.line}</span>}
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** the repo's checks as its own run record last saw them, only the ids it
|
|
104
|
+
* declares today: one dot, red, orange or green, with the count behind it.
|
|
105
|
+
* Hovering it floats the failing and warning checks; a click lists them
|
|
106
|
+
* under the row, so they read without hover too */
|
|
107
|
+
const Status = observer(({ app, r, span }: { app: HomeSt; r: ActivityRow; span: number }) => {
|
|
108
|
+
const c = r.status
|
|
109
|
+
if (c == null) return (
|
|
110
|
+
<td rowSpan={span} className="acstatus acdim">
|
|
111
|
+
<Tip content="no shipkit run recorded in this repo yet" placement="right">·</Tip>
|
|
112
|
+
</td>
|
|
113
|
+
)
|
|
114
|
+
const n = c.state === 'red' ? c.reds.length : c.state === 'orange' ? c.warns.length : 0
|
|
115
|
+
const tone = c.state === 'red' ? 'red' : c.state === 'orange' ? 'warn' : 'ok'
|
|
116
|
+
if (c.reds.length + c.warns.length === 0) return (
|
|
117
|
+
<td rowSpan={span} className={`acstatus t-${tone}`}>
|
|
118
|
+
<Tip content="every check passed on its last run" placement="right">●</Tip>
|
|
119
|
+
</td>
|
|
120
|
+
)
|
|
121
|
+
const open = app.activity.checksOpen.has(r.name)
|
|
122
|
+
return (
|
|
123
|
+
<td rowSpan={span} className={`acstatus t-${tone}`}>
|
|
124
|
+
<Tip content={<CheckList app={app} r={r} />} tipClass="acchktip" interactive placement="right-start" fallbacks={['bottom-start', 'left-start']}>
|
|
125
|
+
<button type="button" className={`acdot t-${tone}`} aria-expanded={open} aria-label={`${r.name}: ${c.reds.length} failing, ${c.warns.length} warning, click to ${open ? 'hide' : 'list'} them`}
|
|
126
|
+
onClick={() => app.activity.toggleChecks(r.name)}>
|
|
127
|
+
●{n > 0 ? ` ${n}` : ''}
|
|
128
|
+
</button>
|
|
129
|
+
</Tip>
|
|
130
|
+
</td>
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const Group = observer(({ app, r, now }: { app: HomeSt; r: ActivityRow; now: number }) => {
|
|
135
|
+
const st = app.activity
|
|
136
|
+
const open = st.expanded.has(r.name)
|
|
137
|
+
const capped = open ? r.commits : r.commits.slice(0, st.cap)
|
|
138
|
+
const more = r.commits.length - capped.length
|
|
139
|
+
const folds = more > 0 || (open && r.commits.length > st.cap)
|
|
140
|
+
const dirt = r.error == null ? dirtWords(r) : ''
|
|
141
|
+
const lines = (dirt !== '' || r.error != null ? 1 : 0) + capped.length + (r.last != null ? 1 : 0) + (folds ? 1 : 0)
|
|
142
|
+
const span = Math.max(1, lines)
|
|
143
|
+
const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
|
|
144
|
+
const head = (
|
|
145
|
+
<>
|
|
146
|
+
<td rowSpan={span} className="acname"><RepoName app={app} name={r.name} /></td>
|
|
147
|
+
<Status app={app} r={r} span={span} />
|
|
148
|
+
<td rowSpan={span} className={`acbranch ${isMainBranch(r.branch) ? 't-ok' : 't-warn'}`} title={`${branch}: ${upstreamWords(r)}`}>{branch}</td>
|
|
149
|
+
{/* the lead in to the commit list, on its first line */}
|
|
150
|
+
<td rowSpan={span} className="acforkcell">
|
|
151
|
+
<button type="button" className="acfork" title="open in Fork" aria-label={`open ${r.name} in Fork`} onClick={() => void st.openFork(r.name)}><Icon d={ICON.fork} /></button>
|
|
152
|
+
</td>
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
const rows: ReactNode[] = []
|
|
156
|
+
if (r.error != null) rows.push(<td key="e" colSpan={4} className="acerr" title={r.error}>git failed: {r.error}</td>)
|
|
157
|
+
else if (dirt !== '') rows.push(
|
|
158
|
+
<td key="d" colSpan={4} className="acdirty">
|
|
159
|
+
<Tip className="acdirt" tipClass="acfiles" placement="bottom-start" content={<pre>{filesWords(r)}</pre>}>{dirt}</Tip>
|
|
160
|
+
</td>,
|
|
161
|
+
)
|
|
162
|
+
for (const c of capped) rows.push(<Commit c={c} now={now} old={false} />)
|
|
163
|
+
// nothing newer in the window: HEAD's last commit, grey
|
|
164
|
+
if (r.last != null) rows.push(<Commit c={r.last} now={now} old />)
|
|
165
|
+
if (folds) rows.push(<td key="m" colSpan={4}><button className="acmore" onClick={() => st.toggle(r.name)}>{open ? 'show fewer' : `${more} more`}</button></td>)
|
|
166
|
+
if (rows.length === 0) rows.push(<td key="n" colSpan={4} className="acdim">no commit yet</td>)
|
|
167
|
+
return (
|
|
168
|
+
<tbody className={`acgrp${idleRow(r) ? ' quiet' : ''}`}>
|
|
169
|
+
{rows.map((cells, i) => <tr key={i}>{i === 0 ? head : null}{cells}</tr>)}
|
|
170
|
+
{st.checksOpen.has(r.name) && failingOf(r).length > 0 ? <tr className="acchkrow"><td colSpan={8}><CheckList app={app} r={r} /></td></tr> : null}
|
|
171
|
+
</tbody>
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const ActivityView = observer(({ app }: { app: HomeSt }) => {
|
|
176
|
+
const st = app.activity
|
|
177
|
+
const d = st.data
|
|
178
|
+
const now = d?.now ?? st.now
|
|
179
|
+
const { busy, idle } = st.rows
|
|
180
|
+
return (
|
|
181
|
+
<div className="hall activity">
|
|
182
|
+
<div className="actop">
|
|
183
|
+
<Bar label="how far back" values={WINDOW_MINUTES} value={st.minutes} words={windowWords} set={(m) => st.setMinutes(m)} />
|
|
184
|
+
<label className="accap" title={`the most commits a repo shows, ${CAP_MIN} to ${CAP_MAX}`}>
|
|
185
|
+
{/* uncontrolled while typed, so an emptied box can take a new number; it reads back the clamped cap on blur */}
|
|
186
|
+
<input type="number" min={CAP_MIN} max={CAP_MAX} step={1} defaultValue={st.cap} aria-label="commits per repo"
|
|
187
|
+
onChange={(e) => { if (e.currentTarget.value !== '') st.setCap(e.currentTarget.value) }}
|
|
188
|
+
onBlur={(e) => { e.currentTarget.value = String(st.cap) }} />
|
|
189
|
+
<span className="acdim">commits</span>
|
|
190
|
+
</label>
|
|
191
|
+
<Next app={app} />
|
|
192
|
+
{d == null ? <span className="acdim">reading every repo…</span> : (
|
|
193
|
+
<>
|
|
194
|
+
<span className="acpill">{busy.length} busy</span>
|
|
195
|
+
<span className="acpill">{idle.length} quiet</span>
|
|
196
|
+
<span className="acpill">read in {d.ms}ms</span>
|
|
197
|
+
</>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
{st.error !== '' ? <div className="hnote">{st.error}</div> : null}
|
|
201
|
+
{d != null && busy.length === 0 ? <div className="acdim">nothing committed or uncommitted in the last {windowWords(st.minutes)}</div> : null}
|
|
202
|
+
{busy.length + idle.length === 0 ? null : (
|
|
203
|
+
<table className={`actable${st.stale ? ' stale' : ''}`}>
|
|
204
|
+
<colgroup>
|
|
205
|
+
<col className="c-name" /><col className="c-status" /><col className="c-branch" />
|
|
206
|
+
<col className="c-fork" /><col className="c-sha" /><col className="c-when" /><col /><col className="c-who" />
|
|
207
|
+
</colgroup>
|
|
208
|
+
<thead>
|
|
209
|
+
<tr><th>repo</th><th>status</th><th>branch</th><th colSpan={5}>commits, newest first</th></tr>
|
|
210
|
+
</thead>
|
|
211
|
+
{busy.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
|
|
212
|
+
{idle.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
|
|
213
|
+
</table>
|
|
214
|
+
)}
|
|
215
|
+
<p className="hdim">in the console: shipkit activity</p>
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
})
|