rvlib-shipkit 1.5.0 → 1.6.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 +46 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +4 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +120 -0
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-start.ts +11 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/via-hub.ts +14 -0
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +5 -9
- package/src/serve/hub-app/home-strip.tsx +4 -17
- package/src/serve/hub-app/hub-app.tsx +18 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/keys.ts +10 -2
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +10 -0
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +24 -5
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
|
@@ -24,7 +24,7 @@ import { useEffect, type CSSProperties } from 'react'
|
|
|
24
24
|
import { middleware, OPEN_MS } from './floating.tsx'
|
|
25
25
|
import { Icon, ICON } from './icons.tsx'
|
|
26
26
|
import { plainClick } from './home-st.ts'
|
|
27
|
-
import { PICKER_KEY, PLACE_KEY } from './keys.ts'
|
|
27
|
+
import { PICKER_KEY, PLACE_KEY, STEP_KEYS } from './keys.ts'
|
|
28
28
|
import { cardModel, faceOf, hueOf, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
|
|
29
29
|
|
|
30
30
|
/** what the list needs of its page: the hub and the home both carry one */
|
|
@@ -195,6 +195,9 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
195
195
|
) : null}
|
|
196
196
|
<div className="pjfoot">
|
|
197
197
|
<SortButton s={s} />
|
|
198
|
+
<span className="pjstep" title={`${STEP_KEYS[0]} and ${STEP_KEYS[1]} open the repo above or below, from any page`}>
|
|
199
|
+
{STEP_KEYS.map((k) => <kbd key={k}>{k}</kbd>)}
|
|
200
|
+
</span>
|
|
198
201
|
{s.archived.length === 0 || s.onArchived == null ? null : (
|
|
199
202
|
<button className={`pjarbtn${s.archivedOpen ? ' on' : ''}`} aria-pressed={s.archivedOpen}
|
|
200
203
|
title={`${s.archivedOpen ? 'hide' : 'show'} the ${s.archived.length} archived repos: nothing runs there, a row opens the archived page`}
|
|
@@ -213,7 +216,7 @@ const orderWords = (s: ProjectsSvc): string => {
|
|
|
213
216
|
return m === 'manual' ? 'manual order' : SORTS.find((x) => x.id === m)!.label
|
|
214
217
|
}
|
|
215
218
|
|
|
216
|
-
/** the sort button:
|
|
219
|
+
/** the sort button: its title names the order the list is in, it opens the menu,
|
|
217
220
|
* and sorts nothing itself */
|
|
218
221
|
const SortButton = observer(({ s }: { s: ProjectsSvc }) => {
|
|
219
222
|
const f = useFloating({
|
|
@@ -231,7 +234,7 @@ const SortButton = observer(({ s }: { s: ProjectsSvc }) => {
|
|
|
231
234
|
<>
|
|
232
235
|
<button ref={f.refs.setReference} className="pgsort" title={`the list is in ${orderWords(s)}: pick a sort`}
|
|
233
236
|
{...getReferenceProps({ onClick: () => s.toggleSortMenu() })}>
|
|
234
|
-
<Icon d={ICON.sort} /><span className="pgsortl">
|
|
237
|
+
<Icon d={ICON.sort} /><span className="pgsortl">sort</span>
|
|
235
238
|
</button>
|
|
236
239
|
{s.sortMenuOpen ? (
|
|
237
240
|
<FloatingPortal>
|
|
@@ -277,6 +277,16 @@ export class ProjectsSvc {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
|
|
280
|
+
/** the repo above (-1) or below (+1) the selected one, round the ends;
|
|
281
|
+
* with none selected, down starts at the top and up at the bottom */
|
|
282
|
+
step(by: -1 | 1): void {
|
|
283
|
+
const n = this.list.length
|
|
284
|
+
if (n === 0) return
|
|
285
|
+
const at = this.list.findIndex((p) => this.isCurrent(p))
|
|
286
|
+
const next = at === -1 ? (by === 1 ? 0 : n - 1) : (at + by + n) % n
|
|
287
|
+
this.select(this.list[next]!.name)
|
|
288
|
+
}
|
|
289
|
+
|
|
280
290
|
/** a click selects once it is sure no second click follows */
|
|
281
291
|
click(name: string): void {
|
|
282
292
|
this.select(name)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// A repo page's sections: the pages reached for most, the top rows of the
|
|
2
2
|
// left pane's tree (tree-row.tsx draws them the way the project list draws its
|
|
3
3
|
// lines: an icon, a label, a count where it says something, its ⌥ shortcut at
|
|
4
|
-
// the end, keys.ts, the current one lit).
|
|
5
|
-
//
|
|
4
|
+
// the end, keys.ts, the current one lit). Every repo draws the same rows, so
|
|
5
|
+
// the list reads the same from repo to repo: a section this repo has nothing
|
|
6
|
+
// in is a greyed row, and only opens a page that can say it is empty (the
|
|
7
|
+
// processes page). Plan, git and admin come from the machine, so a hub of its
|
|
8
|
+
// own has none of the three. The counts read the severity filter, like everything under them,
|
|
6
9
|
// and come from what the page already holds (the kept screen on a reload), so
|
|
7
10
|
// none arrives late and moves the tree.
|
|
8
11
|
import { RUNS_ID } from '../../collections/collection-types.ts'
|
|
@@ -18,6 +21,10 @@ export type RepoSection = {
|
|
|
18
21
|
title: string
|
|
19
22
|
/** null when a number would say nothing */
|
|
20
23
|
count: number | null
|
|
24
|
+
/** this repo has nothing there: the row is greyed */
|
|
25
|
+
empty: boolean
|
|
26
|
+
/** its click opens a page: false for an empty section with no empty page */
|
|
27
|
+
opens: boolean
|
|
21
28
|
tone: 'red' | 'warn' | null
|
|
22
29
|
on: boolean
|
|
23
30
|
go: () => void
|
|
@@ -83,35 +90,42 @@ export function openSection(app: RepoSt, id: SectionId): void {
|
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
/** the sections whose page says it is empty, so an empty row still opens it */
|
|
94
|
+
const EMPTY_PAGES = new Set<SectionId>(['processes'])
|
|
95
|
+
|
|
86
96
|
export function repoSections(app: RepoSt): RepoSection[] {
|
|
87
97
|
const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
|
|
88
98
|
const cur = sectionOfRepo(app)
|
|
89
99
|
const res = app.cols.resources.filter((r) => passesFilter(r, app.filter))
|
|
90
100
|
const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
|
|
91
101
|
const none = { count: null, tone: null }
|
|
92
|
-
const line = (id: SectionId, title: string, count: Pick<RepoSection, 'count' | 'tone'
|
|
102
|
+
const line = (id: SectionId, title: string, count: Pick<RepoSection, 'count' | 'tone'>, empty: string | null = null): RepoSection => {
|
|
103
|
+
const opens = empty == null || EMPTY_PAGES.has(id)
|
|
104
|
+
return { id, label: sectionLabel(id), title: empty ?? title, ...(empty == null ? count : none), empty: empty != null, opens, on: opens && cur === id, go: opens ? () => openSection(app, id) : () => {} }
|
|
105
|
+
}
|
|
93
106
|
const out: RepoSection[] = [line('readme', 'the README, what the repo is', none)]
|
|
94
107
|
const ownReds = (from: CheckOwner): number => res.filter((r) => r.check?.from === from && r.state === 'red').length
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
const unless = (on: boolean, why: string): string | null => (on ? null : why)
|
|
109
|
+
out.push(line('contracts', 'what a release venue asks of this repo, and what it still lacks', counted(reds('contracts'), 'red'), unless(has('contracts'), 'no contract: no release venue applies to this repo')))
|
|
110
|
+
const noChecks = unless(has('checks'), 'no checks yet: its hub has not answered')
|
|
111
|
+
out.push(line('shipkit', 'the checks shipkit ships to every repo, one page each', counted(ownReds('shipkit'), 'red'), noChecks))
|
|
112
|
+
out.push(line('repo', 'the checks this repo declares itself, one page each', counted(ownReds('repo'), 'red'), noChecks))
|
|
113
|
+
out.push(line('secrets', 'the credentials this repo needs, present or missing', counted(reds('creds'), 'red'), unless(has('creds'), 'no secrets: this repo needs no credential')))
|
|
114
|
+
out.push(line('when', 'what runs when: every check on every context (commit, ci, full, release, health, save, clock), with the legend', none, noChecks))
|
|
100
115
|
// a page, never a resource: every hub keeps a run record
|
|
101
116
|
out.push(line('runs', 'every run on record, the slowest checks and the longest runs', none))
|
|
102
117
|
// the plan and git come from the machine, so a hub of its own has neither
|
|
103
118
|
if (app.home.machine != null) out.push(line('plan', 'what the machine runs here on its own time, and when', none))
|
|
104
119
|
const procs = app.globalProcesses
|
|
105
|
-
|
|
120
|
+
out.push(line('processes', 'the processes this repo declares: start, stop, open, their description and their log', counted(procs.filter((r) => r.dot === 'crashed').length, 'red'),
|
|
121
|
+
unless(procs.length > 0, 'no process: this repo declares no global process (a <id>.shipkit.ts file with globalProcess adds one)')))
|
|
106
122
|
if (app.home.machine != null) out.push(line('git', 'the branch, its upstream, what is not committed, the last commits', none))
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if ((app.media.assets[0]?.cat ?? app.cols.cachedCat) != null) out.push(line('media', 'the assets, in the atelier', none))
|
|
114
|
-
if (has(WIKIS)) out.push(line('wikis', 'every wiki this repo mounts, as a link graph', none))
|
|
123
|
+
const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
|
|
124
|
+
out.push(line('asks', 'what waits on a human', counted(open, 'warn'), unless(has('asks'), 'no asks: nothing has ever waited on a human here')))
|
|
125
|
+
out.push(line('reflections', 'friction this repo logged against the wiki, waiting to be folded in', counted(res.filter((r) => r.type === 'reflection').length, 'warn'), unless(has('reflections'), 'no reflections: this repo logged no friction')))
|
|
126
|
+
out.push(line('tests', 'every test file, slowest first, then each test check', counted(reds('tests'), 'red'), unless(has('tests'), 'no tests: this repo has no test file')))
|
|
127
|
+
out.push(line('media', 'the assets, in the atelier', none, unless((app.media.assets[0]?.cat ?? app.cols.cachedCat) != null, 'no media: this repo has no asset kit')))
|
|
128
|
+
out.push(line('wikis', 'every wiki this repo mounts, as a link graph', none, unless(has(WIKIS), 'no wiki: this repo mounts none')))
|
|
115
129
|
if (app.home.machine != null) out.push(line('admin', 'reload the hub, keep the repo active, archive it', none))
|
|
116
130
|
return out
|
|
117
131
|
}
|
|
@@ -227,9 +227,9 @@ export class RepoSt {
|
|
|
227
227
|
out.push({ id: `asset:${a.id}`, label: a.label, section: a.cat, run: () => this.selectAsset(a.id) })
|
|
228
228
|
// the section lines, with their keys: the same list the left pane draws
|
|
229
229
|
const secs = repoSections(this)
|
|
230
|
-
for (const s of secs) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
|
|
230
|
+
for (const s of secs.filter((x) => x.opens)) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
|
|
231
231
|
for (const col of this.cols.sidebarCols) {
|
|
232
|
-
if (!secs.some((s) => s.id === col.id)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
|
|
232
|
+
if (!secs.some((s) => s.id === col.id && s.opens)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
|
|
233
233
|
for (const i of col.instances)
|
|
234
234
|
out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
|
|
235
235
|
}
|
|
@@ -256,10 +256,27 @@ export class RepoSt {
|
|
|
256
256
|
void this.tests.load(check)
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** a walk with the tree's arrows is under way, and whether the current
|
|
260
|
+
* history entry is its own: a held arrow opens ~30 pages a second, and
|
|
261
|
+
* the browser throws past 100 pushState calls per 10 seconds */
|
|
262
|
+
private walking = false
|
|
263
|
+
private walked = false
|
|
264
|
+
/** open pages as one walk: the first step is a history entry, every next
|
|
265
|
+
* one rewrites it, so back returns to where the walk started */
|
|
266
|
+
walk(open: () => void): void {
|
|
267
|
+
this.walking = true
|
|
268
|
+
try {
|
|
269
|
+
open()
|
|
270
|
+
} finally {
|
|
271
|
+
this.walking = false
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
259
275
|
/** the ONE place a selection is written, so the url always describes what
|
|
260
276
|
* is on screen. A new page is a history entry, so back returns to the
|
|
261
|
-
* page before it. The same address is never
|
|
262
|
-
* lands here too, with the hash already naming
|
|
277
|
+
* page before it, except inside a walk (walk). The same address is never
|
|
278
|
+
* pushed twice: applyHash lands here too, with the hash already naming
|
|
279
|
+
* this selection */
|
|
263
280
|
setSel(sel: Sel): void {
|
|
264
281
|
this.sel = sel
|
|
265
282
|
const h = hashFor(sel)
|
|
@@ -269,7 +286,9 @@ export class RepoSt {
|
|
|
269
286
|
// the tool set rides after the asset id (media-svc syncHash), so an
|
|
270
287
|
// address is the same page whatever tools it carries
|
|
271
288
|
if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) return
|
|
272
|
-
history.
|
|
289
|
+
if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
|
|
290
|
+
else history.pushState(null, '', `#${h}`)
|
|
291
|
+
this.walked = this.walking
|
|
273
292
|
}
|
|
274
293
|
/** one tree row, opened: an asset goes to the atelier, everything else to
|
|
275
294
|
* its own item card. A gate check has a page of its own now, so nothing
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// whose rows under them are every resource of the repo. Only the tree
|
|
4
4
|
// scrolls, so the filter stays in reach. The row itself is tree-row.tsx.
|
|
5
5
|
import { observer } from 'mobx-react-lite'
|
|
6
|
-
import { SEARCH_KEY } from './keys.ts'
|
|
6
|
+
import { isTreeWalk, SEARCH_KEY } from './keys.ts'
|
|
7
7
|
import type { RepoSt } from './repo-st.ts'
|
|
8
8
|
import { TreeRowView } from './tree-row.tsx'
|
|
9
9
|
|
|
@@ -11,9 +11,9 @@ const Tree = observer(({ app }: { app: RepoSt }) => {
|
|
|
11
11
|
const t = app.tree
|
|
12
12
|
const empty = app.cols.loaded && app.filter !== 'all' && app.query.trim() === '' && t.visible.length === 0
|
|
13
13
|
return (
|
|
14
|
-
//
|
|
15
|
-
// focus
|
|
16
|
-
<div className="tree"
|
|
14
|
+
// the page's key handler drives it (hub-app.tsx), so the arrows need
|
|
15
|
+
// no focus here; the atelier consumes its own keys before that
|
|
16
|
+
<div className="tree">
|
|
17
17
|
{t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
|
|
18
18
|
{!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
|
|
19
19
|
{empty ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : 'nothing needs you ✨'}</div> : null}
|
|
@@ -46,7 +46,7 @@ export const Sidebar = observer(({ app }: { app: RepoSt }) => (
|
|
|
46
46
|
<span className="sfield">
|
|
47
47
|
<input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree by name or path" value={app.query}
|
|
48
48
|
onChange={(e) => app.setQuery(e.target.value)}
|
|
49
|
-
onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
|
|
49
|
+
onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery(''); else if (isTreeWalk(e.key) && app.tree.key(e.key)) e.preventDefault() }} />
|
|
50
50
|
<kbd className="skey">{SEARCH_KEY}</kbd>
|
|
51
51
|
</span>
|
|
52
52
|
<SegFilter app={app} />
|
|
@@ -171,6 +171,7 @@ const groupOf = (r: Resource): Seg[] => {
|
|
|
171
171
|
if (r.test != null) return []
|
|
172
172
|
if (r.check != null || r.type === 'contract' || r.type === 'secret') return []
|
|
173
173
|
if (r.type === 'ask') return isDone(r) ? [{ key: 'type:ask:done', label: 'done', kind: 'done', page: null }] : []
|
|
174
|
+
if (r.type === 'asset' && r.group != null) return [{ key: `cat:${r.group}`, label: r.group, kind: 'category', page: { t: 'cat', cat: r.group } }]
|
|
174
175
|
if (r.type === 'wiki' || r.type === 'asset' || r.type === 'test' || r.type === 'process' || r.type === 'reflection') return []
|
|
175
176
|
return [{ key: `type:${r.type}`, label: r.type, kind: r.type, page: { t: 'col', id: r.collection } }]
|
|
176
177
|
}
|
|
@@ -381,6 +382,7 @@ export const TYPE_MARK: Record<string, TypeMark> = {
|
|
|
381
382
|
ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
|
|
382
383
|
secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
|
|
383
384
|
asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
|
|
385
|
+
category: { glyph: '\u25a6', hue: 'k1', word: 'a category of the kit: its page shows every asset in it' },
|
|
384
386
|
post: { glyph: '\u25a4', hue: 'k2', word: 'a post waiting in the social queue' },
|
|
385
387
|
workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
|
|
386
388
|
wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
|
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
// No row carries prose: the words (the duration too) are the tooltips and
|
|
7
7
|
// the page it opens. A click opens, only the chevron folds.
|
|
8
8
|
import { observer } from 'mobx-react-lite'
|
|
9
|
-
import type
|
|
9
|
+
import { useEffect, useRef, type CSSProperties, type RefObject } from 'react'
|
|
10
10
|
import { Icon, ICON } from './icons.tsx'
|
|
11
11
|
import { sectionKeyLabel } from './keys.ts'
|
|
12
12
|
import type { RepoSt } from './repo-st.ts'
|
|
13
13
|
import { GLYPH, markOf, rowName, rowSlots, type TreeRow } from './tree-build.ts'
|
|
14
14
|
|
|
15
|
+
/** the row under the cursor stays in sight while the arrows move it */
|
|
16
|
+
const useInSight = (on: boolean): RefObject<HTMLDivElement | null> => {
|
|
17
|
+
const el = useRef<HTMLDivElement | null>(null)
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (on) el.current?.scrollIntoView({ block: 'nearest' })
|
|
20
|
+
}, [on])
|
|
21
|
+
return el
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/** the fold marker: a chevron, right when the branch is shut and down when
|
|
16
25
|
* it is open. The 9px triangle it replaces read as a dot with an opinion */
|
|
17
26
|
export const Caret = ({ open }: { open: boolean }): React.ReactElement => (
|
|
@@ -34,8 +43,9 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
|
|
|
34
43
|
const id = row.section!
|
|
35
44
|
const s = t.sections.find((x) => x.id === id)
|
|
36
45
|
const key = s == null ? null : sectionKeyLabel(id)
|
|
46
|
+
const el = useInSight(t.cursor === row.key)
|
|
37
47
|
return (
|
|
38
|
-
<div className={`tsec${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
|
|
48
|
+
<div ref={el} className={`tsec${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
|
|
39
49
|
title={s == null ? row.label : `${s.title} (${key})`} onClick={() => t.click(row)}>
|
|
40
50
|
<Fold app={app} row={row} />
|
|
41
51
|
<Icon d={ICON[id]} />
|
|
@@ -46,16 +56,19 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
|
|
|
46
56
|
)
|
|
47
57
|
})
|
|
48
58
|
|
|
49
|
-
export const TreeRowView =
|
|
50
|
-
|
|
59
|
+
export const TreeRowView = ({ app, row }: { app: RepoSt; row: TreeRow }): React.ReactElement =>
|
|
60
|
+
row.section != null ? <SectionRowView app={app} row={row} /> : <LeafRowView app={app} row={row} />
|
|
61
|
+
|
|
62
|
+
const LeafRowView = observer(({ app, row }: { app: RepoSt; row: TreeRow }) => {
|
|
51
63
|
const t = app.tree
|
|
52
64
|
const u = app.checks
|
|
53
65
|
const running = row.checks.some((id) => u.isRunning(id))
|
|
54
66
|
const s = rowSlots(row, running)
|
|
55
67
|
const mark = markOf(row.res, row.kind)
|
|
56
68
|
const name = rowName(row)
|
|
69
|
+
const el = useInSight(t.cursor === row.key)
|
|
57
70
|
return (
|
|
58
|
-
<div className={`trow${t.cursor === row.key ? ' on' : ''}`} style={{ '--d': row.depth } as CSSProperties}
|
|
71
|
+
<div ref={el} className={`trow${t.cursor === row.key ? ' on' : ''}`} style={{ '--d': row.depth } as CSSProperties}
|
|
59
72
|
title={s.hint} onClick={() => t.click(row)}>
|
|
60
73
|
<span className="tw" onClick={row.dir ? (e) => { e.stopPropagation(); t.fold(row) } : undefined}>
|
|
61
74
|
{row.dir ? <Caret open={t.isOpen(row.key)} /> : null}
|
|
@@ -150,12 +150,35 @@ export class TreeSvc {
|
|
|
150
150
|
if (row.dir) this.toggle(row.key)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** where the arrows start: the cursor, else the section of the page shown */
|
|
154
|
+
private get from(): number {
|
|
155
|
+
const rows = this.rows
|
|
156
|
+
const i = rows.findIndex((r) => r.key === this.cursor)
|
|
157
|
+
if (i !== -1) return i
|
|
158
|
+
const on = this.sections.find((s) => s.on)?.id
|
|
159
|
+
return on == null ? -1 : rows.findIndex((r) => r.section === on)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** an arrow lands on a row and opens its page at once. A branch with
|
|
163
|
+
* nothing to open only takes the cursor: a fold is the chevron's job */
|
|
164
|
+
private land(row: TreeRow): void {
|
|
165
|
+
this.cursor = row.key
|
|
166
|
+
if (row.section != null || row.page != null || row.res != null) this.st.walk(() => this.activate(row))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** a greyed section with no page: the arrows step over it */
|
|
170
|
+
private inert(row: TreeRow): boolean {
|
|
171
|
+
return row.section != null && this.sections.find((s) => s.id === row.section)?.opens === false
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
move(d: number): void {
|
|
154
175
|
const rows = this.rows
|
|
155
176
|
if (rows.length === 0) return
|
|
156
|
-
const i =
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
const i = this.from
|
|
178
|
+
let next = i === -1 ? (d > 0 ? 0 : rows.length - 1) : i + d
|
|
179
|
+
while (next >= 0 && next < rows.length && this.inert(rows[next]!)) next += d
|
|
180
|
+
if (next < 0 || next >= rows.length || next === i) return
|
|
181
|
+
this.land(rows[next]!)
|
|
159
182
|
}
|
|
160
183
|
/** right descends: a folded row unfolds, anything else steps down */
|
|
161
184
|
right(): void {
|
|
@@ -171,10 +194,7 @@ export class TreeSvc {
|
|
|
171
194
|
const rows = this.rows
|
|
172
195
|
const i = rows.findIndex((x) => x.key === r.key)
|
|
173
196
|
for (let n = i - 1; n >= 0; n--) {
|
|
174
|
-
if (rows[n]!.depth < r.depth)
|
|
175
|
-
this.cursor = rows[n]!.key
|
|
176
|
-
return
|
|
177
|
-
}
|
|
197
|
+
if (rows[n]!.depth < r.depth) return this.land(rows[n]!)
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
enter(): void {
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -202,7 +202,9 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
202
202
|
// ONE way a run executes. In process for a hub of its own; as a detached
|
|
203
203
|
// job in the daemon, which follows it: its lines and verdicts reach the
|
|
204
204
|
// same taps, and a reload of the daemon leaves it running
|
|
205
|
-
const exec = async (kind: 'ci' | 'check',
|
|
205
|
+
const exec = async (kind: 'ci' | 'check', sent: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
|
|
206
|
+
// the switches a terminal set ride beside the options, into the job's env
|
|
207
|
+
const { env, ...opts } = sent as { env?: Record<string, string> }
|
|
206
208
|
if (jobs == null) {
|
|
207
209
|
const turn = background ? await takeSlot(autoGatePool(), { repo: c.name, check: 'auto gate' }, { pollMs: 250 }) : null
|
|
208
210
|
try {
|
|
@@ -214,7 +216,7 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
214
216
|
}
|
|
215
217
|
// a job still running here, started before this image or by a terminal's --detach
|
|
216
218
|
while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
|
|
217
|
-
const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background } })
|
|
219
|
+
const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
|
|
218
220
|
tap.job?.(j.id)
|
|
219
221
|
return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
|
|
220
222
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// and the uncommitted count ask git only when a stat says a commit, a tag or
|
|
5
5
|
// the index moved, a few repos per poll, and a repo's run record is opened
|
|
6
6
|
// only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
|
|
7
|
-
// serves only the icon files a discovered repo carries,
|
|
7
|
+
// serves only the icon files a discovered repo carries, the app-icon route
|
|
8
|
+
// draws the app icon (core/app-icon.ts) for the admin page's preview, and POST
|
|
8
9
|
// /api/projects/open opens a discovered repo in VS Code, POST
|
|
9
10
|
// /api/projects/fork in Fork: a request names a repo, never a path. The rail ORDER is one text file for the whole machine
|
|
10
11
|
// (ORDER_FILE), so every hub shows the same order and a hand edit is seen
|
|
@@ -21,11 +22,14 @@ import {
|
|
|
21
22
|
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
22
23
|
import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
|
|
23
24
|
import { writeIdentity } from '../core/identity.ts'
|
|
25
|
+
import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
|
|
24
26
|
import { writeArchived } from '../core/archived.ts'
|
|
25
27
|
|
|
26
28
|
export type { Gate }
|
|
27
29
|
|
|
28
30
|
export const POLL_MS = 5_000
|
|
31
|
+
/** the app icon preview: sharp on a 2x screen at the admin page's 128px */
|
|
32
|
+
const PREVIEW_PX = 256
|
|
29
33
|
/** how long a release answer holds for a checkout whose `.git` is not a
|
|
30
34
|
* folder, since no stat can say when it moved */
|
|
31
35
|
export const RELEASE_TTL_MS = 60_000
|
|
@@ -377,6 +381,23 @@ export function projectGutter(d: GutterDeps) {
|
|
|
377
381
|
headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
|
|
378
382
|
})
|
|
379
383
|
}
|
|
384
|
+
if (path.startsWith('/api/projects/app-icon/')) {
|
|
385
|
+
if (repos.length === 0) await answer()
|
|
386
|
+
const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
|
|
387
|
+
if (r == null) return new Response('not a house repo', { status: 404 })
|
|
388
|
+
const icon = appIconOf(r.path)
|
|
389
|
+
if (icon == null) return new Response('no png mark: the app wears the default icon', { status: 404 })
|
|
390
|
+
// the colours a picker is on right now, before they are saved; else the config's
|
|
391
|
+
const q = new URL(req.url).searchParams
|
|
392
|
+
const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
|
|
393
|
+
const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
|
|
394
|
+
try {
|
|
395
|
+
const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
|
|
396
|
+
return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
|
|
397
|
+
} catch (e) {
|
|
398
|
+
return new Response(`could not draw the icon from ${icon}: ${(e as Error).message}`, { status: 422 })
|
|
399
|
+
}
|
|
400
|
+
}
|
|
380
401
|
if (path === '/api/projects/identity' && req.method === 'POST') {
|
|
381
402
|
if (repos.length === 0) await answer()
|
|
382
403
|
const b = (await req.json().catch(() => ({}))) as { name?: unknown; colorLight?: unknown; colorDark?: unknown }
|
|
@@ -294,6 +294,10 @@ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, P
|
|
|
294
294
|
return { rerun, cleared }
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/** the recap bun prints after the last file, "3 tests failed:", repeats
|
|
298
|
+
* every (fail) line under no header of its own: it closes the last file */
|
|
299
|
+
export const BUN_RECAP = /^\s*\d+ tests? failed:\s*$/
|
|
300
|
+
|
|
297
301
|
/** the suites with a failing test, read off bun's report: a file that failed
|
|
298
302
|
* prints its path as a header line, then its `(fail)` lines under it. A
|
|
299
303
|
* passing file prints nothing at all in a piped run, which is why this reads
|
|
@@ -304,6 +308,7 @@ export function failedSuites(output: string): string[] {
|
|
|
304
308
|
for (const line of output.split('\n')) {
|
|
305
309
|
const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
|
|
306
310
|
if (head != null) { cur = normSuitePath(head[1]!); continue }
|
|
311
|
+
if (BUN_RECAP.test(line)) { cur = null; continue }
|
|
307
312
|
// a file that cannot load prints no `(fail)` line, only this banner
|
|
308
313
|
if (cur != null && (/^\(fail\) /.test(line) || line.startsWith('# Unhandled error between tests'))) out.add(cur)
|
|
309
314
|
}
|
|
@@ -322,6 +327,7 @@ export function failLines(output: string): Map<string, string> {
|
|
|
322
327
|
const line = raw.trimEnd()
|
|
323
328
|
const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
|
|
324
329
|
if (head != null) { cur = normSuitePath(head[1]!); err = null; continue }
|
|
330
|
+
if (BUN_RECAP.test(line)) { cur = null; continue }
|
|
325
331
|
if (cur == null || out.has(cur)) continue
|
|
326
332
|
if (err == null && /^\s*error: /.test(line)) err = line.trim()
|
|
327
333
|
const fail = line.match(/^\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/)
|