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
@@ -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}
@@ -12,6 +12,7 @@ export const ICON = {
12
12
  reflections: 'M4 3.5h12v9H9l-4 3.5v-3.5H4zM7.5 7h5M7.5 9.5h3',
13
13
  monitor: 'M3 4h14v9H3zM7 16.5h6M10 13v3.5',
14
14
  archived: 'M2.5 4h15v3.5h-15zM4 7.5V16h12V7.5M8 10.5h4',
15
+ insights: 'M3 16.5h14M5 16.5V11M9 16.5V5M13 16.5V8.5M17 16.5V12',
15
16
  home: 'M3 9.5 10 3.5l7 6M5 8v8.5h10V8M8.5 16.5v-4.5h3v4.5',
16
17
  planned: 'M5.5 3h9M5.5 17h9M6.5 3c0 4 7 4 7 7s-7 3-7 7M13.5 3c0 4-7 4-7 7s7 3 7 7',
17
18
  browser: 'M10 2.5a7.5 7.5 0 1 0 0 15a7.5 7.5 0 1 0 0-15M2.5 10h15M10 2.5c-3 3-3 12 0 15M10 2.5c3 3 3 12 0 15',
@@ -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" />
@@ -0,0 +1,111 @@
1
+ // the insights page's state: the daemon's boxes for one scope, the tag that
2
+ // filters which boxes show, and one query that filters every box's rows. It
3
+ // reads once when the page shows and on the refresh button, never on a clock:
4
+ // manifests change far slower than anyone reads them. The scope and the tag
5
+ // are remembered per viewer.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import { rowMatches, SCOPES, tagsOf, type InsightBox, type InsightRow, type InsightsAnswer, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { ownTrouble, reach } from './reach.ts'
10
+
11
+ /** the rows a box shows before its "show all" */
12
+ export const BOX_ROWS = 25
13
+ const SCOPE_KEY = 'home:insights:scope'
14
+ const TAG_KEY = 'home:insights:tag'
15
+
16
+ function kept(key: string): string | null {
17
+ try {
18
+ return globalThis.localStorage?.getItem(key) ?? null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+ function keep(key: string, v: string): void {
24
+ try { globalThis.localStorage?.setItem(key, v) } catch { /* private window: the pick lasts the tab */ }
25
+ }
26
+
27
+ export class InsightsSt {
28
+ data: InsightsAnswer | null = null
29
+ error = ''
30
+ loading = false
31
+ scope: Scope = (SCOPES as readonly string[]).includes(kept(SCOPE_KEY) ?? '') ? (kept(SCOPE_KEY) as Scope) : 'house'
32
+ /** the tag whose boxes show, null for every box */
33
+ tag: string | null = kept(TAG_KEY) || null
34
+ query = ''
35
+ /** boxes showing every row, past BOX_ROWS */
36
+ full = new Set<string>()
37
+ /** `box/key` of each row whose uses show under it */
38
+ open = new Set<string>()
39
+ private gen = 0
40
+
41
+ constructor(private st: HomeSt) {
42
+ makeAutoObservable<this, 'st' | 'gen'>(this, { st: false, gen: false, data: observableRef })
43
+ }
44
+
45
+ get tags(): string[] {
46
+ return tagsOf(this.data?.boxes ?? [])
47
+ }
48
+
49
+ /** the boxes the tag lets through, each with the rows the query lets through */
50
+ get boxes(): { box: InsightBox; rows: InsightRow[] }[] {
51
+ return (this.data?.boxes ?? [])
52
+ .filter((b) => this.tag == null || !this.tags.includes(this.tag) || b.tags.includes(this.tag))
53
+ .map((box) => ({ box, rows: box.rows.filter((r) => rowMatches(box, r, this.query)) }))
54
+ }
55
+
56
+ setScope(s: Scope): void {
57
+ if (s === this.scope) return
58
+ this.scope = s
59
+ keep(SCOPE_KEY, s)
60
+ void this.load()
61
+ }
62
+
63
+ setTag(t: string | null): void {
64
+ this.tag = t
65
+ keep(TAG_KEY, t ?? '')
66
+ }
67
+
68
+ setQuery(q: string): void {
69
+ this.query = q
70
+ }
71
+
72
+ toggleFull(box: string): void {
73
+ if (this.full.has(box)) this.full.delete(box)
74
+ else this.full.add(box)
75
+ }
76
+
77
+ toggleRow(box: string, key: string): void {
78
+ const k = `${box}/${key}`
79
+ if (this.open.has(k)) this.open.delete(k)
80
+ else this.open.add(k)
81
+ }
82
+
83
+ openRepo(name: string): void {
84
+ this.st.select(name)
85
+ }
86
+
87
+ /** the refresh button: the daemon reads every manifest again */
88
+ refresh(): void {
89
+ if (!this.loading) void this.load(true)
90
+ }
91
+
92
+ async load(fresh = false): Promise<void> {
93
+ const id = ++this.gen
94
+ this.loading = true
95
+ try {
96
+ const r = await reach(`/api/home/insights.json?scope=${this.scope}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(60_000) })
97
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
98
+ const j = (await r.json()) as InsightsAnswer
99
+ runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
100
+ } catch (e) {
101
+ runInAction(() => { if (id === this.gen) this.error = ownTrouble(e, this.st.machine) })
102
+ } finally {
103
+ runInAction(() => { if (id === this.gen) this.loading = false })
104
+ }
105
+ }
106
+
107
+ /** reads when the page shows */
108
+ start(): void {
109
+ void this.load()
110
+ }
111
+ }
@@ -0,0 +1,112 @@
1
+ // `/insights`: boxes about every repo of a scope, each carrying tags. On top:
2
+ // the scope (house repos or all of ~/dev), the tag row that filters the boxes,
3
+ // one query that filters every box's rows, and the refresh. A box lists its
4
+ // rows by how many repos use each key; a click on a row shows every use, the
5
+ // house repos as links to their page.
6
+ import { observer } from 'mobx-react-lite'
7
+ import { placeWords, type InsightBox, type InsightRow, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { plainClick, repoPage } from './home-st.ts'
10
+ import { BOX_ROWS } from './insights-st.ts'
11
+
12
+ const SCOPE_WORDS: Record<Scope, { label: string; title: string }> = {
13
+ house: { label: 'house', title: 'the repos on the project list' },
14
+ dev: { label: '~/dev', title: 'every folder of ~/dev with a package.json or a .git, wired or not' },
15
+ }
16
+
17
+ /** the values a row shows inline before "+n" */
18
+ const VALUES_SHOWN = 3
19
+
20
+ const Row = observer(({ app, box, r }: { app: HomeSt; box: InsightBox; r: InsightRow }) => {
21
+ const st = app.insights
22
+ const open = st.open.has(`${box.id}/${r.key}`)
23
+ const house = new Set(app.projects.list.map((p) => p.name))
24
+ return (
25
+ <>
26
+ <tr className={`inrow${open ? ' open' : ''}`} onClick={() => st.toggleRow(box.id, r.key)} title={`${r.uses.length} use${r.uses.length === 1 ? '' : 's'}: click to list them`}>
27
+ <td className="inkey">{r.key}</td>
28
+ <td className="incount">{r.repos}</td>
29
+ <td className="invals">
30
+ {r.values.slice(0, VALUES_SHOWN).map((v) => <code key={v}>{v}</code>)}
31
+ {r.values.length > VALUES_SHOWN ? <span className="acdim"> +{r.values.length - VALUES_SHOWN}</span> : null}
32
+ </td>
33
+ </tr>
34
+ {!open ? null : r.uses.map((u, i) => (
35
+ <tr key={i} className="inuse">
36
+ <td className="inplace">
37
+ {house.has(u.repo) ? (
38
+ <a href={repoPage(u.repo)} onClick={(e) => {
39
+ if (!plainClick(e)) return
40
+ e.preventDefault()
41
+ st.openRepo(u.repo)
42
+ }}>{placeWords(u)}</a>
43
+ ) : placeWords(u)}
44
+ </td>
45
+ <td colSpan={2} className="invals"><code>{u.value}</code>{u.dev ? <span className="acdim"> dev</span> : null}</td>
46
+ </tr>
47
+ ))}
48
+ </>
49
+ )
50
+ })
51
+
52
+ const Box = observer(({ app, box, rows }: { app: HomeSt; box: InsightBox; rows: InsightRow[] }) => {
53
+ const st = app.insights
54
+ const full = st.full.has(box.id)
55
+ const shown = full ? rows : rows.slice(0, BOX_ROWS)
56
+ return (
57
+ <section className="inbox">
58
+ <h3>
59
+ <span>{box.title}</span>
60
+ {box.tags.map((t) => <button key={t} className={`intag${st.tag === t ? ' on' : ''}`} onClick={() => st.setTag(st.tag === t ? null : t)}>{t}</button>)}
61
+ <span className="sp" />
62
+ <span className="acdim">{rows.length === box.rows.length ? rows.length : `${rows.length} of ${box.rows.length}`}</span>
63
+ </h3>
64
+ <p className="acdim">{box.hint}</p>
65
+ {rows.length === 0 ? <p className="acdim">no {box.noun} matches</p> : (
66
+ <table className="intable">
67
+ <colgroup><col /><col className="c-count" /><col /></colgroup>
68
+ <thead><tr><th>{box.noun}</th><th>repos</th><th>values</th></tr></thead>
69
+ <tbody>{shown.map((r) => <Row key={r.key} app={app} box={box} r={r} />)}</tbody>
70
+ </table>
71
+ )}
72
+ {rows.length > BOX_ROWS ? <button className="ghost hsm inmore" onClick={() => st.toggleFull(box.id)}>{full ? `show the first ${BOX_ROWS}` : `show all ${rows.length}`}</button> : null}
73
+ </section>
74
+ )
75
+ })
76
+
77
+ export const InsightsView = observer(({ app }: { app: HomeSt }) => {
78
+ const st = app.insights
79
+ const d = st.data
80
+ return (
81
+ <div className="hall activity insights">
82
+ <div className="actop">
83
+ <div className="acwin" role="radiogroup" aria-label="which repos">
84
+ {(Object.keys(SCOPE_WORDS) as Scope[]).map((s) => (
85
+ <button key={s} role="radio" aria-checked={st.scope === s} className={st.scope === s ? 'on' : ''} title={SCOPE_WORDS[s].title} onClick={() => st.setScope(s)}>{SCOPE_WORDS[s].label}</button>
86
+ ))}
87
+ </div>
88
+ <div className="acwin" role="radiogroup" aria-label="which boxes">
89
+ <button role="radio" aria-checked={st.tag == null} className={st.tag == null ? 'on' : ''} onClick={() => st.setTag(null)}>all</button>
90
+ {st.tags.map((t) => <button key={t} role="radio" aria-checked={st.tag === t} className={st.tag === t ? 'on' : ''} onClick={() => st.setTag(t)}>{t}</button>)}
91
+ </div>
92
+ <input className="inquery" type="search" placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
93
+ <button type="button" className="acpill" disabled={st.loading} onClick={() => st.refresh()} title="read every package.json again now · an answer is kept 5 minutes">
94
+ <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
95
+ </button>
96
+ {d == null ? <span className="acdim">reading every package.json…</span> : (
97
+ <>
98
+ <span className="acpill">{d.repos} repos</span>
99
+ <span className="acpill">{d.files} package.json</span>
100
+ <span className="acpill">read in {d.ms}ms</span>
101
+ </>
102
+ )}
103
+ </div>
104
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
105
+ {d != null && d.errors.length > 0 ? <div className="hnote" title={d.errors.join('\n')}>{d.errors.length} package.json would not parse</div> : null}
106
+ <div className={`ingrid${d != null && d.scope !== st.scope ? ' stale' : ''}`}>
107
+ {st.boxes.map(({ box, rows }) => <Box key={box.id} app={app} box={box} rows={rows} />)}
108
+ </div>
109
+ <p className="hdim">in the console: shipkit insights</p>
110
+ </div>
111
+ )
112
+ })
@@ -0,0 +1,24 @@
1
+ /* the insights page (insights-view.tsx): the activity page's top bar, then a
2
+ grid of boxes. A row opens in place to list its uses. */
3
+ .ingrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr));gap:var(--s3);align-items:start}
4
+ .ingrid.stale{opacity:.5}
5
+ .inbox{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);min-width:0}
6
+ .inbox h3{display:flex;align-items:center;gap:var(--s2);margin:0;font-size:13px;font-weight:650}
7
+ .inbox > p{margin:var(--s0) 0 var(--s2);font-size:12px}
8
+ .intag{border:1px solid var(--line);border-radius:999px;padding:0 var(--s2);background:none;color:var(--dim);font-size:11px;font-weight:400;line-height:18px;cursor:pointer}
9
+ .intag:hover,.intag.on{color:var(--accent);border-color:var(--accent)}
10
+ .inquery{height:22px;min-width:200px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:0 var(--s2);font:inherit;font-size:12px}
11
+ .inquery:focus{outline:none;border-color:var(--accent)}
12
+ .intable{width:100%;table-layout:fixed;border-collapse:collapse}
13
+ .intable col.c-count{width:56px}
14
+ .intable th{text-align:left;font-weight:400;color:var(--dim);font-size:11.5px;padding:var(--s0) var(--s1);border-bottom:1px solid var(--line)}
15
+ .intable td{padding:0 var(--s1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
16
+ .inrow{cursor:pointer}
17
+ .inrow:hover td,.inrow.open td{background:var(--panel2)}
18
+ .inkey{font-weight:600}
19
+ .incount{font-variant-numeric:tabular-nums;text-align:right;padding-right:var(--s3) !important}
20
+ .invals code{margin-right:var(--s1);font-size:11.5px;color:var(--dim)}
21
+ .inuse td{font-size:12px}
22
+ .inuse .inplace{padding-left:var(--s3)}
23
+ .inuse .inplace a{color:var(--accent);text-decoration:none}
24
+ .inmore{margin-top:var(--s1)}
@@ -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' }
@@ -25,9 +27,11 @@ export function pageKey(e: KeyLike): PageKey | null {
25
27
  }
26
28
 
27
29
  /** the shortcut each place of the home carries on its button */
28
- export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' } as const
30
+ export const PLACE_KEY: Record<'overview' | 'planned' | 'activity', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
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
  }
@@ -7,6 +7,7 @@
7
7
  // of this (HomeSt.machine is null).
8
8
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
9
9
  import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
+ import type { WidgetView } from '../../home/widgets.ts'
10
11
  import type { WorkerView } from '../../home/hubs.ts'
11
12
  import type { OpenHow, ResourceView } from '../../home/resources.ts'
12
13
  import type { PmAnswer, Role } from '../../home/pm.ts'
@@ -51,6 +52,8 @@ export class MachineSt {
51
52
  pm: PmAnswer | null = null
52
53
  /** every house repo's widgets, as the daemon last rendered them */
53
54
  widgets: WidgetsAnswer | null = null
55
+ /** `repo/id` of each widget a refresh is rendering */
56
+ refreshing = new Set<string>()
54
57
  /** why pm did not answer the last poll, or '' while it does */
55
58
  pmDown = ''
56
59
  /** the last action that failed */
@@ -261,6 +264,26 @@ export class MachineSt {
261
264
  }
262
265
  }
263
266
 
267
+ /** one widget rendered now by the daemon, with force (its cache or
268
+ * backoff skipped), its new view swapped in */
269
+ async refreshWidget(repo: string, id: string): Promise<void> {
270
+ const k = `${repo}/${id}`
271
+ if (this.refreshing.has(k)) return
272
+ this.refreshing.add(k)
273
+ try {
274
+ const r = await reach('/api/home/widgets/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo, id }) })
275
+ if (!r.ok) throw new Error(await r.text())
276
+ const v = (await r.json()) as WidgetView
277
+ runInAction(() => {
278
+ if (this.widgets != null) this.widgets = { ...this.widgets, widgets: this.widgets.widgets.map((w) => (w.repo === repo && w.id === id ? v : w)) }
279
+ })
280
+ } catch (e) {
281
+ runInAction(() => { this.error = ownTrouble(e, this) })
282
+ } finally {
283
+ runInAction(() => { this.refreshing.delete(k) })
284
+ }
285
+ }
286
+
264
287
  hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
265
288
  return this.post('/api/home/hub', { name, act })
266
289
  }
@@ -1,5 +1,5 @@
1
1
  // the one picker of the page (cmd+j, keys.ts), a node of the root: the
2
- // home's places (home, activity, planned, in the list's order) first, then the shown repo's own jump
2
+ // home's places (home, activity, planned, insights, archived) first, then the shown repo's own jump
3
3
  // targets, then every other house repo as a place to go. Another repo's
4
4
  // resources are never listed: a jump to a repo selects it in place, and its
5
5
  // own targets are one more cmd+j away.
@@ -27,6 +27,7 @@ export class PaletteSt {
27
27
  { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
28
  { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
29
29
  { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
30
+ { id: 'place:insights', label: 'insights', section: 'place', face: { place: 'insights' }, run: () => st.navigate('/insights') },
30
31
  { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
31
32
  ]
32
33
  }
@@ -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)}
@@ -24,7 +24,7 @@ import { useEffect, type CSSProperties } from 'react'
24
24
  import { middleware, OPEN_MS } from './floating.tsx'
25
25
  import { Icon, ICON } from './icons.tsx'
26
26
  import { plainClick } from './home-st.ts'
27
- import { PICKER_KEY, PLACE_KEY } from './keys.ts'
27
+ import { PICKER_KEY, PLACE_KEY, STEP_KEYS } from './keys.ts'
28
28
  import { cardModel, faceOf, hueOf, SORTS, type Place, type Project, type ProjectsSvc } from './projects-svc.ts'
29
29
 
30
30
  /** what the list needs of its page: the hub and the home both carry one */
@@ -37,6 +37,7 @@ const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
37
37
  { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
38
38
  { id: 'activity', label: 'activity', title: 'what every repo committed lately, what it holds uncommitted, its branch and whether it is pushed' },
39
39
  { id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
40
+ { id: 'insights', label: 'insights', title: 'boxes about every repo: the packages they use and by how many, version drift, script names' },
40
41
  ]
41
42
 
42
43
  /** the icon's size on a row, in px, as attributes and in the sheet */
@@ -165,10 +166,10 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
165
166
  const on = id === 'picker' ? s.pickerOpen() : s.place === id
166
167
  const key = id === 'picker' ? PICKER_KEY : PLACE_KEY[id]
167
168
  return (
168
- <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={`${pl.title} (${key})`} onClick={run}>
169
+ <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={key == null ? pl.title : `${pl.title} (${key})`} onClick={run}>
169
170
  <Icon d={ICON[id === 'picker' ? 'search' : id === 'overview' ? 'home' : id]} />
170
171
  <span className="pjname">{pl.label}</span>
171
- <kbd className="pjkey">{key}</kbd>
172
+ {key == null ? null : <kbd className="pjkey">{key}</kbd>}
172
173
  </button>
173
174
  )
174
175
  })}
@@ -195,6 +196,9 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
195
196
  ) : null}
196
197
  <div className="pjfoot">
197
198
  <SortButton s={s} />
199
+ <span className="pjstep" title={`${STEP_KEYS[0]} and ${STEP_KEYS[1]} open the repo above or below, from any page`}>
200
+ {STEP_KEYS.map((k) => <kbd key={k}>{k}</kbd>)}
201
+ </span>
198
202
  {s.archived.length === 0 || s.onArchived == null ? null : (
199
203
  <button className={`pjarbtn${s.archivedOpen ? ' on' : ''}`} aria-pressed={s.archivedOpen}
200
204
  title={`${s.archivedOpen ? 'hide' : 'show'} the ${s.archived.length} archived repos: nothing runs there, a row opens the archived page`}
@@ -213,7 +217,7 @@ const orderWords = (s: ProjectsSvc): string => {
213
217
  return m === 'manual' ? 'manual order' : SORTS.find((x) => x.id === m)!.label
214
218
  }
215
219
 
216
- /** the sort button: it names the order the list is in and opens the menu,
220
+ /** the sort button: its title names the order the list is in, it opens the menu,
217
221
  * and sorts nothing itself */
218
222
  const SortButton = observer(({ s }: { s: ProjectsSvc }) => {
219
223
  const f = useFloating({
@@ -231,7 +235,7 @@ const SortButton = observer(({ s }: { s: ProjectsSvc }) => {
231
235
  <>
232
236
  <button ref={f.refs.setReference} className="pgsort" title={`the list is in ${orderWords(s)}: pick a sort`}
233
237
  {...getReferenceProps({ onClick: () => s.toggleSortMenu() })}>
234
- <Icon d={ICON.sort} /><span className="pgsortl">{orderWords(s)}</span>
238
+ <Icon d={ICON.sort} /><span className="pgsortl">sort</span>
235
239
  </button>
236
240
  {s.sortMenuOpen ? (
237
241
  <FloatingPortal>
@@ -178,7 +178,7 @@ export function cardModel(p: Project, now: number): CardModel {
178
178
  }
179
179
 
180
180
  /** a page of the home that is no repo's */
181
- export type Place = 'overview' | 'planned' | 'activity'
181
+ export type Place = 'overview' | 'planned' | 'activity' | 'insights'
182
182
 
183
183
  export class ProjectsSvc {
184
184
  /** the last list this browser saw until the first poll answers, so the
@@ -277,6 +277,16 @@ export class ProjectsSvc {
277
277
  }
278
278
 
279
279
 
280
+ /** the repo above (-1) or below (+1) the selected one, round the ends;
281
+ * with none selected, down starts at the top and up at the bottom */
282
+ step(by: -1 | 1): void {
283
+ const n = this.list.length
284
+ if (n === 0) return
285
+ const at = this.list.findIndex((p) => this.isCurrent(p))
286
+ const next = at === -1 ? (by === 1 ? 0 : n - 1) : (at + by + n) % n
287
+ this.select(this.list[next]!.name)
288
+ }
289
+
280
290
  /** a click selects once it is sure no second click follows */
281
291
  click(name: string): void {
282
292
  this.select(name)