rvlib-shipkit 1.1.0 → 1.2.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 +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- 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/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -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 +208 -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 +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -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 +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -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/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- 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 +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- 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 +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- 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/{hub-st.ts → repo-st.ts} +155 -84
- 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/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- 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 +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -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/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The header over every page, and the live strip under it. The header is the
|
|
2
|
-
// repo name
|
|
3
|
-
//
|
|
2
|
+
// repo name (on the home the strip above names it), one link per core page,
|
|
3
|
+
// and on the right the repo's own controls and the two runs that are not one
|
|
4
|
+
// check with what is running now. The strip is the one run chart
|
|
4
5
|
// (timeline-chart.tsx) over a rolling window that ends now: folded, a thin
|
|
5
6
|
// bar of the last two minutes; unfolded, the full chart over ten, zoomable.
|
|
6
7
|
// Each fold has ONE height whatever runs, so a run never moves the page.
|
|
@@ -8,14 +9,15 @@ import { observer } from 'mobx-react-lite'
|
|
|
8
9
|
import { RUNS_ID } from '../../collections/collection-types.ts'
|
|
9
10
|
import { Help } from './help.tsx'
|
|
10
11
|
import { openWikis, WIKIS } from './wikis-view.tsx'
|
|
11
|
-
import type {
|
|
12
|
+
import type { RepoSt } from './repo-st.ts'
|
|
12
13
|
import { byWords, TimelineChart } from './timeline-chart.tsx'
|
|
14
|
+
import { RepoControls } from './home-strip.tsx'
|
|
13
15
|
|
|
14
16
|
export type CorePage = { id: string; label: string; on: boolean; go: () => void }
|
|
15
17
|
|
|
16
18
|
/** the pages reached for most, in the order they are reached for. A page this
|
|
17
19
|
* repo does not have is not a link */
|
|
18
|
-
export function corePages(app:
|
|
20
|
+
export function corePages(app: RepoSt): CorePage[] {
|
|
19
21
|
const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
|
|
20
22
|
const sel = app.sel
|
|
21
23
|
const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
|
|
@@ -25,7 +27,7 @@ export function corePages(app: HubSt): CorePage[] {
|
|
|
25
27
|
// always here
|
|
26
28
|
out.push({ id: 'runs', label: 'runs', on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
|
|
27
29
|
if (has('asks')) out.push({ id: 'asks', label: 'asks', on: onCol('asks'), go: () => app.showCol('asks') })
|
|
28
|
-
const firstCat = app.media.assets[0]?.cat
|
|
30
|
+
const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
|
|
29
31
|
if (firstCat != null) out.push({ id: 'media', label: 'media', on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
|
|
30
32
|
if (has(WIKIS)) out.push({ id: WIKIS, label: 'wikis', on: onCol(WIKIS), go: () => openWikis(app) })
|
|
31
33
|
return out
|
|
@@ -33,14 +35,15 @@ export function corePages(app: HubSt): CorePage[] {
|
|
|
33
35
|
|
|
34
36
|
/** what is running, in one line: the hub's own run first, since it says the
|
|
35
37
|
* most, else the newest run the record has in flight */
|
|
36
|
-
const RunState = observer(({ app }: { app:
|
|
38
|
+
const RunState = observer(({ app }: { app: RepoSt }) => {
|
|
37
39
|
const own = app.checks.strip
|
|
38
40
|
const run = app.live.view?.runs[0]
|
|
39
41
|
const text = own !== '' ? own : run != null ? `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}` : 'idle'
|
|
40
42
|
const going = app.checks.busy || run != null
|
|
41
43
|
return (
|
|
42
44
|
<span className={`hstate${going ? ' on' : ''}`} title={text}>
|
|
43
|
-
{
|
|
45
|
+
{/* the spinner's slot is always there, so the text never moves */}
|
|
46
|
+
<span className={`spin${going ? '' : ' idle'}`} />
|
|
44
47
|
<span className="htext">{text}</span>
|
|
45
48
|
</span>
|
|
46
49
|
)
|
|
@@ -48,7 +51,7 @@ const RunState = observer(({ app }: { app: HubSt }) => {
|
|
|
48
51
|
|
|
49
52
|
/** what is wrong with the hub itself, one chip per problem. Nothing when all
|
|
50
53
|
* is well: the header states trouble, never health */
|
|
51
|
-
const HubStatus = observer(({ app }: { app:
|
|
54
|
+
const HubStatus = observer(({ app }: { app: RepoSt }) => (
|
|
52
55
|
<>
|
|
53
56
|
{app.cols.hubStatus.map((p) => (
|
|
54
57
|
<span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
|
|
@@ -56,17 +59,22 @@ const HubStatus = observer(({ app }: { app: HubSt }) => (
|
|
|
56
59
|
</>
|
|
57
60
|
))
|
|
58
61
|
|
|
59
|
-
export const HeaderBar = observer(({ app }: { app:
|
|
62
|
+
export const HeaderBar = observer(({ app }: { app: RepoSt }) => {
|
|
60
63
|
const u = app.checks
|
|
61
64
|
return (
|
|
62
65
|
<header className="hdr">
|
|
63
|
-
|
|
66
|
+
{/* on the home the strip names the repo, in its colour */}
|
|
67
|
+
{app.home.machine != null ? null : <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>}
|
|
64
68
|
<nav className="hnav">
|
|
69
|
+
{/* the spinner's slot stays once the repo is read, so the links never move */}
|
|
70
|
+
<span className={`spin${app.cols.loaded ? ' idle' : ''}`} title={app.cols.loaded ? undefined : app.cols.cached ? 'showing what this browser kept, reading the repo' : 'reading the repo'} />
|
|
65
71
|
{corePages(app).map((p) => (
|
|
66
72
|
<button key={p.id} className={p.on ? 'on' : ''} onClick={p.go}>{p.label}</button>
|
|
67
73
|
))}
|
|
68
74
|
</nav>
|
|
69
75
|
<span className="sp" />
|
|
76
|
+
<RepoControls app={app.home} name={app.key} />
|
|
77
|
+
<ProcessChips app={app} />
|
|
70
78
|
<HubStatus app={app} />
|
|
71
79
|
<RunState app={app} />
|
|
72
80
|
<button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
|
|
@@ -75,6 +83,30 @@ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
|
|
|
75
83
|
)
|
|
76
84
|
})
|
|
77
85
|
|
|
86
|
+
/** one switch per process the repo declares (serve.processes): its dot, its
|
|
87
|
+
* id, a click starts or stops it, and a link once it serves */
|
|
88
|
+
export const ProcessChips = observer(({ app }: { app: RepoSt }) => {
|
|
89
|
+
const s = app.processes
|
|
90
|
+
if (s.list.length === 0) return null
|
|
91
|
+
return (
|
|
92
|
+
<span className="hprocs">
|
|
93
|
+
{s.list.map((p) => {
|
|
94
|
+
const on = p.status === 'up' || p.status === 'starting'
|
|
95
|
+
const title = [p.cmd, p.note, p.external ? 'started outside the hub' : null, p.tail.at(-1)].filter((x) => x != null && x !== '').join('\n')
|
|
96
|
+
return (
|
|
97
|
+
<span key={p.id} className="hproc">
|
|
98
|
+
<button className={`ghost ${p.status}`} title={title} disabled={p.status === 'stopping' || (on && p.external)}
|
|
99
|
+
onClick={() => void s.act(p.id, on ? 'stop' : 'start')}>
|
|
100
|
+
<i className={`pdot ${p.status}`} />{p.id}
|
|
101
|
+
</button>
|
|
102
|
+
{p.url != null && p.status === 'up' ? <a href={p.url} target="_blank" rel="noreferrer" title={p.url}>↗</a> : null}
|
|
103
|
+
</span>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</span>
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
78
110
|
/** the key that folds and unfolds the strip, printed on its chevron */
|
|
79
111
|
export const LIVE_KEY = 'l'
|
|
80
112
|
|
|
@@ -82,7 +114,7 @@ export const LIVE_KEY = 'l'
|
|
|
82
114
|
export const LIVE_ROW_H = 30
|
|
83
115
|
|
|
84
116
|
/** what the strip is, in the (?) beside its chevron */
|
|
85
|
-
export const LIVE_HELP = "every check shipkit runs, whoever started it: this hub, an agent in a terminal, the
|
|
117
|
+
export const LIVE_HELP = "every check shipkit runs, whoever started it: this hub, an agent in a terminal, the plan, here and in the other repos. Folded, the last two minutes: a bar grows at the right while its check runs and slides left once it ends, in its verdict colour. Unfolded, the whole record, opened on the last ten minutes: the run bands, the time axis, and over the lanes how many checks ran at once, with the cap dashed and the stretches past it in orange. It follows now: the right edge is the present and the view slides with it. The wheel zooms and a drag pans; leaving the right edge stops the view where it is so a bar can be read, and ● live, the k key or panning back to the right edge follows now again. While a tooltip is open the view stands still. fit shows every run on record. A lane at the bottom is another repo with something running; a click opens its hub when it serves. The count is the checks in flight against the runner's cap"
|
|
86
118
|
|
|
87
119
|
const Chevron = ({ open }: { open: boolean }) => (
|
|
88
120
|
<svg className={open ? 'cvt open' : 'cvt'} viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
|
|
@@ -90,7 +122,7 @@ const Chevron = ({ open }: { open: boolean }) => (
|
|
|
90
122
|
</svg>
|
|
91
123
|
)
|
|
92
124
|
|
|
93
|
-
export const LiveStrip = observer(({ app }: { app:
|
|
125
|
+
export const LiveStrip = observer(({ app }: { app: RepoSt }) => {
|
|
94
126
|
const l = app.live
|
|
95
127
|
const v = l.view
|
|
96
128
|
const run = v?.runs[0]
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// every check has its own page (checks-svc.ts owns the run itself). This card
|
|
6
6
|
// exists so the whole text of a run stays reachable.
|
|
7
7
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
8
|
-
import type {
|
|
8
|
+
import type { RepoSt } from './repo-st.ts'
|
|
9
9
|
|
|
10
10
|
export class HealthSvc {
|
|
11
11
|
statusMd: string | null = null
|
|
12
12
|
/** the lines of the run in flight, newest last */
|
|
13
13
|
live: string[] = []
|
|
14
14
|
|
|
15
|
-
constructor(private st:
|
|
15
|
+
constructor(private st: RepoSt) {
|
|
16
16
|
makeAutoObservable<this, 'st'>(this, { st: false })
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -25,7 +25,7 @@ export class HealthSvc {
|
|
|
25
25
|
|
|
26
26
|
async load(): Promise<void> {
|
|
27
27
|
try {
|
|
28
|
-
const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
|
|
28
|
+
const s = (await (await fetch(this.st.url('/api/status.json'))).json()) as { statusMd: string | null }
|
|
29
29
|
runInAction(() => { this.statusMd = s.statusMd })
|
|
30
30
|
} catch (e) {
|
|
31
31
|
runInAction(() => { this.statusMd = `FAIL status fetch broke: ${(e as Error).message}` })
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// THE help affordance, once, for every surface of the hub: a small `(?)` that
|
|
2
|
-
// shows one sentence
|
|
3
|
-
//
|
|
2
|
+
// shows one sentence when it is hovered or focused, placed by floating-ui
|
|
3
|
+
// (floating.tsx) above it, or wherever it fits.
|
|
4
4
|
//
|
|
5
5
|
// A term lives in TERMS rather than in the component that shows it, so the
|
|
6
6
|
// same word is explained the same way wherever it appears.
|
|
7
7
|
|
|
8
|
+
import { Tip } from './floating.tsx'
|
|
9
|
+
|
|
8
10
|
export const TERMS: Record<string, string> = {
|
|
9
11
|
always: 'it runs on every gate and every health pass',
|
|
10
12
|
release: 'it runs on the release path only: the full gate and the health pass run it, the quick gate does not',
|
|
@@ -23,8 +25,9 @@ export const TERMS: Record<string, string> = {
|
|
|
23
25
|
|
|
24
26
|
/** one sentence, either given here or named by its term. `text` takes a node
|
|
25
27
|
* so a legend can ride in the same bubble as a sentence */
|
|
26
|
-
export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement => {
|
|
29
|
+
const c = text ?? (term != null ? TERMS[term] ?? term : '')
|
|
30
|
+
// the sentence also rides inside the glyph, out of sight, so a screen
|
|
31
|
+
// reader and a search of the page find it without a hover
|
|
32
|
+
return <Tip className="hlp" tabIndex={0} tipClass="hbub" content={c}>?<span className="vh">{c}</span></Tip>
|
|
33
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// HomeSt: THE root of the page (build/app-state-tree), exposed as window.app.
|
|
2
|
+
// One tree for every page the home serves, '/', '/planned' and '/r/<repo>/':
|
|
3
|
+
// the machine, the project list, the plan, and one RepoSt per repo visited, built on
|
|
4
|
+
// the first visit and kept for the life of the page (same name, same
|
|
5
|
+
// instance), so a repo you come back to shows what it showed. Only the shown
|
|
6
|
+
// repo polls. Every other repo whose hub is up is read ahead in the
|
|
7
|
+
// background, one at a time, so its first visit has its data at once. A hub serving on its own is the same tree with no machine and
|
|
8
|
+
// one repo at base ''.
|
|
9
|
+
//
|
|
10
|
+
// the one constraint a reader breaks first: the url and the tree move
|
|
11
|
+
// together, and only `show` moves the page. A list click, a link, back,
|
|
12
|
+
// forward and a hand edited address all land there.
|
|
13
|
+
import { makeAutoObservable, observableShallow, reaction, type IReactionDisposer } from 'mobx'
|
|
14
|
+
import { currentHash } from './hash-route.ts'
|
|
15
|
+
import { MachineSt, type MachineOpts } from './machine-st.ts'
|
|
16
|
+
import { LayoutSt } from './layout-st.ts'
|
|
17
|
+
import { PaletteSt } from './palette-st.ts'
|
|
18
|
+
import { PlannedSt } from './planned-st.ts'
|
|
19
|
+
import { ProjectsSvc } from './projects-svc.ts'
|
|
20
|
+
import { parseFilter, RepoSt } from './repo-st.ts'
|
|
21
|
+
|
|
22
|
+
/** the longest one repo's read ahead holds the next one back */
|
|
23
|
+
export const PREFETCH_WAIT_MS = 10_000
|
|
24
|
+
|
|
25
|
+
/** what the page shows: the machine overview, the plan, or one repo */
|
|
26
|
+
export type Page = 'overview' | 'planned' | 'repo'
|
|
27
|
+
|
|
28
|
+
/** where a repo's hub answers on the home */
|
|
29
|
+
export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
|
|
30
|
+
/** the page of a repo on the home */
|
|
31
|
+
export const repoPage = (name: string): string => `${repoBase(name)}/`
|
|
32
|
+
|
|
33
|
+
/** the page an address on the home names */
|
|
34
|
+
export function routeOf(pathname: string): { page: Page; repo: string | null } {
|
|
35
|
+
const m = /^\/r\/([^/]+)/.exec(pathname)
|
|
36
|
+
if (m != null) return { page: 'repo', repo: decodeURIComponent(m[1]!) }
|
|
37
|
+
if (/^\/planned\/?$/.test(pathname)) return { page: 'planned', repo: null }
|
|
38
|
+
return { page: 'overview', repo: null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** a plain left click: anything else keeps the browser's own behaviour
|
|
42
|
+
* (a new tab, a new window, a download) */
|
|
43
|
+
export const plainClick = (e: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean =>
|
|
44
|
+
e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
|
|
45
|
+
|
|
46
|
+
export type HomeOpts = MachineOpts & {
|
|
47
|
+
/** true on the home's web; false on a hub serving on its own */
|
|
48
|
+
home: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class HomeSt {
|
|
52
|
+
/** the machine, on the home; null on a hub of its own */
|
|
53
|
+
machine: MachineSt | null
|
|
54
|
+
/** the list of every repo on this disk */
|
|
55
|
+
projects: ProjectsSvc
|
|
56
|
+
/** the plan, polled while it is the page */
|
|
57
|
+
planned: PlannedSt
|
|
58
|
+
/** the one picker, cmd+k */
|
|
59
|
+
palette: PaletteSt
|
|
60
|
+
/** the widths of the project list and a repo's sidebar */
|
|
61
|
+
layout: LayoutSt
|
|
62
|
+
page: Page
|
|
63
|
+
/** the key of the shown repo: its name on the home, '' for the one repo
|
|
64
|
+
* of a hub of its own. null on the overview and the plan */
|
|
65
|
+
selected: string | null = null
|
|
66
|
+
/** every repo visited, by key, kept for the life of the page */
|
|
67
|
+
repos = new Map<string, RepoSt>()
|
|
68
|
+
private disposers: IReactionDisposer[] = []
|
|
69
|
+
/** a read ahead is going: one repo at a time, never two loops */
|
|
70
|
+
private prefetching = false
|
|
71
|
+
private readonly onUrl = (): void => this.fromUrl()
|
|
72
|
+
|
|
73
|
+
constructor(o: HomeOpts) {
|
|
74
|
+
this.projects = new ProjectsSvc()
|
|
75
|
+
this.planned = new PlannedSt(this)
|
|
76
|
+
this.palette = new PaletteSt(this)
|
|
77
|
+
this.layout = new LayoutSt()
|
|
78
|
+
this.machine = o.home ? new MachineSt(this, o) : null
|
|
79
|
+
this.page = o.home ? 'overview' : 'repo'
|
|
80
|
+
makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, palette: false, layout: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
|
|
81
|
+
const m = this.machine
|
|
82
|
+
if (m != null) {
|
|
83
|
+
this.projects.onSelect = (name) => this.select(name)
|
|
84
|
+
this.projects.onPlace = (p) => this.navigate(p === 'overview' ? '/' : '/planned')
|
|
85
|
+
this.projects.dotOf = (name) => m.dotOf(name)
|
|
86
|
+
this.projects.warnOf = (name) => m.warnOf(name)
|
|
87
|
+
} else {
|
|
88
|
+
this.projects.onSelect = (name) => void this.openInHome(name)
|
|
89
|
+
const own = this.repo('')
|
|
90
|
+
own.markSeen()
|
|
91
|
+
this.selected = own.key
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** the repo on screen, or null */
|
|
96
|
+
get shown(): RepoSt | null {
|
|
97
|
+
return this.page !== 'repo' || this.selected == null ? null : this.repos.get(this.selected) ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** every repo shown once, in visit order: each keeps its own mounted
|
|
101
|
+
* region. A repo only read ahead has no region yet */
|
|
102
|
+
get visited(): RepoSt[] {
|
|
103
|
+
return [...this.repos.values()].filter((r) => r.seen)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get title(): string {
|
|
107
|
+
if (this.page === 'overview') return 'home'
|
|
108
|
+
if (this.page === 'planned') return 'planned'
|
|
109
|
+
const r = this.shown
|
|
110
|
+
return r == null || r.name === '' ? 'hub' : `${r.name} · hub`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** the repo of this key, built on its first visit and never again */
|
|
114
|
+
repo(key: string): RepoSt {
|
|
115
|
+
let r = this.repos.get(key)
|
|
116
|
+
if (r == null) {
|
|
117
|
+
r = new RepoSt(this, key, this.machine == null ? '' : repoBase(key))
|
|
118
|
+
this.repos.set(key, r)
|
|
119
|
+
// what this browser kept of it paints before its hub answers
|
|
120
|
+
void r.cols.restore()
|
|
121
|
+
}
|
|
122
|
+
return r
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
boot(): void {
|
|
126
|
+
this.machine?.boot()
|
|
127
|
+
this.disposers.push(reaction(() => this.title, (t) => { document.title = t }, { fireImmediately: true }))
|
|
128
|
+
// back, forward, a pasted link and a hand edited hash all arrive
|
|
129
|
+
// here; fromUrl is idempotent, so the two events firing together
|
|
130
|
+
// for one move cost nothing
|
|
131
|
+
window.addEventListener('popstate', this.onUrl)
|
|
132
|
+
window.addEventListener('hashchange', this.onUrl)
|
|
133
|
+
this.fromUrl()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
dispose(): void {
|
|
137
|
+
for (const d of this.disposers) d()
|
|
138
|
+
this.disposers = []
|
|
139
|
+
window.removeEventListener('popstate', this.onUrl)
|
|
140
|
+
window.removeEventListener('hashchange', this.onUrl)
|
|
141
|
+
this.machine?.stop()
|
|
142
|
+
this.planned.stop()
|
|
143
|
+
for (const r of this.repos.values()) r.dispose()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** a list click, a repo row: that repo, in place, as a history entry */
|
|
147
|
+
select(name: string): void {
|
|
148
|
+
this.show('repo', name, 'push')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** a link to a page of the home: the same page, in place */
|
|
152
|
+
navigate(path: string): void {
|
|
153
|
+
const u = new URL(path, 'http://home.invalid')
|
|
154
|
+
const r = routeOf(u.pathname)
|
|
155
|
+
if (r.page === 'planned') this.planned.query = u.searchParams.get('q') ?? ''
|
|
156
|
+
this.show(r.page, r.repo, 'push')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** the url decides the page: the first load, back, forward and a hand
|
|
160
|
+
* edited address all land here */
|
|
161
|
+
fromUrl(): void {
|
|
162
|
+
if (this.machine == null) {
|
|
163
|
+
this.shown?.applyHash()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const r = routeOf(location.pathname)
|
|
167
|
+
// an address from before repo pages had their own path (`/#<repo>`)
|
|
168
|
+
const legacy = r.page === 'overview' ? currentHash() : ''
|
|
169
|
+
if (legacy !== '') return this.show('repo', legacy, 'replace')
|
|
170
|
+
if (r.page === 'planned') this.planned.query = new URLSearchParams(location.search).get('q') ?? ''
|
|
171
|
+
if (r.page === 'repo') this.repo(r.repo!).filter = parseFilter(location.search)
|
|
172
|
+
this.show(r.page, r.repo, 'none')
|
|
173
|
+
this.shown?.applyHash()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** the one place the page changes. A repo left behind pauses and keeps
|
|
177
|
+
* everything; the one shown resumes once its hub answers */
|
|
178
|
+
private show(page: Page, repo: string | null, how: 'push' | 'replace' | 'none'): void {
|
|
179
|
+
const prev = this.shown
|
|
180
|
+
const next = page === 'repo' && repo != null ? this.repo(repo) : null
|
|
181
|
+
// the page already shown is never a second history entry
|
|
182
|
+
if (how === 'push' && page === this.page && next === prev && page !== 'planned') how = 'none'
|
|
183
|
+
if (prev != null && prev !== next) prev.pause()
|
|
184
|
+
next?.markSeen()
|
|
185
|
+
this.page = page
|
|
186
|
+
this.selected = next?.key ?? null
|
|
187
|
+
this.projects.selected = this.selected
|
|
188
|
+
this.projects.place = page === 'repo' ? null : page
|
|
189
|
+
if (how !== 'none') {
|
|
190
|
+
const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : '/'
|
|
191
|
+
if (how === 'push') history.pushState(null, '', to)
|
|
192
|
+
else history.replaceState(null, '', to)
|
|
193
|
+
}
|
|
194
|
+
if (page === 'planned') this.planned.start()
|
|
195
|
+
else this.planned.stop()
|
|
196
|
+
if (next != null && next !== prev && this.machine?.data != null) void this.machine.hub(next.key, 'ensure')
|
|
197
|
+
this.wake()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** the shown repo polls once its hub answers, or at once on a hub of its
|
|
201
|
+
* own. One that started keeps going when its hub goes away, and says so */
|
|
202
|
+
wake(): void {
|
|
203
|
+
const r = this.shown
|
|
204
|
+
if (r == null) return
|
|
205
|
+
if (this.machine == null || r.started || (this.machine.fresh && this.machine.worker(r.key)?.status === 'up')) r.resume()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** read ahead the first screen of every repo whose hub is up, in list
|
|
209
|
+
* order, one at a time, once the repo shown has its own. A repo read
|
|
210
|
+
* once is not read again: it polls when it shows */
|
|
211
|
+
async prefetch(): Promise<void> {
|
|
212
|
+
const m = this.machine
|
|
213
|
+
if (m?.data == null || !m.fresh || this.prefetching) return
|
|
214
|
+
const shown = this.shown
|
|
215
|
+
if (shown != null && !shown.cols.loaded) return
|
|
216
|
+
this.prefetching = true
|
|
217
|
+
try {
|
|
218
|
+
for (const p of this.projects.list) {
|
|
219
|
+
// a repo shown meanwhile has its own first screen to read:
|
|
220
|
+
// it goes first, and the next machine poll resumes this loop
|
|
221
|
+
const now = this.shown
|
|
222
|
+
if (now != null && !now.cols.loaded) return
|
|
223
|
+
if (m.worker(p.name)?.status !== 'up') continue
|
|
224
|
+
const r = this.repo(p.name)
|
|
225
|
+
if (r.started) continue
|
|
226
|
+
// a hub that hangs holds the loop back this long, never forever
|
|
227
|
+
await Promise.race([r.prefetch(), new Promise((ok) => setTimeout(ok, PREFETCH_WAIT_MS))])
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
this.prefetching = false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** a list click on a hub serving on its own: that repo's page on the
|
|
235
|
+
* home when one serves, else the folder in VS Code */
|
|
236
|
+
async openInHome(name: string): Promise<void> {
|
|
237
|
+
const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
|
|
238
|
+
if (home?.up !== true) return this.projects.open(name)
|
|
239
|
+
window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* the home's strip over a hub page, and the overview. hub.css owns every
|
|
2
|
+
token and the base controls: no colour is written here. */
|
|
3
|
+
.hstrip{display:flex;align-items:center;flex-wrap:nowrap;gap:var(--s2);height:32px;flex:none;padding:0 var(--s3);border-bottom:1px solid var(--line);overflow:hidden;white-space:nowrap}
|
|
4
|
+
.hstrip .sp{flex:1}
|
|
5
|
+
.hplace{font-size:12px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;color:var(--dim)}
|
|
6
|
+
.hrepo{display:inline-flex;align-items:center;gap:var(--s2);min-width:0}
|
|
7
|
+
.hrepo img,.hrico{flex:none;width:18px;height:18px;border-radius:4px;object-fit:contain}
|
|
8
|
+
.hrname{font-size:13px;font-weight:650;letter-spacing:.04em}
|
|
9
|
+
.hswatch{display:inline-flex;gap:2px}
|
|
10
|
+
.hswatch input{width:16px;height:16px;padding:0;border:1px solid var(--line);border-radius:3px;background:none;cursor:pointer}
|
|
11
|
+
.hswatch input::-webkit-color-swatch-wrapper{padding:0}
|
|
12
|
+
.hswatch input::-webkit-color-swatch{border:none;border-radius:2px}
|
|
13
|
+
.halert{display:inline-flex;align-items:center;font-size:12px;color:var(--errink)}
|
|
14
|
+
.hctl{display:inline-flex;align-items:center;gap:var(--s2)}
|
|
15
|
+
.hstat,.hflag{display:inline-flex;align-items:center;gap:var(--s1);font-size:12px;color:var(--dim)}
|
|
16
|
+
.herr{font-size:12px;color:var(--errink);max-width:40ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
17
|
+
.hdown{padding:var(--s2) var(--s3);font-size:13px;color:var(--errink);border-bottom:1px solid var(--red)}
|
|
18
|
+
.hdown b{color:var(--red);margin-right:var(--s2)}
|
|
19
|
+
.hdot{display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:var(--s1);background:var(--line)}
|
|
20
|
+
.hdot.up{background:var(--ok)}.hdot.starting,.hdot.stopping{background:var(--warn)}.hdot.crashed{background:var(--red)}
|
|
21
|
+
.hres{display:inline-flex;align-items:center;padding:var(--s0) var(--s2);font-size:12px}
|
|
22
|
+
.hres.down{color:var(--dim)}
|
|
23
|
+
.hproc{display:inline-flex}
|
|
24
|
+
/* a process log on hover, placed by floating-ui (floating.tsx); the pointer
|
|
25
|
+
can move into it to scroll */
|
|
26
|
+
.hpop{z-index:20;margin:0;padding:var(--s2);width:min(80ch,80vw);max-height:40vh;overflow:auto;
|
|
27
|
+
background:var(--panel);border:1px solid var(--line);border-radius:3px;box-shadow:0 6px 20px var(--shadow);font:11px/1.45 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
|
|
28
|
+
.hmon{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(340px,100%),1fr));gap:var(--s3)}
|
|
29
|
+
.hwidget{padding:var(--s2) var(--s3);border:1px solid var(--line);border-radius:3px;font-size:12px;overflow:auto}
|
|
30
|
+
.hwidget h3{margin:0 0 var(--s2);font-size:12px;font-weight:650}
|
|
31
|
+
.hwidget table{border-collapse:collapse}
|
|
32
|
+
.hwidget td{padding:0 var(--s3) 0 0;white-space:nowrap;vertical-align:middle}
|
|
33
|
+
.hbad{color:var(--errink)}
|
|
34
|
+
.hwait{flex:1;min-height:0;padding:var(--s4);color:var(--dim);font-size:13px;overflow:auto}
|
|
35
|
+
.hwait p{margin:0 0 var(--s2)}
|
|
36
|
+
.hlog{margin:var(--s2) 0 0;padding:var(--s2);background:var(--panel);border:1px solid var(--line);border-radius:3px;
|
|
37
|
+
font:11px/1.45 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap;max-height:60vh;overflow:auto}
|
|
38
|
+
.hall{flex:1;min-height:0;overflow:auto;padding:var(--s4)}
|
|
39
|
+
/* the sections one under the other: shipkit, the process cards, the monitor */
|
|
40
|
+
.hstack{display:flex;flex-direction:column;gap:var(--s4)}
|
|
41
|
+
/* one card per global process, as many columns as fit, never a horizontal scroll */
|
|
42
|
+
.hcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:var(--s3)}
|
|
43
|
+
.hcard{display:flex;flex-direction:column;gap:var(--s1);min-width:0;padding:var(--s2) var(--s3);background:var(--panel);border:1px solid var(--line);border-radius:4px;font-size:12px}
|
|
44
|
+
.hchead{display:flex;align-items:center;gap:var(--s2)}
|
|
45
|
+
.hcstate{font-size:12px;color:var(--dim)}
|
|
46
|
+
.hcstate.up{color:var(--ok)}.hcstate.crashed{color:var(--red)}.hcstate.starting,.hcstate.stopping{color:var(--warn)}
|
|
47
|
+
.hcfile{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
48
|
+
.hcurl{color:var(--accent);text-decoration:none;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
49
|
+
.hcurl:hover{text-decoration:underline}
|
|
50
|
+
.hcbtns{display:flex;flex-wrap:wrap;gap:var(--s1);margin-top:var(--s1)}
|
|
51
|
+
.hsec{min-width:0}
|
|
52
|
+
.hsec h2{display:flex;align-items:center;gap:var(--s2);height:28px;margin:0 0 var(--s2);padding-bottom:var(--s1);border-bottom:1px solid var(--line);
|
|
53
|
+
font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600}
|
|
54
|
+
.hsec h2 .sp{flex:1}
|
|
55
|
+
.hsec h2 button{letter-spacing:0;text-transform:none}
|
|
56
|
+
.hico{flex:none;color:var(--accent)}
|
|
57
|
+
.hrow .sp{flex:1}
|
|
58
|
+
.hrow{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2);padding:var(--s1) 0;border-bottom:1px solid var(--line)}
|
|
59
|
+
.hrow .hlog{flex-basis:100%}
|
|
60
|
+
.hlink{display:inline-flex;align-items:center;color:inherit;text-decoration:none;font-size:13px}
|
|
61
|
+
.hlink:hover{text-decoration:underline}
|
|
62
|
+
.hdim{font-size:12px;color:var(--dim)}
|
|
63
|
+
.hnote{font-size:12px;color:var(--warn)}
|
|
64
|
+
/* a filled bar any widget can draw: <span class="wbar" style="--p:51%"><i></i></span>,
|
|
65
|
+
green below 70%, `mid` from 70%, `hi` from 90% (the widget picks the class) */
|
|
66
|
+
.wbar{display:inline-block;vertical-align:middle;width:84px;height:7px;margin-right:var(--s2);border-radius:4px;background:var(--line);overflow:hidden}
|
|
67
|
+
.wbar > i{display:block;height:100%;width:var(--p,0%);background:var(--ok)}
|
|
68
|
+
.wbar.mid > i{background:var(--warn)}
|
|
69
|
+
.wbar.hi > i{background:var(--red)}
|
|
70
|
+
.hcard .hrow{border-bottom:0;padding:var(--s0) 0}
|