rvlib-shipkit 0.1.1 → 0.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 +137 -0
- package/README.md +23 -8
- package/package.json +15 -3
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +179 -0
- package/src/assets/kits/identity-kit.ts +119 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +142 -0
- package/src/assets/server/studio-page.ts +620 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +412 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Serve.ts +34 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +44 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +82 -15
- package/src/serve/hub-pages.ts +84 -0
- package/src/serve/run-serve.ts +85 -0
- package/src/serve/serve-hub.ts +77 -0
- package/src/shipkit.ts +39 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -41
- package/src/cli/shipkit-wiki.ts +0 -74
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The hub's two no-build pages (served by serve-hub.ts): the landing page
|
|
2
|
+
// whose links ARE the toolbox, and the /status page (STATUS.md + a
|
|
3
|
+
// "run check" button streaming wire lines). Same style family as the
|
|
4
|
+
// studio: one template string, vanilla JS, no build step, no CDN.
|
|
5
|
+
import type { HubTool } from './serve-hub.ts'
|
|
6
|
+
|
|
7
|
+
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<')
|
|
8
|
+
|
|
9
|
+
const SHELL = `
|
|
10
|
+
:root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
|
|
11
|
+
--accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
|
|
12
|
+
*{box-sizing:border-box}
|
|
13
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
|
|
14
|
+
.wrap{max-width:860px;margin:0 auto;padding:40px 24px}
|
|
15
|
+
h1{font-size:20px;margin:0}
|
|
16
|
+
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
|
|
17
|
+
.crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
|
|
18
|
+
.crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
export function landingPage(o: { name: string; port: number; tools: HubTool[] }): string {
|
|
22
|
+
const cards = o.tools
|
|
23
|
+
.map((t) => `<a class=card href="${esc(t.path)}"><b>${esc(t.label)}</b><span>${esc(t.desc)}</span><code>${esc(t.path)}</code></a>`)
|
|
24
|
+
.join('\n')
|
|
25
|
+
return `<!doctype html><html><head><meta charset=utf-8>
|
|
26
|
+
<meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · shipkit</title>
|
|
27
|
+
<style>${SHELL}
|
|
28
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:26px}
|
|
29
|
+
.card{display:flex;flex-direction:column;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
|
|
30
|
+
padding:16px;text-decoration:none;color:var(--ink)}
|
|
31
|
+
.card:hover{border-color:var(--accent);background:var(--panel2)}
|
|
32
|
+
.card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
|
|
33
|
+
.card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
|
|
34
|
+
</style></head><body><div class=wrap>
|
|
35
|
+
<div class=crumb>shipkit hub</div>
|
|
36
|
+
<h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
|
|
37
|
+
<div class=cards>
|
|
38
|
+
${cards}
|
|
39
|
+
</div>
|
|
40
|
+
</div></body></html>`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function statusPage(o: { name: string; statusMd: string | null }): string {
|
|
44
|
+
const body = o.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)'
|
|
45
|
+
return `<!doctype html><html><head><meta charset=utf-8>
|
|
46
|
+
<meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · status</title>
|
|
47
|
+
<style>${SHELL}
|
|
48
|
+
.bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
|
|
49
|
+
button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
|
|
50
|
+
button:disabled{opacity:.45;cursor:default}
|
|
51
|
+
#note{font-size:12px;color:var(--dim)}
|
|
52
|
+
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
|
|
53
|
+
font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
|
|
54
|
+
.ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
|
|
55
|
+
</style></head><body><div class=wrap>
|
|
56
|
+
<div class=crumb><a href="/">shipkit hub</a> / status</div>
|
|
57
|
+
<h1>${esc(o.name)}<small>STATUS.md</small></h1>
|
|
58
|
+
<div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
|
|
59
|
+
<pre id=out></pre>
|
|
60
|
+
<script>
|
|
61
|
+
const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
|
|
62
|
+
// wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
|
|
63
|
+
const esc=s=>s.replace(/&/g,'&').replace(/</g,'<')
|
|
64
|
+
const cls=l=>{const t=l.trimStart()
|
|
65
|
+
if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
|
|
66
|
+
if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
|
|
67
|
+
if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
|
|
68
|
+
if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
|
|
69
|
+
return ''}
|
|
70
|
+
const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
|
|
71
|
+
paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
|
|
72
|
+
btn.onclick=async()=>{
|
|
73
|
+
btn.disabled=true;note.textContent='running…';let text=''
|
|
74
|
+
try{
|
|
75
|
+
const r=await fetch('status/run',{method:'POST'})
|
|
76
|
+
const rd=r.body.getReader(),dec=new TextDecoder()
|
|
77
|
+
for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
|
|
78
|
+
note.textContent='done — STATUS.md refreshed on disk'
|
|
79
|
+
}catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
|
|
80
|
+
btn.disabled=false
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
</div></body></html>`
|
|
84
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// shipkit serve — resolve the repo's toolbox and run the hub. What mounts
|
|
2
|
+
// comes from convention + repo.config's `serve` block: the wiki graph is
|
|
3
|
+
// always on (custom provider via serve.wikiGraph, else the generic md
|
|
4
|
+
// scan), agent/ graph when the dir exists, the studio when .rv-assets/
|
|
5
|
+
// kit.ts loads. Port: --port > serve.port > a deterministic house-range
|
|
6
|
+
// default (4800-4979, hashed from the repo name — stable across launches;
|
|
7
|
+
// pin serve.port when two repos collide).
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { fileURLToPath } from 'url'
|
|
11
|
+
import { loadKit } from '../assets/kit-config.ts'
|
|
12
|
+
import { page as studioPage } from '../assets/server/studio-page.ts'
|
|
13
|
+
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
14
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
15
|
+
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
16
|
+
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
17
|
+
|
|
18
|
+
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
19
|
+
|
|
20
|
+
export function defaultHubPort(name: string): number {
|
|
21
|
+
let h = 0
|
|
22
|
+
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
|
23
|
+
return 4800 + (h % 180)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
|
|
27
|
+
const wikiGraph = c.serve?.wikiGraph != null
|
|
28
|
+
? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
|
|
29
|
+
: { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
|
|
30
|
+
const agentRoot = join(root, 'agent')
|
|
31
|
+
const agentGraph = existsSync(agentRoot)
|
|
32
|
+
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
33
|
+
: null
|
|
34
|
+
let studio: StudioRoutes | null = null
|
|
35
|
+
if (existsSync(join(root, '.rv-assets/kit.ts'))) {
|
|
36
|
+
// a broken kit must not take the graphs and status down with it —
|
|
37
|
+
// WARN + mount everything else
|
|
38
|
+
try {
|
|
39
|
+
studio = await studioRoutes({ kit: await loadKit(root), root, page: studioPage })
|
|
40
|
+
} catch (e) {
|
|
41
|
+
io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
45
|
+
return {
|
|
46
|
+
root,
|
|
47
|
+
name: c.name,
|
|
48
|
+
port: o?.port ?? c.serve?.port ?? defaultHubPort(c.name),
|
|
49
|
+
wikiGraph,
|
|
50
|
+
agentGraph,
|
|
51
|
+
studio,
|
|
52
|
+
checkCmd: [process.execPath, cli, 'check'],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
|
|
57
|
+
const c = await loadRepoConfig(root)
|
|
58
|
+
const deps = await resolveHubDeps(root, c, io, o)
|
|
59
|
+
try {
|
|
60
|
+
const hub = serveHub(deps)
|
|
61
|
+
const base = `http://localhost:${hub.server.port}`
|
|
62
|
+
io.print(`ok: ${c.name} hub → ${base}`)
|
|
63
|
+
for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
|
|
64
|
+
await new Promise(() => {}) // the hub owns the process — never resolve
|
|
65
|
+
return 0
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e instanceof Error && e.message.includes('in use')) {
|
|
68
|
+
// same repo in another window is fine; a different repo hashed
|
|
69
|
+
// onto this port is a real collision — never claim its hub
|
|
70
|
+
const owner = await fetch(`http://127.0.0.1:${deps.port}/hub.json`, { signal: AbortSignal.timeout(1500) })
|
|
71
|
+
.then((r) => r.json() as Promise<{ hub?: string; name?: string; tools?: { path: string; label: string }[] }>)
|
|
72
|
+
.catch(() => null)
|
|
73
|
+
if (owner?.hub === 'shipkit' && owner.name === c.name) {
|
|
74
|
+
// a re-run terminal stays as clickable as a first run — the
|
|
75
|
+
// owner's /hub.json tool list is the same source of truth
|
|
76
|
+
io.print(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
|
|
77
|
+
for (const t of owner.tools ?? []) io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
throw e
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// shipkit serve — ONE hub server per repo, ONE port (his call 2026-07-22:
|
|
2
|
+
// "a single shipkit task in vscode with multiple urls I can click"). The
|
|
3
|
+
// landing page's links are the whole toolbox: /graph/wiki · /graph/agent ·
|
|
4
|
+
// /assets/ (the studio, mounted IN-PROCESS) · /status (STATUS.md + run
|
|
5
|
+
// check). Route logic only — resolving WHAT to mount from repo.config is
|
|
6
|
+
// run-serve.ts's job.
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
|
|
10
|
+
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
11
|
+
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
12
|
+
import { landingPage, statusPage } from './hub-pages.ts'
|
|
13
|
+
|
|
14
|
+
export type HubGraph = { title: string; load: () => MdGraph }
|
|
15
|
+
export type HubTool = { path: string; label: string; desc: string }
|
|
16
|
+
export type HubDeps = {
|
|
17
|
+
root: string
|
|
18
|
+
name: string
|
|
19
|
+
/** 0 = random (tests); real launches pass repo.config/default */
|
|
20
|
+
port: number
|
|
21
|
+
wikiGraph: HubGraph
|
|
22
|
+
/** null = no agent/ dir */
|
|
23
|
+
agentGraph: HubGraph | null
|
|
24
|
+
/** null = no .rv-assets/kit.ts (or it failed to load — caller prints the WARN) */
|
|
25
|
+
studio: StudioRoutes | null
|
|
26
|
+
/** argv shelling the check run streamed by /status/run (test seam) */
|
|
27
|
+
checkCmd: string[]
|
|
28
|
+
}
|
|
29
|
+
export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
|
|
30
|
+
|
|
31
|
+
// the toolbox as data — the landing page, /hub.json, and the stdout ok:
|
|
32
|
+
// lines all render THIS list, so they can never disagree
|
|
33
|
+
export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>): HubTool[] {
|
|
34
|
+
const tools: HubTool[] = [{ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title }]
|
|
35
|
+
if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
|
|
36
|
+
if (d.studio != null) tools.push({ path: '/assets/', label: 'assets studio', desc: 'roll, curate, pick, ship' })
|
|
37
|
+
tools.push({ path: '/status', label: 'status', desc: 'STATUS.md + run check' })
|
|
38
|
+
return tools
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serveHub(d: HubDeps): Hub {
|
|
42
|
+
const tools = hubTools(d)
|
|
43
|
+
const html = (s: string): Response => new Response(s, { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
44
|
+
const server: ReturnType<typeof Bun.serve> = Bun.serve({
|
|
45
|
+
hostname: '127.0.0.1',
|
|
46
|
+
port: d.port,
|
|
47
|
+
async fetch(req): Promise<Response> {
|
|
48
|
+
const path = new URL(req.url).pathname
|
|
49
|
+
if (path === '/') return html(landingPage({ name: d.name, port: server.port ?? d.port, tools }))
|
|
50
|
+
// identity probe: lets a second launch tell "same repo, another
|
|
51
|
+
// window" from "a DIFFERENT repo hashed onto my port"
|
|
52
|
+
if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
|
|
53
|
+
if (path === '/graph/wiki') return html(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
|
|
54
|
+
if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
55
|
+
if (d.agentGraph != null && path === '/graph/agent') return html(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
|
|
56
|
+
if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
|
|
57
|
+
if (path === '/status') {
|
|
58
|
+
const p = join(d.root, 'STATUS.md')
|
|
59
|
+
return html(statusPage({ name: d.name, statusMd: existsSync(p) ? await Bun.file(p).text() : null }))
|
|
60
|
+
}
|
|
61
|
+
if (path === '/status/run' && req.method === 'POST') {
|
|
62
|
+
// stream wire lines as the check emits them; sh merges stderr
|
|
63
|
+
// so FAIL lines reach the page, not just the terminal
|
|
64
|
+
const proc = Bun.spawn(['sh', '-c', 'exec "$0" "$@" 2>&1', ...d.checkCmd], { cwd: d.root, stdout: 'pipe', stderr: 'ignore' })
|
|
65
|
+
return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
66
|
+
}
|
|
67
|
+
if (d.studio != null) {
|
|
68
|
+
// the studio page fetches RELATIVE urls — it must live under
|
|
69
|
+
// the slash-terminated prefix for them to resolve
|
|
70
|
+
if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
|
|
71
|
+
if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
|
|
72
|
+
}
|
|
73
|
+
return new Response('not found', { status: 404 })
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
return { server, tools, stop: () => server.stop(true) }
|
|
77
|
+
}
|
package/src/shipkit.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
// rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
|
|
2
2
|
// everything else is the machinery behind the shipkit CLI.
|
|
3
3
|
export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
|
|
4
|
-
export type { RepoConfig,
|
|
4
|
+
export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './repo-config.ts'
|
|
5
|
+
// the hub (`shipkit serve`): one server, one port, every tool a click away
|
|
6
|
+
export { serveHub, hubTools } from './serve/serve-hub.ts'
|
|
7
|
+
export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
|
|
8
|
+
export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
|
|
9
|
+
export { landingPage, statusPage } from './serve/hub-pages.ts'
|
|
10
|
+
export { discoverChecks } from './core/discover-checks.ts'
|
|
11
|
+
export type { DiscoveredCheck } from './core/discover-checks.ts'
|
|
5
12
|
export { runTasks, selectTasks } from './core/task-runner.ts'
|
|
6
13
|
export type { TaskDef, TaskResult, RunOpts, RunSummary } from './core/task-runner.ts'
|
|
7
14
|
export { CheckSuite } from './core/check-suite.ts'
|
|
8
|
-
export type { CheckSuiteOpts, GetSecret, SecretSpec } from './core/check-suite.ts'
|
|
15
|
+
export type { CheckSuiteOpts, CheckOutcome, GetSecret, SecretSpec } from './core/check-suite.ts'
|
|
16
|
+
// tui: type-only here — the store/view/panels import ink+mobx, which must
|
|
17
|
+
// not load on every `shipkit ci`; runtime pieces live at rvlib-shipkit/src/tui/*
|
|
18
|
+
export type { TuiPanelDef, TuiRow, TuiRowStatus, MarkedLine } from './tui/tui-panel.ts'
|
|
9
19
|
export { CheckCtx, RateLimited, ToolUnresolved } from './core/check-ctx.ts'
|
|
10
|
-
export {
|
|
20
|
+
export { spawnWithTimeout } from './core/spawn-timeout.ts'
|
|
21
|
+
export type { SpawnOutcome, SpawnTimeoutOpts } from './core/spawn-timeout.ts'
|
|
22
|
+
export { writeStatus } from './core/write-status.ts'
|
|
23
|
+
export type { StatusInput, StatusOutcome } from './core/write-status.ts'
|
|
24
|
+
export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
|
|
11
25
|
export type { Line } from './core/wire.ts'
|
|
12
26
|
export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
|
|
13
27
|
export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
|
|
@@ -16,6 +30,28 @@ export type { ContractManifest, ArtifactRule, Detector, ContractEnv } from './co
|
|
|
16
30
|
export { allContracts } from './contracts/contract-manifests.ts'
|
|
17
31
|
export { listAsks, openAsks, checkAsks, createAsk, renderAsksTable } from './asks/asks-queue.ts'
|
|
18
32
|
export type { Ask, CreateOpts } from './asks/asks-queue.ts'
|
|
33
|
+
// assets surface (folded in from the standalone atelier lib; that npm
|
|
34
|
+
// package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
|
|
35
|
+
// declares its kinds; `shipkit assets …` derives the rest.
|
|
36
|
+
export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
|
|
37
|
+
export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
38
|
+
export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
|
|
39
|
+
export type { Brand } from './assets/kits/identity-kit.ts'
|
|
40
|
+
export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
|
|
41
|
+
export type { Studio, StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
|
|
42
|
+
export { page as studioPage } from './assets/server/studio-page.ts'
|
|
43
|
+
export { StudioQueue } from './assets/server/studio-queue.ts'
|
|
44
|
+
export type { Job, QueueState } from './assets/server/studio-queue.ts'
|
|
45
|
+
export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'
|
|
46
|
+
export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
|
|
47
|
+
export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
|
|
48
|
+
export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
|
|
49
|
+
export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
|
|
50
|
+
export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
|
|
51
|
+
export { toPng, alphaHeight } from './assets/image-ops.ts'
|
|
52
|
+
export { updateJson, readJson } from './assets/json-store.ts'
|
|
53
|
+
export { assetDir, listRolls, isRollFile, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
|
|
54
|
+
export type { PickState, PromptOverlay } from './assets/roll-workspace.ts'
|
|
19
55
|
export { scanMdGraph } from './wiki/scan-md.ts'
|
|
20
56
|
export { lintMd } from './wiki/lint-md.ts'
|
|
21
57
|
export { serveMdGraph } from './wiki/serve-graph.ts'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// built-in panel: contracts + every discovered *.check.ts through runCheck
|
|
2
|
+
// (both TTL cache layers intact) — per-check results stream in via the
|
|
3
|
+
// onResult tap. status:false: the tasks panel owns STATUS.md in a TUI run.
|
|
4
|
+
import { discoverChecks } from '../core/discover-checks.ts'
|
|
5
|
+
import { runCheck } from '../core/run-check.ts'
|
|
6
|
+
import { formatLine } from '../core/wire.ts'
|
|
7
|
+
import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
|
|
8
|
+
|
|
9
|
+
export function checksPanel(root: string): TuiPanelDef {
|
|
10
|
+
let rows: TuiRow[] = []
|
|
11
|
+
return {
|
|
12
|
+
id: 'checks',
|
|
13
|
+
title: 'Checks',
|
|
14
|
+
rows: () => rows,
|
|
15
|
+
refresh: async (update) => {
|
|
16
|
+
rows = ['contracts', ...discoverChecks(root).map((d) => d.name)]
|
|
17
|
+
.map((n): TuiRow => ({ key: n, label: n, status: 'pending', detail: [] }))
|
|
18
|
+
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
|
19
|
+
update()
|
|
20
|
+
await runCheck(root, {
|
|
21
|
+
status: false,
|
|
22
|
+
print: () => {},
|
|
23
|
+
onResult: (name, r) => {
|
|
24
|
+
const row: TuiRow = {
|
|
25
|
+
key: name,
|
|
26
|
+
label: name,
|
|
27
|
+
status: r.status,
|
|
28
|
+
...(r.cached ? { hint: 'cached' } : {}),
|
|
29
|
+
detail: r.lines.map((l) => formatLine(l, false)),
|
|
30
|
+
}
|
|
31
|
+
const i = byKey.get(name)
|
|
32
|
+
if (i == null) { byKey.set(name, rows.length); rows.push(row) } // suite-side names (secret checks) still land
|
|
33
|
+
else rows[i] = row
|
|
34
|
+
update()
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// built-in panel: the ci gate (floor + contracts + repo tasks) through
|
|
2
|
+
// runCi — results stream in via the onTaskFinish tap. STATUS.md refreshes
|
|
3
|
+
// exactly like a terminal `shipkit ci`; the checks panel writes nothing
|
|
4
|
+
// (one STATUS writer per TUI session).
|
|
5
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
6
|
+
import { allTasks } from '../repo-config.ts'
|
|
7
|
+
import { runCi } from '../core/run-ci.ts'
|
|
8
|
+
import { formatLine } from '../core/wire.ts'
|
|
9
|
+
import type { TaskResult } from '../core/task-runner.ts'
|
|
10
|
+
import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
|
|
11
|
+
|
|
12
|
+
const taskRow = (r: TaskResult): TuiRow => ({
|
|
13
|
+
key: r.id,
|
|
14
|
+
label: r.id,
|
|
15
|
+
status: r.status === 'pass' ? (r.lines.some((l) => l.kind === 'warn') ? 'warn' : 'pass') : r.status === 'fail' ? 'fail' : 'skip',
|
|
16
|
+
hint: `${(r.durationMs / 1000).toFixed(1)}s`,
|
|
17
|
+
detail: [
|
|
18
|
+
...r.lines.map((l) => formatLine(l, false)),
|
|
19
|
+
...(r.status === 'fail' && r.lastLine !== '' ? [`FAIL: ${r.lastLine}`] : []),
|
|
20
|
+
...(r.note !== '' ? [`skip: ${r.note}`] : []),
|
|
21
|
+
...(r.logPath != null ? [`full log: ${r.logPath}`] : []),
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export function tasksPanel(root: string, config: RepoConfig): TuiPanelDef {
|
|
26
|
+
let rows: TuiRow[] = []
|
|
27
|
+
return {
|
|
28
|
+
id: 'tasks',
|
|
29
|
+
title: 'Tasks',
|
|
30
|
+
rows: () => rows,
|
|
31
|
+
refresh: async (update) => {
|
|
32
|
+
const { tasks, disabled, unfilled } = allTasks(config, root)
|
|
33
|
+
rows = [
|
|
34
|
+
...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
|
|
35
|
+
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: disabled by repo.config defaults'] })),
|
|
36
|
+
...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
|
|
37
|
+
]
|
|
38
|
+
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
|
39
|
+
update()
|
|
40
|
+
await runCi(root, {
|
|
41
|
+
print: () => {}, // rows carry everything the wire lines would
|
|
42
|
+
onTaskFinish: (r) => {
|
|
43
|
+
const i = byKey.get(r.id)
|
|
44
|
+
if (i == null) return
|
|
45
|
+
rows[i] = taskRow(r)
|
|
46
|
+
update()
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
// bail/interrupt leftovers never got a finish tap — visible, never a fake pass
|
|
50
|
+
rows = rows.map((r) => (r.status === 'pending' ? { ...r, status: 'skip', detail: ['skip: never ran (bailed or interrupted)'] } : r))
|
|
51
|
+
update()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// shipkit tui — the generic view: tab bar, list left, detail right, wire
|
|
2
|
+
// lines normalized to one visual language (✓/✗/⚠/~/»). All data comes from
|
|
3
|
+
// panels (tui-panel.ts); this file owns ONLY rendering + keys.
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { Box, Text, useApp, useInput } from 'ink'
|
|
6
|
+
import { observer } from 'mobx-react-lite'
|
|
7
|
+
import { markLine, ROW_MARK, type TuiRow } from './tui-panel.ts'
|
|
8
|
+
import type { TuiStore } from './tui-store.ts'
|
|
9
|
+
|
|
10
|
+
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
11
|
+
|
|
12
|
+
const RowLine = observer(function RowLine(p: { row: TuiRow; selected: boolean; spin: number; width: number }) {
|
|
13
|
+
const m = ROW_MARK[p.row.status]
|
|
14
|
+
const mark = p.row.status === 'pending' ? SPIN[p.spin % SPIN.length]! : (p.row.mark ?? m.mark)
|
|
15
|
+
return (
|
|
16
|
+
<Text inverse={p.selected} wrap="truncate">
|
|
17
|
+
<Text color={m.color}>{mark.padEnd(2)}</Text>
|
|
18
|
+
<Text dimColor={p.row.status === 'pending'}>{p.row.label.padEnd(p.width)}</Text>
|
|
19
|
+
{p.row.hint != null && <Text dimColor> {p.row.hint}</Text>}
|
|
20
|
+
</Text>
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const Detail = observer(function Detail(p: { row: TuiRow; spin: number }) {
|
|
25
|
+
const m = ROW_MARK[p.row.status]
|
|
26
|
+
return (
|
|
27
|
+
<Box flexDirection="column" borderStyle="round" borderColor={m.color ?? 'gray'} paddingX={1} flexGrow={1}>
|
|
28
|
+
<Box justifyContent="space-between">
|
|
29
|
+
<Text bold>{p.row.label}</Text>
|
|
30
|
+
{p.row.status === 'pending'
|
|
31
|
+
? <Text color="cyan">{SPIN[p.spin % SPIN.length]} running</Text>
|
|
32
|
+
: <Text bold color={m.color}>{p.row.status.toUpperCase()}</Text>}
|
|
33
|
+
</Box>
|
|
34
|
+
{p.row.detail.map((l, i) => {
|
|
35
|
+
const c = markLine(l)
|
|
36
|
+
return <Text key={i} bold={c.bold} wrap="truncate-end"><Text color={c.color}>{c.mark}</Text> {c.text}</Text>
|
|
37
|
+
})}
|
|
38
|
+
{p.row.status !== 'pending' && p.row.detail.length === 0 && <Text dimColor>no output</Text>}
|
|
39
|
+
</Box>
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const Summary = observer(function Summary(p: { store: TuiStore }) {
|
|
44
|
+
const c = p.store.counts
|
|
45
|
+
return (
|
|
46
|
+
<Text>
|
|
47
|
+
<Text color="green">{c.pass} ok</Text><Text dimColor> · </Text>
|
|
48
|
+
<Text color="yellow">{c.warn} warn</Text><Text dimColor> · </Text>
|
|
49
|
+
<Text color="red">{c.fail} fail</Text>
|
|
50
|
+
{c.pending > 0 && <Text color="cyan"> · {SPIN[p.store.spin % SPIN.length]} {c.pending} running</Text>}
|
|
51
|
+
</Text>
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const TuiApp = observer(function TuiApp(p: { store: TuiStore }) {
|
|
56
|
+
const app = useApp()
|
|
57
|
+
const s = p.store
|
|
58
|
+
useInput((input, key) => {
|
|
59
|
+
if (input === 'q') app.exit()
|
|
60
|
+
else if (input === 'r') s.refresh()
|
|
61
|
+
else if (/^[1-9]$/.test(input)) s.setTab(Number(input) - 1)
|
|
62
|
+
else if (key.tab || key.rightArrow) s.cycleTab(1)
|
|
63
|
+
else if (key.leftArrow) s.cycleTab(-1)
|
|
64
|
+
else if (key.downArrow || input === 'j') s.move(1)
|
|
65
|
+
else if (key.upArrow || input === 'k') s.move(-1)
|
|
66
|
+
})
|
|
67
|
+
const rows = s.activeRows
|
|
68
|
+
const width = Math.max(8, ...rows.map((r) => r.label.length))
|
|
69
|
+
return (
|
|
70
|
+
<Box flexDirection="column">
|
|
71
|
+
<Box justifyContent="space-between">
|
|
72
|
+
<Text bold color="cyan">⚙ {s.name} — shipkit</Text>
|
|
73
|
+
<Summary store={s} />
|
|
74
|
+
</Box>
|
|
75
|
+
<Box gap={2}>
|
|
76
|
+
{s.panels.map((panel, i) => (
|
|
77
|
+
<Text key={panel.id} bold inverse={i === s.tab}> {i + 1} {panel.title}{s.rowsOf(panel).length > 0 ? ` (${s.rowsOf(panel).length})` : ''} </Text>
|
|
78
|
+
))}
|
|
79
|
+
{s.error != null && <Text color="red">{s.error}</Text>}
|
|
80
|
+
</Box>
|
|
81
|
+
<Box gap={1}>
|
|
82
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
83
|
+
{rows.length === 0 && <Text color="green">{s.activePanel.emptyText ?? '(empty)'}</Text>}
|
|
84
|
+
{rows.map((r, i) => <RowLine key={r.key} row={r} selected={i === s.sel} spin={s.spin} width={width} />)}
|
|
85
|
+
</Box>
|
|
86
|
+
{s.selectedRow != null && <Detail row={s.selectedRow} spin={s.spin} />}
|
|
87
|
+
</Box>
|
|
88
|
+
<Text dimColor>1-{s.panels.length}/tab switch · j/k move · r refresh · q quit</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
)
|
|
91
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// TUI panel contract — render-free and mobx-free on purpose: a panel is
|
|
2
|
+
// DATA (rows + wire-format detail lines), the generic view (tui-app.tsx)
|
|
3
|
+
// owns all ink. Built-ins: tasks-panel.ts (the ci gate) + checks-panel.ts
|
|
4
|
+
// (contracts + discovered *.check.ts); a repo injects extras via
|
|
5
|
+
// repo.config `tui: { panels: [...] }`.
|
|
6
|
+
|
|
7
|
+
export type TuiRowStatus = 'pass' | 'fail' | 'warn' | 'skip' | 'pending' | 'info'
|
|
8
|
+
|
|
9
|
+
export type TuiRow = {
|
|
10
|
+
key: string
|
|
11
|
+
/** left-list label */
|
|
12
|
+
label: string
|
|
13
|
+
/** drives the list mark + detail border color; 'pending' shows the spinner */
|
|
14
|
+
status: TuiRowStatus
|
|
15
|
+
/** dim right-side hint (duration, age, cached, owner) */
|
|
16
|
+
hint?: string
|
|
17
|
+
/** list-mark override (default derives from status — asks rows use '?') */
|
|
18
|
+
mark?: string
|
|
19
|
+
/** detail-panel lines — wire format (ok:/WARN/FAIL:/skip:/fix:) or plain text */
|
|
20
|
+
detail: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TuiPanelDef = {
|
|
24
|
+
id: string
|
|
25
|
+
/** tab label */
|
|
26
|
+
title: string
|
|
27
|
+
/** shown green when rows() is empty after refresh (e.g. "no open asks …") */
|
|
28
|
+
emptyText?: string
|
|
29
|
+
/** current rows — the store re-reads after every update() */
|
|
30
|
+
rows: () => TuiRow[]
|
|
31
|
+
/** (re)load data; call update() after each mutation so the view repaints.
|
|
32
|
+
* The spinner runs until every panel's returned promise settled. */
|
|
33
|
+
refresh: (update: () => void) => void | Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const ROW_MARK: Record<TuiRowStatus, { mark: string; color?: string }> = {
|
|
37
|
+
pass: { mark: '✓', color: 'green' },
|
|
38
|
+
fail: { mark: '✗', color: 'red' },
|
|
39
|
+
warn: { mark: '⚠', color: 'yellow' },
|
|
40
|
+
skip: { mark: '~', color: 'gray' },
|
|
41
|
+
pending: { mark: '', color: 'cyan' }, // the view substitutes the spinner frame
|
|
42
|
+
info: { mark: '·' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// normalize a wire/detail line to the one visual language (✓/✗/⚠/~/»),
|
|
46
|
+
// no raw 'ok'/'FAIL' prefixes in the detail panel
|
|
47
|
+
export type MarkedLine = { mark: string; color?: string; text: string; bold?: boolean }
|
|
48
|
+
|
|
49
|
+
export const markLine = (l: string): MarkedLine => {
|
|
50
|
+
if (/^ok[:\s]/.test(l)) return { mark: '✓', color: 'green', text: l.replace(/^ok[:\s]+/, '') }
|
|
51
|
+
if (l.startsWith('FAIL')) return { mark: '✗', color: 'red', text: l.replace(/^FAIL[:\s]+/, '') }
|
|
52
|
+
if (l.startsWith('WARN')) return { mark: '⚠', color: 'yellow', text: l.replace(/^WARN[:\s]+/, '') }
|
|
53
|
+
if (l.startsWith('skip:')) return { mark: '~', color: 'gray', text: l.replace(/^skip:\s*/, '') }
|
|
54
|
+
if (/^\s+fix: /.test(l)) return { mark: '»', color: 'yellow', text: l.trim() }
|
|
55
|
+
if (l.startsWith('VERDICT')) return { mark: '»', text: l, bold: true }
|
|
56
|
+
return { mark: ' ', text: l }
|
|
57
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// TUI state — MobX per the house playbook (build/state + build/mobx):
|
|
2
|
+
// `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
|
|
3
|
+
// decorators), runInAction around every post-await mutation. Panels are
|
|
4
|
+
// plain data providers (no mobx): they signal mutations through update(),
|
|
5
|
+
// which bumps the observable `version` counter every rows() read tracks.
|
|
6
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
+
import type { TuiPanelDef, TuiRow, TuiRowStatus } from './tui-panel.ts'
|
|
8
|
+
|
|
9
|
+
export class TuiStore {
|
|
10
|
+
tab = 0
|
|
11
|
+
sel = 0
|
|
12
|
+
spin = 0 // spinner frame, ticked while any panel refresh is in flight
|
|
13
|
+
version = 0 // bumped by panel update() — activeRows re-derives from it
|
|
14
|
+
error: string | null = null
|
|
15
|
+
inflight = 0 // panel refresh promises not yet settled
|
|
16
|
+
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly panels: TuiPanelDef[]
|
|
19
|
+
private spinTimer: ReturnType<typeof setInterval> | null = null
|
|
20
|
+
|
|
21
|
+
constructor(name: string, panels: TuiPanelDef[]) {
|
|
22
|
+
if (panels.length === 0) throw new Error('TuiStore: at least one panel required')
|
|
23
|
+
this.name = name
|
|
24
|
+
this.panels = panels
|
|
25
|
+
// panels/name/spinTimer are infra, never observed
|
|
26
|
+
makeAutoObservable<TuiStore, 'spinTimer'>(this, { name: false, panels: false, spinTimer: false })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get activePanel(): TuiPanelDef {
|
|
30
|
+
return this.panels[this.tab]!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get activeRows(): TuiRow[] {
|
|
34
|
+
void this.version // panels mutate outside mobx — update() bumps this
|
|
35
|
+
return this.activePanel.rows()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get selectedRow(): TuiRow | null {
|
|
39
|
+
return this.activeRows[this.sel] ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** per-status counts over the ACTIVE panel — the header summary */
|
|
43
|
+
get counts(): Record<TuiRowStatus, number> {
|
|
44
|
+
const c: Record<TuiRowStatus, number> = { pass: 0, fail: 0, warn: 0, skip: 0, pending: 0, info: 0 }
|
|
45
|
+
for (const r of this.activeRows) c[r.status]++
|
|
46
|
+
return c
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
rowsOf(panel: TuiPanelDef): TuiRow[] {
|
|
50
|
+
void this.version
|
|
51
|
+
return panel.rows()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setTab(i: number): void {
|
|
55
|
+
if (i < 0 || i >= this.panels.length || i === this.tab) return
|
|
56
|
+
this.tab = i
|
|
57
|
+
this.sel = 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cycleTab(delta = 1): void {
|
|
61
|
+
this.setTab((this.tab + delta + this.panels.length) % this.panels.length)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
move(delta: number): void {
|
|
65
|
+
const max = Math.max(0, this.activeRows.length - 1)
|
|
66
|
+
this.sel = Math.min(max, Math.max(0, this.sel + delta))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// kick every panel's refresh; results stream in via update(). A refresh
|
|
70
|
+
// while one is in flight is ignored — panels own no superseding logic.
|
|
71
|
+
refresh(): void {
|
|
72
|
+
if (this.inflight > 0) return
|
|
73
|
+
this.error = null
|
|
74
|
+
this.startSpinner()
|
|
75
|
+
const update = (): void => runInAction(() => { this.version++ })
|
|
76
|
+
for (const p of this.panels) {
|
|
77
|
+
this.inflight++
|
|
78
|
+
void Promise.resolve()
|
|
79
|
+
.then(() => p.refresh(update))
|
|
80
|
+
.catch((e) => runInAction(() => { this.error = `${p.id}: ${String(e).slice(0, 200)}` })) // never fail silently — surfaced in the header
|
|
81
|
+
.finally(() => runInAction(() => {
|
|
82
|
+
this.version++
|
|
83
|
+
if (--this.inflight === 0) this.stopSpinner()
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private startSpinner(): void {
|
|
89
|
+
if (this.spinTimer != null) return
|
|
90
|
+
this.spinTimer = setInterval(() => runInAction(() => { this.spin++ }), 120)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private stopSpinner(): void {
|
|
94
|
+
if (this.spinTimer == null) return
|
|
95
|
+
clearInterval(this.spinTimer)
|
|
96
|
+
this.spinTimer = null
|
|
97
|
+
}
|
|
98
|
+
}
|