rvlib-shipkit 1.5.0 → 1.7.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Insights.ts +78 -0
  17. package/src/cli/cmd_Keys.ts +12 -1
  18. package/src/cli/cmd_Overview.ts +1 -1
  19. package/src/cli/cmd_Serve.ts +2 -1
  20. package/src/cli/cmd_TraceAudit.ts +62 -0
  21. package/src/cli.ts +6 -0
  22. package/src/collections/collection-types.ts +3 -0
  23. package/src/collections/providers.ts +5 -3
  24. package/src/core/app-icon.ts +154 -0
  25. package/src/core/audit-report.ts +21 -0
  26. package/src/core/cache-audit.ts +72 -0
  27. package/src/core/canonical-scripts.ts +1 -1
  28. package/src/core/check-ctx.ts +14 -5
  29. package/src/core/check-runner.ts +6 -3
  30. package/src/core/floor-resolve.ts +5 -7
  31. package/src/core/git-root.ts +13 -0
  32. package/src/core/ignored-block.ts +94 -0
  33. package/src/core/ignored-inputs.ts +47 -0
  34. package/src/core/jobs.ts +9 -2
  35. package/src/core/one-lib.ts +59 -4
  36. package/src/core/read-set.ts +97 -9
  37. package/src/core/run-ci.ts +6 -1
  38. package/src/core/run-fixers.ts +8 -2
  39. package/src/core/trace-audit.ts +129 -0
  40. package/src/home/gui-app/ShipkitGui.swift +125 -41
  41. package/src/home/gui-app.ts +24 -27
  42. package/src/home/home-routes.ts +15 -0
  43. package/src/home/home-start.ts +11 -5
  44. package/src/home/insights-shape.ts +124 -0
  45. package/src/home/insights.ts +90 -0
  46. package/src/home/open-app.ts +2 -2
  47. package/src/home/serve-web.ts +3 -3
  48. package/src/home/via-hub.ts +14 -0
  49. package/src/home/widgets.ts +12 -2
  50. package/src/ledger/db.ts +2 -13
  51. package/src/ledger/gate-checks.ts +21 -3
  52. package/src/ledger/git-key.ts +148 -30
  53. package/src/ledger/git-scan.ts +5 -2
  54. package/src/ledger/ledger.ts +131 -16
  55. package/src/ledger/store.ts +14 -0
  56. package/src/ledger/toolchain-identity.ts +210 -25
  57. package/src/problems/extract.ts +3 -0
  58. package/src/repo-config.ts +18 -1
  59. package/src/serve/hub-app/admin-svc.ts +36 -3
  60. package/src/serve/hub-app/admin-view.tsx +33 -1
  61. package/src/serve/hub-app/col-card.tsx +1 -44
  62. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  63. package/src/serve/hub-app/col-tables.ts +27 -0
  64. package/src/serve/hub-app/home-st.ts +13 -6
  65. package/src/serve/hub-app/home-strip.css +7 -9
  66. package/src/serve/hub-app/home-strip.tsx +8 -17
  67. package/src/serve/hub-app/home.html +1 -0
  68. package/src/serve/hub-app/hub-app.tsx +20 -4
  69. package/src/serve/hub-app/hub.css +24 -20
  70. package/src/serve/hub-app/icons.tsx +1 -0
  71. package/src/serve/hub-app/index.html +1 -0
  72. package/src/serve/hub-app/insights-st.ts +111 -0
  73. package/src/serve/hub-app/insights-view.tsx +112 -0
  74. package/src/serve/hub-app/insights.css +24 -0
  75. package/src/serve/hub-app/keys.ts +11 -3
  76. package/src/serve/hub-app/machine-st.ts +23 -0
  77. package/src/serve/hub-app/palette-st.ts +2 -1
  78. package/src/serve/hub-app/processes-view.tsx +16 -9
  79. package/src/serve/hub-app/project-list.css +4 -1
  80. package/src/serve/hub-app/project-list.tsx +9 -5
  81. package/src/serve/hub-app/projects-svc.ts +11 -1
  82. package/src/serve/hub-app/repo-sections.ts +31 -17
  83. package/src/serve/hub-app/repo-st.ts +25 -6
  84. package/src/serve/hub-app/sidebar.tsx +5 -5
  85. package/src/serve/hub-app/tree-build.ts +2 -0
  86. package/src/serve/hub-app/tree-row.tsx +18 -5
  87. package/src/serve/hub-app/tree-svc.ts +27 -7
  88. package/src/serve/hub-runtime.ts +4 -2
  89. package/src/serve/project-gutter-route.ts +22 -1
  90. package/src/shipkit-files.ts +3 -2
  91. package/src/suite/judge-suites.ts +6 -0
  92. package/src/trace/interpose.c +655 -0
  93. package/src/trace/read-trace.ts +485 -0
  94. package/src/trace/tracer.ts +282 -0
@@ -1,17 +1,50 @@
1
1
  // A repo's admin page, as state: what administers the repo rather than works
2
- // in it. Archive asks first (the confirmation is this state, so it survives a
2
+ // in it. Its identity colours, with the app icon they give it. Archive asks first (the confirmation is this state, so it survives a
3
3
  // re-render and a test drives it headless), reload and keep active go to the
4
4
  // machine by the repo's key. On a hub of its own there is no machine, and no
5
5
  // admin page.
6
- import { makeAutoObservable } from 'mobx'
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { Identity } from '../../core/identity-color.ts'
7
8
  import type { RepoSt } from './repo-st.ts'
8
9
 
10
+ /** how long a picker rests before the preview draws its colours */
11
+ export const PREVIEW_REST_MS = 150
12
+
9
13
  export class AdminSvc {
10
14
  /** the archive button was pressed: its confirmation shows in its place */
11
15
  confirming = false
16
+ /** the colours the preview keeps while a picker moves, so a drag draws a
17
+ * few icons, not one per frame; null follows the repo's colours */
18
+ held: Identity | null = null
19
+ /** the repo has no png mark, or it would not draw: the app wears the default icon */
20
+ noIcon = false
21
+ private rest: ReturnType<typeof setTimeout> | null = null
12
22
 
13
23
  constructor(private st: RepoSt) {
14
- makeAutoObservable<this, 'st'>(this, { st: false })
24
+ makeAutoObservable<this, 'st' | 'rest'>(this, { st: false, rest: false })
25
+ }
26
+
27
+ /** by key: the project list and the icon route know a repo by its
28
+ * folder, and its config can name it otherwise */
29
+ get identity(): Identity {
30
+ return this.st.home.projects.identityOf(this.st.key)
31
+ }
32
+ /** one colour, shown at once and saved to repo.config.ts once it rests */
33
+ setColour(patch: Partial<Identity>): void {
34
+ this.held ??= this.identity
35
+ this.st.home.projects.setIdentity(this.st.key, patch)
36
+ if (this.rest != null) clearTimeout(this.rest)
37
+ this.rest = setTimeout(() => runInAction(() => { this.held = null }), PREVIEW_REST_MS)
38
+ }
39
+ get previewSrc(): string {
40
+ const id = this.held ?? this.identity
41
+ return `/api/projects/app-icon/${encodeURIComponent(this.st.key)}?light=${encodeURIComponent(id.colorLight)}&dark=${encodeURIComponent(id.colorDark)}`
42
+ }
43
+ previewFailed(): void {
44
+ this.noIcon = true
45
+ }
46
+ previewLoaded(): void {
47
+ this.noIcon = false
15
48
  }
16
49
 
17
50
  askArchive(): void {
@@ -1,5 +1,6 @@
1
1
  // THE ADMIN PAGE of a repo (`#page:admin`): what administers the repo rather
2
- // than works in it, one row per control, each with the one sentence that says
2
+ // than works in it. Its identity first (the colours, and the app icon they
3
+ // draw, core/app-icon.ts), then one row per control, each with the one sentence that says
3
4
  // what it does. State in admin-svc.ts. Only on the home: a hub of its own has
4
5
  // no machine to administer it.
5
6
  import { observer } from 'mobx-react-lite'
@@ -16,6 +17,36 @@ const Row = ({ title, says, children }: { title: string; says: string; children:
16
17
  </div>
17
18
  )
18
19
 
20
+ /** the repo's two colours, and what they look like: its app icon and its name on both pages */
21
+ const IdentityBox = observer(({ app }: { app: RepoSt }) => {
22
+ const a = app.admin
23
+ const id = a.identity
24
+ const name = app.name
25
+ return (
26
+ <div className="adbox adid">
27
+ <div className="adicon">
28
+ <img src={a.previewSrc} alt={`${name} app icon`} width={128} height={128} hidden={a.noIcon}
29
+ onLoad={() => a.previewLoaded()} onError={() => a.previewFailed()} />
30
+ {a.noIcon ? <span className="dim">no png mark (.shipkit/identity/icon.png, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
31
+ </div>
32
+ <div className="adtext">
33
+ <b>identity</b>
34
+ <span className="dim">the repo's two colours, written to repo.config.ts as identity. The app icon sets the mark on the one it stands out on most, and each app redraws it at its next open.</span>
35
+ <label className="adpick">
36
+ <input type="color" value={id.colorDark} onChange={(e) => a.setColour({ colorDark: e.currentTarget.value })} />
37
+ <span className="adsample dark" style={{ color: id.colorDark }}>{name}</span>
38
+ <span className="dim">on a dark page, colorDark {id.colorDark}</span>
39
+ </label>
40
+ <label className="adpick">
41
+ <input type="color" value={id.colorLight} onChange={(e) => a.setColour({ colorLight: e.currentTarget.value })} />
42
+ <span className="adsample light" style={{ color: id.colorLight }}>{name}</span>
43
+ <span className="dim">on a light page, colorLight {id.colorLight}</span>
44
+ </label>
45
+ </div>
46
+ </div>
47
+ )
48
+ })
49
+
19
50
  export const AdminPage = observer(({ app }: { app: RepoSt }) => {
20
51
  const a = app.admin
21
52
  const w = app.home.machine?.worker(app.key)
@@ -26,6 +57,7 @@ export const AdminPage = observer(({ app }: { app: RepoSt }) => {
26
57
  <span className="tid">admin</span>
27
58
  <h3 className="ctitle">{name}</h3>
28
59
  </div>
60
+ <IdentityBox app={app} />
29
61
  <div className="adbox">
30
62
  <Row title="reload the hub" says={`stops ${name}'s hub in the daemon and loads it again, its repo.config.ts read fresh and its processes started again. The daemon already does it on its own when repo.config.ts changes: this is for a hub that is stuck.${w == null ? '' : ` Its hub is ${w.status} now.`}`}>
31
63
  <button className="ghost" onClick={() => void a.reload()}>reload</button>
@@ -1,5 +1,4 @@
1
- // Collection views: the per-collection card grid (ColGrid), the item card
2
- // (asks · creds · posts), and the health/status card with the streaming
1
+ // Collection views: the item card (asks · creds · posts), and the health/status card with the streaming
3
2
  // check runner. Every verb posts to /api/act/* and lands in a git-tracked
4
3
  // truth file; the truth chip names the file the item IS (on disagreement
5
4
  // the file wins).
@@ -101,48 +100,6 @@ export const ColCard = observer(({ app, col, id }: { app: RepoSt; col: string; i
101
100
  )
102
101
  })
103
102
 
104
- // collection view — one card per visible instance, the stage-side twin of a
105
- // sidebar section (every section gets both views)
106
- export const ColGrid = observer(({ app, id }: { app: RepoSt; id: string }) => {
107
- const c = app.cols
108
- const col = c.sidebarCols.find((x) => x.id === id)
109
- if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
110
- const items = c.visibleInstances(col)
111
- return (
112
- <div className="colgrid">
113
- <div className="cghead">{col.title}<span className="cnt">{items.length}</span></div>
114
- {items.length === 0 ? <div className="ph">{app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : 'nothing here yet'}</div> : (
115
- <div className="cgcards">
116
- {items.map((i) => {
117
- if (id === 'creds') {
118
- const cred = credOf(i)
119
- return (
120
- <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
121
- <div className="cgtop"><CredHead cred={cred} /></div>
122
- {cred.service != null ? <div className="creddesc">{cred.service}</div> : null}
123
- {i.md != null ? <div className="md clamp" dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
124
- {i.artifact?.url != null ? (
125
- <div className="credrow"><a className="mint" href={i.artifact.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>mint ↗</a></div>
126
- ) : null}
127
- </div>
128
- )
129
- }
130
- return (
131
- <div key={i.id} className="cgcard" onClick={() => c.select(col.id, i.id)}>
132
- <div className="cgtop"><b className={`tl ${i.state}`}>{i.text}</b></div>
133
- {/* a link click opens its tab — it must not ALSO yank into the item view */}
134
- {i.md != null ? <div className="md clamp" onClick={(e) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation() }} dangerouslySetInnerHTML={{ __html: renderMd(i.md) }} /> : null}
135
- {i.file != null ? <span className="truth">{i.file}</span> : null}
136
- {i.remarks != null && i.remarks.length > 0 ? <div className="cgrem">{i.remarks[i.remarks.length - 1]}</div> : null}
137
- </div>
138
- )
139
- })}
140
- </div>
141
- )}
142
- </div>
143
- )
144
- })
145
-
146
103
  // graph item view — the graph ITSELF fills the stage, lint line as a slim header
147
104
  const wireClass = (l: string): string => {
148
105
  const t = l.trimStart()
@@ -8,7 +8,7 @@
8
8
  import { observer } from 'mobx-react-lite'
9
9
  import type { ReactNode } from 'react'
10
10
  import { checkName } from './check-words.ts'
11
- import { askGroups, checkGroups, dayMs, findingLine, planWord, type TableGroup } from './col-tables.ts'
11
+ import { askGroups, checkGroups, itemGroups, secretGroups, dayMs, findingLine, planWord, type TableGroup } from './col-tables.ts'
12
12
  import { durationTone, fmtMs } from './dur.ts'
13
13
  import type { CheckOwner } from './hash-route.ts'
14
14
  import type { Resource } from './hub-types.ts'
@@ -25,10 +25,12 @@ const Page = ({ title, n, lead, cols, empty, children }: { title: string; n: num
25
25
  <div className="cghead">{title}<span className="cnt">{n}</span></div>
26
26
  {lead == null || lead === '' ? null : <div className="ctlead">{lead}</div>}
27
27
  {n === 0 ? <div className="ph">{empty}</div> : (
28
- <table className="actable cttable">
29
- <colgroup>{cols}</colgroup>
30
- {children}
31
- </table>
28
+ <div className="ctbox">
29
+ <table className="actable cttable">
30
+ <colgroup>{cols}</colgroup>
31
+ {children}
32
+ </table>
33
+ </div>
32
34
  )}
33
35
  </div>
34
36
  )
@@ -129,3 +131,67 @@ export const AskTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
129
131
  </Page>
130
132
  )
131
133
  })
134
+
135
+ const SECRET_TONE: Record<string, string> = { missing: 't-red', minor: 'acdim', present: 't-ok' }
136
+
137
+ /** a secret's sentence without its own name in front, which the name column says */
138
+ const secretSentence = (r: Resource): string => (r.text.startsWith(r.title) ? r.text.slice(r.title.length).replace(/^[\s,]+/, '') : r.text)
139
+
140
+ const SecretRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
141
+ const slot = r.slot ?? 'present'
142
+ const tone = SECRET_TONE[slot] ?? ''
143
+ return (
144
+ <tr className={`ctrow s-${slot}`} data-k={r.id} onClick={() => app.cols.select(r.collection, r.id)} title={r.text}>
145
+ <td className={`ctdot ${tone}`}>{slot === 'minor' ? '○' : '●'}</td>
146
+ <td className="ctid">{r.title}</td>
147
+ <td className={`ctkind ${tone}`}>{slot}</td>
148
+ <td className="ctfind">{secretSentence(r)}</td>
149
+ </tr>
150
+ )
151
+ })
152
+
153
+ /** the secrets page: one line per credential, never a card */
154
+ export const SecretTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
155
+ const col = app.cols.sidebarCols.find((x) => x.id === id)
156
+ if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
157
+ const items = app.cols.visibleInstances(col)
158
+ const cols = (
159
+ <>
160
+ <col className="c-dot" /><col className="c-name" /><col className="c-kind" /><col />
161
+ </>
162
+ )
163
+ return (
164
+ <Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
165
+ {secretGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={4} row={(r) => <SecretRow key={r.id} app={app} r={r} />} />)}
166
+ </Page>
167
+ )
168
+ })
169
+
170
+ const ITEM_TONE: Record<string, string> = { red: 't-red', warn: 't-warn', ok: 't-ok', idle: '', quiet: 'acdim' }
171
+
172
+ const ItemRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => (
173
+ <tr className={`ctrow s-${r.state}`} data-k={r.id} onClick={() => app.cols.select(r.collection, r.id)} title={r.file ?? r.text}>
174
+ <td className={`ctdot ${ITEM_TONE[r.state] ?? ''}`}>●</td>
175
+ <td className={`cttext ${ITEM_TONE[r.state] ?? ''}`}>{r.text}</td>
176
+ <td className="ctkind">{r.slot ?? ''}</td>
177
+ <td className="ctfind">{r.file ?? ''}</td>
178
+ </tr>
179
+ ))
180
+
181
+ /** every collection without a table of its own: one line per item, never a
182
+ * card. What it says in full is its own page, one click away */
183
+ export const ItemTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
184
+ const col = app.cols.sidebarCols.find((x) => x.id === id)
185
+ if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
186
+ const items = app.cols.visibleInstances(col)
187
+ const cols = (
188
+ <>
189
+ <col className="c-dot" /><col /><col className="c-kind" /><col className="c-file" />
190
+ </>
191
+ )
192
+ return (
193
+ <Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
194
+ {itemGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={4} row={(r) => <ItemRow key={r.id} app={app} r={r} />} />)}
195
+ </Page>
196
+ )
197
+ })
@@ -113,3 +113,30 @@ export function askGroups(rows: readonly Resource[]): TableGroup<AskGroupKey>[]
113
113
  ]
114
114
  return groups.filter((g) => g.rows.length > 0)
115
115
  }
116
+
117
+ export type SecretGroupKey = 'missing' | 'minor' | 'present'
118
+
119
+ /** the secrets in the order a reader wants them: what is missing first, a
120
+ * minor one nothing uses yet next, then what is present, each by name */
121
+ export function secretGroups(rows: readonly Resource[]): TableGroup<SecretGroupKey>[] {
122
+ const by = (key: SecretGroupKey): Resource[] => rows.filter((r) => (r.slot ?? 'present') === key).sort((a, b) => a.title.localeCompare(b.title))
123
+ const groups: TableGroup<SecretGroupKey>[] = [
124
+ { key: 'missing', label: `${by('missing').length} missing`, rows: by('missing'), foldedByDefault: false },
125
+ { key: 'minor', label: `${by('minor').length} minor`, rows: by('minor'), foldedByDefault: false },
126
+ { key: 'present', label: `${by('present').length} present`, rows: by('present'), foldedByDefault: false },
127
+ ]
128
+ return groups.filter((g) => g.rows.length > 0)
129
+ }
130
+
131
+ export type ItemGroupKey = 'red' | 'warn' | 'rest'
132
+
133
+ /** any other collection, one line per item: what is red first, then what
134
+ * warns, then the rest, each by its words */
135
+ export function itemGroups(rows: readonly Resource[]): TableGroup<ItemGroupKey>[] {
136
+ const keyOf = (r: Resource): ItemGroupKey => (r.state === 'red' ? 'red' : r.state === 'warn' ? 'warn' : 'rest')
137
+ const by = (key: ItemGroupKey): Resource[] => rows.filter((r) => keyOf(r) === key).sort((a, b) => a.text.localeCompare(b.text))
138
+ const word: Record<ItemGroupKey, string> = { red: 'red', warn: 'warning', rest: 'the rest' }
139
+ return (['red', 'warn', 'rest'] as const)
140
+ .map((key) => ({ key, label: `${by(key).length} ${word[key]}`, rows: by(key), foldedByDefault: false }))
141
+ .filter((g) => g.rows.length > 0)
142
+ }
@@ -18,6 +18,7 @@ import { DockSt } from './dock-st.ts'
18
18
  import { PaletteSt } from './palette-st.ts'
19
19
  import { PlannedSt } from './planned-st.ts'
20
20
  import { ActivitySt } from './activity-st.ts'
21
+ import { InsightsSt } from './insights-st.ts'
21
22
  import { ProjectsSvc } from './projects-svc.ts'
22
23
  import { parseFilter, RepoSt } from './repo-st.ts'
23
24
  import { openSection, repoSections, sectionOfRepo } from './repo-sections.ts'
@@ -25,11 +26,11 @@ import { openSection, repoSections, sectionOfRepo } from './repo-sections.ts'
25
26
  /** the longest one repo's read ahead holds the next one back */
26
27
  export const PREFETCH_WAIT_MS = 10_000
27
28
 
28
- /** what the page shows: the machine overview, the plan, the activity, or one repo */
29
- export type Page = 'overview' | 'planned' | 'activity' | 'archived' | 'repo'
29
+ /** what the page shows: the machine overview, the plan, the activity, the insights, the archived repos, or one repo */
30
+ export type Page = 'overview' | 'planned' | 'activity' | 'insights' | 'archived' | 'repo'
30
31
 
31
32
  /** the address of each page of the home that is no repo's */
32
- export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/activity', archived: '/archived' } as const
33
+ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/activity', insights: '/insights', archived: '/archived' } as const
33
34
 
34
35
  /** where a repo's hub answers on the home */
35
36
  export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
@@ -46,6 +47,7 @@ export function routeOf(pathname: string): { page: Page; repo: string | null } {
46
47
  if (m != null) return { page: 'repo', repo: decodeURIComponent(m[1]!) }
47
48
  if (/^\/planned\/?$/.test(pathname)) return { page: 'planned', repo: null }
48
49
  if (/^\/activity\/?$/.test(pathname)) return { page: 'activity', repo: null }
50
+ if (/^\/insights\/?$/.test(pathname)) return { page: 'insights', repo: null }
49
51
  if (/^\/archived\/?$/.test(pathname)) return { page: 'archived', repo: null }
50
52
  return { page: 'overview', repo: null }
51
53
  }
@@ -69,6 +71,7 @@ export class HomeSt {
69
71
  planned: PlannedSt
70
72
  /** what every repo committed and holds, polled while it is the page */
71
73
  activity: ActivitySt
74
+ insights: InsightsSt
72
75
  /** the one picker, cmd+j */
73
76
  palette: PaletteSt
74
77
  /** the widths of the project list and a repo's sidebar */
@@ -91,12 +94,13 @@ export class HomeSt {
91
94
  this.projects = new ProjectsSvc()
92
95
  this.planned = new PlannedSt(this)
93
96
  this.activity = new ActivitySt(this)
97
+ this.insights = new InsightsSt(this)
94
98
  this.palette = new PaletteSt(this)
95
99
  this.layout = new LayoutSt()
96
100
  this.dock = new DockSt(this)
97
101
  this.machine = o.home ? new MachineSt(this, o) : null
98
102
  this.page = o.home ? 'overview' : 'repo'
99
- makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
103
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
100
104
  const m = this.machine
101
105
  if (m != null) {
102
106
  this.projects.onSelect = (name) => this.select(name)
@@ -129,6 +133,7 @@ export class HomeSt {
129
133
  if (this.page === 'overview') return 'home'
130
134
  if (this.page === 'planned') return 'planned'
131
135
  if (this.page === 'activity') return 'activity'
136
+ if (this.page === 'insights') return 'insights'
132
137
  if (this.page === 'archived') return 'archived'
133
138
  const r = this.shown
134
139
  return r == null || r.name === '' ? 'hub' : `${r.name} · hub`
@@ -181,7 +186,7 @@ export class HomeSt {
181
186
  const want = from == null || from === to ? null : sectionOfRepo(from)
182
187
  if (want != null && sectionOfRepo(to) !== want) {
183
188
  const known = to.cols.loaded || to.cols.cached
184
- openSection(to, known && !repoSections(to).some((s) => s.id === want) ? 'readme' : want)
189
+ openSection(to, known && !repoSections(to).some((s) => s.id === want && s.opens) ? 'readme' : want)
185
190
  }
186
191
  this.show('repo', name, 'push')
187
192
  }
@@ -222,6 +227,7 @@ export class HomeSt {
222
227
  * everything; the one shown resumes once its hub answers */
223
228
  private show(page: Page, repo: string | null, how: 'push' | 'replace' | 'none'): void {
224
229
  const prev = this.shown
230
+ const prevPage = this.page
225
231
  const next = page === 'repo' && repo != null ? this.repo(repo) : null
226
232
  // the page already shown is never a second history entry
227
233
  if (how === 'push' && page === this.page && next === prev && page !== 'planned') how = 'none'
@@ -234,12 +240,13 @@ export class HomeSt {
234
240
  this.projects.away = next == null
235
241
  this.projects.place = page === 'repo' || page === 'archived' ? null : page
236
242
  if (how !== 'none') {
237
- const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : page === 'activity' || page === 'archived' ? PLACE_PATH[page] : '/'
243
+ const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : page === 'activity' || page === 'insights' || page === 'archived' ? PLACE_PATH[page] : '/'
238
244
  if (how === 'push') history.pushState(null, '', to)
239
245
  else history.replaceState(null, '', to)
240
246
  }
241
247
  if (page === 'planned') this.planned.start()
242
248
  else this.planned.stop()
249
+ if (page === 'insights' && prevPage !== 'insights') this.insights.start()
243
250
  if (page === 'activity') this.activity.start()
244
251
  else this.activity.stop()
245
252
  if (next != null && next !== prev && this.machine?.data != null) void this.machine.hub(next.key, 'ensure')
@@ -6,10 +6,6 @@
6
6
  .hrepo{display:inline-flex;align-items:center;gap:var(--s2);min-width:0;flex:0 1 auto}
7
7
  .hrepo img,.hrico{flex:none;width:18px;height:18px;border-radius:4px;object-fit:contain}
8
8
  .hrname{font-size:13px;font-weight:650;letter-spacing:.04em;min-width:0;overflow:hidden;text-overflow:ellipsis}
9
- .hswatch{display:inline-flex;gap:2px}
10
- .hswatch input{width:16px;height:16px;padding:0;border:1px solid var(--line);border-radius:3px;background:none;cursor:pointer}
11
- .hswatch input::-webkit-color-swatch-wrapper{padding:0}
12
- .hswatch input::-webkit-color-swatch{border:none;border-radius:2px}
13
9
  /* trouble, as cards (float-cards.tsx), never taking room, so one coming or
14
10
  going moves nothing: at the top right, in a zero height box right after
15
11
  the bar, so just under it whatever its height. The words wrap, never cut */
@@ -61,6 +57,8 @@
61
57
  .hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
62
58
  .hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
63
59
  .hwidget h3 .hdim{font-weight:400}
60
+ .hwidget h3 .hwre{width:16px;height:16px;margin-left:var(--s1)}
61
+ .hwidget h3 .hwre .hico{width:12px;height:12px}
64
62
  /* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
65
63
  .wmeters td{padding-right:var(--s1)}
66
64
  .wmeters td:nth-child(3n){padding-right:var(--s3)}
@@ -156,8 +154,8 @@
156
154
  .wbar.hi > i{background:var(--red)}
157
155
 
158
156
  /* a process card on the processes page: the menu's two panels at page width */
159
- .pcard .hmacts{max-width:none}
160
- .pcard .hmabout{max-width:none;max-height:none}
161
- .pcard>.cstitle{margin:var(--s3) 0 var(--s1)}
162
- .pcard .hlog{margin-top:0}
163
- .pcard.lit{border-color:var(--accent)}
157
+ .pdetail .hmacts{max-width:none}
158
+ .pdetail .hmabout{max-width:none;max-height:none}
159
+ .pdetail>.cstitle{margin:var(--s3) 0 var(--s1)}
160
+ .pdetail>.cstitle:first-child{margin-top:0}
161
+ .pdetail .hlog{margin-top:0}
@@ -157,9 +157,10 @@ const ProcessRepo = observer(({ app, name, icon }: { app: HomeSt; name: string;
157
157
  /** the processes as one table in the activity page's style: shipkit's own
158
158
  * first, then a row group per declaring repo in the project list's order,
159
159
  * its name in the first column, at the top of its group, then one line per
160
- * process. The url's header carries the new window button */
161
- const ProcessTable = observer(({ app, m }: { app: HomeSt; m: MachineSt }) => {
162
- const groups = m.processGroups
160
+ * process. The url's header carries the new window button. `repo` keeps
161
+ * that one repo's group, the table of its processes page */
162
+ export const ProcessTable = observer(({ app, m, repo }: { app: HomeSt; m: MachineSt; repo?: string }) => {
163
+ const groups = repo == null ? m.processGroups : m.processGroups.filter((g) => g.core !== true && g.repo === repo)
163
164
  const rows = groups.flatMap((g) => g.rows)
164
165
  // wide enough for the longest set of declared actions, in the button font
165
166
  const act = Math.max(0, ...rows.map((r) => r.actions.reduce((n, a) => n + a.label.length + 3, 0)))
@@ -210,6 +211,10 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
210
211
  {x.title}
211
212
  {head == null ? null : <span className="hdim"> · <span dangerouslySetInnerHTML={{ __html: head }} /></span>}
212
213
  {x.error != null && x.html != null ? <i className="gpwarn" title={x.error} /> : null}
214
+ <button className="gpi hwre" aria-label="refresh" disabled={m.refreshing.has(`${x.repo}/${x.id}`)} onClick={() => void m.refreshWidget(x.repo, x.id)}
215
+ title="render it again now, skipping any wait it keeps between samples">
216
+ {m.refreshing.has(`${x.repo}/${x.id}`) ? <span className="spin" /> : <Icon d={ICON.restart} />}
217
+ </button>
213
218
  </h3>
214
219
  {x.html != null ? <div dangerouslySetInnerHTML={{ __html: body }} />
215
220
  : x.error != null ? <div className="hwerr">{x.error}</div>
@@ -225,19 +230,6 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
225
230
  )
226
231
  })
227
232
 
228
- /** the two colours of a repo, a picker each: the page draws the dark one */
229
- export const IdentitySwatches = observer(({ app, name }: { app: HomeSt; name: string }) => {
230
- const id = app.projects.identityOf(name)
231
- return (
232
- <span className="hswatch">
233
- <input type="color" aria-label="colour on a dark background" title={`${name} on a dark background (this page): ${id.colorDark}, written to repo.config.ts identity.colorDark`}
234
- value={id.colorDark} onChange={(e) => app.projects.setIdentity(name, { colorDark: e.currentTarget.value })} />
235
- <input type="color" aria-label="colour on a light background" title={`${name} on a light background: ${id.colorLight}, written to repo.config.ts identity.colorLight`}
236
- value={id.colorLight} onChange={(e) => app.projects.setIdentity(name, { colorLight: e.currentTarget.value })} />
237
- </span>
238
- )
239
- })
240
-
241
233
  /** the shown repo in the strip: its icon, its name in its colour */
242
234
  const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
243
235
  const p = app.projects.list.find((x) => x.name === name)
@@ -247,7 +239,6 @@ const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
247
239
  {/* the icon's box is there before the project list is, so the name never moves */}
248
240
  {p?.icon != null ? <img src={p.icon} alt="" width={18} height={18} /> : <i className="hrico" />}
249
241
  <b className="hrname" style={{ color: id.colorDark }}>{name}</b>
250
- <IdentitySwatches app={app} name={name} />
251
242
  </span>
252
243
  )
253
244
  })
@@ -11,6 +11,7 @@
11
11
  <link rel="stylesheet" href="./planned.css" />
12
12
  <link rel="stylesheet" href="./when.css" />
13
13
  <link rel="stylesheet" href="./activity.css" />
14
+ <link rel="stylesheet" href="./insights.css" />
14
15
  <link rel="stylesheet" href="./readme.css" />
15
16
  <link rel="stylesheet" href="./dock.css" />
16
17
  <link rel="stylesheet" href="./controls.css" />
@@ -11,8 +11,8 @@ import { runInAction } from 'mobx'
11
11
  import { observer } from 'mobx-react-lite'
12
12
  import { StrictMode, useEffect, useLayoutEffect, useRef } from 'react'
13
13
  import { createRoot } from 'react-dom/client'
14
- import { ColCard, ColGrid, StatusCard } from './col-card.tsx'
15
- import { AskTable, CheckTable } from './col-tables-view.tsx'
14
+ import { ColCard, StatusCard } from './col-card.tsx'
15
+ import { AskTable, CheckTable, ItemTable, SecretTable } from './col-tables-view.tsx'
16
16
  import { WIKIS, WikisView } from './wikis-view.tsx'
17
17
  import { ComfyOverlay, ComfyView } from './comfy-view.tsx'
18
18
  import type { Filter, RepoSt } from './repo-st.ts'
@@ -37,6 +37,7 @@ import { LIST, SIDEBAR } from './layout-st.ts'
37
37
  import { ArchivedView, HomeStrip, NotUp, Overview } from './home-strip.tsx'
38
38
  import { PlannedView } from './planned-view.tsx'
39
39
  import { ActivityView } from './activity-view.tsx'
40
+ import { InsightsView } from './insights-view.tsx'
40
41
  import { Palette } from './palette-view.tsx'
41
42
  import { SCREEN_WAIT_MS } from './screen-cache.ts'
42
43
  import { isSearchKey, pageKey, sectionKey } from './keys.ts'
@@ -93,7 +94,7 @@ const Main = observer(({ app }: { app: RepoSt }) => {
93
94
  if (app.sel?.t === 'col') return (
94
95
  <main>
95
96
  {errs}
96
- {app.sel.id === 'checks' ? <CheckTable app={app} id={app.sel.id} /> : app.sel.id === 'asks' ? <AskTable app={app} id={app.sel.id} /> : <ColGrid app={app} id={app.sel.id} />}
97
+ {app.sel.id === 'checks' ? <CheckTable app={app} id={app.sel.id} /> : app.sel.id === 'asks' ? <AskTable app={app} id={app.sel.id} /> : app.sel.id === 'creds' ? <SecretTable app={app} id={app.sel.id} /> : <ItemTable app={app} id={app.sel.id} />}
97
98
  </main>
98
99
  )
99
100
  if (app.sel?.t === 'tests') return <main>{errs}<div className="stagewrap"><TestsPage app={app} check={app.sel.check} /></div></main>
@@ -195,13 +196,18 @@ const RepoView = observer(({ app }: { app: RepoSt }) => {
195
196
  function onKey(e: KeyboardEvent): void {
196
197
  // the command keys beat the focus check: they work from anywhere, any page
197
198
  const pk = pageKey(e)
198
- if (pk != null) {
199
+ // in a text field ⌘↑ and ⌘↓ keep moving the caret to its ends
200
+ const typing = (e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA'
201
+ if (pk != null && !(pk.t === 'step' && typing)) {
199
202
  e.preventDefault()
200
203
  if (pk.t === 'picker') home.palette.toggle()
201
204
  else if (pk.t === 'dock') home.dock.cycle()
202
205
  else if (pk.t === 'home' || pk.t === 'planned' || pk.t === 'activity') {
203
206
  home.palette.close()
204
207
  if (home.machine != null) home.navigate(PLACE_PATH[pk.t === 'home' ? 'overview' : pk.t])
208
+ } else if (pk.t === 'step') {
209
+ home.palette.close()
210
+ home.projects.step(pk.by)
205
211
  } else {
206
212
  const p = home.projects.list[pk.n - 1]
207
213
  home.palette.close()
@@ -235,6 +241,15 @@ function onKey(e: KeyboardEvent): void {
235
241
  return
236
242
  }
237
243
  if (e.metaKey || e.ctrlKey || e.altKey) return
244
+ // the tree's keys need no focus: the arrows move it from anywhere on the
245
+ // page, Enter and space only where no button or link claims them
246
+ // the atelier's ←/→ roll, so at its first or last roll they stop there
247
+ const rolls = app.sel?.t === 'asset' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')
248
+ const own = !rolls && (e.key.startsWith('Arrow') || t.closest('button, a, select, [role="button"]') == null)
249
+ if (own && app.tree.key(e.key)) {
250
+ e.preventDefault()
251
+ return
252
+ }
238
253
  if (e.key === FOLLOW_KEY && (!app.live.chart.follow || !home.dock.chart.follow)) {
239
254
  e.preventDefault()
240
255
  app.live.chart.attach()
@@ -306,6 +321,7 @@ const App = observer(() => {
306
321
  {home.page === 'overview' ? <Overview app={home} /> : null}
307
322
  {home.page === 'planned' ? <PlannedView app={home} /> : null}
308
323
  {home.page === 'activity' ? <ActivityView app={home} /> : null}
324
+ {home.page === 'insights' ? <InsightsView app={home} /> : null}
309
325
  {home.page === 'archived' ? <ArchivedView app={home} /> : null}
310
326
  {home.visited.map((r) => <RepoView key={r.key} app={r} />)}
311
327
  </Panel>