rvlib-shipkit 1.5.0 → 1.6.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 (78) hide show
  1. package/CHANGELOG.md +46 -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_Keys.ts +12 -1
  17. package/src/cli/cmd_Overview.ts +1 -1
  18. package/src/cli/cmd_Serve.ts +2 -1
  19. package/src/cli/cmd_TraceAudit.ts +62 -0
  20. package/src/cli.ts +4 -0
  21. package/src/collections/collection-types.ts +3 -0
  22. package/src/collections/providers.ts +5 -3
  23. package/src/core/app-icon.ts +154 -0
  24. package/src/core/audit-report.ts +21 -0
  25. package/src/core/cache-audit.ts +72 -0
  26. package/src/core/canonical-scripts.ts +1 -1
  27. package/src/core/check-ctx.ts +14 -5
  28. package/src/core/check-runner.ts +6 -3
  29. package/src/core/floor-resolve.ts +5 -7
  30. package/src/core/git-root.ts +13 -0
  31. package/src/core/ignored-block.ts +94 -0
  32. package/src/core/ignored-inputs.ts +47 -0
  33. package/src/core/jobs.ts +9 -2
  34. package/src/core/one-lib.ts +59 -4
  35. package/src/core/read-set.ts +97 -9
  36. package/src/core/run-ci.ts +6 -1
  37. package/src/core/run-fixers.ts +8 -2
  38. package/src/core/trace-audit.ts +120 -0
  39. package/src/home/gui-app.ts +24 -27
  40. package/src/home/home-start.ts +11 -5
  41. package/src/home/open-app.ts +2 -2
  42. package/src/home/via-hub.ts +14 -0
  43. package/src/ledger/db.ts +2 -13
  44. package/src/ledger/gate-checks.ts +21 -3
  45. package/src/ledger/git-key.ts +148 -30
  46. package/src/ledger/git-scan.ts +5 -2
  47. package/src/ledger/ledger.ts +131 -16
  48. package/src/ledger/store.ts +14 -0
  49. package/src/ledger/toolchain-identity.ts +210 -25
  50. package/src/problems/extract.ts +3 -0
  51. package/src/repo-config.ts +18 -1
  52. package/src/serve/hub-app/admin-svc.ts +36 -3
  53. package/src/serve/hub-app/admin-view.tsx +33 -1
  54. package/src/serve/hub-app/col-card.tsx +1 -44
  55. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  56. package/src/serve/hub-app/col-tables.ts +27 -0
  57. package/src/serve/hub-app/home-st.ts +1 -1
  58. package/src/serve/hub-app/home-strip.css +5 -9
  59. package/src/serve/hub-app/home-strip.tsx +4 -17
  60. package/src/serve/hub-app/hub-app.tsx +18 -4
  61. package/src/serve/hub-app/hub.css +24 -20
  62. package/src/serve/hub-app/keys.ts +10 -2
  63. package/src/serve/hub-app/processes-view.tsx +16 -9
  64. package/src/serve/hub-app/project-list.css +4 -1
  65. package/src/serve/hub-app/project-list.tsx +6 -3
  66. package/src/serve/hub-app/projects-svc.ts +10 -0
  67. package/src/serve/hub-app/repo-sections.ts +31 -17
  68. package/src/serve/hub-app/repo-st.ts +24 -5
  69. package/src/serve/hub-app/sidebar.tsx +5 -5
  70. package/src/serve/hub-app/tree-build.ts +2 -0
  71. package/src/serve/hub-app/tree-row.tsx +18 -5
  72. package/src/serve/hub-app/tree-svc.ts +27 -7
  73. package/src/serve/hub-runtime.ts +4 -2
  74. package/src/serve/project-gutter-route.ts +22 -1
  75. package/src/suite/judge-suites.ts +6 -0
  76. package/src/trace/interpose.c +655 -0
  77. package/src/trace/read-trace.ts +485 -0
  78. 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
+ }
@@ -181,7 +181,7 @@ export class HomeSt {
181
181
  const want = from == null || from === to ? null : sectionOfRepo(from)
182
182
  if (want != null && sectionOfRepo(to) !== want) {
183
183
  const known = to.cols.loaded || to.cols.cached
184
- openSection(to, known && !repoSections(to).some((s) => s.id === want) ? 'readme' : want)
184
+ openSection(to, known && !repoSections(to).some((s) => s.id === want && s.opens) ? 'readme' : want)
185
185
  }
186
186
  this.show('repo', name, 'push')
187
187
  }
@@ -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 */
@@ -156,8 +152,8 @@
156
152
  .wbar.hi > i{background:var(--red)}
157
153
 
158
154
  /* 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)}
155
+ .pdetail .hmacts{max-width:none}
156
+ .pdetail .hmabout{max-width:none;max-height:none}
157
+ .pdetail>.cstitle{margin:var(--s3) 0 var(--s1)}
158
+ .pdetail>.cstitle:first-child{margin-top:0}
159
+ .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)))
@@ -225,19 +226,6 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
225
226
  )
226
227
  })
227
228
 
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
229
  /** the shown repo in the strip: its icon, its name in its colour */
242
230
  const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
243
231
  const p = app.projects.list.find((x) => x.name === name)
@@ -247,7 +235,6 @@ const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
247
235
  {/* the icon's box is there before the project list is, so the name never moves */}
248
236
  {p?.icon != null ? <img src={p.icon} alt="" width={18} height={18} /> : <i className="hrico" />}
249
237
  <b className="hrname" style={{ color: id.colorDark }}>{name}</b>
250
- <IdentitySwatches app={app} name={name} />
251
238
  </span>
252
239
  )
253
240
  })
@@ -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'
@@ -93,7 +93,7 @@ const Main = observer(({ app }: { app: RepoSt }) => {
93
93
  if (app.sel?.t === 'col') return (
94
94
  <main>
95
95
  {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} />}
96
+ {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
97
  </main>
98
98
  )
99
99
  if (app.sel?.t === 'tests') return <main>{errs}<div className="stagewrap"><TestsPage app={app} check={app.sel.check} /></div></main>
@@ -195,13 +195,18 @@ const RepoView = observer(({ app }: { app: RepoSt }) => {
195
195
  function onKey(e: KeyboardEvent): void {
196
196
  // the command keys beat the focus check: they work from anywhere, any page
197
197
  const pk = pageKey(e)
198
- if (pk != null) {
198
+ // in a text field ⌘↑ and ⌘↓ keep moving the caret to its ends
199
+ const typing = (e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA'
200
+ if (pk != null && !(pk.t === 'step' && typing)) {
199
201
  e.preventDefault()
200
202
  if (pk.t === 'picker') home.palette.toggle()
201
203
  else if (pk.t === 'dock') home.dock.cycle()
202
204
  else if (pk.t === 'home' || pk.t === 'planned' || pk.t === 'activity') {
203
205
  home.palette.close()
204
206
  if (home.machine != null) home.navigate(PLACE_PATH[pk.t === 'home' ? 'overview' : pk.t])
207
+ } else if (pk.t === 'step') {
208
+ home.palette.close()
209
+ home.projects.step(pk.by)
205
210
  } else {
206
211
  const p = home.projects.list[pk.n - 1]
207
212
  home.palette.close()
@@ -235,6 +240,15 @@ function onKey(e: KeyboardEvent): void {
235
240
  return
236
241
  }
237
242
  if (e.metaKey || e.ctrlKey || e.altKey) return
243
+ // the tree's keys need no focus: the arrows move it from anywhere on the
244
+ // page, Enter and space only where no button or link claims them
245
+ // the atelier's ←/→ roll, so at its first or last roll they stop there
246
+ const rolls = app.sel?.t === 'asset' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')
247
+ const own = !rolls && (e.key.startsWith('Arrow') || t.closest('button, a, select, [role="button"]') == null)
248
+ if (own && app.tree.key(e.key)) {
249
+ e.preventDefault()
250
+ return
251
+ }
238
252
  if (e.key === FOLLOW_KEY && (!app.live.chart.follow || !home.dock.chart.follow)) {
239
253
  e.preventDefault()
240
254
  app.live.chart.attach()
@@ -89,6 +89,9 @@ aside > .tscroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:3
89
89
  The chevron column is the tree's own, so a section's rows line up under it */
90
90
  .tsec{display:grid;grid-template-columns:14px 16px minmax(0,1fr) auto auto;align-items:center;gap:var(--s2);height:26px;padding:0 var(--s1) 0 var(--s2);color:var(--dim);font-size:13px;cursor:pointer;white-space:nowrap}
91
91
  .tsec:hover{background:var(--panel2);color:var(--ink)}
92
+ .tsec.empty{opacity:.4}
93
+ .tsec.noop{cursor:default}
94
+ .tsec.noop:hover{background:none;color:var(--dim)}
92
95
  .tsec.on{box-shadow:inset 2px 0 0 var(--accent)}
93
96
  .tsec.cur{background:var(--sel);color:var(--ink);box-shadow:inset 3px 0 0 var(--accent);font-weight:600}
94
97
  .tsec .hico{width:16px;height:16px}
@@ -631,19 +634,21 @@ a.tok{color:var(--accent)}
631
634
  padding:var(--s1) var(--s2);font-size:12.5px;outline:none}
632
635
  .wform input:focus{border-color:var(--accent)}
633
636
 
634
- /* ── collection view (one card per instance) ── */
635
- .colgrid{flex:1;min-height:0;overflow-y:auto;padding:var(--s3) var(--s4)}
636
- .cghead{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);
637
+ /* ── a collection's page: its title and count, then its table ── */
638
+ .cghead{font-size:15px;font-weight:600;color:var(--ink);
637
639
  display:flex;gap:var(--s2);align-items:baseline;margin:0 0 var(--s3)}
638
640
  .ctlead{font-size:11px;color:var(--dim);margin:calc(-1 * var(--s1)) 0 var(--s2)}
639
- .cghead .cnt{font-size:11px;color:var(--dim);letter-spacing:0;text-transform:none}
640
- /* the checks and asks pages: one dense table each (col-tables-view.tsx), on
641
+ .cghead .cnt{font-size:12px;font-weight:400;color:var(--dim)}
642
+ /* the checks, asks and secrets pages: one dense table each (col-tables-view.tsx), on
641
643
  the activity table (.actable), a group header per state that folds */
642
644
  .ctpage{flex:1;min-height:0;overflow-y:auto;padding:var(--s3) var(--s4);font-size:12.5px;container-type:inline-size}
645
+ /* the table sits on the panel surface, the way every card section does, so
646
+ its lines read against something lighter than the page */
647
+ .ctbox{background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:var(--s1) 0}
643
648
  .cttable{font-variant-numeric:tabular-nums}
644
649
  .cttable td{padding:var(--s0) var(--s2);vertical-align:baseline}
645
- .ctgrp td{padding:var(--s2) var(--s2) var(--s1);color:var(--dim);font-size:11.5px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none}
646
- .ctgrp:hover td{color:var(--ink)}
650
+ .ctgrp td{padding:var(--s2) var(--s2) var(--s1);color:var(--ink);font-size:12.5px;font-weight:600;border-bottom:1px solid var(--line);cursor:pointer;user-select:none}
651
+ .ctgrp:hover td{color:var(--accent)}
647
652
  .ctgrp.g-fail td{color:var(--red)}
648
653
  .ctgrp.g-warn td{color:var(--warn)}
649
654
  .ctcaret{display:inline-block;width:14px}
@@ -656,17 +661,8 @@ a.tok{color:var(--accent)}
656
661
  .ctbudget,.ctplan,.ctblocks{color:var(--dim)}
657
662
  .ctprobe{font-size:11.5px}
658
663
  .cttable .c-dot{width:22px}.cttable .c-id{width:140px}.cttable .c-title{width:16%}.cttable .c-age{width:72px}.cttable .c-dur{width:152px}.cttable .c-plan{width:88px}
659
- .cttable .c-kind{width:92px}.cttable .c-blocks{width:32%}.cttable .c-probe{width:84px}
664
+ .cttable .c-name{width:30%}.cttable .c-file{width:34%}.cttable .c-kind{width:92px}.cttable .c-blocks{width:32%}.cttable .c-probe{width:84px}
660
665
  @container (max-width:760px){.cttable .c-title{width:0}.cttable .cttitle{display:none}.cttable .c-plan{width:0}.cttable .ctplan{display:none}}
661
- .cgcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:var(--s3)}
662
- .cgcard{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:var(--s2) var(--s3);cursor:pointer;min-width:0}
663
- .cgcard:hover{border-color:var(--accent)}
664
- .cgcard .cgtop{display:flex;gap:var(--s2);align-items:center;min-width:0}
665
- .cgcard .cgtop b{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
666
- .cgcard .truth{display:block;font:11px ui-monospace,Menlo,monospace;color:var(--dim);margin-top:var(--s1);
667
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
668
- .cgcard .cgrem{color:var(--ok);border-left:2px solid var(--ok);padding-left:var(--s2);font-size:12px;margin-top:var(--s1);
669
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
670
666
 
671
667
  /* ── graph item view (the graph itself fills the stage) ── */
672
668
  .graphview{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3) var(--s4) var(--s4)}
@@ -709,13 +705,10 @@ a.tok{color:var(--accent)}
709
705
  overflow-x:auto;font:11px/1.5 ui-monospace,Menlo,monospace;white-space:pre}
710
706
  .md pre code{background:none;border:0;padding:0}
711
707
  .md a{color:var(--accent)}
712
- .md.clamp{max-height:180px;overflow:hidden;margin-top:var(--s1);
713
- -webkit-mask-image:linear-gradient(var(--solid) 65%,transparent);mask-image:linear-gradient(var(--solid) 65%,transparent)}
714
708
 
715
709
  /* ── secret (cred) cards ── */
716
710
  .credname{font:600 13px ui-monospace,Menlo,monospace}
717
711
  .creddesc{font-size:12px;color:var(--dim);margin-top:var(--s1)}
718
- .credrow{display:flex;gap:var(--s2);align-items:center;margin-top:var(--s2)}
719
712
  .stchip{font-size:10px;border-radius:5px;padding:var(--s0) var(--s2);letter-spacing:.08em;text-transform:uppercase;
720
713
  border:1px solid;flex:none;font-weight:600}
721
714
  .stchip.ok{color:var(--ok);border-color:var(--ok)}
@@ -759,6 +752,17 @@ pre{background:var(--panel);border:1px solid var(--line);border-radius:3px;paddi
759
752
  .adctl{flex:none;display:flex;align-items:center}
760
753
  .adflag{display:inline-flex;align-items:center;gap:var(--s1);font-size:12px;color:var(--dim)}
761
754
  .adflag input{width:auto;margin:0}
755
+ .adid{display:flex;align-items:flex-start;gap:var(--s4)}
756
+ .adicon{flex:none;width:128px;display:flex;flex-direction:column;gap:var(--s1);font-size:12px}
757
+ .adicon img{display:block;width:128px;height:128px}
758
+ .adicon img[hidden]{display:none}
759
+ .adpick{display:flex;align-items:center;gap:var(--s2);margin-top:var(--s1);font-size:12px;cursor:pointer}
760
+ .colcard .adpick input{flex:none;min-width:0;width:28px;height:28px;padding:0;margin:0;border:1px solid var(--line);border-radius:4px;background:none;cursor:pointer}
761
+ .adpick input::-webkit-color-swatch-wrapper{padding:0}
762
+ .adpick input::-webkit-color-swatch{border:none;border-radius:3px}
763
+ .adsample{padding:var(--s0) var(--s2);border-radius:4px;font-weight:600;font-size:13px}
764
+ .adsample.dark{background:var(--bg)}
765
+ .adsample.light{background:var(--tag)}
762
766
  .adconfirm{display:inline-flex;align-items:center;gap:var(--s2);font-size:12px}
763
767
  /* a repo's plan page: one line per planned check */
764
768
  .plntable{width:100%;border-collapse:collapse;font-size:12.5px}
@@ -1,12 +1,12 @@
1
1
  // the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
2
- // nth repo of the project list, then the home row under the right hand: ⌘J
2
+ // nth repo of the project list, ⌘↑ and ⌘↓ the repo above or below, then the home row under the right hand: ⌘J
3
3
  // the picker, ⌘K the overview, ⌘L the activity, ⌘' the plan. ⌘B cycles the
4
4
  // dock at the bottom of the page (dock-st.ts): the line, slim, open.
5
5
  // A digit and the quote are read from the physical key (`code`), so an
6
6
  // AZERTY keyboard, where the key under 1 types `&`, selects the same repo. Ctrl stands in for
7
7
  // ⌘ off a Mac. The macOS app binds none of these to a menu item, so they
8
8
  // reach the page (tests/home-strip.test.tsx reads ShipkitGui.swift).
9
- export type PageKey = { t: 'repo'; n: number } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'picker' } | { t: 'dock' }
9
+ export type PageKey = { t: 'repo'; n: number } | { t: 'step'; by: -1 | 1 } | { t: 'home' } | { t: 'planned' } | { t: 'activity' } | { t: 'picker' } | { t: 'dock' }
10
10
 
11
11
  type KeyLike = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
12
12
 
@@ -15,6 +15,8 @@ export function pageKey(e: KeyLike): PageKey | null {
15
15
  const digit = /^Digit([1-9])$/.exec(e.code)
16
16
  if (digit != null) return { t: 'repo', n: Number(digit[1]) }
17
17
  if (e.shiftKey) return null
18
+ if (e.key === 'ArrowUp') return { t: 'step', by: -1 }
19
+ if (e.key === 'ArrowDown') return { t: 'step', by: 1 }
18
20
  if (e.code === 'Quote' || e.key === "'") return { t: 'planned' }
19
21
  const k = e.key.toLowerCase()
20
22
  if (k === 'j') return { t: 'picker' }
@@ -28,6 +30,8 @@ export function pageKey(e: KeyLike): PageKey | null {
28
30
  export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' } as const
29
31
  /** the picker's shortcut, on the first button, before home */
30
32
  export const PICKER_KEY = '⌘J'
33
+ /** the repo above and below, hinted at the project list's foot */
34
+ export const STEP_KEYS = ['⌘↑', '⌘↓'] as const
31
35
  /** the dock's shortcut, on the dock's own header */
32
36
  export const DOCK_KEY = '⌘B'
33
37
 
@@ -35,6 +39,10 @@ export const DOCK_KEY = '⌘B'
35
39
  * badge at the input's right prints this */
36
40
  export const SEARCH_KEY = '/'
37
41
 
42
+ /** in the filter, up and down walk the tree rows the filter keeps; left
43
+ * and right stay the text caret's */
44
+ export const isTreeWalk = (key: string): boolean => key === 'ArrowUp' || key === 'ArrowDown'
45
+
38
46
  export function isSearchKey(e: Pick<KeyLike, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
39
47
  return e.key === SEARCH_KEY && !e.metaKey && !e.ctrlKey && !e.altKey
40
48
  }
@@ -1,22 +1,24 @@
1
- // The processes page of a repo: one card per global process it declares,
2
- // each the process menu's two panels (actions, then its description as
3
- // markdown) and its log under them. pm runs them, so the page reads the
4
- // machine, never the hub. The card a tree row opened is scrolled to and lit.
1
+ // The processes page of a repo: the overview's process table, this repo's
2
+ // lines alone, never a card per process. The process a tree row opened gets
3
+ // its description and its log under the table. pm runs them, so the page
4
+ // reads the machine, never the hub.
5
5
  import { observer } from 'mobx-react-lite'
6
6
  import { useEffect, useRef } from 'react'
7
- import { Log, ProcessMenu } from './home-strip.tsx'
7
+ import { Log, ProcessMenu, ProcessTable } from './home-strip.tsx'
8
8
  import type { RepoSt } from './repo-st.ts'
9
9
  import type { ProcessRow } from '../../home/process-rows.ts'
10
10
 
11
11
  const noop = (): void => {}
12
12
 
13
- const ProcessCard = observer(({ app, r, lit }: { app: RepoSt; r: ProcessRow; lit: boolean }) => {
13
+ /** the opened process: its menu (actions, then its description) and its log */
14
+ const ProcessDetail = observer(({ app, r }: { app: RepoSt; r: ProcessRow }) => {
14
15
  const el = useRef<HTMLElement>(null)
15
- useEffect(() => { if (lit) el.current?.scrollIntoView({ block: 'nearest' }) }, [lit])
16
+ useEffect(() => { el.current?.scrollIntoView({ block: 'nearest' }) }, [r.id])
16
17
  const m = app.home.machine
17
18
  if (m == null) return null
18
19
  return (
19
- <section ref={el} className={`csec pcard${lit ? ' lit' : ''}`} id={`process-${r.id}`}>
20
+ <section ref={el} className="csec pdetail" id={`process-${r.id}`}>
21
+ <div className="cstitle">{r.label}</div>
20
22
  <ProcessMenu m={m} r={r} close={noop} lastLog={r.tail.length === 0} />
21
23
  {r.tail.length === 0 ? null : <><div className="cstitle">log</div><Log lines={r.tail} /></>}
22
24
  </section>
@@ -25,6 +27,8 @@ const ProcessCard = observer(({ app, r, lit }: { app: RepoSt; r: ProcessRow; lit
25
27
 
26
28
  export const ProcessesPage = observer(({ app, id }: { app: RepoSt; id: string | null }) => {
27
29
  const rows = app.globalProcesses
30
+ const m = app.home.machine
31
+ const lit = rows.find((r) => r.id === id) ?? null
28
32
  return (
29
33
  <div className="colcard checkcard">
30
34
  <div className="chead">
@@ -32,7 +36,10 @@ export const ProcessesPage = observer(({ app, id }: { app: RepoSt; id: string |
32
36
  <h3 className="ctitle">{app.name}</h3>
33
37
  </div>
34
38
  <div className="usays">the processes this repo declares in its <code>.shipkit.ts</code> files. pm runs them, so they run whether or not the hub answers</div>
35
- {rows.length === 0 ? <div className="dim">this repo declares no global process</div> : rows.map((r) => <ProcessCard key={r.id} app={app} r={r} lit={r.id === id} />)}
39
+ {rows.length === 0 || m == null
40
+ ? <div className="dim">this repo declares no global process</div>
41
+ : <section className="csec"><ProcessTable app={app.home} m={m} repo={app.key} /></section>}
42
+ {lit == null ? null : <ProcessDetail app={app} r={lit} />}
36
43
  </div>
37
44
  )
38
45
  })
@@ -21,11 +21,14 @@
21
21
  .pjarow span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
22
22
  .pjarow img{flex:none;border-radius:3px;object-fit:contain}
23
23
  .pjaini{flex:none;width:14px;height:14px;border-radius:3px;background:var(--panel2)}
24
- /* the foot's two buttons: sort names the order the list is in, archived
24
+ /* the foot: sort (its title names the order), the step keys hint, archived
25
25
  carries its count and stays lit while its rows show */
26
26
  .pgsort,.pjarbtn{flex:none;display:inline-flex;align-items:center;gap:var(--s1);height:22px;padding:0 var(--s1);background:transparent;border:1px solid var(--line);border-radius:3px;
27
27
  color:var(--dim);font-size:11px;cursor:pointer}
28
28
  .pgsort{flex:0 1 auto;min-width:0}
29
+ /* the step keys, a hint: the repo above or below */
30
+ .pjstep{flex:none;display:inline-flex;gap:var(--s0);margin-left:auto;color:var(--dim);font-size:11px}
31
+ .pjstep kbd{padding:0 var(--s1);border:1px solid var(--line);border-radius:3px;font:inherit}
29
32
  .pgsortl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
30
33
  .pgsort .hico,.pjarbtn .hico{flex:none;width:14px;height:14px}
31
34
  .pgsort:hover,.pjarbtn:hover{color:var(--ink);border-color:var(--accent)}