rvlib-shipkit 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// the planned page's state: the machine's one plan as the daemon answers it,
|
|
2
|
+
// as ONE list ordered by time (now, late, next), filtered by a text that
|
|
3
|
+
// matches the project or the check. A check that only waits for a save has no
|
|
4
|
+
// time, so it is not listed. The order and every word a row says are pure
|
|
5
|
+
// functions, so a test pins them rather than a screenshot.
|
|
6
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
7
|
+
import type { PlannedAnswer } from '../../home/home-routes.ts'
|
|
8
|
+
import type { LastStatus } from '../../plan/cadence.ts'
|
|
9
|
+
import type { PlanReason, PlanRow } from '../../plan/planner.ts'
|
|
10
|
+
import type { HomeSt } from './home-st.ts'
|
|
11
|
+
|
|
12
|
+
export const PLANNED_POLL_MS = 1_000
|
|
13
|
+
|
|
14
|
+
export type Section = 'now' | 'late' | 'next'
|
|
15
|
+
|
|
16
|
+
/** the section a row sits in; null for a check with no time of its own */
|
|
17
|
+
export function sectionOf(r: PlanRow): Section | null {
|
|
18
|
+
if (r.state === 'running' || r.state === 'due') return 'now'
|
|
19
|
+
if (r.state === 'late') return 'late'
|
|
20
|
+
if (r.state === 'next') return 'next'
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** a row matches a text filter when its project or check holds every word
|
|
25
|
+
* of it, in any case */
|
|
26
|
+
export function matches(r: PlanRow, query: string): boolean {
|
|
27
|
+
const words = query.toLowerCase().split(/\s+/).filter((w) => w !== '')
|
|
28
|
+
const hay = `${r.repo} ${r.check}`.toLowerCase()
|
|
29
|
+
return words.every((w) => hay.includes(w))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** the one list: running first, then due, the late ones most late first,
|
|
33
|
+
* then the next ones soonest first. Each row carries its section */
|
|
34
|
+
export function timeline(rows: PlanRow[], query = ''): { section: Section; row: PlanRow }[] {
|
|
35
|
+
const rank = (r: PlanRow): number => (r.state === 'running' ? 0 : r.state === 'due' ? 1 : r.state === 'late' ? 2 : 3)
|
|
36
|
+
return rows
|
|
37
|
+
.filter((r) => sectionOf(r) != null && matches(r, query))
|
|
38
|
+
.sort((a, b) => rank(a) - rank(b) || (a.dueAt ?? Infinity) - (b.dueAt ?? Infinity) || a.repo.localeCompare(b.repo) || a.check.localeCompare(b.check))
|
|
39
|
+
.map((row) => ({ section: sectionOf(row)!, row }))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MIN = 60_000
|
|
43
|
+
const HOUR = 60 * MIN
|
|
44
|
+
const DAY = 24 * HOUR
|
|
45
|
+
|
|
46
|
+
/** a span in its largest unit: `<1m`, `25m`, `2h`, `3d` */
|
|
47
|
+
export function spanShort(ms: number): string {
|
|
48
|
+
if (ms < MIN) return '<1m'
|
|
49
|
+
if (ms < HOUR) return `${Math.round(ms / MIN)}m`
|
|
50
|
+
if (ms < DAY) return `${Math.round(ms / HOUR)}h`
|
|
51
|
+
return `${Math.round(ms / DAY)}d`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** when the row runs, relative to now: `now`, `in 4m`, `late 2h` */
|
|
55
|
+
export function whenWords(r: PlanRow, now: number): string {
|
|
56
|
+
if (r.dueAt == null || r.state === 'running' || r.state === 'due') return 'now'
|
|
57
|
+
const d = r.dueAt - now
|
|
58
|
+
if (r.state === 'late') return `late ${spanShort(Math.max(0, -d))}`
|
|
59
|
+
return d > 0 ? `in ${spanShort(d)}` : 'now'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Mark = 'running' | 'failed' | 'late' | 'due' | 'next'
|
|
63
|
+
|
|
64
|
+
/** the one mark a row shows and its few words: running wins, then a failed
|
|
65
|
+
* last result, then late with why it waits */
|
|
66
|
+
export function markOf(r: PlanRow): { mark: Mark; words: string } {
|
|
67
|
+
if (r.state === 'running') return { mark: 'running', words: 'running' }
|
|
68
|
+
if (r.last?.status === 'fail') return { mark: 'failed', words: 'last failed' }
|
|
69
|
+
if (r.state === 'late') return { mark: 'late', words: 'waits: repo inactive' }
|
|
70
|
+
if (r.state === 'due') return { mark: 'due', words: 'due' }
|
|
71
|
+
return { mark: 'next', words: '' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** why it is due or planned, in words */
|
|
75
|
+
export const REASON_WORDS: Record<PlanReason, string> = {
|
|
76
|
+
changed: 'files changed',
|
|
77
|
+
scheduled: 'scheduled',
|
|
78
|
+
late: 'overdue',
|
|
79
|
+
asked: 'asked by hand',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const reasonWords = (r: PlanRow): string => (r.reason == null ? '' : REASON_WORDS[r.reason])
|
|
83
|
+
|
|
84
|
+
/** the tone of a last result: red failing, warn warning, ok passing */
|
|
85
|
+
export const LAST_TONE: Record<LastStatus, 'red' | 'warn' | 'ok' | 'dim'> = { fail: 'red', warn: 'warn', pass: 'ok', skip: 'dim' }
|
|
86
|
+
|
|
87
|
+
/** a title worth showing beside the id: a title that repeats the id loses it */
|
|
88
|
+
export function titleWords(title: string | null, check: string): string | null {
|
|
89
|
+
const t = title?.startsWith(check) === true ? title.slice(check.length).replace(/^[\s:·]+/, '') : title
|
|
90
|
+
return t == null || t === '' ? null : t
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class PlannedSt {
|
|
94
|
+
data: PlannedAnswer | null = null
|
|
95
|
+
error = ''
|
|
96
|
+
/** the text filter, the `?q=` of /planned: every word must be in the project or the check */
|
|
97
|
+
query = ''
|
|
98
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
99
|
+
|
|
100
|
+
constructor(private st: HomeSt) {
|
|
101
|
+
makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false, data: observableRef })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get rows() {
|
|
105
|
+
return timeline(this.data?.rows ?? [], this.query)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** how many checks have a time of their own, filter aside */
|
|
109
|
+
get timed(): number {
|
|
110
|
+
return (this.data?.rows ?? []).filter((r) => sectionOf(r) != null).length
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** the address of the plan with this filter */
|
|
114
|
+
get address(): string {
|
|
115
|
+
const q = this.query.trim()
|
|
116
|
+
return q === '' ? '/planned' : `/planned?q=${encodeURIComponent(q)}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** a keystroke in the filter: the url follows, in place */
|
|
120
|
+
setQuery(q: string): void {
|
|
121
|
+
this.query = q
|
|
122
|
+
if (this.st.page === 'planned') window.history.replaceState(null, '', this.address)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** the project name: that repo, in place */
|
|
126
|
+
openRepo(repo: string): void {
|
|
127
|
+
this.st.select(repo)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** the check: its own page in its repo. The selection is written before
|
|
131
|
+
* the repo is shown, so the one history entry already names the check */
|
|
132
|
+
openCheck(repo: string, check: string): void {
|
|
133
|
+
this.st.repo(repo).cols.select('checks', `check:${check}`)
|
|
134
|
+
this.st.select(repo)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** the check's title as its repo's hub names it, once that hub was read */
|
|
138
|
+
titleOf(repo: string, check: string): string | null {
|
|
139
|
+
const t = this.st.repos.get(repo)?.cols.find('checks', `check:${check}`)?.title ?? null
|
|
140
|
+
return titleWords(t, check)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async poll(): Promise<void> {
|
|
144
|
+
try {
|
|
145
|
+
const r = await fetch('/api/home/planned.json', { signal: AbortSignal.timeout(8_000) })
|
|
146
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
147
|
+
const j = (await r.json()) as PlannedAnswer
|
|
148
|
+
runInAction(() => { this.data = j; this.error = '' })
|
|
149
|
+
} catch (e) {
|
|
150
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async run(row: PlanRow): Promise<void> {
|
|
155
|
+
const r = await fetch('/api/home/planned/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo: row.repo, check: row.check }) })
|
|
156
|
+
if (!r.ok) runInAction(() => { this.error = `run ${row.check}: ${r.status} ${r.statusText}` })
|
|
157
|
+
await this.poll()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** polls while the plan is the page shown */
|
|
161
|
+
start(): void {
|
|
162
|
+
if (this.timer != null) return
|
|
163
|
+
void this.poll()
|
|
164
|
+
this.timer = setInterval(() => void this.poll(), PLANNED_POLL_MS)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
stop(): void {
|
|
168
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
169
|
+
this.timer = null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `/planned`: every check the machine runs on its own time, in ONE list
|
|
2
|
+
// ordered by time under three small labels (now, late, next). A row says
|
|
3
|
+
// when, which project and check, and one mark; everything else (the rule,
|
|
4
|
+
// the reason, the last result, the inactive policy, an error, run now) is in
|
|
5
|
+
// the card that floats over the row on hover. The project opens its repo in
|
|
6
|
+
// place, the check opens its own page in that repo.
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import type { CSSProperties, MouseEvent, ReactNode } from 'react'
|
|
9
|
+
import type { PlanRow } from '../../plan/planner.ts'
|
|
10
|
+
import { Tip } from './floating.tsx'
|
|
11
|
+
import { hashFor } from './hash-route.ts'
|
|
12
|
+
import type { HomeSt } from './home-st.ts'
|
|
13
|
+
import { plainClick, repoPage } from './home-st.ts'
|
|
14
|
+
import { LAST_TONE, markOf, reasonWords, whenWords, type Section } from './planned-st.ts'
|
|
15
|
+
import { ageWords, stampWords } from './time-ago.tsx'
|
|
16
|
+
|
|
17
|
+
/** where a check's own page answers on the home */
|
|
18
|
+
export const checkAddress = (repo: string, check: string): string =>
|
|
19
|
+
`${repoPage(repo)}#${hashFor({ t: 'item', col: 'checks', id: `check:${check}` })}`
|
|
20
|
+
|
|
21
|
+
/** a link that works as one (a new tab on cmd+click) and moves in place on a plain click */
|
|
22
|
+
const InPlace = ({ href, className, go, children }: { href: string; className: string; go: () => void; children: ReactNode }) => (
|
|
23
|
+
<a className={className} href={href} onClick={(e: MouseEvent) => {
|
|
24
|
+
if (!plainClick(e)) return
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
go()
|
|
27
|
+
}}>{children}</a>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
/** the repo's rail icon, or nothing */
|
|
31
|
+
const Face = observer(({ app, repo }: { app: HomeSt; repo: string }) => {
|
|
32
|
+
const icon = app.projects.list.find((p) => p.name === repo)?.icon ?? null
|
|
33
|
+
return icon == null ? null : <img className="plface" src={icon} alt="" width={14} height={14} draggable={false} />
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** one line of the card: a dim label and its value */
|
|
37
|
+
const Line = ({ k, children }: { k: string; children: ReactNode }) => (
|
|
38
|
+
<div className="plkv"><span>{k}</span><span>{children}</span></div>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const Card = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
|
|
42
|
+
const st = app.planned
|
|
43
|
+
const title = st.titleOf(r.repo, r.check)
|
|
44
|
+
const running = r.state === 'running'
|
|
45
|
+
return (
|
|
46
|
+
<div className="plcardin">
|
|
47
|
+
<div className="plct">{r.repo} · {r.check}{title != null ? <span className="pldim"> {title}</span> : null}</div>
|
|
48
|
+
{r.error != null ? <div className="plerr">{r.error}</div> : null}
|
|
49
|
+
<Line k="when">{r.dueAt == null ? whenWords(r, now) : `${whenWords(r, now)}, ${stampWords(r.dueAt)}`}</Line>
|
|
50
|
+
{r.reason != null ? <Line k="why">{reasonWords(r)}</Line> : null}
|
|
51
|
+
<Line k="rule">{r.rule}</Line>
|
|
52
|
+
<Line k="inactive">{r.active ? r.inactive : `repo inactive: ${r.inactive}`}</Line>
|
|
53
|
+
<Line k="last">
|
|
54
|
+
{r.last == null ? 'never ran' : (
|
|
55
|
+
<span className={`l-${LAST_TONE[r.last.status]}`}>
|
|
56
|
+
{r.last.status}, {ageWords(r.last.at, now)}{r.last.streak > 1 ? `, ${r.last.streak} in a row` : ''}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
</Line>
|
|
60
|
+
<button className="plgo" disabled={running} onClick={() => void st.run(r)}>{running ? 'running' : 'run now'}</button>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const Row = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
|
|
66
|
+
const st = app.planned
|
|
67
|
+
const m = markOf(r)
|
|
68
|
+
const colour = { '--pc': app.projects.identityOf(r.repo).colorDark } as CSSProperties
|
|
69
|
+
return (
|
|
70
|
+
<Tip className={`plr m-${m.mark}`} content={<Card app={app} r={r} now={now} />} tipClass="plcard" placement="right-start" fallbacks={['bottom-end', 'top-end']} interactive>
|
|
71
|
+
<span className="plwhen">{whenWords(r, now)}</span>
|
|
72
|
+
<span className="plwhat">
|
|
73
|
+
<InPlace className="plrepo" href={repoPage(r.repo)} go={() => st.openRepo(r.repo)}>
|
|
74
|
+
<Face app={app} repo={r.repo} />
|
|
75
|
+
<span style={colour}>{r.repo}</span>
|
|
76
|
+
</InPlace>
|
|
77
|
+
<span className="pldim"> · </span>
|
|
78
|
+
<InPlace className="plcheck" href={checkAddress(r.repo, r.check)} go={() => st.openCheck(r.repo, r.check)}>{r.check}</InPlace>
|
|
79
|
+
</span>
|
|
80
|
+
<span className="plmark">
|
|
81
|
+
{m.mark === 'running' ? <i className="spin" /> : <i className={`plsym s-${m.mark}`} />}
|
|
82
|
+
{m.words !== '' ? <span>{m.words}</span> : null}
|
|
83
|
+
</span>
|
|
84
|
+
</Tip>
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const PlannedView = observer(({ app }: { app: HomeSt }) => {
|
|
89
|
+
const st = app.planned
|
|
90
|
+
const d = st.data
|
|
91
|
+
const now = d?.now ?? Date.now()
|
|
92
|
+
let prev: Section | null = null
|
|
93
|
+
return (
|
|
94
|
+
<div className="hall planned">
|
|
95
|
+
<input className="plfind" type="search" placeholder="filter" aria-label="filter by project or check" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
|
|
96
|
+
{st.error !== '' ? <div className="hnote">{st.error}</div> : null}
|
|
97
|
+
{(d?.errors ?? []).map((e) => <div key={e.repo} className="hnote">{e.repo}: {e.error}</div>)}
|
|
98
|
+
<div className="pllist">
|
|
99
|
+
{st.rows.flatMap(({ section, row }) => {
|
|
100
|
+
const item = <Row key={`${row.repo}\0${row.check}`} app={app} r={row} now={now} />
|
|
101
|
+
if (section === prev) return [item]
|
|
102
|
+
prev = section
|
|
103
|
+
return [<div key={`s:${section}`} className="plsec">{section}</div>, item]
|
|
104
|
+
})}
|
|
105
|
+
{d == null ? <div className="pldim">loading…</div> : st.rows.length === 0 ? <div className="pldim">{st.timed === 0 ? 'nothing is planned' : 'nothing matches'}</div> : null}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* the planned page (planned-view.tsx): one quiet list, one font size. Each
|
|
2
|
+
row is a grid on the same three columns (when, project · check, mark), so
|
|
3
|
+
the columns line up without a header. The card on hover takes the pointer,
|
|
4
|
+
unlike .ftip, because it holds the run button. */
|
|
5
|
+
.planned{font-size:13px}
|
|
6
|
+
.plfind{display:block;width:240px;max-width:100%;box-sizing:border-box;margin-bottom:var(--s3);background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:var(--s1) var(--s2);font:inherit}
|
|
7
|
+
.plfind:focus{outline:none;border-color:var(--accent)}
|
|
8
|
+
.pllist{max-width:560px}
|
|
9
|
+
.plsec{margin-top:var(--s3);padding:var(--s1) var(--s2);color:var(--dim);text-transform:lowercase}
|
|
10
|
+
.plsec:first-child{margin-top:0}
|
|
11
|
+
.plr{display:grid;grid-template-columns:64px minmax(0,1fr) auto;align-items:center;column-gap:var(--s3);padding:var(--s0) var(--s2);border-radius:3px;white-space:nowrap}
|
|
12
|
+
.plr:hover{background:var(--panel)}
|
|
13
|
+
.plwhen{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
14
|
+
.m-late .plwhen{color:var(--warn)}
|
|
15
|
+
.m-running .plwhen{color:var(--accent)}
|
|
16
|
+
.plwhat{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
17
|
+
.plrepo,.plcheck{color:inherit;text-decoration:none}
|
|
18
|
+
.plrepo span{color:var(--pc)}
|
|
19
|
+
.plrepo:hover span,.plcheck:hover{text-decoration:underline}
|
|
20
|
+
.plface{width:14px;height:14px;margin-right:var(--s1);vertical-align:-2px;border-radius:3px;object-fit:contain}
|
|
21
|
+
.pldim{color:var(--dim)}
|
|
22
|
+
.plmark{display:flex;align-items:center;gap:var(--s1);color:var(--dim)}
|
|
23
|
+
.plmark .spin{width:9px;height:9px;border-width:1.5px}
|
|
24
|
+
.plsym{display:inline-block;width:7px;height:7px;border-radius:50%;flex:none;box-sizing:border-box}
|
|
25
|
+
.s-next{border:1px solid var(--dim)}
|
|
26
|
+
.s-due{background:var(--ink)}
|
|
27
|
+
.s-late{background:var(--warn)}
|
|
28
|
+
.s-failed{width:auto;height:auto;border-radius:0;font-style:normal;color:var(--red)}
|
|
29
|
+
.s-failed::before{content:'✕'}
|
|
30
|
+
.m-failed .plmark{color:var(--red)}
|
|
31
|
+
.plcard{z-index:40;width:max-content;max-width:360px;background:var(--panel2);border:1px solid var(--line);border-radius:4px;box-shadow:0 2px 10px var(--shadow);padding:var(--s2) var(--s3);font-size:13px;color:var(--ink)}
|
|
32
|
+
.plcardin{display:flex;flex-direction:column;gap:var(--s1)}
|
|
33
|
+
.plct{margin-bottom:var(--s1)}
|
|
34
|
+
.plkv{display:grid;grid-template-columns:64px minmax(0,1fr);column-gap:var(--s2)}
|
|
35
|
+
.plkv > span:first-child{color:var(--dim)}
|
|
36
|
+
.plkv > span:last-child{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
37
|
+
.plerr{color:var(--red)}
|
|
38
|
+
.l-ok{color:var(--ok)}.l-warn{color:var(--warn)}.l-red{color:var(--red)}.l-dim{color:var(--dim)}
|
|
39
|
+
.plgo{align-self:flex-start;margin-top:var(--s1);padding:0 var(--s2);font:inherit;line-height:22px}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// the repo's declared processes (serve.processes) as the hub supervises them,
|
|
2
|
+
// polled while its repo is the one shown. A repo that declares none never
|
|
3
|
+
// polls again after the first answer.
|
|
4
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
5
|
+
import type { ProcessView } from '../repo-processes.ts'
|
|
6
|
+
import type { RepoSt } from './repo-st.ts'
|
|
7
|
+
|
|
8
|
+
export const PROCESSES_POLL_MS = 2_000
|
|
9
|
+
|
|
10
|
+
export class ProcessesSvc {
|
|
11
|
+
list: ProcessView[] = []
|
|
12
|
+
error = ''
|
|
13
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
14
|
+
/** bumped by stop, so a chain that left before it dies at its next await */
|
|
15
|
+
private gen = 0
|
|
16
|
+
private polling = false
|
|
17
|
+
|
|
18
|
+
constructor(private st: RepoSt) {
|
|
19
|
+
makeAutoObservable<this, 'st' | 'timer' | 'gen' | 'polling'>(this, { st: false, timer: false, gen: false, polling: false, list: observableRef })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start(): void {
|
|
23
|
+
if (this.polling) return
|
|
24
|
+
this.polling = true
|
|
25
|
+
const gen = ++this.gen
|
|
26
|
+
const tick = async (): Promise<void> => {
|
|
27
|
+
await this.load()
|
|
28
|
+
if (gen === this.gen && this.list.length > 0) this.timer = setTimeout(() => void tick(), PROCESSES_POLL_MS)
|
|
29
|
+
}
|
|
30
|
+
void tick()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stop(): void {
|
|
34
|
+
this.polling = false
|
|
35
|
+
this.gen++
|
|
36
|
+
if (this.timer != null) clearTimeout(this.timer)
|
|
37
|
+
this.timer = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async load(): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch(this.st.url('/api/processes.json'))
|
|
43
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
44
|
+
const j = (await r.json()) as { processes: ProcessView[] }
|
|
45
|
+
runInAction(() => {
|
|
46
|
+
this.list = j.processes
|
|
47
|
+
this.error = ''
|
|
48
|
+
})
|
|
49
|
+
} catch (e) {
|
|
50
|
+
runInAction(() => { this.error = `processes: ${(e as Error).message}` })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async act(id: string, act: 'start' | 'stop' | 'restart'): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
const r = await fetch(this.st.url('/api/processes/act'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, act }) })
|
|
57
|
+
if (!r.ok) throw new Error(await r.text())
|
|
58
|
+
await this.load()
|
|
59
|
+
} catch (e) {
|
|
60
|
+
runInAction(() => { this.error = `${act} ${id}: ${(e as Error).message}` })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* the project list, the page's left panel. Its classes are pj*: another
|
|
2
|
+
sheet of the page already styles .plist (prose-hits.css). It fills its
|
|
3
|
+
panel (hub-app.tsx, react-resizable-panels), whose width is the only one:
|
|
4
|
+
this sheet sizes the rows and never the list. Colours are hub.css tokens only, spacing is its scale:
|
|
5
|
+
tests/hub-css-invariants.test.ts reads this sheet too. Every icon is 18px
|
|
6
|
+
here AND as attributes on the img (ROW_ICON), so a sheet that fails to
|
|
7
|
+
apply still draws a row, never a 512px icon. */
|
|
8
|
+
.pjlist{flex:1;width:100%;height:100%;display:flex;flex-direction:column;background:var(--bg);min-width:0}
|
|
9
|
+
.pjhead{flex:none;display:flex;align-items:center;gap:var(--s1);height:36px;padding:0 var(--s2);border-bottom:1px solid var(--line)}
|
|
10
|
+
.pjhead .sp{flex:1}
|
|
11
|
+
/* the rows scroll, the head does not, and no scrollbar draws a band beside
|
|
12
|
+
them: WebKit (the macOS app) ignores scrollbar-width, hence the pseudo */
|
|
13
|
+
.pjrows{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:var(--s1) 0;scrollbar-width:none}
|
|
14
|
+
.pjrows::-webkit-scrollbar{display:none}
|
|
15
|
+
.pjwait{display:flex;align-items:center;gap:var(--s2);padding:var(--s1) var(--s3);font-size:12px;color:var(--dim)}
|
|
16
|
+
.pjrow{position:relative;display:flex;align-items:center;gap:var(--s2);width:100%;height:28px;padding:0 var(--s3);
|
|
17
|
+
background:transparent;border:0;border-radius:0;text-align:left;color:var(--dim);font-size:13px;cursor:pointer}
|
|
18
|
+
.pjrow:hover{background:var(--panel2);color:var(--ink)}
|
|
19
|
+
/* the shown repo: its row lit, marked on the left in its identity colour */
|
|
20
|
+
.pjrow.cur{background:var(--sel);color:var(--pjc,var(--ink));box-shadow:inset 3px 0 0 var(--pjc,var(--accent));font-weight:600}
|
|
21
|
+
.pjrow img,.pjrow .pgi{flex:none;width:18px;height:18px;border-radius:4px;object-fit:contain;pointer-events:none}
|
|
22
|
+
.pjname{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
23
|
+
/* the initial on a hue hashed from the name (projects-svc hueOf) */
|
|
24
|
+
.pgi{display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;user-select:none;
|
|
25
|
+
color:var(--c);background:color-mix(in srgb,var(--c) 22%,var(--panel2))}
|
|
26
|
+
.pgi.h1{--c:var(--k1)}
|
|
27
|
+
.pgi.h2{--c:var(--k2)}
|
|
28
|
+
.pgi.h3{--c:var(--k3)}
|
|
29
|
+
.pgi.h4{--c:var(--k4)}
|
|
30
|
+
.pgi.h5{--c:var(--k5)}
|
|
31
|
+
.pgi.h6{--c:var(--k6)}
|
|
32
|
+
/* the row says three things and no more (projects-svc faceOf) */
|
|
33
|
+
/* closed in VS Code: the icon and the name dim, the badge does not */
|
|
34
|
+
.pjrow.dim img,.pjrow.dim .pgi,.pjrow.dim .pjname{opacity:.5}
|
|
35
|
+
/* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
|
|
36
|
+
.pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
|
|
37
|
+
.pjrow:hover .pjkey,.pjrow.cur .pjkey{opacity:1}
|
|
38
|
+
/* the one badge: open asks, at the end of the row */
|
|
39
|
+
.pga{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
|
|
40
|
+
font-size:10px;font-weight:700;line-height:16px;text-align:center;color:var(--bg);background:var(--warn)}
|
|
41
|
+
/* Claude sessions running: a thin edge on the left that breathes */
|
|
42
|
+
.pgs{position:absolute;left:0;top:5px;bottom:5px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
|
|
43
|
+
/* the home only: the repo's hub state (starting, crashed) */
|
|
44
|
+
.pgd{flex:none;width:6px;height:6px;border-radius:3px;pointer-events:none}
|
|
45
|
+
.pgd.up{background:var(--ok)}.pgd.starting{background:var(--warn)}.pgd.crashed{background:var(--red)}
|
|
46
|
+
/* the home only: a global process this repo uses is off */
|
|
47
|
+
.pgw{flex:none;width:6px;height:6px;border-radius:1px;background:var(--warn)}
|
|
48
|
+
@keyframes pgpulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
49
|
+
@media (prefers-reduced-motion:reduce){.pgs{animation:none}}
|
|
50
|
+
/* the hover card: beside its row, placed by floating-ui (floating.tsx), one
|
|
51
|
+
at a time, never interactive. Header, then what needs a human, then the
|
|
52
|
+
label and value rows of the check page (.wfact .wk .wv from hub.css) */
|
|
53
|
+
.pgcard{z-index:20;width:300px;padding:var(--s3);pointer-events:none;
|
|
54
|
+
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)}
|
|
55
|
+
.pgch{display:flex;align-items:center;gap:var(--s2);min-width:0}
|
|
56
|
+
.pgcface{flex:none;display:flex}
|
|
57
|
+
.pgcface img,.pgcface .pgi{width:20px;height:20px;border-radius:4px;font-size:11px}
|
|
58
|
+
.pgcname{flex:1;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
59
|
+
.pgcthis{font-weight:400;font-size:11px;color:var(--dim)}
|
|
60
|
+
.pgcver{flex:none;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
|
|
61
|
+
.pgcline{margin-top:var(--s1);color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
62
|
+
.pgcdesc{margin-top:var(--s1);color:var(--dim);font-size:11px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
|
|
63
|
+
/* NEEDS YOU: a warn toned bullet, set apart from the facts */
|
|
64
|
+
.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}
|
|
65
|
+
.pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
|
|
66
|
+
.pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
67
|
+
.pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
68
|
+
.pgcmore{flex:none}
|
|
69
|
+
.pgcfacts{margin-top:var(--s2)}
|
|
70
|
+
.pgcfacts .wk{flex-basis:64px;white-space:nowrap}
|
|
71
|
+
.pgcfacts .wv{text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;word-break:normal}
|
|
72
|
+
.pgcfacts .wv.warn{color:var(--warn)}
|
|
73
|
+
.pgerr{font-size:12px;font-weight:700;color:var(--red);cursor:help}
|
|
74
|
+
/* the home only: the overview and the plan, one button each, the page shown
|
|
75
|
+
marked, its shortcut small in the bottom right corner */
|
|
76
|
+
.pgplace{position:relative;flex:none;width:40px;height:26px;display:flex;align-items:center;justify-content:center;padding:0;
|
|
77
|
+
background:transparent;border:1px solid var(--line);border-radius:4px;color:var(--dim);cursor:pointer}
|
|
78
|
+
.pgplace:hover{color:var(--ink);background:var(--panel2)}
|
|
79
|
+
.pgplace.on{color:var(--accent);background:var(--sel);border-color:var(--accent)}
|
|
80
|
+
.pgplace .hico{flex:none;color:inherit;margin-right:var(--s2)}
|
|
81
|
+
.pgplace kbd{position:absolute;right:var(--s0);bottom:0;font:8px/1 ui-monospace,Menlo,monospace;color:var(--dim);background:none;border:0;padding:0}
|
|
82
|
+
/* the one time sort by last activity */
|
|
83
|
+
.pgsort{flex:none;width:28px;height:20px;padding:0;background:transparent;border:1px solid var(--line);border-radius:3px;
|
|
84
|
+
color:var(--dim);font-size:12px;line-height:18px;cursor:pointer}
|
|
85
|
+
.pgsort:hover{color:var(--ink);border-color:var(--accent)}
|
|
86
|
+
/* a drag: the row being moved fades, and the gap it would land in is a line
|
|
87
|
+
drawn on a row edge */
|
|
88
|
+
.pjrow.dragging{opacity:.4}
|
|
89
|
+
.pjrow.dropb::before,.pjrow.dropa::after{content:'';position:absolute;left:var(--s2);right:var(--s2);height:2px;border-radius:1px;background:var(--accent);pointer-events:none}
|
|
90
|
+
.pjrow.dropb::before{top:-1px}
|
|
91
|
+
.pjrow.dropa::after{bottom:-1px}
|
|
92
|
+
/* the separators of the page's panels: a 1px divider in the line colour, the
|
|
93
|
+
panels touching it, and a wider hit area that draws nothing (6px each side).
|
|
94
|
+
The accent shows only on hover and while dragging */
|
|
95
|
+
.rsep{position:relative;flex:none;width:1px;background:var(--line)}
|
|
96
|
+
.rsep::before{content:'';position:absolute;top:0;bottom:0;left:-6px;right:-6px;cursor:col-resize;background:transparent;z-index:6}
|
|
97
|
+
.rsep:hover,.rsep[data-separator="hover"],.rsep[data-separator="active"]{background:var(--accent)}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// the project list: one row per repo that shares this page's house wiki,
|
|
2
|
+
// down the left of the page, a small icon and the repo's name on one line,
|
|
3
|
+
// the shown repo's row marked in its identity colour. Its width is the
|
|
4
|
+
// panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
|
|
5
|
+
// three things and no more (faceOf): the open asks as its one badge, a dim
|
|
6
|
+
// icon while VS Code does not have the folder open, a thin edge breathing
|
|
7
|
+
// while Claude sessions run in it; on the home, a dot while its hub starts
|
|
8
|
+
// or crashed and a mark while a global process it uses is off. Everything
|
|
9
|
+
// else is the hover card, placed by floating-ui beside the row. A click
|
|
10
|
+
// selects the repo in place (so does ⌘1 to ⌘9 for the first nine rows, the
|
|
11
|
+
// hint at each row's end), a double click opens the folder in VS Code, a
|
|
12
|
+
// drag reorders the list, the button on top sorts it once by last activity,
|
|
13
|
+
// and on the home two buttons open the overview and the plan, each with its
|
|
14
|
+
// shortcut in its corner. It renders from
|
|
15
|
+
// the first paint with the list this browser kept, so a load moves nothing.
|
|
16
|
+
import { autoUpdate, FloatingPortal, useFloating, useHover, useInteractions } from '@floating-ui/react'
|
|
17
|
+
import { observer } from 'mobx-react-lite'
|
|
18
|
+
import { useEffect, type CSSProperties } from 'react'
|
|
19
|
+
import { middleware, OPEN_MS } from './floating.tsx'
|
|
20
|
+
import { Icon, ICON } from './icons.tsx'
|
|
21
|
+
import { PLACE_KEY } from './keys.ts'
|
|
22
|
+
import { cardModel, faceOf, hueOf, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
|
|
23
|
+
|
|
24
|
+
/** what the list needs of its page: the hub and the home both carry one */
|
|
25
|
+
export type ListHost = { projects: ProjectsSvc }
|
|
26
|
+
|
|
27
|
+
/** the two places of the home, at the top of the list */
|
|
28
|
+
const PLACES: { id: Place; label: string; title: string }[] = [
|
|
29
|
+
{ id: 'overview', label: 'home', title: 'the overview: shipkit, every global process and every repo on this machine' },
|
|
30
|
+
{ id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** the icon's size on a row, in px, as attributes and in the sheet */
|
|
34
|
+
export const ROW_ICON = 18
|
|
35
|
+
|
|
36
|
+
export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: number }) => {
|
|
37
|
+
const s = app.projects
|
|
38
|
+
const gap = s.shownGap
|
|
39
|
+
const face = faceOf(p)
|
|
40
|
+
const open = s.hover === p.name
|
|
41
|
+
const f = useFloating({
|
|
42
|
+
open,
|
|
43
|
+
onOpenChange: (v) => (v ? s.hoverIn(p.name) : s.hoverOut(p.name)),
|
|
44
|
+
placement: 'right-start',
|
|
45
|
+
strategy: 'fixed',
|
|
46
|
+
middleware: middleware(8),
|
|
47
|
+
whileElementsMounted: autoUpdate,
|
|
48
|
+
})
|
|
49
|
+
const hover = useHover(f.context, { delay: { open: OPEN_MS, close: 0 }, enabled: s.drag == null })
|
|
50
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
|
|
51
|
+
// the drop line rides on a row edge, so showing it moves nothing
|
|
52
|
+
const edge = gap === i ? ' dropb' : gap === i + 1 && i === s.list.length - 1 ? ' dropa' : ''
|
|
53
|
+
const cur = s.isCurrent(p)
|
|
54
|
+
const cls = `pjrow${cur ? ' cur' : ''}${face.dim ? ' dim' : ''}${s.drag === p.name ? ' dragging' : ''}${edge}`
|
|
55
|
+
const dot = s.dotOf(p.name)
|
|
56
|
+
const warn = s.warnOf(p.name)
|
|
57
|
+
// the shown repo's row is marked in its own colour
|
|
58
|
+
const mark = cur ? ({ '--pjc': s.identityOf(p.name).colorDark } as CSSProperties) : undefined
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
|
|
62
|
+
{...getReferenceProps({
|
|
63
|
+
onClick: () => s.click(p.name),
|
|
64
|
+
onDoubleClick: () => s.doubleClick(p.name),
|
|
65
|
+
onDragStart: (e) => {
|
|
66
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
67
|
+
e.dataTransfer.setData('text/plain', p.name)
|
|
68
|
+
s.dragStart(p.name)
|
|
69
|
+
},
|
|
70
|
+
onDragOver: (e) => {
|
|
71
|
+
if (s.drag == null) return
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
74
|
+
s.dragOver(i, e.clientY - r.top, r.height)
|
|
75
|
+
},
|
|
76
|
+
onDrop: (e) => {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
s.drop()
|
|
79
|
+
},
|
|
80
|
+
onDragEnd: () => s.dragEnd(),
|
|
81
|
+
})}>
|
|
82
|
+
<Face p={p} px={ROW_ICON} />
|
|
83
|
+
<span className="pjname">{p.name}</span>
|
|
84
|
+
{face.badge != null ? <b className="pga">{face.badge}</b> : null}
|
|
85
|
+
{face.busy ? <i className="pgs" /> : null}
|
|
86
|
+
{dot != null ? <i className={`pgd ${dot}`} /> : null}
|
|
87
|
+
{warn != null ? <i className="pgw" title={warn} /> : null}
|
|
88
|
+
{i < 9 ? <kbd className="pjkey">⌘{i + 1}</kbd> : null}
|
|
89
|
+
</button>
|
|
90
|
+
{open ? (
|
|
91
|
+
<FloatingPortal>
|
|
92
|
+
<div ref={f.refs.setFloating} className="pgcard" role="tooltip" style={f.floatingStyles} {...getFloatingProps()}>
|
|
93
|
+
<Card p={p} now={Date.now()} />
|
|
94
|
+
</div>
|
|
95
|
+
</FloatingPortal>
|
|
96
|
+
) : null}
|
|
97
|
+
</>
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// the img carries its size as attributes too: an icon file is 512px, and
|
|
102
|
+
// without them a sheet that fails to apply draws it at that size
|
|
103
|
+
const Face = ({ p, px }: { p: Project; px: number }) => (p.icon != null
|
|
104
|
+
? <img src={p.icon} alt="" width={px} height={px} draggable={false} />
|
|
105
|
+
: <span className={`pgi h${hueOf(p.name)}`}>{(p.name.match(/[a-zA-Z]/)?.[0] ?? p.name.charAt(0)).toUpperCase()}</span>)
|
|
106
|
+
|
|
107
|
+
/** the card for one repo: header, what needs a human, then the fact rows,
|
|
108
|
+
* in the label and value shape the check page's WHAT widget uses */
|
|
109
|
+
export const Card = ({ p, now }: { p: Project; now: number }) => {
|
|
110
|
+
const m = cardModel(p, now)
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
<div className="pgch">
|
|
114
|
+
<span className="pgcface"><Face p={p} px={20} /></span>
|
|
115
|
+
<b className="pgcname">{m.head.name}{m.head.current ? <span className="pgcthis"> this hub</span> : null}</b>
|
|
116
|
+
{m.head.version == null ? null : <span className="pgcver">v{m.head.version.replace(/^v/, '')}</span>}
|
|
117
|
+
</div>
|
|
118
|
+
{m.head.branch == null ? null : <div className="pgcline">{m.head.branch}</div>}
|
|
119
|
+
{m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
|
|
120
|
+
{m.needs == null ? null : (
|
|
121
|
+
<div className="pgcneeds">
|
|
122
|
+
{m.needs.asks == null ? null : (
|
|
123
|
+
<div className="pgcneed">
|
|
124
|
+
<span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
|
|
125
|
+
{m.needs.asks.more > 0 ? <span className="pgcmore">+{m.needs.asks.more}</span> : null}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{m.needs.gate == null ? null : <div className="pgcneed red">{m.needs.gate}</div>}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{m.facts.length === 0 ? null : (
|
|
132
|
+
<div className="pgcfacts">
|
|
133
|
+
{m.facts.map((f) => (
|
|
134
|
+
<div key={f.k} className="wfact">
|
|
135
|
+
<span className="wk">{f.k}</span>
|
|
136
|
+
<span className={`wv${f.tone == null ? '' : ` ${f.tone}`}`}>{f.v}</span>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
146
|
+
useEffect(() => app.projects.start(), [app])
|
|
147
|
+
const s = app.projects
|
|
148
|
+
return (
|
|
149
|
+
<nav className="pjlist" aria-label="projects">
|
|
150
|
+
<div className="pjhead">
|
|
151
|
+
{s.onPlace == null ? null : PLACES.map((pl) => (
|
|
152
|
+
<button key={pl.id} className={`pgplace${s.place === pl.id ? ' on' : ''}`} aria-label={pl.label} title={`${pl.title} (${PLACE_KEY[pl.id]})`} onClick={() => s.onPlace?.(pl.id)}>
|
|
153
|
+
<Icon d={ICON[pl.id === 'overview' ? 'home' : 'planned']} />
|
|
154
|
+
<kbd>{PLACE_KEY[pl.id]}</kbd>
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
157
|
+
<span className="sp" />
|
|
158
|
+
{s.error !== '' ? <span className="pgerr" title={s.error}>!</span> : null}
|
|
159
|
+
<button className="pgsort" title="sort by last activity, once: the result is the new manual order" onClick={() => s.sortByActivity()}>⇅</button>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="pjrows">
|
|
162
|
+
{s.list.length === 0 && s.error === '' ? <span className="pjwait"><span className="spin" />reading the repos…</span> : null}
|
|
163
|
+
{s.list.map((p, i) => <Row key={p.name} app={app} p={p} i={i} />)}
|
|
164
|
+
</div>
|
|
165
|
+
</nav>
|
|
166
|
+
)
|
|
167
|
+
})
|