rvlib-shipkit 1.2.0 → 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 +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- 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 +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -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_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -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 +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- 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 +1 -1
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- 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/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +19 -2
- package/src/ledger/world-cache.ts +51 -4
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +11 -3
- 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-words.ts +2 -0
- 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/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +11 -7
- 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 +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- 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 +25 -6
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +20 -3
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
|
|
2
|
-
// nth repo of the project list,
|
|
3
|
-
// the picker.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// nth repo of the project list, then the home row under the right hand: ⌘J
|
|
3
|
+
// the overview, ⌘K the picker, ⌘L the activity, ⌘' the plan. ⌘B cycles the
|
|
4
|
+
// dock at the bottom of the page (dock-st.ts): the line, slim, open.
|
|
5
|
+
// A digit and the quote are read from the physical key (`code`), so an
|
|
6
|
+
// AZERTY keyboard, where the key under 1 types `&`, selects the same repo. Ctrl stands in for
|
|
6
7
|
// ⌘ off a Mac. The macOS app binds none of these to a menu item, so they
|
|
7
8
|
// reach the page (tests/home-strip.test.tsx reads ShipkitGui.swift).
|
|
8
|
-
export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'picker' }
|
|
9
|
+
export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'picker' } | { t: 'dock' }
|
|
9
10
|
|
|
10
11
|
type KeyLike = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
|
|
11
12
|
|
|
@@ -14,12 +15,46 @@ export function pageKey(e: KeyLike): PageKey | null {
|
|
|
14
15
|
const digit = /^Digit([1-9])$/.exec(e.code)
|
|
15
16
|
if (digit != null) return { t: 'repo', n: Number(digit[1]) }
|
|
16
17
|
if (e.shiftKey) return null
|
|
18
|
+
if (e.code === 'Quote' || e.key === "'") return { t: 'planned' }
|
|
17
19
|
const k = e.key.toLowerCase()
|
|
18
20
|
if (k === 'j') return { t: 'home' }
|
|
19
|
-
if (k === 'p') return { t: 'planned' }
|
|
20
21
|
if (k === 'k') return { t: 'picker' }
|
|
22
|
+
if (k === 'l') return { t: 'activity' }
|
|
23
|
+
if (k === 'b') return { t: 'dock' }
|
|
21
24
|
return null
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** the shortcut each place of the home carries on its button */
|
|
25
|
-
export const PLACE_KEY = { overview: '⌘J', planned: '⌘
|
|
28
|
+
export const PLACE_KEY = { overview: '⌘J', planned: "⌘'", activity: '⌘L' } as const
|
|
29
|
+
/** the picker's shortcut, on the second button, after home */
|
|
30
|
+
export const PICKER_KEY = '⌘K'
|
|
31
|
+
/** the dock's shortcut, on the dock's own header */
|
|
32
|
+
export const DOCK_KEY = '⌘B'
|
|
33
|
+
|
|
34
|
+
/** the tree's filter, on a repo page: a plain `/` focuses it, and its
|
|
35
|
+
* badge at the input's right prints this */
|
|
36
|
+
export const SEARCH_KEY = '/'
|
|
37
|
+
|
|
38
|
+
export function isSearchKey(e: Pick<KeyLike, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
|
|
39
|
+
return e.key === SEARCH_KEY && !e.metaKey && !e.ctrlKey && !e.altKey
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** a repo page's sections, each on ⌥ and its letter. ⌥ because every other
|
|
43
|
+
* layer is spoken for: the plain letters are the severity filter (a n r)
|
|
44
|
+
* and the chart's follow key, ⌘ with these letters is copy, reload, select
|
|
45
|
+
* all, new tab, minimize and close in the browser and the macOS app, and ⌥
|
|
46
|
+
* plus a letter only types a character inside a text field, where the page
|
|
47
|
+
* leaves the key alone. Read from the physical key (`code`): on a Mac ⌥C
|
|
48
|
+
* types ç, and AZERTY moves the letters */
|
|
49
|
+
export type SectionId = 'checks' | 'runs' | 'asks' | 'tests' | 'media' | 'wikis' | 'admin'
|
|
50
|
+
export const SECTION_LETTER: Record<SectionId, string> = { checks: 'C', runs: 'R', asks: 'A', tests: 'T', media: 'M', wikis: 'W', admin: 'D' }
|
|
51
|
+
/** the badge at the end of a section's line */
|
|
52
|
+
export const sectionKeyLabel = (id: SectionId): string => `⌥${SECTION_LETTER[id]}`
|
|
53
|
+
|
|
54
|
+
export function sectionKey(e: KeyLike): SectionId | null {
|
|
55
|
+
if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return null
|
|
56
|
+
const m = /^Key([A-Z])$/.exec(e.code)
|
|
57
|
+
if (m == null) return null
|
|
58
|
+
const hit = Object.entries(SECTION_LETTER).find(([, l]) => l === m[1])
|
|
59
|
+
return hit == null ? null : (hit[0] as SectionId)
|
|
60
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// (chart-view.ts, timeline-chart.tsx).
|
|
1
|
+
// A repo's live runs, as the page holds them: `/api/live.json` read every
|
|
2
|
+
// second while the repo shows (the runs of the last ten minutes and the ones
|
|
3
|
+
// in flight), which the header's run state reads, and the chart of the runs
|
|
4
|
+
// page over it: the whole record (the recent runs of /api/live.json over the
|
|
5
|
+
// older ones of /api/timeline.json), opened on its last ten minutes and fit
|
|
6
|
+
// to all of it on demand (chart-view.ts, timeline-chart.tsx). What runs in
|
|
7
|
+
// every repo at once is the dock's (dock-st.ts).
|
|
9
8
|
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
10
9
|
import type { LiveView } from '../../ledger/live-timeline.ts'
|
|
11
10
|
import type { TimelineInvocation } from '../../ledger/run-timeline.ts'
|
|
@@ -15,16 +14,15 @@ import { chartHeight } from './timeline-geometry.ts'
|
|
|
15
14
|
|
|
16
15
|
/** how often the page asks. A strip a second behind the runner is still live */
|
|
17
16
|
export const LIVE_POLL_MS = 1_000
|
|
18
|
-
/** the
|
|
19
|
-
export const FOLDED_WINDOW_MS = 120_000
|
|
17
|
+
/** the span the chart opens on */
|
|
20
18
|
export const OPEN_WINDOW_MS = 600_000
|
|
21
|
-
/** the most the
|
|
19
|
+
/** the most the chart grows to: past it the lanes scroll inside it */
|
|
22
20
|
export const OPEN_MAX_H = 320
|
|
23
|
-
/** how long the
|
|
21
|
+
/** how long the chart keeps a height its lanes no longer fill,
|
|
24
22
|
* so it grows at once and never shrinks under the eye every second */
|
|
25
23
|
export const HEIGHT_HOLD_MS = 10_000
|
|
26
24
|
|
|
27
|
-
/** the height the
|
|
25
|
+
/** the height the chart shows: up at once, down only once the
|
|
28
26
|
* lanes have wanted less for HEIGHT_HOLD_MS */
|
|
29
27
|
export type HeldHeight = { h: number; lowSince: number | null }
|
|
30
28
|
export function settleHeight(prev: HeldHeight, wanted: number, now: number, holdMs = HEIGHT_HOLD_MS): HeldHeight {
|
|
@@ -33,32 +31,20 @@ export function settleHeight(prev: HeldHeight, wanted: number, now: number, hold
|
|
|
33
31
|
return now - prev.lowSince >= holdMs ? { h: wanted, lowSince: null } : prev
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
/** the whole record the
|
|
34
|
+
/** the whole record the chart draws: every run of the live view,
|
|
37
35
|
* then the older runs of the record it does not carry, newest first */
|
|
38
36
|
export function mergeRuns(recent: TimelineInvocation[], record: TimelineInvocation[]): TimelineInvocation[] {
|
|
39
37
|
const seen = new Set(recent.map((i) => i.runId))
|
|
40
38
|
return [...recent, ...record.filter((i) => !seen.has(i.runId))].sort((a, b) => b.started - a.started)
|
|
41
39
|
}
|
|
42
|
-
/** the lanes the folded strip has room for */
|
|
43
|
-
export const FOLDED_LANES = 5
|
|
44
|
-
|
|
45
|
-
/** where the fold is remembered. localStorage is per origin and every
|
|
46
|
-
* repo's hub has its own port, so this is per repo already */
|
|
47
|
-
export const OPEN_KEY = 'hub:liveOpen'
|
|
48
|
-
|
|
49
|
-
const readOpen = (): boolean => {
|
|
50
|
-
try { return globalThis.localStorage?.getItem(OPEN_KEY) === '1' } catch { return false }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
/** the server clock now: the view's stamp plus what the page clock has
|
|
54
41
|
* counted since it arrived, since the page may be another machine */
|
|
55
|
-
export const serverNow = (view:
|
|
42
|
+
export const serverNow = (view: { now: number }, receivedAt: number, clientNow: number): number => view.now + Math.max(0, clientNow - receivedAt)
|
|
56
43
|
|
|
57
|
-
/** what the chart draws:
|
|
58
|
-
*
|
|
59
|
-
export function liveSource(view: LiveView | null, now: number,
|
|
44
|
+
/** what the runs page's chart draws: the whole record, opening on its last
|
|
45
|
+
* ten minutes */
|
|
46
|
+
export function liveSource(view: LiveView | null, now: number, record: TimelineInvocation[] = []): ChartSource | null {
|
|
60
47
|
if (view == null) return null
|
|
61
|
-
if (!open) return { invocations: view.invocations, now, windowMs: FOLDED_WINDOW_MS, laneCap: Math.max(1, FOLDED_LANES - view.repos.length), zoomable: false, cap: view.cap }
|
|
62
48
|
return { invocations: mergeRuns(view.invocations, record), now, windowMs: null, zoomable: true, cap: view.cap, homeSpanMs: OPEN_WINDOW_MS }
|
|
63
49
|
}
|
|
64
50
|
|
|
@@ -67,10 +53,7 @@ export class LiveSvc {
|
|
|
67
53
|
/** the page clock when `view` arrived */
|
|
68
54
|
receivedAt = 0
|
|
69
55
|
error = ''
|
|
70
|
-
/**
|
|
71
|
-
note = ''
|
|
72
|
-
open = readOpen()
|
|
73
|
-
/** the unfolded chart's height, held so it does not jump every second */
|
|
56
|
+
/** the chart's height, held so it does not jump every second */
|
|
74
57
|
held: HeldHeight = { h: 0, lowSince: null }
|
|
75
58
|
chart: ChartView
|
|
76
59
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
@@ -78,7 +61,7 @@ export class LiveSvc {
|
|
|
78
61
|
private started = false
|
|
79
62
|
|
|
80
63
|
constructor(private st: RepoSt) {
|
|
81
|
-
this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.
|
|
64
|
+
this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.st.timeline.data?.invocations ?? []))
|
|
82
65
|
makeAutoObservable<this, 'st' | 'timer' | 'inFlight' | 'started'>(this, { st: false, timer: false, inFlight: false, started: false, view: observableRef, chart: false })
|
|
83
66
|
}
|
|
84
67
|
|
|
@@ -87,18 +70,9 @@ export class LiveSvc {
|
|
|
87
70
|
return this.view == null ? this.st.clock.now : serverNow(this.view, this.receivedAt, this.st.clock.now)
|
|
88
71
|
}
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
try { globalThis.localStorage?.setItem(OPEN_KEY, this.open ? '1' : '0') } catch { /* private window: the fold lasts the tab */ }
|
|
93
|
-
// either fold starts live: the folded strip is a trail, nothing else
|
|
94
|
-
this.chart.attach()
|
|
95
|
-
this.opened()
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** unfolded: back to the last ten minutes, the older record fetched, and
|
|
99
|
-
* a height from the lanes as they are */
|
|
73
|
+
/** back to the last ten minutes, the older record fetched, and a height
|
|
74
|
+
* from the lanes as they are */
|
|
100
75
|
private opened(): void {
|
|
101
|
-
if (!this.open) return
|
|
102
76
|
this.chart.home()
|
|
103
77
|
this.chart.attach()
|
|
104
78
|
this.held = { h: 0, lowSince: null }
|
|
@@ -108,8 +82,10 @@ export class LiveSvc {
|
|
|
108
82
|
|
|
109
83
|
/** the height the lanes want now, as the held rule lets it show */
|
|
110
84
|
settle(now: number): void {
|
|
85
|
+
// no runs page on screen, no chart to size: the record is not laid out
|
|
86
|
+
if (this.chart.viewW === 0) return
|
|
111
87
|
const l = this.chart.layout
|
|
112
|
-
const lanes =
|
|
88
|
+
const lanes = l?.lanes ?? 0
|
|
113
89
|
this.held = settleHeight(this.held, Math.min(OPEN_MAX_H, chartHeight(lanes)), now)
|
|
114
90
|
}
|
|
115
91
|
|
|
@@ -139,7 +115,7 @@ export class LiveSvc {
|
|
|
139
115
|
this.view = v
|
|
140
116
|
this.receivedAt = Date.now()
|
|
141
117
|
this.error = ''
|
|
142
|
-
|
|
118
|
+
this.settle(Date.now())
|
|
143
119
|
})
|
|
144
120
|
// a run that ended, from a terminal as much as from a button,
|
|
145
121
|
// wrote new numbers: the rows and the timeline read them now
|
|
@@ -158,20 +134,4 @@ export class LiveSvc {
|
|
|
158
134
|
get running(): boolean {
|
|
159
135
|
return (this.view?.runs.length ?? 0) > 0
|
|
160
136
|
}
|
|
161
|
-
|
|
162
|
-
/** a repo lane was clicked: its hub, when it serves */
|
|
163
|
-
async openRepo(repo: string): Promise<void> {
|
|
164
|
-
try {
|
|
165
|
-
const r = (await (await fetch(this.st.url(`/api/repo-hub?repo=${encodeURIComponent(repo)}`))).json()) as { url: string | null }
|
|
166
|
-
if (r.url != null) window.open(r.url, '_blank')
|
|
167
|
-
else this.say(`${repo} has no hub serving`)
|
|
168
|
-
} catch (e) {
|
|
169
|
-
this.say(`${repo}: ${(e as Error).message}`)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private say(text: string): void {
|
|
174
|
-
runInAction(() => { this.note = text })
|
|
175
|
-
setTimeout(() => runInAction(() => { if (this.note === text) this.note = '' }), 4_000)
|
|
176
|
-
}
|
|
177
137
|
}
|
|
@@ -10,6 +10,8 @@ import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
|
|
|
10
10
|
import type { WorkerView } from '../../home/hubs.ts'
|
|
11
11
|
import type { ResourceView } from '../../home/resources.ts'
|
|
12
12
|
import type { PmAnswer, Role } from '../../home/pm.ts'
|
|
13
|
+
import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
|
|
14
|
+
import { ClockSvc } from './clock-svc.ts'
|
|
13
15
|
import type { HomeSt } from './home-st.ts'
|
|
14
16
|
import type { HubDot } from './projects-svc.ts'
|
|
15
17
|
|
|
@@ -22,6 +24,21 @@ export const MACHINE_TIMEOUT_MS = 8_000
|
|
|
22
24
|
* on the next load (the overview) while the daemon and pm are asked again */
|
|
23
25
|
export const MACHINE_KEY = 'hub:machine'
|
|
24
26
|
|
|
27
|
+
/** how long a copied pid says so */
|
|
28
|
+
export const COPIED_MS = 1_200
|
|
29
|
+
|
|
30
|
+
/** how open opens a global process's url, kept per browser */
|
|
31
|
+
export const OPEN_HOW_KEY = 'hub:open-how'
|
|
32
|
+
export type OpenHow = 'browser' | 'app'
|
|
33
|
+
|
|
34
|
+
function loadOpenHow(): OpenHow {
|
|
35
|
+
try {
|
|
36
|
+
return globalThis.localStorage?.getItem(OPEN_HOW_KEY) === 'app' ? 'app' : 'browser'
|
|
37
|
+
} catch {
|
|
38
|
+
return 'browser'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
type Kept = { data: HomeAnswer | null; pm: PmAnswer | null; widgets: WidgetsAnswer | null }
|
|
26
43
|
|
|
27
44
|
function loadKept(): Kept | null {
|
|
@@ -48,11 +65,19 @@ export class MachineSt {
|
|
|
48
65
|
pmDown = ''
|
|
49
66
|
/** the last action that failed */
|
|
50
67
|
error = ''
|
|
68
|
+
/** where open sends a global process's url: the default browser, or a
|
|
69
|
+
* shipkit app window */
|
|
70
|
+
openHow: OpenHow = loadOpenHow()
|
|
51
71
|
/** why the daemon did not answer the last poll, or '' while it does */
|
|
52
72
|
down = ''
|
|
53
73
|
/** the daemon answered once since the load: until then data, pm and
|
|
54
74
|
* widgets are what this browser kept, shown but never acted on */
|
|
55
75
|
fresh = false
|
|
76
|
+
/** the one second the process ages move on, ticking while the machine polls */
|
|
77
|
+
clock: ClockSvc
|
|
78
|
+
/** the pid a click just copied, which says so for COPIED_MS */
|
|
79
|
+
copiedPid: number | null = null
|
|
80
|
+
private copiedTimer: ReturnType<typeof setTimeout> | null = null
|
|
56
81
|
/** what was last kept, so an unchanged machine is not written again */
|
|
57
82
|
private kept = ''
|
|
58
83
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
@@ -63,13 +88,14 @@ export class MachineSt {
|
|
|
63
88
|
private pollError = false
|
|
64
89
|
|
|
65
90
|
constructor(private st: HomeSt, private o: MachineOpts = {}) {
|
|
91
|
+
this.clock = new ClockSvc()
|
|
66
92
|
const k = loadKept()
|
|
67
93
|
if (k != null) {
|
|
68
94
|
this.data = k.data
|
|
69
95
|
this.pm = k.pm
|
|
70
96
|
this.widgets = k.widgets
|
|
71
97
|
}
|
|
72
|
-
makeAutoObservable<this, 'st' | 'timer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, timer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
|
|
98
|
+
makeAutoObservable<this, 'st' | 'timer' | 'copiedTimer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, clock: false, timer: false, copiedTimer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
/** the repo the page shows, null on the overview and the plan */
|
|
@@ -85,6 +111,13 @@ export class MachineSt {
|
|
|
85
111
|
return this.worker(this.repo)
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
/** the processes table: shipkit's own first, then one group per repo in
|
|
115
|
+
* the project list's order, one row per process (process-rows.ts, what
|
|
116
|
+
* `shipkit global-processes` prints) */
|
|
117
|
+
get processGroups(): ProcessGroup[] {
|
|
118
|
+
return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null })
|
|
119
|
+
}
|
|
120
|
+
|
|
88
121
|
resource(id: string): ResourceView | null {
|
|
89
122
|
return this.pm?.resources.find((r) => r.id === id) ?? null
|
|
90
123
|
}
|
|
@@ -110,10 +143,12 @@ export class MachineSt {
|
|
|
110
143
|
if (this.repo != null && this.data != null) void this.hub(this.repo, 'ensure')
|
|
111
144
|
})
|
|
112
145
|
this.timer = setInterval(() => void this.load(), MACHINE_POLL_MS)
|
|
146
|
+
this.clock.start()
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
stop(): void {
|
|
116
150
|
if (this.timer != null) clearInterval(this.timer)
|
|
151
|
+
this.clock.stop()
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
/** pm is polled beside the daemon: either one down leaves the other shown */
|
|
@@ -249,6 +284,34 @@ export class MachineSt {
|
|
|
249
284
|
return this.post('/api/pm/resource', { id, act, action })
|
|
250
285
|
}
|
|
251
286
|
|
|
287
|
+
/** the url of a global process, the way this browser picked (started
|
|
288
|
+
* first when it is down) */
|
|
289
|
+
openProcess(id: string): Promise<boolean> {
|
|
290
|
+
return this.processAct(id, this.openHow === 'app' ? 'app' : 'open')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** a process's pid to the clipboard, and the "copied" flash on it; a
|
|
294
|
+
* refusal (plain http, an unfocused page) is said in the strip */
|
|
295
|
+
async copyPid(pid: number): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
|
|
298
|
+
await navigator.clipboard.writeText(String(pid))
|
|
299
|
+
} catch (e) {
|
|
300
|
+
runInAction(() => { this.error = `could not copy pid ${pid}: ${(e as Error).message}` })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
runInAction(() => { this.copiedPid = pid })
|
|
304
|
+
if (this.copiedTimer != null) clearTimeout(this.copiedTimer)
|
|
305
|
+
this.copiedTimer = setTimeout(() => runInAction(() => { this.copiedPid = null }), COPIED_MS)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
setOpenHow(how: OpenHow): void {
|
|
309
|
+
this.openHow = how
|
|
310
|
+
try {
|
|
311
|
+
globalThis.localStorage?.setItem(OPEN_HOW_KEY, how)
|
|
312
|
+
} catch { /* private window or blocked site data: the pick lasts the tab */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
252
315
|
/** restart the daemon or the web through pm */
|
|
253
316
|
restartChild(id: Role): Promise<boolean> {
|
|
254
317
|
return this.post('/api/pm/child', { id, act: 'restart' })
|
package/src/serve/hub-app/md.ts
CHANGED
|
@@ -8,13 +8,15 @@ import sanitizeHtml from 'sanitize-html'
|
|
|
8
8
|
|
|
9
9
|
marked.use({ gfm: true, breaks: false, async: false })
|
|
10
10
|
|
|
11
|
+
const CARD_TAGS = [
|
|
12
|
+
'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
|
|
13
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
14
|
+
'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
|
|
15
|
+
]
|
|
16
|
+
|
|
11
17
|
export const renderMd = (src: string): string =>
|
|
12
18
|
sanitizeHtml(marked.parse(src) as string, {
|
|
13
|
-
allowedTags:
|
|
14
|
-
'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
|
|
15
|
-
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
16
|
-
'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
|
|
17
|
-
],
|
|
19
|
+
allowedTags: CARD_TAGS,
|
|
18
20
|
allowedAttributes: { a: ['href', 'target', 'rel'] },
|
|
19
21
|
allowedSchemes: ['http', 'https'],
|
|
20
22
|
allowProtocolRelative: false,
|
|
@@ -37,3 +39,34 @@ export const renderMdInline = (src: string): string =>
|
|
|
37
39
|
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
38
40
|
},
|
|
39
41
|
})
|
|
42
|
+
|
|
43
|
+
/** where a README image loads from: an http(s) url as written, a path
|
|
44
|
+
* relative to the repo root through `fileUrl`, anything else (data:, file:,
|
|
45
|
+
* `//host`) nowhere */
|
|
46
|
+
export const readmeImageSrc = (src: string, fileUrl: (rel: string) => string): string | null => {
|
|
47
|
+
const s = src.trim()
|
|
48
|
+
if (/^https?:\/\//i.test(s)) return s
|
|
49
|
+
if (s === '' || s.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(s)) return null
|
|
50
|
+
const rel = s.replace(/[?#].*$/, '').replace(/^(\.\/|\/)+/, '')
|
|
51
|
+
return rel === '' ? null : fileUrl(rel.split('/').map(encodeURIComponent).join('/'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** a whole README: the card allowlist plus images and the few tags READMEs
|
|
55
|
+
* lean on. A relative image is rewritten to the repo's file route, an image
|
|
56
|
+
* with no loadable source is dropped */
|
|
57
|
+
export const renderReadme = (src: string, fileUrl: (rel: string) => string): string =>
|
|
58
|
+
sanitizeHtml(marked.parse(src) as string, {
|
|
59
|
+
allowedTags: [...CARD_TAGS, 'h5', 'h6', 'img', 'details', 'summary', 'kbd', 'sup', 'sub', 'del'],
|
|
60
|
+
allowedAttributes: { a: ['href', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height'] },
|
|
61
|
+
allowedSchemes: ['http', 'https'],
|
|
62
|
+
allowProtocolRelative: false,
|
|
63
|
+
transformTags: {
|
|
64
|
+
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
65
|
+
img: (tagName, attribs) => {
|
|
66
|
+
const { src: raw, ...rest } = attribs
|
|
67
|
+
const src = readmeImageSrc(raw ?? '', fileUrl)
|
|
68
|
+
return { tagName, attribs: src == null ? rest : { ...rest, src } }
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
|
|
72
|
+
})
|
|
@@ -44,7 +44,7 @@ export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
|
|
|
44
44
|
export function planWords(row: PlanRow, now: number): { words: string; at: number | null } {
|
|
45
45
|
if (row.state === 'running') return { words: 'running now', at: row.dueAt }
|
|
46
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: '
|
|
47
|
+
if (row.state === 'late') return { words: 'waits for the repo to be active', at: row.dueAt }
|
|
48
48
|
if (row.state === 'next' && row.dueAt != null) return { words: `in ${aheadWords(row.dueAt - now)}`, at: row.dueAt }
|
|
49
49
|
return { words: 'on the next save that moves what it reads', at: null }
|
|
50
50
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// the one picker of the page (cmd+k, keys.ts), a node of the root: the
|
|
2
|
-
// home's places (home, planned) first, then the shown repo's own jump
|
|
2
|
+
// home's places (home, planned, activity) first, then the shown repo's own jump
|
|
3
3
|
// targets, then every other house repo as a place to go. Another repo's
|
|
4
4
|
// resources are never listed: a jump to a repo selects it in place, and its
|
|
5
5
|
// own targets are one more cmd+k away.
|
|
@@ -24,24 +24,28 @@ export class PaletteSt {
|
|
|
24
24
|
const st = this.st
|
|
25
25
|
if (st.machine == null) return []
|
|
26
26
|
return [
|
|
27
|
-
{ id: 'place:home', label: 'home', section: 'place', run: () => st.navigate('/') },
|
|
28
|
-
{ id: 'place:planned', label: 'planned', section: 'place', run: () => st.navigate('/planned') },
|
|
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') },
|
|
29
31
|
]
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
/** every
|
|
34
|
+
/** every house repo but the one on screen, as a place to go: on a
|
|
35
|
+
* global page that is every repo */
|
|
33
36
|
get others(): Jump[] {
|
|
34
37
|
const st = this.st
|
|
35
38
|
if (st.machine == null) return []
|
|
36
|
-
|
|
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) }))
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
get jumps(): Jump[] {
|
|
40
44
|
return [...this.places, ...(this.st.shown?.jumps ?? []), ...this.others]
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
/** ranked: prefix matches first, then substring. With no query, home
|
|
44
|
-
* planned, then the shown repo's first targets, then every other repo,
|
|
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,
|
|
45
49
|
* so a jump elsewhere is always in reach */
|
|
46
50
|
get results(): Jump[] {
|
|
47
51
|
const q = this.query.trim().toLowerCase()
|
|
@@ -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
|
+
})
|