rvlib-shipkit 0.38.1 → 1.0.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 +367 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>hub</title>
|
|
7
7
|
<link rel="stylesheet" href="./hub.css" />
|
|
8
|
+
<link rel="stylesheet" href="./project-gutter.css" />
|
|
9
|
+
<link rel="stylesheet" href="./prose-hits.css" />
|
|
8
10
|
</head>
|
|
9
11
|
<body>
|
|
10
12
|
<div id="root"></div>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The live strip under the header, as the page holds it: `/api/live.json`
|
|
2
|
+
// read every second (the runs of the last ten minutes, the ones in flight,
|
|
3
|
+
// here and in the other house repos), whether the strip is folded, and the
|
|
4
|
+
// chart it draws. Folded, the chart is the thin rolling window of the last
|
|
5
|
+
// two minutes; unfolded, the whole record (the recent runs of /api/live.json
|
|
6
|
+
// over the older ones of /api/timeline.json), opened on its last ten minutes
|
|
7
|
+
// and fit to all of it on demand. It is the hub's one run chart
|
|
8
|
+
// (chart-view.ts, timeline-chart.tsx).
|
|
9
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
10
|
+
import type { LiveView } from '../../ledger/live-timeline.ts'
|
|
11
|
+
import type { TimelineInvocation } from '../../ledger/run-timeline.ts'
|
|
12
|
+
import { ChartView, type ChartSource } from './chart-view.ts'
|
|
13
|
+
import type { HubSt } from './hub-st.ts'
|
|
14
|
+
import { chartHeight } from './timeline-geometry.ts'
|
|
15
|
+
|
|
16
|
+
/** how often the page asks. A strip a second behind the runner is still live */
|
|
17
|
+
export const LIVE_POLL_MS = 1_000
|
|
18
|
+
/** the folded strip's window, and the span the unfolded one opens on */
|
|
19
|
+
export const FOLDED_WINDOW_MS = 120_000
|
|
20
|
+
export const OPEN_WINDOW_MS = 600_000
|
|
21
|
+
/** the most the unfolded chart grows to: past it the lanes scroll inside it */
|
|
22
|
+
export const OPEN_MAX_H = 320
|
|
23
|
+
/** how long the unfolded chart keeps a height its lanes no longer fill,
|
|
24
|
+
* so it grows at once and never shrinks under the eye every second */
|
|
25
|
+
export const HEIGHT_HOLD_MS = 10_000
|
|
26
|
+
|
|
27
|
+
/** the height the unfolded chart shows: up at once, down only once the
|
|
28
|
+
* lanes have wanted less for HEIGHT_HOLD_MS */
|
|
29
|
+
export type HeldHeight = { h: number; lowSince: number | null }
|
|
30
|
+
export function settleHeight(prev: HeldHeight, wanted: number, now: number, holdMs = HEIGHT_HOLD_MS): HeldHeight {
|
|
31
|
+
if (wanted >= prev.h) return { h: wanted, lowSince: null }
|
|
32
|
+
if (prev.lowSince == null) return { h: prev.h, lowSince: now }
|
|
33
|
+
return now - prev.lowSince >= holdMs ? { h: wanted, lowSince: null } : prev
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** the whole record the unfolded chart draws: every run of the live view,
|
|
37
|
+
* then the older runs of the record it does not carry, newest first */
|
|
38
|
+
export function mergeRuns(recent: TimelineInvocation[], record: TimelineInvocation[]): TimelineInvocation[] {
|
|
39
|
+
const seen = new Set(recent.map((i) => i.runId))
|
|
40
|
+
return [...recent, ...record.filter((i) => !seen.has(i.runId))].sort((a, b) => b.started - a.started)
|
|
41
|
+
}
|
|
42
|
+
/** the lanes the folded strip has room for */
|
|
43
|
+
export const FOLDED_LANES = 5
|
|
44
|
+
|
|
45
|
+
/** where the fold is remembered. localStorage is per origin and every
|
|
46
|
+
* repo's hub has its own port, so this is per repo already */
|
|
47
|
+
export const OPEN_KEY = 'hub:liveOpen'
|
|
48
|
+
|
|
49
|
+
const readOpen = (): boolean => {
|
|
50
|
+
try { return globalThis.localStorage?.getItem(OPEN_KEY) === '1' } catch { return false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** the server clock now: the view's stamp plus what the page clock has
|
|
54
|
+
* counted since it arrived, since the page may be another machine */
|
|
55
|
+
export const serverNow = (view: LiveView, receivedAt: number, clientNow: number): number => view.now + Math.max(0, clientNow - receivedAt)
|
|
56
|
+
|
|
57
|
+
/** what the chart draws: folded, the rolling two minutes; open, the whole
|
|
58
|
+
* record, opening on its last ten */
|
|
59
|
+
export function liveSource(view: LiveView | null, now: number, open: boolean, record: TimelineInvocation[] = []): ChartSource | null {
|
|
60
|
+
if (view == null) return null
|
|
61
|
+
if (!open) return { invocations: view.invocations, now, windowMs: FOLDED_WINDOW_MS, laneCap: Math.max(1, FOLDED_LANES - view.repos.length), zoomable: false, cap: view.cap }
|
|
62
|
+
return { invocations: mergeRuns(view.invocations, record), now, windowMs: null, zoomable: true, cap: view.cap, homeSpanMs: OPEN_WINDOW_MS }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class LiveSvc {
|
|
66
|
+
view: LiveView | null = null
|
|
67
|
+
/** the page clock when `view` arrived */
|
|
68
|
+
receivedAt = 0
|
|
69
|
+
error = ''
|
|
70
|
+
/** a short answer to a click (a repo whose hub is not serving) */
|
|
71
|
+
note = ''
|
|
72
|
+
open = readOpen()
|
|
73
|
+
/** the unfolded chart's height, held so it does not jump every second */
|
|
74
|
+
held: HeldHeight = { h: 0, lowSince: null }
|
|
75
|
+
chart: ChartView
|
|
76
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
77
|
+
private inFlight = false
|
|
78
|
+
|
|
79
|
+
constructor(private st: HubSt) {
|
|
80
|
+
this.chart = new ChartView(null, () => liveSource(this.view, this.now, this.open, this.st.timeline.data?.invocations ?? []))
|
|
81
|
+
makeAutoObservable<this, 'st' | 'timer' | 'inFlight'>(this, { st: false, timer: false, inFlight: false, view: observableRef, chart: false })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the server clock, as of the page clock's last tick */
|
|
85
|
+
get now(): number {
|
|
86
|
+
return this.view == null ? this.st.clock.now : serverNow(this.view, this.receivedAt, this.st.clock.now)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
toggle(): void {
|
|
90
|
+
this.open = !this.open
|
|
91
|
+
try { globalThis.localStorage?.setItem(OPEN_KEY, this.open ? '1' : '0') } catch { /* private window: the fold lasts the tab */ }
|
|
92
|
+
// either fold starts live: the folded strip is a trail, nothing else
|
|
93
|
+
this.chart.attach()
|
|
94
|
+
this.opened()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** unfolded: back to the last ten minutes, the older record fetched, and
|
|
98
|
+
* a height from the lanes as they are */
|
|
99
|
+
private opened(): void {
|
|
100
|
+
if (!this.open) return
|
|
101
|
+
this.chart.home()
|
|
102
|
+
this.chart.attach()
|
|
103
|
+
this.held = { h: 0, lowSince: null }
|
|
104
|
+
this.settle(this.st.clock.now)
|
|
105
|
+
if (this.st.timeline.data == null) void this.st.timeline.load()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** the height the lanes want now, as the held rule lets it show */
|
|
109
|
+
settle(now: number): void {
|
|
110
|
+
const l = this.chart.layout
|
|
111
|
+
const lanes = (l?.lanes ?? 0) + (this.view?.repos.length ?? 0)
|
|
112
|
+
this.held = settleHeight(this.held, Math.min(OPEN_MAX_H, chartHeight(lanes)), now)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
start(): void {
|
|
116
|
+
if (this.timer != null) return
|
|
117
|
+
this.opened()
|
|
118
|
+
void this.poll()
|
|
119
|
+
this.timer = setInterval(() => void this.poll(), LIVE_POLL_MS)
|
|
120
|
+
}
|
|
121
|
+
dispose(): void {
|
|
122
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
123
|
+
this.timer = null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async poll(): Promise<void> {
|
|
127
|
+
// a slow answer must not stack a second request behind it
|
|
128
|
+
if (this.inFlight) return
|
|
129
|
+
this.inFlight = true
|
|
130
|
+
try {
|
|
131
|
+
const r = await fetch('/api/live.json')
|
|
132
|
+
if (!r.ok) throw new Error(await r.text())
|
|
133
|
+
const v = (await r.json()) as LiveView
|
|
134
|
+
const ended = this.running && v.runs.length === 0
|
|
135
|
+
runInAction(() => {
|
|
136
|
+
this.view = v
|
|
137
|
+
this.receivedAt = Date.now()
|
|
138
|
+
this.error = ''
|
|
139
|
+
if (this.open) this.settle(Date.now())
|
|
140
|
+
})
|
|
141
|
+
// a run that ended, from a terminal as much as from a button,
|
|
142
|
+
// wrote new numbers: the rows and the timeline read them now
|
|
143
|
+
// rather than on the next ten second poll
|
|
144
|
+
if (ended) {
|
|
145
|
+
void this.st.cols.poll()
|
|
146
|
+
if (this.st.timeline.data != null) void this.st.timeline.load()
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
runInAction(() => { this.error = `the live runs could not be read: ${(e as Error).message}` })
|
|
150
|
+
} finally {
|
|
151
|
+
this.inFlight = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get running(): boolean {
|
|
156
|
+
return (this.view?.runs.length ?? 0) > 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** a repo lane was clicked: its hub, when it serves */
|
|
160
|
+
async openRepo(repo: string): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const r = (await (await fetch(`/api/repo-hub?repo=${encodeURIComponent(repo)}`)).json()) as { url: string | null }
|
|
163
|
+
if (r.url != null) window.open(r.url, '_blank')
|
|
164
|
+
else this.say(`${repo} has no hub serving`)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
this.say(`${repo}: ${(e as Error).message}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private say(text: string): void {
|
|
171
|
+
runInAction(() => { this.note = text })
|
|
172
|
+
setTimeout(() => runInAction(() => { if (this.note === text) this.note = '' }), 4_000)
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/serve/hub-app/md.ts
CHANGED
|
@@ -22,3 +22,18 @@ export const renderMd = (src: string): string =>
|
|
|
22
22
|
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
23
23
|
},
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
/** ONE LINE of markdown, for text that already carries a line structure of
|
|
27
|
+
* its own. A wire line keeps its prefix and its place in the log, and the
|
|
28
|
+
* backtick spans and urls inside it still render: no block tag survives the
|
|
29
|
+
* allowlist, so nothing here can break the line it was given. */
|
|
30
|
+
export const renderMdInline = (src: string): string =>
|
|
31
|
+
sanitizeHtml(marked.parseInline(src) as string, {
|
|
32
|
+
allowedTags: ['code', 'strong', 'em', 'b', 'i', 'a'],
|
|
33
|
+
allowedAttributes: { a: ['href', 'target', 'rel'] },
|
|
34
|
+
allowedSchemes: ['http', 'https'],
|
|
35
|
+
allowProtocolRelative: false,
|
|
36
|
+
transformTags: {
|
|
37
|
+
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
38
|
+
},
|
|
39
|
+
})
|
|
@@ -552,7 +552,10 @@ export class MediaSvc {
|
|
|
552
552
|
if (k === 'ArrowRight' && i < rolls.length - 1) this.setActive(rolls[i + 1]!.name)
|
|
553
553
|
else if (k === 'ArrowLeft' && i > 0) this.setActive(rolls[i - 1]!.name)
|
|
554
554
|
else if (k === 'Enter' && this.active != null) void this.pick(this.active)
|
|
555
|
-
|
|
555
|
+
// SHIFT+A only, the binding the help line prints: a bare `a` belongs
|
|
556
|
+
// to the page (the all/needs/red filter), and a letter bound in both
|
|
557
|
+
// places is bound in neither
|
|
558
|
+
else if (k === 'A' && this.active != null) void this.alt(this.active)
|
|
556
559
|
else if ((k === 'x' || k === 'X') && this.active != null) void this.discard(this.active)
|
|
557
560
|
else return false
|
|
558
561
|
return true
|
|
@@ -144,6 +144,10 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
|
|
|
144
144
|
const label = (id: string): string =>
|
|
145
145
|
id === 'json' ? 'asset json' : id === 'meta' ? 'roll meta' : id.startsWith('c:') ? id.slice(2) : id
|
|
146
146
|
const cur = [...m.tools].sort().join(',')
|
|
147
|
+
// one draft per KIND, never per asset: tuning the loras for the road rat is
|
|
148
|
+
// tuning them for all 107 creatures, which is the point of a shared draft
|
|
149
|
+
const wfId = d.info?.workflow?.id ?? null
|
|
150
|
+
const kindId = d.info?.kindId ?? 'default'
|
|
147
151
|
return (
|
|
148
152
|
<div className="toolsbar">
|
|
149
153
|
{/* tabs = tool presets (kit-declared first) — switching swaps the
|
|
@@ -157,6 +161,12 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
|
|
|
157
161
|
{m.validTools(d).map((id) => (
|
|
158
162
|
<button key={id} className={`chip${m.tools.has(id) ? ' on' : ''}`} onClick={() => m.toggleTool(id)}>{label(id)}</button>
|
|
159
163
|
))}
|
|
164
|
+
{/* the workflow behind this asset, tuned where the asset already is.
|
|
165
|
+
Only on an asset a comfy workflow actually draws */}
|
|
166
|
+
{wfId != null ? (
|
|
167
|
+
<button className="chip" title={`tune ${wfId} for every ${kindId} asset`}
|
|
168
|
+
onClick={() => void app.comfy.tune(wfId, kindId)}>tune workflow</button>
|
|
169
|
+
) : null}
|
|
160
170
|
<span className="sp" />
|
|
161
171
|
<button className="ghost" onClick={() => m.toggleVariants()}>
|
|
162
172
|
{m.variantsOpen ? 'variants ▸' : `variants (${d.rolls.length}) ◂`}
|
|
@@ -263,6 +273,11 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
263
273
|
<label>size for ALL {cat} <input type="number" step={0.1} min={0.1} value={m.allSize}
|
|
264
274
|
onChange={(e) => runInAction(() => { m.allSize = e.target.value })} /></label>
|
|
265
275
|
<button className="ghost" onClick={() => void m.applyAllSize()}>apply</button>
|
|
276
|
+
{/* rolls for the whole category, the one control the tree
|
|
277
|
+
has no row to carry */}
|
|
278
|
+
{m.catRows.every((r) => m.assets.find((a) => a.id === r.id)?.manual === true) ? null : (
|
|
279
|
+
<span className="catgen">{[1, 2, 3].map((n) => <button key={n} className="ghost" onClick={() => void m.genCat(cat, n)}>+{n}</button>)}</span>
|
|
280
|
+
)}
|
|
266
281
|
<span className="jmsg">{m.allMsg}</span>
|
|
267
282
|
</div>
|
|
268
283
|
<div className="cards">
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check on a clock
|
|
2
|
+
// except the hub's daily health pass (serve/health-schedule.ts), so the answer
|
|
3
|
+
// is read off where the check runs (`when`), what it remembers, and that one
|
|
4
|
+
// timer. Pure, so every case is pinned by a test rather than a screenshot.
|
|
5
|
+
import type { Schedule, CheckFacts } from './hub-types.ts'
|
|
6
|
+
|
|
7
|
+
export type NextRun = {
|
|
8
|
+
/** the value the page shows */
|
|
9
|
+
words: string
|
|
10
|
+
/** the exact moment, when there is one: the tooltip's stamp */
|
|
11
|
+
at: number | null
|
|
12
|
+
/** the sentence behind the value, for the row's help */
|
|
13
|
+
help: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MIN = 60_000
|
|
17
|
+
const HOUR = 60 * MIN
|
|
18
|
+
const DAY = 24 * HOUR
|
|
19
|
+
|
|
20
|
+
/** a span ahead, in the two largest units: `~14h`, `~3d11h`, `~25m` */
|
|
21
|
+
export function aheadWords(ms: number): string {
|
|
22
|
+
if (ms < MIN) return '<1m'
|
|
23
|
+
if (ms < HOUR) return `~${Math.round(ms / MIN)}m`
|
|
24
|
+
if (ms < DAY) return `~${Math.round(ms / HOUR)}h`
|
|
25
|
+
let d = Math.floor(ms / DAY)
|
|
26
|
+
let h = Math.round((ms - d * DAY) / HOUR)
|
|
27
|
+
if (h === 24) { d++; h = 0 }
|
|
28
|
+
return `~${d}d${h > 0 ? `${h}h` : ''}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** a span as a rhythm: `24h`, `3d`, `90m`. Whole days read as days from two
|
|
32
|
+
* days up, because `every ~72h` makes a reader do the division */
|
|
33
|
+
export function spanWords(ms: number): string {
|
|
34
|
+
if (ms >= 2 * DAY && ms % DAY === 0) return `${ms / DAY}d`
|
|
35
|
+
if (ms >= HOUR) return `${Math.round((ms / HOUR) * 10) / 10}h`
|
|
36
|
+
if (ms >= MIN) return `${Math.round(ms / MIN)}m`
|
|
37
|
+
return `${Math.round(ms / 1000)}s`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'> & { keepsUntil?: number | null }
|
|
41
|
+
|
|
42
|
+
export function nextRun(u: NextRunFacts, s: Schedule | null, now: number): NextRun {
|
|
43
|
+
if (u.when === 'always')
|
|
44
|
+
return { words: 'next shipkit ci', at: null, help: 'it runs on every `shipkit ci` and in every health pass. A cached answer is used when nothing it depends on moved' }
|
|
45
|
+
if (u.when === 'release')
|
|
46
|
+
return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it, and so does the health pass. The quick gate you run while working does not' }
|
|
47
|
+
if (u.when === 'off')
|
|
48
|
+
return { words: u.whenReason != null && u.whenReason !== '' ? `never: ${u.whenReason}` : 'never', at: null, help: 'it is off: nothing runs it until the switch below puts it somewhere' }
|
|
49
|
+
if (s == null)
|
|
50
|
+
return { words: 'next health pass', at: null, help: 'this hub runs no daily timer, so it runs when `shipkit check` runs or when you press run the health pass' }
|
|
51
|
+
if (s.running) return { words: 'running now', at: now, help: 'the daily health pass is running right now' }
|
|
52
|
+
const pass = Math.max(now, s.nextAt)
|
|
53
|
+
const keeps = u.keepsUntil ?? null
|
|
54
|
+
if (keeps != null && keeps > pass) {
|
|
55
|
+
const k = Math.ceil((keeps - pass) / s.everyMs)
|
|
56
|
+
const at = pass + k * s.everyMs
|
|
57
|
+
return {
|
|
58
|
+
words: `in ${aheadWords(at - now)}`,
|
|
59
|
+
at,
|
|
60
|
+
help: `the health pass runs every ${spanWords(s.everyMs)}, and uses this check's cached answer until it runs out. The first pass after that runs it again`,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
words: pass <= now ? 'due now' : `in ${aheadWords(pass - now)}`,
|
|
65
|
+
at: pass,
|
|
66
|
+
help: `the next health pass: the hub runs it every ${spanWords(s.everyMs)} while it serves, and at startup when the last one is older than that`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* the project gutter. It is FIXED to the left edge, so it takes no cell of
|
|
2
|
+
the .app grid, and the page makes room for it by padding, whatever the
|
|
3
|
+
grid holds. Colours are hub.css tokens only, spacing is its scale:
|
|
4
|
+
tests/hub-css-invariants.test.ts reads this sheet too. */
|
|
5
|
+
.app:has(> .pgut){--gut:44px;padding-left:var(--gut)}
|
|
6
|
+
.pgut{position:fixed;left:0;top:0;bottom:0;width:var(--gut,44px);z-index:5;display:flex;flex-direction:column;align-items:center;
|
|
7
|
+
gap:var(--s1);padding:var(--s2) 0;background:var(--bg);border-right:1px solid var(--line);overflow-y:auto;scrollbar-width:none}
|
|
8
|
+
.pgt{position:relative;flex:none;width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;
|
|
9
|
+
background:transparent;border:0;border-radius:6px;cursor:pointer}
|
|
10
|
+
.pgt:hover{background:var(--panel2)}
|
|
11
|
+
.pgt.cur{background:var(--sel)}
|
|
12
|
+
.pgt img{width:24px;height:24px;border-radius:5px;object-fit:cover;pointer-events:none}
|
|
13
|
+
/* the initial on a hue hashed from the name (projects-svc hueOf) */
|
|
14
|
+
.pgi{width:24px;height:24px;border-radius:5px;display:flex;align-items:center;justify-content:center;
|
|
15
|
+
font-size:12px;font-weight:600;user-select:none;color:var(--c);background:color-mix(in srgb,var(--c) 22%,var(--panel2))}
|
|
16
|
+
.pgi.h1{--c:var(--k1)}
|
|
17
|
+
.pgi.h2{--c:var(--k2)}
|
|
18
|
+
.pgi.h3{--c:var(--k3)}
|
|
19
|
+
.pgi.h4{--c:var(--k4)}
|
|
20
|
+
.pgi.h5{--c:var(--k5)}
|
|
21
|
+
.pgi.h6{--c:var(--k6)}
|
|
22
|
+
/* the face says three things and no more (projects-svc faceOf) */
|
|
23
|
+
/* closed in VS Code: the icon dims, the badge does not */
|
|
24
|
+
.pgt.dim img,.pgt.dim .pgi{opacity:.45}
|
|
25
|
+
/* the one badge: open asks, top right */
|
|
26
|
+
.pga{position:absolute;top:0;right:0;min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;
|
|
27
|
+
font-size:9px;font-weight:700;line-height:14px;text-align:center;color:var(--bg);background:var(--warn)}
|
|
28
|
+
/* Claude sessions running: a thin edge on the left that breathes */
|
|
29
|
+
.pgs{position:absolute;left:0;top:6px;bottom:6px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
|
|
30
|
+
@keyframes pgpulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
31
|
+
@media (prefers-reduced-motion:reduce){.pgs{animation:none}}
|
|
32
|
+
/* the hover card: right of the rail, one at a time, never interactive.
|
|
33
|
+
Header, then what needs a human, then the label and value rows of the
|
|
34
|
+
check page (.wfact .wk .wv from hub.css) */
|
|
35
|
+
.pgcard{position:fixed;left:calc(var(--gut,44px) + var(--s2));z-index:20;width:300px;padding:var(--s3);pointer-events:none;
|
|
36
|
+
background:var(--panel);border:1px solid var(--line);border-radius:4px;box-shadow:0 6px 20px var(--shadow);font-size:12px;line-height:1.45;color:var(--ink)}
|
|
37
|
+
.pgch{display:flex;align-items:center;gap:var(--s2);min-width:0}
|
|
38
|
+
.pgcface{flex:none;display:flex}
|
|
39
|
+
.pgcface img,.pgcface .pgi{width:20px;height:20px;border-radius:4px;font-size:11px}
|
|
40
|
+
.pgcname{flex:1;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
41
|
+
.pgcthis{font-weight:400;font-size:11px;color:var(--dim)}
|
|
42
|
+
.pgcver{flex:none;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
|
|
43
|
+
.pgcline{margin-top:var(--s1);color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
44
|
+
.pgcdesc{margin-top:var(--s1);color:var(--dim);font-size:11px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
|
|
45
|
+
/* NEEDS YOU: a warn toned bullet, set apart from the facts */
|
|
46
|
+
.pgcneeds{margin-top:var(--s2);padding:var(--s1) var(--s2);border-left:2px solid var(--warn);background:var(--panel2);border-radius:0 3px 3px 0}
|
|
47
|
+
.pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
|
|
48
|
+
.pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
49
|
+
.pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
50
|
+
.pgcmore{flex:none}
|
|
51
|
+
.pgcfacts{margin-top:var(--s2)}
|
|
52
|
+
.pgcfacts .wk{flex-basis:64px;white-space:nowrap}
|
|
53
|
+
.pgcfacts .wv{text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;word-break:normal}
|
|
54
|
+
.pgcfacts .wv.warn{color:var(--warn)}
|
|
55
|
+
.pgerr{font-size:12px;font-weight:700;color:var(--red);cursor:help}
|
|
56
|
+
/* the one time sort by last activity, above the icons */
|
|
57
|
+
.pgsort{flex:none;width:32px;height:20px;padding:0;background:transparent;border:1px solid var(--line);border-radius:3px;
|
|
58
|
+
color:var(--dim);font-size:12px;line-height:18px;cursor:pointer}
|
|
59
|
+
.pgsort:hover{color:var(--ink);border-color:var(--accent)}
|
|
60
|
+
/* a drag: the tile being moved fades, and the gap it would land in is a line
|
|
61
|
+
drawn on a tile edge, centred in the gap between two tiles */
|
|
62
|
+
.pgt.dragging{opacity:.4}
|
|
63
|
+
.pgt.dropb::before,.pgt.dropa::after{content:'';position:absolute;left:2px;right:2px;height:2px;border-radius:1px;background:var(--accent);pointer-events:none}
|
|
64
|
+
.pgt.dropb::before{top:-3px}
|
|
65
|
+
.pgt.dropa::after{bottom:-3px}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// the project gutter: one icon per repo that shares this hub's house wiki,
|
|
2
|
+
// down the far left of the page. The face stays quiet (faceOf): at most one
|
|
3
|
+
// badge, the open asks; the icon dims while VS Code does not have the folder
|
|
4
|
+
// open; a thin edge pulses while Claude sessions run in it; the selected
|
|
5
|
+
// background marks this hub's own repo. Everything else is the hover card,
|
|
6
|
+
// shown at once to the right of the icon. A click opens the folder in VS
|
|
7
|
+
// Code, a drag reorders the rail, and the button on top sorts it once by
|
|
8
|
+
// last activity. Fixed position, so it takes no cell of the page grid
|
|
9
|
+
// (project-gutter.css).
|
|
10
|
+
import { observer } from 'mobx-react-lite'
|
|
11
|
+
import { useEffect, useLayoutEffect, useRef } from 'react'
|
|
12
|
+
import type { HubSt } from './hub-st.ts'
|
|
13
|
+
import { cardModel, faceOf, hueOf, type Project } from './projects-svc.ts'
|
|
14
|
+
|
|
15
|
+
export const Tile = observer(({ app, p, i }: { app: HubSt; p: Project; i: number }) => {
|
|
16
|
+
const s = app.projects
|
|
17
|
+
const gap = s.shownGap
|
|
18
|
+
const face = faceOf(p)
|
|
19
|
+
// the drop line rides on a tile edge, so showing it moves nothing
|
|
20
|
+
const edge = gap === i ? ' dropb' : gap === i + 1 && i === s.list.length - 1 ? ' dropa' : ''
|
|
21
|
+
const cls = `pgt${p.current ? ' cur' : ''}${face.dim ? ' dim' : ''}${s.drag === p.name ? ' dragging' : ''}${edge}`
|
|
22
|
+
return (
|
|
23
|
+
<button className={cls} aria-label={p.name}
|
|
24
|
+
draggable onClick={() => void s.open(p.name)}
|
|
25
|
+
onMouseEnter={(e) => {
|
|
26
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
27
|
+
s.hoverIn(p.name, r.top + r.height / 2)
|
|
28
|
+
}}
|
|
29
|
+
onMouseLeave={() => s.hoverOut(p.name)}
|
|
30
|
+
onDragStart={(e) => {
|
|
31
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
32
|
+
e.dataTransfer.setData('text/plain', p.name)
|
|
33
|
+
s.dragStart(p.name)
|
|
34
|
+
}}
|
|
35
|
+
onDragOver={(e) => {
|
|
36
|
+
if (s.drag == null) return
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
39
|
+
s.dragOver(i, e.clientY - r.top, r.height)
|
|
40
|
+
}}
|
|
41
|
+
onDrop={(e) => {
|
|
42
|
+
e.preventDefault()
|
|
43
|
+
s.drop()
|
|
44
|
+
}}
|
|
45
|
+
onDragEnd={() => s.dragEnd()}>
|
|
46
|
+
<Face p={p} />
|
|
47
|
+
{face.badge != null ? <b className="pga">{face.badge}</b> : null}
|
|
48
|
+
{face.busy ? <i className="pgs" /> : null}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const Face = ({ p }: { p: Project }) => (p.icon != null
|
|
54
|
+
? <img src={p.icon} alt="" draggable={false} />
|
|
55
|
+
: <span className={`pgi h${hueOf(p.name)}`}>{(p.name.match(/[a-zA-Z]/)?.[0] ?? p.name.charAt(0)).toUpperCase()}</span>)
|
|
56
|
+
|
|
57
|
+
/** the card for one repo: header, what needs a human, then the fact rows,
|
|
58
|
+
* in the label and value shape the check page's WHAT widget uses */
|
|
59
|
+
export const Card = ({ p, now }: { p: Project; now: number }) => {
|
|
60
|
+
const m = cardModel(p, now)
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<div className="pgch">
|
|
64
|
+
<span className="pgcface"><Face p={p} /></span>
|
|
65
|
+
<b className="pgcname">{m.head.name}{m.head.current ? <span className="pgcthis"> this hub</span> : null}</b>
|
|
66
|
+
{m.head.version == null ? null : <span className="pgcver">v{m.head.version.replace(/^v/, '')}</span>}
|
|
67
|
+
</div>
|
|
68
|
+
{m.head.branch == null ? null : <div className="pgcline">{m.head.branch}</div>}
|
|
69
|
+
{m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
|
|
70
|
+
{m.needs == null ? null : (
|
|
71
|
+
<div className="pgcneeds">
|
|
72
|
+
{m.needs.asks == null ? null : (
|
|
73
|
+
<div className="pgcneed">
|
|
74
|
+
<span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
|
|
75
|
+
{m.needs.asks.more > 0 ? <span className="pgcmore">+{m.needs.asks.more}</span> : null}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
{m.needs.gate == null ? null : <div className="pgcneed red">{m.needs.gate}</div>}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
{m.facts.length === 0 ? null : (
|
|
82
|
+
<div className="pgcfacts">
|
|
83
|
+
{m.facts.map((f) => (
|
|
84
|
+
<div key={f.k} className="wfact">
|
|
85
|
+
<span className="wk">{f.k}</span>
|
|
86
|
+
<span className={`wv${f.tone == null ? '' : ` ${f.tone}`}`}>{f.v}</span>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** the one hover card: right of the rail, centred on the icon's row, then
|
|
96
|
+
* clamped into the viewport once its own height is known */
|
|
97
|
+
export const HoverCard = observer(({ app }: { app: HubSt }) => {
|
|
98
|
+
const s = app.projects
|
|
99
|
+
const p = s.hovered
|
|
100
|
+
const y = s.hover?.y ?? 0
|
|
101
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
const el = ref.current
|
|
104
|
+
if (el == null) return
|
|
105
|
+
const h = el.offsetHeight
|
|
106
|
+
el.style.top = `${Math.max(8, Math.min(y - h / 2, window.innerHeight - h - 8))}px`
|
|
107
|
+
})
|
|
108
|
+
if (p == null) return null
|
|
109
|
+
return (
|
|
110
|
+
<div ref={ref} className="pgcard" role="tooltip">
|
|
111
|
+
<Card p={p} now={Date.now()} />
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
export const ProjectGutter = observer(({ app }: { app: HubSt }) => {
|
|
117
|
+
useEffect(() => app.projects.start(), [app])
|
|
118
|
+
const s = app.projects
|
|
119
|
+
if (s.list.length === 0 && s.error === '') return null
|
|
120
|
+
return (
|
|
121
|
+
<nav className="pgut" aria-label="projects">
|
|
122
|
+
<button className="pgsort" title="sort by last activity, once: the result is the new manual order" onClick={() => s.sortByActivity()}>⇅</button>
|
|
123
|
+
{s.list.map((p, i) => <Tile key={p.name} app={app} p={p} i={i} />)}
|
|
124
|
+
{s.error !== '' ? <span className="pgerr" title={s.error}>!</span> : null}
|
|
125
|
+
<HoverCard app={app} />
|
|
126
|
+
</nav>
|
|
127
|
+
)
|
|
128
|
+
})
|