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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { runInAction } from 'mobx'
|
|
6
6
|
import { observer } from 'mobx-react-lite'
|
|
7
7
|
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
|
8
|
-
import type {
|
|
9
|
-
import { isAud,
|
|
8
|
+
import type { RepoSt } from './repo-st.ts'
|
|
9
|
+
import { isAud, type MediaSvc } from './media-svc.ts'
|
|
10
10
|
import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
|
|
11
11
|
import type { CatRow, CustomPanelRef } from './hub-types.ts'
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ const TEX = 1024
|
|
|
14
14
|
const MIX_UNIT = 24
|
|
15
15
|
const MIX_GROUND = 44
|
|
16
16
|
|
|
17
|
-
export const MediaTopbar = observer(({ app }: { app:
|
|
17
|
+
export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
|
|
18
18
|
const m = app.media
|
|
19
19
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
20
20
|
const taRef = useRef<HTMLTextAreaElement>(null)
|
|
@@ -71,7 +71,7 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
|
|
|
71
71
|
)
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
-
export const InfoChips = observer(({ app }: { app:
|
|
74
|
+
export const InfoChips = observer(({ app }: { app: RepoSt }) => {
|
|
75
75
|
const info = app.media.detail?.info
|
|
76
76
|
if (info == null) return null
|
|
77
77
|
const chips: { k: string; v: ReactNode; warn?: boolean }[] = [{ k: 'backend', v: info.backend }]
|
|
@@ -91,7 +91,7 @@ export const InfoChips = observer(({ app }: { app: HubSt }) => {
|
|
|
91
91
|
)
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
-
export const AssetStage = observer(({ app }: { app:
|
|
94
|
+
export const AssetStage = observer(({ app }: { app: RepoSt }) => {
|
|
95
95
|
const m = app.media
|
|
96
96
|
const stageRef = useRef<HTMLDivElement>(null)
|
|
97
97
|
const imgRef = useRef<HTMLImageElement>(null)
|
|
@@ -116,9 +116,9 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
|
|
|
116
116
|
else if (isAud(shown)) content = (
|
|
117
117
|
// key = the roll identity: heartbeats re-render but never recreate
|
|
118
118
|
// the element, so playback survives; the src carries no cache-bust
|
|
119
|
-
<div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
|
|
119
|
+
<div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={m.rollUrl(m.curId!, shown)} /></div>
|
|
120
120
|
)
|
|
121
|
-
else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
|
|
121
|
+
else content = <img className="roll" src={m.rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
|
|
122
122
|
const imgShown = shown != null && !isAud(shown)
|
|
123
123
|
const measured = imgShown && box != null
|
|
124
124
|
return (
|
|
@@ -137,7 +137,7 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
|
|
|
137
137
|
)
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
export const ToolsBar = observer(({ app }: { app:
|
|
140
|
+
export const ToolsBar = observer(({ app }: { app: RepoSt }) => {
|
|
141
141
|
const m = app.media
|
|
142
142
|
const d = m.detail
|
|
143
143
|
if (d == null || m.curId == null) return null
|
|
@@ -177,7 +177,7 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
|
|
|
177
177
|
|
|
178
178
|
// variants live in a THIN resizable side bar — the stage keeps the room.
|
|
179
179
|
// closed = a slim rail with the count; the drag handle resizes; drop = upload
|
|
180
|
-
export const VariantsBar = observer(({ app }: { app:
|
|
180
|
+
export const VariantsBar = observer(({ app }: { app: RepoSt }) => {
|
|
181
181
|
const m = app.media
|
|
182
182
|
const rolls = m.detail?.rolls ?? []
|
|
183
183
|
const genHere = m.curId != null && m.pending.includes(m.curId)
|
|
@@ -213,7 +213,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
|
|
|
213
213
|
onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
|
|
214
214
|
onMouseLeave={() => runInAction(() => { m.hover = null })}
|
|
215
215
|
onClick={() => m.setActive(x.name)}>
|
|
216
|
-
{isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
|
|
216
|
+
{isAud(x.name) ? <>🔊<audio controls src={m.rollUrl(m.curId!, x.name)} /></> : <img src={m.rollUrl(m.curId!, x.name)} />}
|
|
217
217
|
{x.picked ? <span className="st">★</span> : null}
|
|
218
218
|
<div className="btns">
|
|
219
219
|
<button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
|
|
@@ -228,7 +228,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
|
|
|
228
228
|
})
|
|
229
229
|
|
|
230
230
|
// ── category grid + size-true lineup ─────────────────────────────────────
|
|
231
|
-
const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
|
|
231
|
+
const MixLineup = observer(({ m, rows }: { m: MediaSvc; rows: CatRow[] }) => {
|
|
232
232
|
const items = rows.filter((r) => !r.icon && r.rect != null).sort((a, b) => a.rect!.size - b.rect!.size || a.label.localeCompare(b.label))
|
|
233
233
|
const boxH = 420
|
|
234
234
|
const baseY = boxH - MIX_GROUND
|
|
@@ -241,7 +241,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
|
|
|
241
241
|
cx += slot / 2
|
|
242
242
|
const fx = rect.flip === true ? TEX - rect.x - rect.w : rect.x
|
|
243
243
|
sprites.push(
|
|
244
|
-
<img key={it.id} className="mixsprite" src={thumbUrl(it.id)} style={{
|
|
244
|
+
<img key={it.id} className="mixsprite" src={m.thumbUrl(it.id)} style={{
|
|
245
245
|
width: TEX * ts, height: TEX * ts,
|
|
246
246
|
left: cx - (fx + rect.w / 2) * ts, top: baseY - (rect.y + rect.h / 2) * ts,
|
|
247
247
|
transform: rect.flip === true ? 'scaleX(-1)' : undefined, zIndex: Math.round(cx),
|
|
@@ -262,7 +262,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
|
|
|
262
262
|
)
|
|
263
263
|
})
|
|
264
264
|
|
|
265
|
-
export const CatGrid = observer(({ app }: { app:
|
|
265
|
+
export const CatGrid = observer(({ app }: { app: RepoSt }) => {
|
|
266
266
|
const m = app.media
|
|
267
267
|
const cat = m.catMode
|
|
268
268
|
if (cat == null) return null
|
|
@@ -288,9 +288,9 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
288
288
|
// inner controls stopPropagation
|
|
289
289
|
<div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
|
|
290
290
|
{isAud(row.name)
|
|
291
|
-
? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
|
|
291
|
+
? <div className="aud">🔊<audio controls src={m.rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
|
|
292
292
|
: <div className="th">
|
|
293
|
-
<img src={thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
|
|
293
|
+
<img src={m.thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
|
|
294
294
|
{row.rect != null ? <div className="ovmini" style={{
|
|
295
295
|
left: pc(fl ? TEX - row.rect.x - row.rect.w : row.rect.x), top: pc(row.rect.y),
|
|
296
296
|
width: pc(row.rect.w), height: pc(row.rect.h),
|
|
@@ -314,13 +314,13 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
314
314
|
)
|
|
315
315
|
})}
|
|
316
316
|
</div>
|
|
317
|
-
<MixLineup rows={m.catRows} />
|
|
317
|
+
<MixLineup m={m} rows={m.catRows} />
|
|
318
318
|
</div>
|
|
319
319
|
)
|
|
320
320
|
})
|
|
321
321
|
|
|
322
322
|
// ── dock: the active tools' panels, side by side under the stage ─────────
|
|
323
|
-
const PanelHost = observer(({ app, c }: { app:
|
|
323
|
+
const PanelHost = observer(({ app, c }: { app: RepoSt; c: CustomPanelRef }) => {
|
|
324
324
|
const m = app.media
|
|
325
325
|
const ref = useRef<HTMLDivElement>(null)
|
|
326
326
|
const key = `${m.curId}|${m.active}|${c.id}`
|
|
@@ -337,7 +337,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
|
|
|
337
337
|
void (async () => {
|
|
338
338
|
try {
|
|
339
339
|
// repo-defined editor module, served by the media API
|
|
340
|
-
mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
|
|
340
|
+
mod = (await import(app.url(`/media/${c.url}`))) as Record<string, unknown>
|
|
341
341
|
if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
|
|
342
342
|
if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, api)
|
|
343
343
|
} catch (e) {
|
|
@@ -357,7 +357,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
|
|
|
357
357
|
// floating panels sit ON the canvas (drag by the title bar), so toggling
|
|
358
358
|
// them never reflows the stage — canvas size/position survive tab switches
|
|
359
359
|
const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
|
|
360
|
-
app:
|
|
360
|
+
app: RepoSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
|
|
361
361
|
}) => {
|
|
362
362
|
const m = app.media
|
|
363
363
|
const ref = useRef<HTMLDivElement>(null)
|
|
@@ -408,7 +408,7 @@ const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
|
|
|
408
408
|
)
|
|
409
409
|
})
|
|
410
410
|
|
|
411
|
-
export const FloatPanels = observer(({ app }: { app:
|
|
411
|
+
export const FloatPanels = observer(({ app }: { app: RepoSt }) => {
|
|
412
412
|
const m = app.media
|
|
413
413
|
const customs = (m.detail?.asset.custom ?? []).filter((c) => m.tools.has(`c:${c.id}`))
|
|
414
414
|
const showJson = m.tools.has('json')
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import type {
|
|
1
|
+
// WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check nobody
|
|
2
|
+
// asked for but the plan (plan/planner.ts), so the answer is where the check
|
|
3
|
+
// runs (`when`) and its row of that plan. Pure, so every case is pinned by a
|
|
4
|
+
// test rather than a screenshot.
|
|
5
|
+
import type { PlanRow } from '../../plan/planner.ts'
|
|
6
|
+
import type { CheckFacts } from './hub-types.ts'
|
|
6
7
|
|
|
7
8
|
export type NextRun = {
|
|
8
9
|
/** the value the page shows */
|
|
@@ -37,32 +38,25 @@ export function spanWords(ms: number): string {
|
|
|
37
38
|
return `${Math.round(ms / 1000)}s`
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
|
|
41
|
+
export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
43
|
+
/** a plan row in a few words: why it is due, or what makes it due */
|
|
44
|
+
export function planWords(row: PlanRow, now: number): { words: string; at: number | null } {
|
|
45
|
+
if (row.state === 'running') return { words: 'running now', at: row.dueAt }
|
|
46
|
+
if (row.state === 'due') return { words: row.reason === 'asked' ? 'due now, asked' : row.reason === 'changed' ? 'due now, a save moved it' : 'due now', at: row.dueAt }
|
|
47
|
+
if (row.state === 'late') return { words: 'waits for the repo to be active', at: row.dueAt }
|
|
48
|
+
if (row.state === 'next' && row.dueAt != null) return { words: `in ${aheadWords(row.dueAt - now)}`, at: row.dueAt }
|
|
49
|
+
return { words: 'on the next save that moves what it reads', at: null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function nextRun(u: NextRunFacts, row: PlanRow | null, now: number): NextRun {
|
|
47
53
|
if (u.when === 'off')
|
|
48
54
|
return { words: u.whenReason != null && u.whenReason !== '' ? `never: ${u.whenReason}` : 'never', at: null, help: 'it is off: nothing runs it until the switch below puts it somewhere' }
|
|
49
|
-
if (
|
|
50
|
-
return { words: '
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const at = pass + k * s.everyMs
|
|
57
|
-
return {
|
|
58
|
-
words: `in ${aheadWords(at - now)}`,
|
|
59
|
-
at,
|
|
60
|
-
help: `the health pass runs every ${spanWords(s.everyMs)}, and uses this check's cached answer until it runs out. The first pass after that runs it again`,
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
words: pass <= now ? 'due now' : `in ${aheadWords(pass - now)}`,
|
|
65
|
-
at: pass,
|
|
66
|
-
help: `the next health pass: the hub runs it every ${spanWords(s.everyMs)} while it serves, and at startup when the last one is older than that`,
|
|
67
|
-
}
|
|
55
|
+
if (u.when === 'release')
|
|
56
|
+
return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it. The plan and the quick gate you run while working do not' }
|
|
57
|
+
if (row == null)
|
|
58
|
+
return { words: u.when === 'always' ? 'next shipkit ci' : 'next shipkit check', at: null, help: 'no plan covers it here, so it runs when a terminal or a button runs it' }
|
|
59
|
+
const w = planWords(row, now)
|
|
60
|
+
const inactive = row.state === 'late' || !row.active ? ` While the repo is inactive: ${row.inactive}.` : ''
|
|
61
|
+
return { words: w.words, at: w.at, help: `planned by: ${row.rule}.${inactive}${row.error != null ? ` Error: ${row.error}.` : ''} A shipkit ci or check from a terminal runs it too` }
|
|
68
62
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// the one picker of the page (cmd+k, keys.ts), a node of the root: the
|
|
2
|
+
// home's places (home, planned, activity) first, then the shown repo's own jump
|
|
3
|
+
// targets, then every other house repo as a place to go. Another repo's
|
|
4
|
+
// resources are never listed: a jump to a repo selects it in place, and its
|
|
5
|
+
// own targets are one more cmd+k away.
|
|
6
|
+
import { makeAutoObservable } from 'mobx'
|
|
7
|
+
import type { HomeSt } from './home-st.ts'
|
|
8
|
+
import type { Jump } from './repo-st.ts'
|
|
9
|
+
|
|
10
|
+
/** how many rows the picker shows */
|
|
11
|
+
export const PAL_ROWS = 12
|
|
12
|
+
|
|
13
|
+
export class PaletteSt {
|
|
14
|
+
open = false
|
|
15
|
+
query = ''
|
|
16
|
+
idx = 0
|
|
17
|
+
|
|
18
|
+
constructor(private st: HomeSt) {
|
|
19
|
+
makeAutoObservable<this, 'st'>(this, { st: false })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** the home's places, first in the list: a hub of its own has none */
|
|
23
|
+
get places(): Jump[] {
|
|
24
|
+
const st = this.st
|
|
25
|
+
if (st.machine == null) return []
|
|
26
|
+
return [
|
|
27
|
+
{ id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
|
|
28
|
+
{ id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
|
|
29
|
+
{ id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
|
|
30
|
+
{ id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** every house repo but the one on screen, as a place to go: on a
|
|
35
|
+
* global page that is every repo */
|
|
36
|
+
get others(): Jump[] {
|
|
37
|
+
const st = this.st
|
|
38
|
+
if (st.machine == null) return []
|
|
39
|
+
const on = st.shown?.key
|
|
40
|
+
return st.projects.list.filter((p) => p.name !== on).map((p) => ({ id: `repo:${p.name}`, label: p.name, section: 'repo', face: { repo: p.name }, run: () => st.select(p.name) }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get jumps(): Jump[] {
|
|
44
|
+
return [...this.places, ...(this.st.shown?.jumps ?? []), ...this.others]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ranked: prefix matches first, then substring. With no query, home,
|
|
48
|
+
* planned and activity, then the shown repo's first targets, then every other repo,
|
|
49
|
+
* so a jump elsewhere is always in reach */
|
|
50
|
+
get results(): Jump[] {
|
|
51
|
+
const q = this.query.trim().toLowerCase()
|
|
52
|
+
if (q === '') {
|
|
53
|
+
const own = this.st.shown?.jumps ?? []
|
|
54
|
+
const places = this.places
|
|
55
|
+
const others = this.others
|
|
56
|
+
return [...places, ...own.slice(0, Math.max(4, PAL_ROWS - places.length - others.length)), ...others]
|
|
57
|
+
}
|
|
58
|
+
const pre: Jump[] = []
|
|
59
|
+
const sub: Jump[] = []
|
|
60
|
+
for (const j of this.jumps) {
|
|
61
|
+
const l = j.label.toLowerCase()
|
|
62
|
+
if (l.startsWith(q)) pre.push(j)
|
|
63
|
+
else if (l.includes(q)) sub.push(j)
|
|
64
|
+
}
|
|
65
|
+
return [...pre, ...sub].slice(0, PAL_ROWS * 2)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toggle(): void {
|
|
69
|
+
this.open = !this.open
|
|
70
|
+
this.query = ''
|
|
71
|
+
this.idx = 0
|
|
72
|
+
}
|
|
73
|
+
close(): void {
|
|
74
|
+
this.open = false
|
|
75
|
+
}
|
|
76
|
+
setQuery(v: string): void {
|
|
77
|
+
this.query = v
|
|
78
|
+
this.idx = 0
|
|
79
|
+
}
|
|
80
|
+
setIdx(n: number): void {
|
|
81
|
+
this.idx = n
|
|
82
|
+
}
|
|
83
|
+
move(d: number): void {
|
|
84
|
+
const n = this.results.length
|
|
85
|
+
if (n > 0) this.idx = (this.idx + d + n) % n
|
|
86
|
+
}
|
|
87
|
+
run(j: Jump | undefined = this.results[this.idx]): void {
|
|
88
|
+
if (j == null) return
|
|
89
|
+
this.open = false
|
|
90
|
+
j.run()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// the one picker, cmd+k (palette-st.ts): home, planned, activity and
|
|
2
|
+
// archived, the shown repo's targets, then every other repo. A place wears
|
|
3
|
+
// its button's icon, a repo its own icon, as the project list shows them.
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
import type { HomeSt } from './home-st.ts'
|
|
6
|
+
import { Icon, ICON } from './icons.tsx'
|
|
7
|
+
import { Face } from './project-list.tsx'
|
|
8
|
+
import type { JumpFace } from './repo-st.ts'
|
|
9
|
+
|
|
10
|
+
/** the icon a picker entry wears; an empty slot keeps the labels aligned */
|
|
11
|
+
const PalFace = observer(({ app, face }: { app: HomeSt; face: JumpFace | undefined }) => {
|
|
12
|
+
if (face != null && 'place' in face) return <span className="pface"><Icon d={ICON[face.place]} /></span>
|
|
13
|
+
const p = face == null ? null : app.projects.list.find((x) => x.name === face.repo) ?? null
|
|
14
|
+
return <span className="pface">{p == null ? null : <Face p={p} px={16} />}</span>
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const Palette = observer(({ app }: { app: HomeSt }) => {
|
|
18
|
+
const p = app.palette
|
|
19
|
+
if (!p.open) return null
|
|
20
|
+
return (
|
|
21
|
+
<div className="palov" onClick={() => p.close()}>
|
|
22
|
+
<div className="pal" onClick={(e) => e.stopPropagation()}>
|
|
23
|
+
<input autoFocus placeholder="jump to…" value={p.query}
|
|
24
|
+
onChange={(e) => p.setQuery(e.target.value)}
|
|
25
|
+
onKeyDown={(e) => {
|
|
26
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); p.move(1) }
|
|
27
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); p.move(-1) }
|
|
28
|
+
else if (e.key === 'Enter') p.run()
|
|
29
|
+
else if (e.key === 'Escape') p.close()
|
|
30
|
+
}} />
|
|
31
|
+
<div className="prows">
|
|
32
|
+
{p.results.map((jp, n) => (
|
|
33
|
+
<div key={jp.id} className={`prow${n === p.idx ? ' on' : ''}`}
|
|
34
|
+
onMouseEnter={() => p.setIdx(n)}
|
|
35
|
+
onClick={() => p.run(jp)}>
|
|
36
|
+
<PalFace app={app} face={jp.face} />
|
|
37
|
+
<span className="plabel">{jp.label}</span>
|
|
38
|
+
<span className="pchip">{jp.section}</span>
|
|
39
|
+
{jp.key == null ? null : <kbd className="pjkey">{jp.key}</kbd>}
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
{p.results.length === 0 ? <div className="prow none">no match</div> : null}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// a row of small boxes that stays one row at any width: the ones that do not
|
|
2
|
+
// fit fold into a `+N` box at its end, whose hover lists them. An invisible
|
|
3
|
+
// copy of every box, out of the flow, gives their widths, so what shows is
|
|
4
|
+
// decided before the paint and never wraps. Each box's hover is its title.
|
|
5
|
+
import { observer, useLocalObservable } from 'mobx-react-lite'
|
|
6
|
+
import { useLayoutEffect, useRef, type ReactNode } from 'react'
|
|
7
|
+
import { Tip } from './floating.tsx'
|
|
8
|
+
|
|
9
|
+
export type PillItem = { key: string; node: ReactNode; title: string }
|
|
10
|
+
|
|
11
|
+
/** how many boxes of these widths fit in `avail`, a `+N` box of `plusW`
|
|
12
|
+
* taking its room as soon as one does not */
|
|
13
|
+
export function fitCount(widths: number[], avail: number, plusW: number, gap: number): number {
|
|
14
|
+
const all = widths.reduce((a, w) => a + w, 0) + gap * Math.max(0, widths.length - 1)
|
|
15
|
+
if (all <= avail) return widths.length
|
|
16
|
+
let used = plusW
|
|
17
|
+
for (let i = 0; i < widths.length; i++) {
|
|
18
|
+
used += widths[i]! + gap
|
|
19
|
+
if (used > avail) return i
|
|
20
|
+
}
|
|
21
|
+
return widths.length
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = (text: string): ReactNode => text.split('\n').map((l, i) => <div key={i}>{l}</div>)
|
|
25
|
+
|
|
26
|
+
export const PillRow = observer(({ items, className }: { items: PillItem[]; className?: string }) => {
|
|
27
|
+
const st = useLocalObservable(() => ({ n: Number.POSITIVE_INFINITY, set(v: number) { this.n = v } }))
|
|
28
|
+
const box = useRef<HTMLDivElement | null>(null)
|
|
29
|
+
const probe = useRef<HTMLDivElement | null>(null)
|
|
30
|
+
const measure = (): void => {
|
|
31
|
+
const b = box.current
|
|
32
|
+
const p = probe.current
|
|
33
|
+
if (b == null || p == null) return
|
|
34
|
+
const kids = [...p.children] as HTMLElement[]
|
|
35
|
+
const plus = kids.pop()
|
|
36
|
+
if (plus == null) return
|
|
37
|
+
const gap = Number.parseFloat(getComputedStyle(b).columnGap) || 0
|
|
38
|
+
st.set(fitCount(kids.map((k) => k.getBoundingClientRect().width), b.clientWidth, plus.getBoundingClientRect().width, gap))
|
|
39
|
+
}
|
|
40
|
+
// after every render: the boxes' words change with the clock
|
|
41
|
+
useLayoutEffect(measure)
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
const b = box.current
|
|
44
|
+
if (b == null || typeof ResizeObserver === 'undefined') return
|
|
45
|
+
const ro = new ResizeObserver(() => measure())
|
|
46
|
+
ro.observe(b)
|
|
47
|
+
return () => ro.disconnect()
|
|
48
|
+
}, [])
|
|
49
|
+
const n = Math.min(st.n, items.length)
|
|
50
|
+
const hidden = items.slice(n)
|
|
51
|
+
return (
|
|
52
|
+
<div ref={box} className={`pchips${className == null ? '' : ` ${className}`}`}>
|
|
53
|
+
{items.slice(0, n).map((it) => <Tip key={it.key} className="pchipw" content={lines(it.title)}>{it.node}</Tip>)}
|
|
54
|
+
{hidden.length === 0 ? null : (
|
|
55
|
+
<Tip className="pchipw" content={hidden.map((h) => <div key={h.key}>{lines(h.title)}</div>)}>
|
|
56
|
+
<span className="pchip dim">+{hidden.length}</span>
|
|
57
|
+
</Tip>
|
|
58
|
+
)}
|
|
59
|
+
<div ref={probe} className="pchips pchipprobe" aria-hidden="true">
|
|
60
|
+
{items.map((it) => <span key={it.key} className="pchipw">{it.node}</span>)}
|
|
61
|
+
<span className="pchip dim">+{items.length}</span>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// the planned page's state: the machine's one plan as the daemon answers it,
|
|
2
|
+
// as ONE list ordered by time (now, next), filtered by a text that
|
|
3
|
+
// matches the project or the check. A check that only waits for a save has no
|
|
4
|
+
// time, so it is not listed. The order and every word a row says are pure
|
|
5
|
+
// functions, so a test pins them rather than a screenshot.
|
|
6
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
7
|
+
import type { PlannedAnswer } from '../../home/home-routes.ts'
|
|
8
|
+
import type { PlanRow } from '../../plan/planner.ts'
|
|
9
|
+
import { sectionOf, timeline, titleWords, waitingRows, waitingWords } from '../../plan/plan-timeline.ts'
|
|
10
|
+
import type { HomeSt } from './home-st.ts'
|
|
11
|
+
|
|
12
|
+
export const PLANNED_POLL_MS = 1_000
|
|
13
|
+
|
|
14
|
+
export * from '../../plan/plan-timeline.ts'
|
|
15
|
+
|
|
16
|
+
export class PlannedSt {
|
|
17
|
+
data: PlannedAnswer | null = null
|
|
18
|
+
error = ''
|
|
19
|
+
/** the text filter, the `?q=` of /planned: every word must be in the project or the check */
|
|
20
|
+
query = ''
|
|
21
|
+
/** the checks waiting for their repo to be active, listed under their line */
|
|
22
|
+
waitingOpen = false
|
|
23
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
24
|
+
|
|
25
|
+
constructor(private st: HomeSt) {
|
|
26
|
+
makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false, data: observableRef })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get rows() {
|
|
30
|
+
return timeline(this.data?.rows ?? [], this.query)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** the checks waiting for their repo to be active, the filter applied */
|
|
34
|
+
get waiting(): PlanRow[] {
|
|
35
|
+
return waitingRows(this.data?.rows ?? [], this.query)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** their one line, or '' */
|
|
39
|
+
get waitingLine(): string {
|
|
40
|
+
return waitingWords(this.data?.rows ?? [], this.query)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
toggleWaiting(): void {
|
|
44
|
+
this.waitingOpen = !this.waitingOpen
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** how many checks have a time of their own, filter aside */
|
|
48
|
+
get timed(): number {
|
|
49
|
+
return (this.data?.rows ?? []).filter((r) => sectionOf(r) != null).length
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the address of the plan with this filter */
|
|
53
|
+
get address(): string {
|
|
54
|
+
const q = this.query.trim()
|
|
55
|
+
return q === '' ? '/planned' : `/planned?q=${encodeURIComponent(q)}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** a keystroke in the filter: the url follows, in place */
|
|
59
|
+
setQuery(q: string): void {
|
|
60
|
+
this.query = q
|
|
61
|
+
if (this.st.page === 'planned') window.history.replaceState(null, '', this.address)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** the project name: that repo, in place */
|
|
65
|
+
openRepo(repo: string): void {
|
|
66
|
+
this.st.select(repo)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
openCheck(repo: string, check: string): void {
|
|
70
|
+
this.st.openCheck(repo, check)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** the check's title as its repo's hub names it, once that hub was read */
|
|
74
|
+
titleOf(repo: string, check: string): string | null {
|
|
75
|
+
const t = this.st.repos.get(repo)?.cols.find('checks', `check:${check}`)?.title ?? null
|
|
76
|
+
return titleWords(t, check)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async poll(): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch('/api/home/planned.json', { signal: AbortSignal.timeout(8_000) })
|
|
82
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
83
|
+
const j = (await r.json()) as PlannedAnswer
|
|
84
|
+
runInAction(() => { this.data = j; this.error = '' })
|
|
85
|
+
} catch (e) {
|
|
86
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async run(row: PlanRow): Promise<void> {
|
|
91
|
+
const r = await fetch('/api/home/planned/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo: row.repo, check: row.check }) })
|
|
92
|
+
if (!r.ok) runInAction(() => { this.error = `run ${row.check}: ${r.status} ${r.statusText}` })
|
|
93
|
+
await this.poll()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** polls while the plan is the page shown */
|
|
97
|
+
start(): void {
|
|
98
|
+
if (this.timer != null) return
|
|
99
|
+
void this.poll()
|
|
100
|
+
this.timer = setInterval(() => void this.poll(), PLANNED_POLL_MS)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop(): void {
|
|
104
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
105
|
+
this.timer = null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `/planned`: every check the machine runs on its own time, in ONE list
|
|
2
|
+
// ordered by time under two small labels (now, next), then one quiet line
|
|
3
|
+
// counting the checks that wait for their repo to be active, which opens on a
|
|
4
|
+
// click. A row says
|
|
5
|
+
// when, which project and check, and one mark; everything else (the rule,
|
|
6
|
+
// the reason, the last result, the inactive policy, an error, run now) is in
|
|
7
|
+
// the card that floats over the row on hover. The project opens its repo in
|
|
8
|
+
// place, the check opens its own page in that repo.
|
|
9
|
+
import { observer } from 'mobx-react-lite'
|
|
10
|
+
import type { CSSProperties, MouseEvent, ReactNode } from 'react'
|
|
11
|
+
import type { PlanRow } from '../../plan/planner.ts'
|
|
12
|
+
import { Tip } from './floating.tsx'
|
|
13
|
+
import type { HomeSt } from './home-st.ts'
|
|
14
|
+
import { checkAddress, plainClick, repoPage } from './home-st.ts'
|
|
15
|
+
import { LAST_TONE, markOf, reasonWords, whenWords, type Section } from './planned-st.ts'
|
|
16
|
+
import { ageWords, stampWords } from './time-ago.tsx'
|
|
17
|
+
|
|
18
|
+
/** a link that works as one (a new tab on cmd+click) and moves in place on a plain click */
|
|
19
|
+
const InPlace = ({ href, className, go, children }: { href: string; className: string; go: () => void; children: ReactNode }) => (
|
|
20
|
+
<a className={className} href={href} onClick={(e: MouseEvent) => {
|
|
21
|
+
if (!plainClick(e)) return
|
|
22
|
+
e.preventDefault()
|
|
23
|
+
go()
|
|
24
|
+
}}>{children}</a>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
/** the repo's rail icon, or nothing */
|
|
28
|
+
const Face = observer(({ app, repo }: { app: HomeSt; repo: string }) => {
|
|
29
|
+
const icon = app.projects.list.find((p) => p.name === repo)?.icon ?? null
|
|
30
|
+
return icon == null ? null : <img className="plface" src={icon} alt="" width={14} height={14} draggable={false} />
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** one line of the card: a dim label and its value */
|
|
34
|
+
const Line = ({ k, children }: { k: string; children: ReactNode }) => (
|
|
35
|
+
<div className="plkv"><span>{k}</span><span>{children}</span></div>
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const Card = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
|
|
39
|
+
const st = app.planned
|
|
40
|
+
const title = st.titleOf(r.repo, r.check)
|
|
41
|
+
const running = r.state === 'running'
|
|
42
|
+
return (
|
|
43
|
+
<div className="plcardin">
|
|
44
|
+
<div className="plct">{r.repo} · {r.check}{title != null ? <span className="pldim"> {title}</span> : null}</div>
|
|
45
|
+
{r.error != null ? <div className="plerr">{r.error}</div> : null}
|
|
46
|
+
<Line k="when">{r.dueAt == null ? whenWords(r, now) : `${whenWords(r, now)}, ${stampWords(r.dueAt)}`}</Line>
|
|
47
|
+
{r.reason != null ? <Line k="why">{reasonWords(r)}</Line> : null}
|
|
48
|
+
<Line k="rule">{r.rule}</Line>
|
|
49
|
+
<Line k="inactive">{r.active ? r.inactive : `repo inactive: ${r.inactive}`}</Line>
|
|
50
|
+
<Line k="last">
|
|
51
|
+
{r.last == null ? 'never ran' : (
|
|
52
|
+
<span className={`l-${LAST_TONE[r.last.status]}`}>
|
|
53
|
+
{r.last.status}, {ageWords(r.last.at, now)}{r.last.streak > 1 ? `, ${r.last.streak} in a row` : ''}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</Line>
|
|
57
|
+
<button className="plgo" disabled={running} onClick={() => void st.run(r)}>{running ? 'running' : 'run now'}</button>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const Row = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
|
|
63
|
+
const st = app.planned
|
|
64
|
+
const m = markOf(r)
|
|
65
|
+
const colour = { '--pc': app.projects.identityOf(r.repo).colorDark } as CSSProperties
|
|
66
|
+
return (
|
|
67
|
+
<Tip className={`plr m-${m.mark}`} content={<Card app={app} r={r} now={now} />} tipClass="plcard" placement="right-start" fallbacks={['bottom-end', 'top-end']} interactive>
|
|
68
|
+
<span className="plwhen">{whenWords(r, now)}</span>
|
|
69
|
+
<span className="plwhat">
|
|
70
|
+
<InPlace className="plrepo" href={repoPage(r.repo)} go={() => st.openRepo(r.repo)}>
|
|
71
|
+
<Face app={app} repo={r.repo} />
|
|
72
|
+
<span style={colour}>{r.repo}</span>
|
|
73
|
+
</InPlace>
|
|
74
|
+
<span className="pldim"> · </span>
|
|
75
|
+
<InPlace className="plcheck" href={checkAddress(r.repo, r.check)} go={() => st.openCheck(r.repo, r.check)}>{r.check}</InPlace>
|
|
76
|
+
</span>
|
|
77
|
+
<span className="plmark">
|
|
78
|
+
{m.mark === 'running' ? <i className="spin" /> : <i className={`plsym s-${m.mark}`} />}
|
|
79
|
+
{m.words !== '' ? <span>{m.words}</span> : null}
|
|
80
|
+
</span>
|
|
81
|
+
</Tip>
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
export const PlannedView = observer(({ app }: { app: HomeSt }) => {
|
|
86
|
+
const st = app.planned
|
|
87
|
+
const d = st.data
|
|
88
|
+
const now = d?.now ?? Date.now()
|
|
89
|
+
let prev: Section | null = null
|
|
90
|
+
return (
|
|
91
|
+
<div className="hall planned">
|
|
92
|
+
<input className="plfind" type="search" placeholder="filter" aria-label="filter by project or check" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
|
|
93
|
+
{st.error !== '' ? <div className="hnote">{st.error}</div> : null}
|
|
94
|
+
{(d?.held ?? []).map((h) => <div key={h} className="pldim">{h}</div>)}
|
|
95
|
+
{(d?.errors ?? []).map((e) => <div key={e.repo} className="hnote">{e.repo}: {e.error}</div>)}
|
|
96
|
+
<div className="pllist">
|
|
97
|
+
{st.rows.flatMap(({ section, row }) => {
|
|
98
|
+
const item = <Row key={`${row.repo}\0${row.check}`} app={app} r={row} now={now} />
|
|
99
|
+
if (section === prev) return [item]
|
|
100
|
+
prev = section
|
|
101
|
+
return [<div key={`s:${section}`} className="plsec">{section}</div>, item]
|
|
102
|
+
})}
|
|
103
|
+
{d == null ? <div className="pldim">loading…</div> : st.rows.length === 0 ? <div className="pldim">{st.timed === 0 ? 'nothing is planned' : 'nothing matches'}</div> : null}
|
|
104
|
+
</div>
|
|
105
|
+
{st.waitingLine !== '' ? (
|
|
106
|
+
<div className="plwait">
|
|
107
|
+
<button className="plwaitline" aria-expanded={st.waitingOpen} onClick={() => st.toggleWaiting()}>{st.waitingOpen ? '▾' : '▸'} {st.waitingLine}</button>
|
|
108
|
+
{st.waitingOpen ? <div className="pllist">{st.waiting.map((r) => <Row key={`${r.repo}\0${r.check}`} app={app} r={r} now={now} />)}</div> : null}
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
<p className="hdim">in the console: shipkit planned</p>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
})
|