rvlib-shipkit 1.6.0 → 1.8.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 +26 -2
- package/package.json +1 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli.ts +6 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +25 -0
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/serve-web.ts +3 -3
- package/src/home/widgets.ts +12 -2
- package/src/serve/hub-app/home-st.ts +12 -5
- package/src/serve/hub-app/home-strip.css +2 -0
- package/src/serve/hub-app/home-strip.tsx +4 -0
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +2 -0
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +1 -1
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/project-list.tsx +3 -2
- package/src/serve/hub-app/projects-svc.ts +1 -1
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +27 -7
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +196 -14
- package/src/suite/suite-reads-preload.ts +335 -0
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
.hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
|
|
58
58
|
.hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
|
|
59
59
|
.hwidget h3 .hdim{font-weight:400}
|
|
60
|
+
.hwidget h3 .hwre{width:16px;height:16px;margin-left:var(--s1)}
|
|
61
|
+
.hwidget h3 .hwre .hico{width:12px;height:12px}
|
|
60
62
|
/* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
|
|
61
63
|
.wmeters td{padding-right:var(--s1)}
|
|
62
64
|
.wmeters td:nth-child(3n){padding-right:var(--s3)}
|
|
@@ -211,6 +211,10 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
|
|
|
211
211
|
{x.title}
|
|
212
212
|
{head == null ? null : <span className="hdim"> · <span dangerouslySetInnerHTML={{ __html: head }} /></span>}
|
|
213
213
|
{x.error != null && x.html != null ? <i className="gpwarn" title={x.error} /> : null}
|
|
214
|
+
<button className="gpi hwre" aria-label="refresh" disabled={m.refreshing.has(`${x.repo}/${x.id}`)} onClick={() => void m.refreshWidget(x.repo, x.id)}
|
|
215
|
+
title="render it again now, skipping any wait it keeps between samples">
|
|
216
|
+
{m.refreshing.has(`${x.repo}/${x.id}`) ? <span className="spin" /> : <Icon d={ICON.restart} />}
|
|
217
|
+
</button>
|
|
214
218
|
</h3>
|
|
215
219
|
{x.html != null ? <div dangerouslySetInnerHTML={{ __html: body }} />
|
|
216
220
|
: x.error != null ? <div className="hwerr">{x.error}</div>
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<link rel="stylesheet" href="./planned.css" />
|
|
12
12
|
<link rel="stylesheet" href="./when.css" />
|
|
13
13
|
<link rel="stylesheet" href="./activity.css" />
|
|
14
|
+
<link rel="stylesheet" href="./insights.css" />
|
|
14
15
|
<link rel="stylesheet" href="./readme.css" />
|
|
15
16
|
<link rel="stylesheet" href="./dock.css" />
|
|
16
17
|
<link rel="stylesheet" href="./controls.css" />
|
|
@@ -37,6 +37,7 @@ import { LIST, SIDEBAR } from './layout-st.ts'
|
|
|
37
37
|
import { ArchivedView, HomeStrip, NotUp, Overview } from './home-strip.tsx'
|
|
38
38
|
import { PlannedView } from './planned-view.tsx'
|
|
39
39
|
import { ActivityView } from './activity-view.tsx'
|
|
40
|
+
import { InsightsView } from './insights-view.tsx'
|
|
40
41
|
import { Palette } from './palette-view.tsx'
|
|
41
42
|
import { SCREEN_WAIT_MS } from './screen-cache.ts'
|
|
42
43
|
import { isSearchKey, pageKey, sectionKey } from './keys.ts'
|
|
@@ -320,6 +321,7 @@ const App = observer(() => {
|
|
|
320
321
|
{home.page === 'overview' ? <Overview app={home} /> : null}
|
|
321
322
|
{home.page === 'planned' ? <PlannedView app={home} /> : null}
|
|
322
323
|
{home.page === 'activity' ? <ActivityView app={home} /> : null}
|
|
324
|
+
{home.page === 'insights' ? <InsightsView app={home} /> : null}
|
|
323
325
|
{home.page === 'archived' ? <ArchivedView app={home} /> : null}
|
|
324
326
|
{home.visited.map((r) => <RepoView key={r.key} app={r} />)}
|
|
325
327
|
</Panel>
|
|
@@ -12,6 +12,7 @@ export const ICON = {
|
|
|
12
12
|
reflections: 'M4 3.5h12v9H9l-4 3.5v-3.5H4zM7.5 7h5M7.5 9.5h3',
|
|
13
13
|
monitor: 'M3 4h14v9H3zM7 16.5h6M10 13v3.5',
|
|
14
14
|
archived: 'M2.5 4h15v3.5h-15zM4 7.5V16h12V7.5M8 10.5h4',
|
|
15
|
+
insights: 'M3 16.5h14M5 16.5V11M9 16.5V5M13 16.5V8.5M17 16.5V12',
|
|
15
16
|
home: 'M3 9.5 10 3.5l7 6M5 8v8.5h10V8M8.5 16.5v-4.5h3v4.5',
|
|
16
17
|
planned: 'M5.5 3h9M5.5 17h9M6.5 3c0 4 7 4 7 7s-7 3-7 7M13.5 3c0 4-7 4-7 7s7 3 7 7',
|
|
17
18
|
browser: 'M10 2.5a7.5 7.5 0 1 0 0 15a7.5 7.5 0 1 0 0-15M2.5 10h15M10 2.5c-3 3-3 12 0 15M10 2.5c3 3 3 12 0 15',
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<link rel="stylesheet" href="./planned.css" />
|
|
12
12
|
<link rel="stylesheet" href="./when.css" />
|
|
13
13
|
<link rel="stylesheet" href="./activity.css" />
|
|
14
|
+
<link rel="stylesheet" href="./insights.css" />
|
|
14
15
|
<link rel="stylesheet" href="./readme.css" />
|
|
15
16
|
<link rel="stylesheet" href="./dock.css" />
|
|
16
17
|
<link rel="stylesheet" href="./controls.css" />
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// the insights page's state: the daemon's boxes for one scope, the tag that
|
|
2
|
+
// filters which boxes show, and one query that filters every box's rows. It
|
|
3
|
+
// reads once when the page shows and on the refresh button, never on a clock:
|
|
4
|
+
// manifests change far slower than anyone reads them. The scope and the tag
|
|
5
|
+
// are remembered per viewer.
|
|
6
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
7
|
+
import { rowMatches, SCOPES, tagsOf, type InsightBox, type InsightRow, type InsightsAnswer, type Scope } from '../../home/insights-shape.ts'
|
|
8
|
+
import type { HomeSt } from './home-st.ts'
|
|
9
|
+
import { ownTrouble, reach } from './reach.ts'
|
|
10
|
+
|
|
11
|
+
/** the rows a box shows before its "show all" */
|
|
12
|
+
export const BOX_ROWS = 25
|
|
13
|
+
const SCOPE_KEY = 'home:insights:scope'
|
|
14
|
+
const TAG_KEY = 'home:insights:tag'
|
|
15
|
+
|
|
16
|
+
function kept(key: string): string | null {
|
|
17
|
+
try {
|
|
18
|
+
return globalThis.localStorage?.getItem(key) ?? null
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function keep(key: string, v: string): void {
|
|
24
|
+
try { globalThis.localStorage?.setItem(key, v) } catch { /* private window: the pick lasts the tab */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class InsightsSt {
|
|
28
|
+
data: InsightsAnswer | null = null
|
|
29
|
+
error = ''
|
|
30
|
+
loading = false
|
|
31
|
+
scope: Scope = (SCOPES as readonly string[]).includes(kept(SCOPE_KEY) ?? '') ? (kept(SCOPE_KEY) as Scope) : 'house'
|
|
32
|
+
/** the tag whose boxes show, null for every box */
|
|
33
|
+
tag: string | null = kept(TAG_KEY) || null
|
|
34
|
+
query = ''
|
|
35
|
+
/** boxes showing every row, past BOX_ROWS */
|
|
36
|
+
full = new Set<string>()
|
|
37
|
+
/** `box/key` of each row whose uses show under it */
|
|
38
|
+
open = new Set<string>()
|
|
39
|
+
private gen = 0
|
|
40
|
+
|
|
41
|
+
constructor(private st: HomeSt) {
|
|
42
|
+
makeAutoObservable<this, 'st' | 'gen'>(this, { st: false, gen: false, data: observableRef })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get tags(): string[] {
|
|
46
|
+
return tagsOf(this.data?.boxes ?? [])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** the boxes the tag lets through, each with the rows the query lets through */
|
|
50
|
+
get boxes(): { box: InsightBox; rows: InsightRow[] }[] {
|
|
51
|
+
return (this.data?.boxes ?? [])
|
|
52
|
+
.filter((b) => this.tag == null || !this.tags.includes(this.tag) || b.tags.includes(this.tag))
|
|
53
|
+
.map((box) => ({ box, rows: box.rows.filter((r) => rowMatches(box, r, this.query)) }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setScope(s: Scope): void {
|
|
57
|
+
if (s === this.scope) return
|
|
58
|
+
this.scope = s
|
|
59
|
+
keep(SCOPE_KEY, s)
|
|
60
|
+
void this.load()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setTag(t: string | null): void {
|
|
64
|
+
this.tag = t
|
|
65
|
+
keep(TAG_KEY, t ?? '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setQuery(q: string): void {
|
|
69
|
+
this.query = q
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
toggleFull(box: string): void {
|
|
73
|
+
if (this.full.has(box)) this.full.delete(box)
|
|
74
|
+
else this.full.add(box)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toggleRow(box: string, key: string): void {
|
|
78
|
+
const k = `${box}/${key}`
|
|
79
|
+
if (this.open.has(k)) this.open.delete(k)
|
|
80
|
+
else this.open.add(k)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
openRepo(name: string): void {
|
|
84
|
+
this.st.select(name)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** the refresh button: the daemon reads every manifest again */
|
|
88
|
+
refresh(): void {
|
|
89
|
+
if (!this.loading) void this.load(true)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async load(fresh = false): Promise<void> {
|
|
93
|
+
const id = ++this.gen
|
|
94
|
+
this.loading = true
|
|
95
|
+
try {
|
|
96
|
+
const r = await reach(`/api/home/insights.json?scope=${this.scope}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(60_000) })
|
|
97
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
98
|
+
const j = (await r.json()) as InsightsAnswer
|
|
99
|
+
runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
|
|
100
|
+
} catch (e) {
|
|
101
|
+
runInAction(() => { if (id === this.gen) this.error = ownTrouble(e, this.st.machine) })
|
|
102
|
+
} finally {
|
|
103
|
+
runInAction(() => { if (id === this.gen) this.loading = false })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** reads when the page shows */
|
|
108
|
+
start(): void {
|
|
109
|
+
void this.load()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// `/insights`: boxes about every repo of a scope, each carrying tags. On top:
|
|
2
|
+
// the scope (house repos or all of ~/dev), the tag row that filters the boxes,
|
|
3
|
+
// one query that filters every box's rows, and the refresh. A box lists its
|
|
4
|
+
// rows by how many repos use each key; a click on a row shows every use, the
|
|
5
|
+
// house repos as links to their page.
|
|
6
|
+
import { observer } from 'mobx-react-lite'
|
|
7
|
+
import { placeWords, type InsightBox, type InsightRow, type Scope } from '../../home/insights-shape.ts'
|
|
8
|
+
import type { HomeSt } from './home-st.ts'
|
|
9
|
+
import { plainClick, repoPage } from './home-st.ts'
|
|
10
|
+
import { BOX_ROWS } from './insights-st.ts'
|
|
11
|
+
|
|
12
|
+
const SCOPE_WORDS: Record<Scope, { label: string; title: string }> = {
|
|
13
|
+
house: { label: 'house', title: 'the repos on the project list' },
|
|
14
|
+
dev: { label: '~/dev', title: 'every folder of ~/dev with a package.json or a .git, wired or not' },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** the values a row shows inline before "+n" */
|
|
18
|
+
const VALUES_SHOWN = 3
|
|
19
|
+
|
|
20
|
+
const Row = observer(({ app, box, r }: { app: HomeSt; box: InsightBox; r: InsightRow }) => {
|
|
21
|
+
const st = app.insights
|
|
22
|
+
const open = st.open.has(`${box.id}/${r.key}`)
|
|
23
|
+
const house = new Set(app.projects.list.map((p) => p.name))
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<tr className={`inrow${open ? ' open' : ''}`} onClick={() => st.toggleRow(box.id, r.key)} title={`${r.uses.length} use${r.uses.length === 1 ? '' : 's'}: click to list them`}>
|
|
27
|
+
<td className="inkey">{r.key}</td>
|
|
28
|
+
<td className="incount">{r.repos}</td>
|
|
29
|
+
<td className="invals">
|
|
30
|
+
{r.values.slice(0, VALUES_SHOWN).map((v) => <code key={v}>{v}</code>)}
|
|
31
|
+
{r.values.length > VALUES_SHOWN ? <span className="acdim"> +{r.values.length - VALUES_SHOWN}</span> : null}
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
{!open ? null : r.uses.map((u, i) => (
|
|
35
|
+
<tr key={i} className="inuse">
|
|
36
|
+
<td className="inplace">
|
|
37
|
+
{house.has(u.repo) ? (
|
|
38
|
+
<a href={repoPage(u.repo)} onClick={(e) => {
|
|
39
|
+
if (!plainClick(e)) return
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
st.openRepo(u.repo)
|
|
42
|
+
}}>{placeWords(u)}</a>
|
|
43
|
+
) : placeWords(u)}
|
|
44
|
+
</td>
|
|
45
|
+
<td colSpan={2} className="invals"><code>{u.value}</code>{u.dev ? <span className="acdim"> dev</span> : null}</td>
|
|
46
|
+
</tr>
|
|
47
|
+
))}
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const Box = observer(({ app, box, rows }: { app: HomeSt; box: InsightBox; rows: InsightRow[] }) => {
|
|
53
|
+
const st = app.insights
|
|
54
|
+
const full = st.full.has(box.id)
|
|
55
|
+
const shown = full ? rows : rows.slice(0, BOX_ROWS)
|
|
56
|
+
return (
|
|
57
|
+
<section className="inbox">
|
|
58
|
+
<h3>
|
|
59
|
+
<span>{box.title}</span>
|
|
60
|
+
{box.tags.map((t) => <button key={t} className={`intag${st.tag === t ? ' on' : ''}`} onClick={() => st.setTag(st.tag === t ? null : t)}>{t}</button>)}
|
|
61
|
+
<span className="sp" />
|
|
62
|
+
<span className="acdim">{rows.length === box.rows.length ? rows.length : `${rows.length} of ${box.rows.length}`}</span>
|
|
63
|
+
</h3>
|
|
64
|
+
<p className="acdim">{box.hint}</p>
|
|
65
|
+
{rows.length === 0 ? <p className="acdim">no {box.noun} matches</p> : (
|
|
66
|
+
<table className="intable">
|
|
67
|
+
<colgroup><col /><col className="c-count" /><col /></colgroup>
|
|
68
|
+
<thead><tr><th>{box.noun}</th><th>repos</th><th>values</th></tr></thead>
|
|
69
|
+
<tbody>{shown.map((r) => <Row key={r.key} app={app} box={box} r={r} />)}</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
)}
|
|
72
|
+
{rows.length > BOX_ROWS ? <button className="ghost hsm inmore" onClick={() => st.toggleFull(box.id)}>{full ? `show the first ${BOX_ROWS}` : `show all ${rows.length}`}</button> : null}
|
|
73
|
+
</section>
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const InsightsView = observer(({ app }: { app: HomeSt }) => {
|
|
78
|
+
const st = app.insights
|
|
79
|
+
const d = st.data
|
|
80
|
+
return (
|
|
81
|
+
<div className="hall activity insights">
|
|
82
|
+
<div className="actop">
|
|
83
|
+
<div className="acwin" role="radiogroup" aria-label="which repos">
|
|
84
|
+
{(Object.keys(SCOPE_WORDS) as Scope[]).map((s) => (
|
|
85
|
+
<button key={s} role="radio" aria-checked={st.scope === s} className={st.scope === s ? 'on' : ''} title={SCOPE_WORDS[s].title} onClick={() => st.setScope(s)}>{SCOPE_WORDS[s].label}</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
<div className="acwin" role="radiogroup" aria-label="which boxes">
|
|
89
|
+
<button role="radio" aria-checked={st.tag == null} className={st.tag == null ? 'on' : ''} onClick={() => st.setTag(null)}>all</button>
|
|
90
|
+
{st.tags.map((t) => <button key={t} role="radio" aria-checked={st.tag === t} className={st.tag === t ? 'on' : ''} onClick={() => st.setTag(t)}>{t}</button>)}
|
|
91
|
+
</div>
|
|
92
|
+
<input className="inquery" type="search" placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
|
|
93
|
+
<button type="button" className="acpill" disabled={st.loading} onClick={() => st.refresh()} title="read every package.json again now · an answer is kept 5 minutes">
|
|
94
|
+
<span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
|
|
95
|
+
</button>
|
|
96
|
+
{d == null ? <span className="acdim">reading every package.json…</span> : (
|
|
97
|
+
<>
|
|
98
|
+
<span className="acpill">{d.repos} repos</span>
|
|
99
|
+
<span className="acpill">{d.files} package.json</span>
|
|
100
|
+
<span className="acpill">read in {d.ms}ms</span>
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
{st.error !== '' ? <div className="hnote">{st.error}</div> : null}
|
|
105
|
+
{d != null && d.errors.length > 0 ? <div className="hnote" title={d.errors.join('\n')}>{d.errors.length} package.json would not parse</div> : null}
|
|
106
|
+
<div className={`ingrid${d != null && d.scope !== st.scope ? ' stale' : ''}`}>
|
|
107
|
+
{st.boxes.map(({ box, rows }) => <Box key={box.id} app={app} box={box} rows={rows} />)}
|
|
108
|
+
</div>
|
|
109
|
+
<p className="hdim">in the console: shipkit insights</p>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* the insights page (insights-view.tsx): the activity page's top bar, then a
|
|
2
|
+
grid of boxes. A row opens in place to list its uses. */
|
|
3
|
+
.ingrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr));gap:var(--s3);align-items:start}
|
|
4
|
+
.ingrid.stale{opacity:.5}
|
|
5
|
+
.inbox{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);min-width:0}
|
|
6
|
+
.inbox h3{display:flex;align-items:center;gap:var(--s2);margin:0;font-size:13px;font-weight:650}
|
|
7
|
+
.inbox > p{margin:var(--s0) 0 var(--s2);font-size:12px}
|
|
8
|
+
.intag{border:1px solid var(--line);border-radius:999px;padding:0 var(--s2);background:none;color:var(--dim);font-size:11px;font-weight:400;line-height:18px;cursor:pointer}
|
|
9
|
+
.intag:hover,.intag.on{color:var(--accent);border-color:var(--accent)}
|
|
10
|
+
.inquery{height:22px;min-width:200px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:0 var(--s2);font:inherit;font-size:12px}
|
|
11
|
+
.inquery:focus{outline:none;border-color:var(--accent)}
|
|
12
|
+
.intable{width:100%;table-layout:fixed;border-collapse:collapse}
|
|
13
|
+
.intable col.c-count{width:56px}
|
|
14
|
+
.intable th{text-align:left;font-weight:400;color:var(--dim);font-size:11.5px;padding:var(--s0) var(--s1);border-bottom:1px solid var(--line)}
|
|
15
|
+
.intable td{padding:0 var(--s1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
|
|
16
|
+
.inrow{cursor:pointer}
|
|
17
|
+
.inrow:hover td,.inrow.open td{background:var(--panel2)}
|
|
18
|
+
.inkey{font-weight:600}
|
|
19
|
+
.incount{font-variant-numeric:tabular-nums;text-align:right;padding-right:var(--s3) !important}
|
|
20
|
+
.invals code{margin-right:var(--s1);font-size:11.5px;color:var(--dim)}
|
|
21
|
+
.inuse td{font-size:12px}
|
|
22
|
+
.inuse .inplace{padding-left:var(--s3)}
|
|
23
|
+
.inuse .inplace a{color:var(--accent);text-decoration:none}
|
|
24
|
+
.inmore{margin-top:var(--s1)}
|
|
@@ -27,7 +27,7 @@ export function pageKey(e: KeyLike): PageKey | null {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** the shortcut each place of the home carries on its button */
|
|
30
|
-
export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
|
|
30
|
+
export const PLACE_KEY: Record<'overview' | 'planned' | 'activity', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
|
|
31
31
|
/** the picker's shortcut, on the first button, before home */
|
|
32
32
|
export const PICKER_KEY = '⌘J'
|
|
33
33
|
/** the repo above and below, hinted at the project list's foot */
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// of this (HomeSt.machine is null).
|
|
8
8
|
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
9
9
|
import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
|
|
10
|
+
import type { WidgetView } from '../../home/widgets.ts'
|
|
10
11
|
import type { WorkerView } from '../../home/hubs.ts'
|
|
11
12
|
import type { OpenHow, ResourceView } from '../../home/resources.ts'
|
|
12
13
|
import type { PmAnswer, Role } from '../../home/pm.ts'
|
|
@@ -51,6 +52,8 @@ export class MachineSt {
|
|
|
51
52
|
pm: PmAnswer | null = null
|
|
52
53
|
/** every house repo's widgets, as the daemon last rendered them */
|
|
53
54
|
widgets: WidgetsAnswer | null = null
|
|
55
|
+
/** `repo/id` of each widget a refresh is rendering */
|
|
56
|
+
refreshing = new Set<string>()
|
|
54
57
|
/** why pm did not answer the last poll, or '' while it does */
|
|
55
58
|
pmDown = ''
|
|
56
59
|
/** the last action that failed */
|
|
@@ -261,6 +264,26 @@ export class MachineSt {
|
|
|
261
264
|
}
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
/** one widget rendered now by the daemon, with force (its cache or
|
|
268
|
+
* backoff skipped), its new view swapped in */
|
|
269
|
+
async refreshWidget(repo: string, id: string): Promise<void> {
|
|
270
|
+
const k = `${repo}/${id}`
|
|
271
|
+
if (this.refreshing.has(k)) return
|
|
272
|
+
this.refreshing.add(k)
|
|
273
|
+
try {
|
|
274
|
+
const r = await reach('/api/home/widgets/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo, id }) })
|
|
275
|
+
if (!r.ok) throw new Error(await r.text())
|
|
276
|
+
const v = (await r.json()) as WidgetView
|
|
277
|
+
runInAction(() => {
|
|
278
|
+
if (this.widgets != null) this.widgets = { ...this.widgets, widgets: this.widgets.widgets.map((w) => (w.repo === repo && w.id === id ? v : w)) }
|
|
279
|
+
})
|
|
280
|
+
} catch (e) {
|
|
281
|
+
runInAction(() => { this.error = ownTrouble(e, this) })
|
|
282
|
+
} finally {
|
|
283
|
+
runInAction(() => { this.refreshing.delete(k) })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
264
287
|
hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
|
|
265
288
|
return this.post('/api/home/hub', { name, act })
|
|
266
289
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// the one picker of the page (cmd+j, keys.ts), a node of the root: the
|
|
2
|
-
// home's places (home, activity, planned,
|
|
2
|
+
// home's places (home, activity, planned, insights, archived) 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+j away.
|
|
@@ -27,6 +27,7 @@ export class PaletteSt {
|
|
|
27
27
|
{ id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
|
|
28
28
|
{ id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
|
|
29
29
|
{ id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
|
|
30
|
+
{ id: 'place:insights', label: 'insights', section: 'place', face: { place: 'insights' }, run: () => st.navigate('/insights') },
|
|
30
31
|
{ id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
|
|
31
32
|
]
|
|
32
33
|
}
|
|
@@ -37,6 +37,7 @@ const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
|
|
|
37
37
|
{ id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
|
|
38
38
|
{ id: 'activity', label: 'activity', title: 'what every repo committed lately, what it holds uncommitted, its branch and whether it is pushed' },
|
|
39
39
|
{ id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
|
|
40
|
+
{ id: 'insights', label: 'insights', title: 'boxes about every repo: the packages they use and by how many, version drift, script names' },
|
|
40
41
|
]
|
|
41
42
|
|
|
42
43
|
/** the icon's size on a row, in px, as attributes and in the sheet */
|
|
@@ -165,10 +166,10 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
165
166
|
const on = id === 'picker' ? s.pickerOpen() : s.place === id
|
|
166
167
|
const key = id === 'picker' ? PICKER_KEY : PLACE_KEY[id]
|
|
167
168
|
return (
|
|
168
|
-
<button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={`${pl.title} (${key})`} onClick={run}>
|
|
169
|
+
<button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={key == null ? pl.title : `${pl.title} (${key})`} onClick={run}>
|
|
169
170
|
<Icon d={ICON[id === 'picker' ? 'search' : id === 'overview' ? 'home' : id]} />
|
|
170
171
|
<span className="pjname">{pl.label}</span>
|
|
171
|
-
<kbd className="pjkey">{key}</kbd>
|
|
172
|
+
{key == null ? null : <kbd className="pjkey">{key}</kbd>}
|
|
172
173
|
</button>
|
|
173
174
|
)
|
|
174
175
|
})}
|
|
@@ -178,7 +178,7 @@ export function cardModel(p: Project, now: number): CardModel {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/** a page of the home that is no repo's */
|
|
181
|
-
export type Place = 'overview' | 'planned' | 'activity'
|
|
181
|
+
export type Place = 'overview' | 'planned' | 'activity' | 'insights'
|
|
182
182
|
|
|
183
183
|
export class ProjectsSvc {
|
|
184
184
|
/** the last list this browser saw until the first poll answers, so the
|
|
@@ -30,7 +30,7 @@ export type Sel = HashSel | null
|
|
|
30
30
|
|
|
31
31
|
/** one picker target (cmd+j): run() fires the SAME action clicking would */
|
|
32
32
|
/** what a picker entry wears: a repo's own icon, or a place's button icon */
|
|
33
|
-
export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
|
|
33
|
+
export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'insights' | 'archived' }
|
|
34
34
|
/** `key`: the shortcut that does the same, printed at the entry's end */
|
|
35
35
|
export type Jump = { id: string; label: string; section: string; run: () => void; face?: JumpFace; key?: string }
|
|
36
36
|
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { autoGatePool, takeSlot } from '../core/slots.ts'
|
|
|
41
41
|
import { liveJobs, startJob } from '../core/jobs.ts'
|
|
42
42
|
import { followJob } from '../core/job-follow.ts'
|
|
43
43
|
import { RUN_JOB } from '../home/home-words.ts'
|
|
44
|
+
import { RunLanes, runKey, treeStamp, writesTree } from './run-lanes.ts'
|
|
44
45
|
import { OtherRepos } from './other-repos.ts'
|
|
45
46
|
import { HouseRuns } from './house-runs.ts'
|
|
46
47
|
import { recordStamp } from './project-gutter.ts'
|
|
@@ -185,9 +186,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
185
186
|
const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
|
|
186
187
|
// every run this process has going, whoever started it
|
|
187
188
|
let active = 0
|
|
188
|
-
//
|
|
189
|
-
// the
|
|
190
|
-
|
|
189
|
+
// every run, whoever asked: an identical one attaches, a repair pass goes
|
|
190
|
+
// alone, the rest overlap under the machine slot pool (run-lanes.ts)
|
|
191
|
+
const lanes = new RunLanes(() => treeStamp(root))
|
|
192
|
+
// the gate jobs this runtime started: one beside another is the lanes' call
|
|
193
|
+
const mine = new Set<string>()
|
|
191
194
|
// the latest verdict of every check, printed as one problem frame after
|
|
192
195
|
// each run for the vscode problems pane (problems/problem-line.ts)
|
|
193
196
|
const board = new ProblemBoard()
|
|
@@ -215,13 +218,22 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
215
218
|
}
|
|
216
219
|
}
|
|
217
220
|
// a job still running here, started before this image or by a terminal's --detach
|
|
218
|
-
|
|
221
|
+
const foreign = (): boolean => liveJobs(root).some((j) => !mine.has(j.id))
|
|
222
|
+
if (foreign()) tap.line('note: a gate job this hub did not start is running here, so this one starts when it ends')
|
|
223
|
+
while (foreign()) await Bun.sleep(jobs.pollMs ?? 250)
|
|
219
224
|
const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
|
|
225
|
+
mine.add(j.id)
|
|
220
226
|
tap.job?.(j.id)
|
|
221
|
-
|
|
227
|
+
try {
|
|
228
|
+
return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
|
|
229
|
+
} finally {
|
|
230
|
+
mine.delete(j.id)
|
|
231
|
+
}
|
|
222
232
|
}
|
|
223
233
|
const runChecks: HubDeps['runChecks'] = (tap, o) => {
|
|
224
|
-
|
|
234
|
+
// only a whole gate the hub was asked for runs the repair pass (hubGateOpts)
|
|
235
|
+
const writes = o?.auto !== true && o?.gate === true && (o.only == null || o.only.length === 0)
|
|
236
|
+
return lanes.submit({ key: null, writes, print: tap.line }, async () => {
|
|
225
237
|
active++
|
|
226
238
|
// a run the hub starts on its own takes the machine's background
|
|
227
239
|
// turn, once it is this hub's turn, never while it queues
|
|
@@ -243,27 +255,25 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
243
255
|
for (const l of board.frame()) io.print(l)
|
|
244
256
|
}
|
|
245
257
|
})
|
|
246
|
-
chain = run.catch(() => {})
|
|
247
|
-
return run
|
|
248
258
|
}
|
|
249
259
|
// a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
|
|
250
|
-
// in the same
|
|
251
|
-
//
|
|
260
|
+
// in the same lanes as the hub's own runs, feeding the same problems
|
|
261
|
+
// frame, recorded as the cli's
|
|
252
262
|
const runCli: HubDeps['runCli'] = (kind, opts, print) => {
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
// a --json run prints the json and nothing else, so it never shares
|
|
264
|
+
const key = opts.json === true ? null : runKey(kind, opts)
|
|
265
|
+
// `check --fix` runs in process (home/via-hub.ts), so a check here never repairs
|
|
266
|
+
const writes = kind === 'ci' && writesTree(opts)
|
|
267
|
+
return lanes.submit({ key, writes, print, quiet: opts.json === true }, async (tee) => {
|
|
256
268
|
active++
|
|
257
269
|
try {
|
|
258
270
|
await refresh()
|
|
259
|
-
return await exec(kind, { ...opts, invokedBy: 'cli' }, { line:
|
|
271
|
+
return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: tee, check: onBoard, job: (id) => tee(`${RUN_JOB}${id}`) }, false)
|
|
260
272
|
} finally {
|
|
261
273
|
active--
|
|
262
274
|
for (const l of board.frame()) io.print(l)
|
|
263
275
|
}
|
|
264
276
|
})
|
|
265
|
-
chain = run.catch(() => {})
|
|
266
|
-
return run
|
|
267
277
|
}
|
|
268
278
|
const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
|
|
269
279
|
const only = o?.only != null && o.only.length > 0 ? o.only : undefined
|
|
@@ -287,11 +297,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
287
297
|
if (adopted.length > 0) {
|
|
288
298
|
io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
|
|
289
299
|
active++
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
300
|
+
void lanes.submit({ key: null, writes: true, print: () => {} }, () =>
|
|
301
|
+
Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).then(() => 0).finally(() => {
|
|
302
|
+
active--
|
|
303
|
+
for (const l of board.frame()) io.print(l)
|
|
304
|
+
})).catch(() => {})
|
|
295
305
|
}
|
|
296
306
|
}
|
|
297
307
|
// resolved once: it shells out to git, and the page asks on every poll
|