rvlib-shipkit 1.1.1 → 1.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 +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- 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/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -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 +235 -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 +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -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 +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -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/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- 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 +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- 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 +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- 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 +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- 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/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- 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/sidebar.tsx +84 -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 +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- 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 +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -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/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -1,14 +1,73 @@
|
|
|
1
|
-
// the project
|
|
2
|
-
// wiki, polled from /api/projects.json while the
|
|
1
|
+
// the project list's state: every repo on this disk that shares the house
|
|
2
|
+
// wiki, polled from /api/projects.json while the list is mounted. The
|
|
3
3
|
// server measures at most once per 5s whatever the number of tabs, so this
|
|
4
4
|
// poll is cheap.
|
|
5
5
|
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
6
|
-
import type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
|
|
7
|
-
import type
|
|
6
|
+
import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
|
|
7
|
+
import { identityOfName, type Identity } from '../../core/identity-color.ts'
|
|
8
8
|
|
|
9
|
-
export type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
|
|
9
|
+
export type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
|
|
10
10
|
|
|
11
11
|
export const PROJECTS_POLL_MS = 5_000
|
|
12
|
+
/** how long the colour picker rests before its colour is written */
|
|
13
|
+
export const IDENTITY_SAVE_MS = 400
|
|
14
|
+
|
|
15
|
+
/** where this browser keeps the last project list, painted at once on the
|
|
16
|
+
* next load while the daemon is asked again */
|
|
17
|
+
export const LIST_KEY = 'hub:projects'
|
|
18
|
+
|
|
19
|
+
/** where this browser keeps whether the rail's archived rows show */
|
|
20
|
+
export const ARCHIVED_OPEN_KEY = 'hub:archived-open'
|
|
21
|
+
|
|
22
|
+
const keptOpen = (): boolean => {
|
|
23
|
+
try { return globalThis.localStorage?.getItem(ARCHIVED_OPEN_KEY) === '1' } catch { return false }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** where this browser keeps the last sort it picked */
|
|
27
|
+
export const SORT_KEY = 'hub:sort'
|
|
28
|
+
|
|
29
|
+
/** a sort the foot's menu offers: each one reorders the list once, and the
|
|
30
|
+
* result is the new manual order */
|
|
31
|
+
export type SortMode = 'activity' | 'commit' | 'name' | 'asks'
|
|
32
|
+
|
|
33
|
+
/** the menu's lines, in their order: a label naming the order, a title
|
|
34
|
+
* saying how it breaks ties */
|
|
35
|
+
export const SORTS: { id: SortMode; label: string; title: string }[] = [
|
|
36
|
+
{ id: 'activity', label: 'last activity', title: 'newest first: the last commit, shipkit run or Claude session' },
|
|
37
|
+
{ id: 'commit', label: 'last commit', title: 'newest commit on the checked out branch first' },
|
|
38
|
+
{ id: 'name', label: 'name', title: 'A to Z' },
|
|
39
|
+
{ id: 'asks', label: 'open asks', title: 'the repo waiting on the most asks first' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const keptSort = (): SortMode | null => {
|
|
43
|
+
try {
|
|
44
|
+
const v = globalThis.localStorage?.getItem(SORT_KEY)
|
|
45
|
+
return SORTS.some((m) => m.id === v) ? (v as SortMode) : null
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** the project list this browser last saw, or [] when storage is absent,
|
|
52
|
+
* blocked or holds anything else */
|
|
53
|
+
export function loadList(): Project[] {
|
|
54
|
+
try {
|
|
55
|
+
const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
|
|
56
|
+
return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]) : []
|
|
57
|
+
} catch {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** keeps the list for the next load; false when storage refused it */
|
|
63
|
+
export function saveList(list: Project[]): boolean {
|
|
64
|
+
try {
|
|
65
|
+
globalThis.localStorage?.setItem(LIST_KEY, JSON.stringify(list))
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
12
71
|
|
|
13
72
|
/** one of six kind hues, hashed from the name, so an initial keeps its
|
|
14
73
|
* colour from one load to the next */
|
|
@@ -51,6 +110,20 @@ export const byActivity = (list: Project[]): string[] =>
|
|
|
51
110
|
.sort((a, b) => (b.p.activity ?? -Infinity) - (a.p.activity ?? -Infinity) || a.i - b.i)
|
|
52
111
|
.map(({ p }) => p.name)
|
|
53
112
|
|
|
113
|
+
/** the names in a sort's order, ties in their current order */
|
|
114
|
+
export function sortNames(list: Project[], mode: SortMode): string[] {
|
|
115
|
+
if (mode === 'activity') return byActivity(list)
|
|
116
|
+
const cmp: (a: Project, b: Project) => number = mode === 'commit'
|
|
117
|
+
? (a, b) => (b.commit ?? -Infinity) - (a.commit ?? -Infinity)
|
|
118
|
+
: mode === 'asks' ? (a, b) => b.asks - a.asks
|
|
119
|
+
: (a, b) => a.name.localeCompare(b.name)
|
|
120
|
+
return list.map((p, i) => ({ p, i })).sort((a, b) => cmp(a.p, b.p) || a.i - b.i).map(({ p }) => p.name)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** the hub state dot the home draws on a tile, only when it needs a look:
|
|
124
|
+
* its hub starts or crashed. A hub that is up is the normal case and draws none */
|
|
125
|
+
export type HubDot = 'starting' | 'crashed' | null
|
|
126
|
+
|
|
54
127
|
/** what the icon face shows, and nothing more: at most ONE badge (the open
|
|
55
128
|
* asks, the only fact that needs a human's hands), dim while VS Code does
|
|
56
129
|
* not have the folder open, and a pulsing edge while Claude sessions run */
|
|
@@ -99,22 +172,149 @@ export function cardModel(p: Project, now: number): CardModel {
|
|
|
99
172
|
}
|
|
100
173
|
}
|
|
101
174
|
|
|
175
|
+
/** a page of the home that is no repo's */
|
|
176
|
+
export type Place = 'overview' | 'planned' | 'activity'
|
|
177
|
+
|
|
102
178
|
export class ProjectsSvc {
|
|
103
|
-
list
|
|
104
|
-
|
|
179
|
+
/** the last list this browser saw until the first poll answers, so the
|
|
180
|
+
* list paints at once and nothing moves when the daemon answers */
|
|
181
|
+
list: Project[] = loadList()
|
|
182
|
+
/** why the last poll or open failed, shown on the list's head */
|
|
105
183
|
error = ''
|
|
106
|
-
/** the
|
|
107
|
-
|
|
184
|
+
/** the repos whose repo.config.ts says `archived: true`: never in `list`,
|
|
185
|
+
* only on the archived page */
|
|
186
|
+
archived: ArchivedRow[] = []
|
|
187
|
+
/** the archived button at the foot of the rail is on, its rows shown, per browser */
|
|
188
|
+
archivedOpen = keptOpen()
|
|
189
|
+
/** the sort menu at the foot of the rail is open */
|
|
190
|
+
sortMenuOpen = false
|
|
191
|
+
/** the last sort picked in this browser; `sortMode` says whether the list
|
|
192
|
+
* is still in its order */
|
|
193
|
+
sortPicked: SortMode | null = keptSort()
|
|
194
|
+
/** the daemon answered once: until then the list is the kept one */
|
|
195
|
+
fresh = false
|
|
196
|
+
/** the row under the pointer, whose hover card shows */
|
|
197
|
+
hover: string | null = null
|
|
108
198
|
/** the tile being dragged, and the gap it would land in */
|
|
109
199
|
drag: string | null = null
|
|
110
200
|
gap: number | null = null
|
|
201
|
+
/** the repo the home page shows, or showed last while a global page is
|
|
202
|
+
* up; null until one is picked, and on a hub of its own, whose repo is
|
|
203
|
+
* `current`. The list's api is always the
|
|
204
|
+
* page origin's own: the home's machine wide one, or this hub's */
|
|
205
|
+
selected: string | null = null
|
|
206
|
+
/** what a single click does: the home shows that repo in place, a hub
|
|
207
|
+
* of its own opens the home on it. Default: the folder in VS Code */
|
|
208
|
+
onSelect: (name: string) => void = (name) => void this.open(name)
|
|
209
|
+
/** the two places at the top of the list, the overview and the plan:
|
|
210
|
+
* the home sets it, a hub of its own has none */
|
|
211
|
+
onPlace: ((p: Place) => void) | null = null
|
|
212
|
+
/** the archived page, where an archived row of the rail leads: the home sets it */
|
|
213
|
+
onArchived: (() => void) | null = null
|
|
214
|
+
/** the picker's line after the overview: the home sets it */
|
|
215
|
+
onPicker: (() => void) | null = null
|
|
216
|
+
/** the picker is open, so its line is lit: the home sets it */
|
|
217
|
+
pickerOpen: () => boolean = () => false
|
|
218
|
+
/** the place shown, marked at the top of the list; null on a repo page */
|
|
219
|
+
place: Place | null = null
|
|
220
|
+
/** a global page is up: the selected repo's row stays marked, dimmer */
|
|
221
|
+
away = false
|
|
222
|
+
/** the home's hub state per repo; a hub draws none */
|
|
223
|
+
dotOf: (name: string) => HubDot = () => null
|
|
224
|
+
/** one line when a machine resource the repo uses is off */
|
|
225
|
+
warnOf: (name: string) => string | null = () => null
|
|
111
226
|
private gen = 0
|
|
227
|
+
/** a colour write waiting for the picker to rest, per repo: a poll keeps
|
|
228
|
+
* the colour shown for that repo meanwhile */
|
|
229
|
+
private identitySaves = new Map<string, ReturnType<typeof setTimeout>>()
|
|
112
230
|
/** bumped by every reorder, so a poll that left before it cannot put
|
|
113
231
|
* the old order back */
|
|
114
232
|
private orderGen = 0
|
|
115
233
|
|
|
116
|
-
constructor(
|
|
117
|
-
makeAutoObservable<this, '
|
|
234
|
+
constructor() {
|
|
235
|
+
makeAutoObservable<this, 'gen' | 'orderGen' | 'identitySaves' | 'saveIdentity' | 'kept' | 'keep'>(this, { gen: false, orderGen: false, identitySaves: false, saveIdentity: false, kept: false, keep: false, list: observableRef, onSelect: false, onPlace: false, onPicker: false, pickerOpen: false, onArchived: false, dotOf: false, warnOf: false })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
toggleArchivedOpen(): void {
|
|
239
|
+
this.archivedOpen = !this.archivedOpen
|
|
240
|
+
try { globalThis.localStorage?.setItem(ARCHIVED_OPEN_KEY, this.archivedOpen ? '1' : '0') } catch { /* private window: the choice lasts the tab */ }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
isArchived(name: string): boolean {
|
|
244
|
+
return this.archived.some((p) => p.name === name)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** archive a repo or bring it back: written into its repo.config.ts
|
|
248
|
+
* (`archived`, only that key), then the list is read again, so the repo
|
|
249
|
+
* moves between the rail and the archived page, and the daemon stops or
|
|
250
|
+
* loads its hub on its next tick */
|
|
251
|
+
async setArchived(name: string, on: boolean): Promise<void> {
|
|
252
|
+
try {
|
|
253
|
+
const r = await fetch('/api/projects/archived', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, archived: on }) })
|
|
254
|
+
if (!r.ok) throw new Error(await r.text())
|
|
255
|
+
runInAction(() => { this.error = '' })
|
|
256
|
+
await this.load()
|
|
257
|
+
} catch (e) {
|
|
258
|
+
runInAction(() => { this.error = `${on ? 'archive' : 'unarchive'} ${name}: ${(e as Error).message}` })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** the tile drawn as the current one. The home marks only a repo that
|
|
263
|
+
* was picked (it sets `onPlace`); a hub of its own marks its own repo */
|
|
264
|
+
isCurrent(p: Project): boolean {
|
|
265
|
+
if (this.selected != null) return p.name === this.selected
|
|
266
|
+
return this.onPlace == null && p.current
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
select(name: string): void {
|
|
270
|
+
this.selected = name
|
|
271
|
+
this.onSelect(name)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
/** a click selects once it is sure no second click follows */
|
|
276
|
+
click(name: string): void {
|
|
277
|
+
this.select(name)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** a double click also opens the folder in VS Code (its first click already selected) */
|
|
281
|
+
doubleClick(name: string): void {
|
|
282
|
+
void this.open(name)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** a repo's colours: what its config declares, else the ones SK024 would
|
|
286
|
+
* derive from its name, so every repo reads in a colour of its own */
|
|
287
|
+
identityOf(name: string): Identity {
|
|
288
|
+
return this.list.find((p) => p.name === name)?.identity ?? identityOfName(name)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** one colour set by hand: shown at once, written into the repo's
|
|
292
|
+
* repo.config.ts once the picker rests, so a drag across the colour
|
|
293
|
+
* field is one write, not one per frame */
|
|
294
|
+
setIdentity(name: string, patch: Partial<Identity>): void {
|
|
295
|
+
const next = { ...this.identityOf(name), ...patch }
|
|
296
|
+
this.list = this.list.map((p) => (p.name === name ? { ...p, identity: next } : p))
|
|
297
|
+
const t = this.identitySaves.get(name)
|
|
298
|
+
if (t != null) clearTimeout(t)
|
|
299
|
+
const h: ReturnType<typeof setTimeout> = setTimeout(() => void this.saveIdentity(name, next, h), IDENTITY_SAVE_MS)
|
|
300
|
+
this.identitySaves.set(name, h)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async saveIdentity(name: string, id: Identity, h: ReturnType<typeof setTimeout>): Promise<void> {
|
|
304
|
+
try {
|
|
305
|
+
const r = await fetch('/api/projects/identity', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...id }) })
|
|
306
|
+
if (!r.ok) throw new Error(await r.text())
|
|
307
|
+
const j = (await r.json()) as { identity: Identity }
|
|
308
|
+
runInAction(() => {
|
|
309
|
+
if (this.identitySaves.get(name) === h) this.list = this.list.map((p) => (p.name === name ? { ...p, identity: j.identity } : p))
|
|
310
|
+
this.error = ''
|
|
311
|
+
})
|
|
312
|
+
} catch (e) {
|
|
313
|
+
runInAction(() => { this.error = `colours of ${name}: ${(e as Error).message}` })
|
|
314
|
+
} finally {
|
|
315
|
+
// a later pick rests on its own timer, which keeps the guard
|
|
316
|
+
if (this.identitySaves.get(name) === h) this.identitySaves.delete(name)
|
|
317
|
+
}
|
|
118
318
|
}
|
|
119
319
|
|
|
120
320
|
get dragFrom(): number {
|
|
@@ -127,16 +327,12 @@ export class ProjectsSvc {
|
|
|
127
327
|
return this.gap
|
|
128
328
|
}
|
|
129
329
|
|
|
130
|
-
hoverIn(name: string
|
|
131
|
-
if (this.drag == null) this.hover =
|
|
330
|
+
hoverIn(name: string): void {
|
|
331
|
+
if (this.drag == null) this.hover = name
|
|
132
332
|
}
|
|
133
333
|
|
|
134
334
|
hoverOut(name: string): void {
|
|
135
|
-
if (this.hover
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
get hovered(): Project | null {
|
|
139
|
-
return this.hover == null ? null : this.list.find((p) => p.name === this.hover!.name) ?? null
|
|
335
|
+
if (this.hover === name) this.hover = null
|
|
140
336
|
}
|
|
141
337
|
|
|
142
338
|
dragStart(name: string): void {
|
|
@@ -162,9 +358,30 @@ export class ProjectsSvc {
|
|
|
162
358
|
void this.reorder(moveTo(this.list.map((p) => p.name), from, gap))
|
|
163
359
|
}
|
|
164
360
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
361
|
+
/** the sort the list is in now: the last one picked while the order
|
|
362
|
+
* still matches it, else the manual order a drag made */
|
|
363
|
+
get sortMode(): SortMode | 'manual' {
|
|
364
|
+
const m = this.sortPicked
|
|
365
|
+
if (m == null) return 'manual'
|
|
366
|
+
const now = this.list.map((p) => p.name)
|
|
367
|
+
return sortNames(this.list, m).every((n, i) => n === now[i]) ? m : 'manual'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** the button opens or closes the menu, and sorts nothing */
|
|
371
|
+
toggleSortMenu(): void {
|
|
372
|
+
this.sortMenuOpen = !this.sortMenuOpen
|
|
373
|
+
}
|
|
374
|
+
setSortMenu(open: boolean): void {
|
|
375
|
+
this.sortMenuOpen = open
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** a pick: one reorder in that sort's order, then the menu closes; from
|
|
379
|
+
* then on it is the manual order */
|
|
380
|
+
sortBy(mode: SortMode): void {
|
|
381
|
+
this.sortMenuOpen = false
|
|
382
|
+
this.sortPicked = mode
|
|
383
|
+
try { globalThis.localStorage?.setItem(SORT_KEY, mode) } catch { /* private window: the pick lasts the tab */ }
|
|
384
|
+
void this.reorder(sortNames(this.list, mode))
|
|
168
385
|
}
|
|
169
386
|
|
|
170
387
|
/** shows the new order at once, then saves it for every hub */
|
|
@@ -187,6 +404,7 @@ export class ProjectsSvc {
|
|
|
187
404
|
const gen = ++this.gen
|
|
188
405
|
const tick = async (): Promise<void> => {
|
|
189
406
|
await this.load()
|
|
407
|
+
this.keep()
|
|
190
408
|
if (gen === this.gen) timer = setTimeout(() => void tick(), PROJECTS_POLL_MS)
|
|
191
409
|
}
|
|
192
410
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -197,17 +415,29 @@ export class ProjectsSvc {
|
|
|
197
415
|
}
|
|
198
416
|
}
|
|
199
417
|
|
|
418
|
+
/** the list as it is now, kept for the next load. Only a change is
|
|
419
|
+
* written: the poll repeats the same rows every five seconds */
|
|
420
|
+
private kept = ''
|
|
421
|
+
private keep(): void {
|
|
422
|
+
if (!this.fresh) return
|
|
423
|
+
const s = JSON.stringify(this.list)
|
|
424
|
+
if (s !== this.kept && saveList(this.list)) this.kept = s
|
|
425
|
+
}
|
|
426
|
+
|
|
200
427
|
async load(): Promise<void> {
|
|
201
428
|
const order = this.orderGen
|
|
202
429
|
try {
|
|
203
430
|
const r = await fetch('/api/projects.json')
|
|
204
431
|
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
205
|
-
const j = (await r.json()) as { projects: Project[] }
|
|
432
|
+
const j = (await r.json()) as { projects: Project[]; archived?: ArchivedRow[] }
|
|
206
433
|
runInAction(() => {
|
|
207
434
|
// a reorder since this left, or a drag in flight: the next poll answers
|
|
208
435
|
if (order !== this.orderGen || this.drag != null) return
|
|
209
|
-
this.list
|
|
436
|
+
const mine = new Map(this.list.filter((p) => this.identitySaves.has(p.name)).map((p) => [p.name, p.identity]))
|
|
437
|
+
this.list = mine.size === 0 ? j.projects : j.projects.map((p) => (mine.has(p.name) ? { ...p, identity: mine.get(p.name)! } : p))
|
|
438
|
+
this.archived = j.archived ?? []
|
|
210
439
|
this.error = ''
|
|
440
|
+
this.fresh = true
|
|
211
441
|
})
|
|
212
442
|
} catch (e) {
|
|
213
443
|
runInAction(() => { this.error = `projects: ${(e as Error).message}` })
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// the same reasons a switch is.
|
|
6
6
|
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
7
7
|
import type { ProseGroup, ProseView } from '../../prose/prose-view.ts'
|
|
8
|
-
import type {
|
|
8
|
+
import type { RepoSt } from './repo-st.ts'
|
|
9
9
|
|
|
10
10
|
export type TierFilter = 'all' | 'blocking' | 'soft'
|
|
11
11
|
export type ListKind = 'allow' | 'ban'
|
|
@@ -29,7 +29,7 @@ export class ProseHitsSt {
|
|
|
29
29
|
full = new Set<string>()
|
|
30
30
|
form: { kind: ListKind; word: string; to: string; reason: string } = { kind: 'allow', word: '', to: '', reason: '' }
|
|
31
31
|
|
|
32
|
-
constructor(private st:
|
|
32
|
+
constructor(private st: RepoSt) {
|
|
33
33
|
// .ref: the view is swapped whole on every load, never edited in place
|
|
34
34
|
makeAutoObservable<this, 'st'>(this, { st: false, view: observableRef })
|
|
35
35
|
}
|
|
@@ -37,7 +37,7 @@ export class ProseHitsSt {
|
|
|
37
37
|
async load(): Promise<void> {
|
|
38
38
|
runInAction(() => { this.loading = true })
|
|
39
39
|
try {
|
|
40
|
-
const r = await fetch('/api/prose.json')
|
|
40
|
+
const r = await fetch(this.st.url('/api/prose.json'))
|
|
41
41
|
if (!r.ok) throw new Error(await r.text())
|
|
42
42
|
const v = (await r.json()) as ProseView
|
|
43
43
|
runInAction(() => { this.view = v; this.error = '' })
|
|
@@ -9,7 +9,7 @@ import type { ProseGroup, ProseViewHit } from '../../prose/prose-view.ts'
|
|
|
9
9
|
import type { ProseWords } from '../../prose/ste.ts'
|
|
10
10
|
import { editorHref } from './check-words.ts'
|
|
11
11
|
import { Help } from './help.tsx'
|
|
12
|
-
import type {
|
|
12
|
+
import type { RepoSt } from './repo-st.ts'
|
|
13
13
|
import { GROUP_CAP, HIT_CAP, ProseHitsSt, type ListKind, type TierFilter } from './prose-hits-st.ts'
|
|
14
14
|
|
|
15
15
|
/** blocking tiers read red, the soft tier reads as a warning */
|
|
@@ -47,7 +47,7 @@ const HitLine = ({ root, h, shape }: { root: string; h: ProseViewHit; shape: boo
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const Group = observer(({ app, s, g }: { app:
|
|
50
|
+
const Group = observer(({ app, s, g }: { app: RepoSt; s: ProseHitsSt; g: ProseGroup }) => {
|
|
51
51
|
const open = s.open.has(g.key)
|
|
52
52
|
const t = TIER_WORD[g.tier] ?? { word: g.tier, tone: '' }
|
|
53
53
|
const shown = s.full.has(g.key) ? g.hits : g.hits.slice(0, HIT_CAP)
|
|
@@ -77,7 +77,7 @@ const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGro
|
|
|
77
77
|
)
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
const Hits = observer(({ app, s }: { app:
|
|
80
|
+
const Hits = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
|
|
81
81
|
const v = s.view
|
|
82
82
|
if (v == null) return <div className="dim">{s.error !== '' ? s.error : 'reading the hits…'}</div>
|
|
83
83
|
if (!v.scoped) return <div className="dim">this repo declares no prose scope, so the check reads no file: add `prose: {'{'} include: [...] {'}'}` to repo.config.ts</div>
|
|
@@ -130,7 +130,7 @@ const ListRows = ({ s, words }: { s: ProseHitsSt; words: ProseWords }) => {
|
|
|
130
130
|
)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const Lists = observer(({ app, s }: { app:
|
|
133
|
+
const Lists = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
|
|
134
134
|
const f = s.form
|
|
135
135
|
const g = app.checks.gate
|
|
136
136
|
return (
|
|
@@ -154,7 +154,7 @@ const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
|
|
|
154
154
|
})
|
|
155
155
|
|
|
156
156
|
/** the two sections over one state, the part a test renders headless */
|
|
157
|
-
export const ProseSections = observer(({ app, s }: { app:
|
|
157
|
+
export const ProseSections = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => (
|
|
158
158
|
<>
|
|
159
159
|
<section className="csec">
|
|
160
160
|
<div className="cstitle">hits<Help text="every hit of the last read, grouped by the word or the rule that fired, the noisiest first. A file:line opens the editor on that line" /></div>
|
|
@@ -169,7 +169,7 @@ export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt
|
|
|
169
169
|
|
|
170
170
|
/** mounted by the check page for SK014 only: the state lives while the page
|
|
171
171
|
* is open, and reads the hits once on the way in */
|
|
172
|
-
export const ProseHits = observer(({ app }: { app:
|
|
172
|
+
export const ProseHits = observer(({ app }: { app: RepoSt }) => {
|
|
173
173
|
const s = useMemo(() => new ProseHitsSt(app), [app])
|
|
174
174
|
useEffect(() => { void s.load() }, [s])
|
|
175
175
|
return <ProseSections app={app} s={s} />
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// the repo's README, shown when no row is picked. Read again each time the
|
|
2
|
+
// empty page shows, so an edited README appears without a reload
|
|
3
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
4
|
+
import type { RepoSt } from './repo-st.ts'
|
|
5
|
+
|
|
6
|
+
export class ReadmeSvc {
|
|
7
|
+
/** the file name at the root, null when the repo has none */
|
|
8
|
+
file: string | null = null
|
|
9
|
+
md: string | null = null
|
|
10
|
+
error: string | null = null
|
|
11
|
+
loaded = false
|
|
12
|
+
|
|
13
|
+
constructor(private st: RepoSt) {
|
|
14
|
+
makeAutoObservable<this, 'st'>(this, { st: false })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** where a README image loads from: the hub's file route, under this repo */
|
|
18
|
+
fileUrl = (rel: string): string => this.st.url(`/api/readme-file/${rel}`)
|
|
19
|
+
|
|
20
|
+
async load(): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(this.st.url('/api/readme.json'))
|
|
23
|
+
if (!r.ok) throw new Error(`http ${r.status}: ${await r.text()}`)
|
|
24
|
+
const j = (await r.json()) as { file: string | null; md: string | null }
|
|
25
|
+
runInAction(() => {
|
|
26
|
+
this.file = j.file
|
|
27
|
+
this.md = j.md
|
|
28
|
+
this.error = null
|
|
29
|
+
this.loaded = true
|
|
30
|
+
})
|
|
31
|
+
} catch (e) {
|
|
32
|
+
runInAction(() => {
|
|
33
|
+
this.error = `README fetch broke: ${(e as Error).message}`
|
|
34
|
+
this.loaded = true
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// the page with no row picked: the repo's README, images included, or the
|
|
2
|
+
// hint when the repo has none
|
|
3
|
+
import { observer } from 'mobx-react-lite'
|
|
4
|
+
import { useEffect } from 'react'
|
|
5
|
+
import { renderReadme } from './md.ts'
|
|
6
|
+
import type { RepoSt } from './repo-st.ts'
|
|
7
|
+
|
|
8
|
+
export const ReadmeView = observer(({ app }: { app: RepoSt }) => {
|
|
9
|
+
const r = app.readme
|
|
10
|
+
useEffect(() => { void r.load() }, [r])
|
|
11
|
+
if (r.error != null) return <div className="ph"><div className="errs">{r.error}</div>Pick a row on the left.</div>
|
|
12
|
+
if (r.md == null) return <div className="ph">{r.loaded ? 'Pick a row on the left.' : ''}</div>
|
|
13
|
+
return (
|
|
14
|
+
<div className="readme">
|
|
15
|
+
<div className="readme-head">{r.file} · pick a row on the left for the rest</div>
|
|
16
|
+
<div className="md readme-body" dangerouslySetInnerHTML={{ __html: renderReadme(r.md, r.fileUrl) }} />
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* the README on a repo page with no row picked: a readable column held to the left
|
|
2
|
+
(the pane centers its child, the auto right margin takes the slack), images at their width */
|
|
3
|
+
.readme{width:100%;max-width:860px;margin:0 auto 0 var(--s3);padding:var(--s3) var(--s4)}
|
|
4
|
+
.readme-head{color:var(--dim);font-size:11px;margin-bottom:var(--s2)}
|
|
5
|
+
.readme-body{font-size:13.5px;line-height:1.6}
|
|
6
|
+
.readme .readme-body h1{font-size:22px;margin:var(--s3) 0 var(--s2)}
|
|
7
|
+
.readme .readme-body h2{font-size:17px;margin:var(--s3) 0 var(--s2);border-bottom:1px solid var(--line);padding-bottom:var(--s1)}
|
|
8
|
+
.readme .readme-body h3{font-size:14px}
|
|
9
|
+
.readme-body img{max-width:100%;height:auto;border-radius:4px}
|
|
10
|
+
.readme-body pre{overflow-x:auto;padding:var(--s2);background:var(--hi3);border-radius:4px}
|
|
11
|
+
.readme-body a{color:var(--blue)}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A repo page's sections: the pages reached for most, one line each at the top
|
|
2
|
+
// of the left pane, over the tree, drawn the way the project list draws its
|
|
3
|
+
// lines (an icon, a label, a count where it says something, its ⌥ shortcut at
|
|
4
|
+
// the end, keys.ts), the current one lit. A page this repo does not have is
|
|
5
|
+
// not a line. The lines come from what the page already holds (the kept
|
|
6
|
+
// screen on a reload), so none arrives late and moves the tree.
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { RUNS_ID } from '../../collections/collection-types.ts'
|
|
9
|
+
import { Icon, ICON } from './icons.tsx'
|
|
10
|
+
import { sectionKeyLabel, type SectionId } from './keys.ts'
|
|
11
|
+
import type { RepoSt } from './repo-st.ts'
|
|
12
|
+
import { openWikis, WIKIS } from './wikis-view.tsx'
|
|
13
|
+
|
|
14
|
+
export type RepoSection = {
|
|
15
|
+
id: SectionId
|
|
16
|
+
label: string
|
|
17
|
+
title: string
|
|
18
|
+
/** null when a number would say nothing */
|
|
19
|
+
count: number | null
|
|
20
|
+
tone: 'red' | 'warn' | null
|
|
21
|
+
on: boolean
|
|
22
|
+
go: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const counted = (n: number, tone: 'red' | 'warn'): Pick<RepoSection, 'count' | 'tone'> => (n === 0 ? { count: null, tone: null } : { count: n, tone })
|
|
26
|
+
|
|
27
|
+
export function repoSections(app: RepoSt): RepoSection[] {
|
|
28
|
+
const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
|
|
29
|
+
const sel = app.sel
|
|
30
|
+
const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
|
|
31
|
+
const res = app.cols.resources
|
|
32
|
+
const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
|
|
33
|
+
const none = { count: null, tone: null }
|
|
34
|
+
const out: RepoSection[] = []
|
|
35
|
+
if (has('checks')) out.push({ id: 'checks', label: 'checks', title: 'every check the gate and the health pass run, one page each', ...counted(reds('checks'), 'red'), on: onCol('checks'), go: () => app.showCol('checks') })
|
|
36
|
+
// a page, never a resource: every hub keeps a run record
|
|
37
|
+
out.push({ id: 'runs', label: 'runs', title: 'every run on record, the slowest checks and the longest runs', ...none, on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
|
|
38
|
+
if (has('asks')) {
|
|
39
|
+
const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
|
|
40
|
+
out.push({ id: 'asks', label: 'asks', title: 'what waits on a human', ...counted(open, 'warn'), on: onCol('asks'), go: () => app.showCol('asks') })
|
|
41
|
+
}
|
|
42
|
+
if (has('tests')) out.push({ id: 'tests', label: 'tests', title: 'every test file, slowest first, then each test check', ...counted(reds('tests'), 'red'), on: sel?.t === 'tests' || onCol('tests'), go: () => app.showTests(null) })
|
|
43
|
+
const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
|
|
44
|
+
if (firstCat != null) out.push({ id: 'media', label: 'media', title: 'the assets, in the atelier', ...none, on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
|
|
45
|
+
if (has(WIKIS)) out.push({ id: 'wikis', label: 'wikis', title: 'every wiki this repo mounts, as a link graph', ...none, on: onCol(WIKIS), go: () => openWikis(app) })
|
|
46
|
+
if (app.home.machine != null) out.push({ id: 'admin', label: 'admin', title: 'reload the hub, keep the repo active, archive it', ...none, on: sel?.t === 'admin', go: () => app.showAdmin() })
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Sections = observer(({ app }: { app: RepoSt }) => (
|
|
51
|
+
<nav className="rsecs" aria-label="sections">
|
|
52
|
+
{repoSections(app).map((s) => (
|
|
53
|
+
<button key={s.id} className={`rsec${s.on ? ' cur' : ''}`} aria-current={s.on ? 'page' : undefined} title={`${s.title} (${sectionKeyLabel(s.id)})`} onClick={s.go}>
|
|
54
|
+
<Icon d={ICON[s.id]} />
|
|
55
|
+
<span className="rsecl">{s.label}</span>
|
|
56
|
+
{s.count == null ? null : <b className={`rsecn n${s.tone}`}>{s.count}</b>}
|
|
57
|
+
<kbd className="pjkey">{sectionKeyLabel(s.id)}</kbd>
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</nav>
|
|
61
|
+
))
|