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.
- package/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +6 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +129 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-routes.ts +15 -0
- package/src/home/home-start.ts +11 -5
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/open-app.ts +2 -2
- package/src/home/serve-web.ts +3 -3
- package/src/home/via-hub.ts +14 -0
- package/src/home/widgets.ts +12 -2
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +13 -6
- package/src/serve/hub-app/home-strip.css +7 -9
- package/src/serve/hub-app/home-strip.tsx +8 -17
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +20 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +11 -3
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +9 -5
- package/src/serve/hub-app/projects-svc.ts +11 -1
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- 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
|
|
635
|
-
.
|
|
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:
|
|
640
|
-
/* the checks and
|
|
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(--
|
|
646
|
-
.ctgrp:hover td{color:var(--
|
|
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' }
|
|
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,
|
|
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:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// machine, never the hub.
|
|
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
|
-
|
|
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(() => {
|
|
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=
|
|
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
|
|
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
|
|
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:
|
|
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">
|
|
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)
|