rvlib-shipkit 2.3.0 → 2.5.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 +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
.rv-word{margin-top:var(--s2)}
|
|
119
119
|
.rv-raised{padding:var(--s2) var(--s3);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:4px;background:var(--panel)}
|
|
120
120
|
.rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
|
|
121
|
-
.rv-pairgrid{
|
|
121
|
+
.rv-pairgrid{display:grid;gap:var(--s2)}
|
|
122
122
|
.rv-cell{display:flex;flex-direction:column;align-items:center;gap:var(--s0);min-width:0}
|
|
123
123
|
.rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
|
|
124
124
|
background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
.rv-tile{display:flex;flex-direction:column;gap:var(--s1);padding:var(--s1);border-radius:6px;border:2px solid var(--line)}
|
|
149
149
|
.rv-tile[class*=' re-']{border-color:var(--tone);background:color-mix(in srgb,var(--tone) 8%,transparent)}
|
|
150
150
|
.rv-tile.big{width:100%}
|
|
151
|
-
.rv-tileimg{border-radius:4px;cursor:zoom-in;
|
|
151
|
+
.rv-tileimg{border-radius:4px;cursor:zoom-in;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
|
|
152
152
|
.rv-tile.big .rv-tileimg{cursor:zoom-out}
|
|
153
153
|
.rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
|
|
154
154
|
.rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -159,19 +159,64 @@
|
|
|
159
159
|
.rv-sound button{padding:0 var(--s1);line-height:22px}
|
|
160
160
|
.rv-sound input{width:72px}
|
|
161
161
|
.rv-phonepair{display:flex;flex-direction:column;gap:var(--s2)}
|
|
162
|
+
/* options: one thin row a view, one below the other; the row playing or last touched has the line, the picked one its tone */
|
|
163
|
+
.rv-opts{display:flex;flex-direction:column;gap:var(--s1)}
|
|
164
|
+
.rv-opt{scroll-margin-top:8px;padding:var(--s1) var(--s2);border-radius:6px;border:1px solid var(--line);border-left:4px solid transparent;background:var(--panel)}
|
|
165
|
+
.rv-opt.active{border-left-color:var(--accent)}
|
|
166
|
+
.rv-opt[class*=' re-']{border-left-color:var(--tone)}
|
|
167
|
+
.rv-opt.picked{--tone:var(--ok);border-color:var(--tone);border-left-color:var(--tone);background:color-mix(in srgb,var(--tone) 9%,var(--panel))}
|
|
168
|
+
.rv-optrow{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;min-height:32px}
|
|
169
|
+
.rv-optn{flex:none;width:16px;text-align:right;font:600 12px ui-monospace,Menlo,monospace;color:var(--dim)}
|
|
170
|
+
.rv-optplay{flex:none;width:32px;height:28px;padding:0;font-size:13px}
|
|
171
|
+
.rv-optplay.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
|
|
172
|
+
.rv-optlabel{flex:0 1 auto;min-width:48px;max-width:30%;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
173
|
+
.rv-optthumb{flex:none;height:32px;max-width:72px;border-radius:3px;overflow:hidden;cursor:zoom-in;border:1px solid var(--line);background:var(--panel2)}
|
|
174
|
+
.rv-optthumb.img{width:48px}
|
|
175
|
+
.rv-optthumb img{display:block;width:100%;height:100%;object-fit:cover}
|
|
176
|
+
.rv-strip{position:relative;flex:1 1 140px;min-width:120px;height:28px;border-radius:3px;cursor:pointer;touch-action:none;background:var(--panel2);border:1px solid var(--line);overflow:hidden}
|
|
177
|
+
.rv-stripdone{position:absolute;inset:0 auto 0 0;background:var(--wash)}
|
|
178
|
+
/* a waveform is a mask filled with tokens: --dim to play (5.0:1 on --panel2 light, 5.3:1 dark), --accent played
|
|
179
|
+
(4.9:1 both) */
|
|
180
|
+
.rv-stripwave{position:absolute;inset:0;background:var(--dim)}
|
|
181
|
+
.rv-stripwave.done{background:var(--accent)}
|
|
182
|
+
.rv-stripwave.full{background:var(--accent)}
|
|
183
|
+
.rv-wavebox{position:relative;width:100%;border-radius:4px;background:var(--panel2)}
|
|
184
|
+
.rv-striphead{position:absolute;top:0;bottom:0;width:2px;transform:translateX(-1px);background:var(--accent)}
|
|
185
|
+
.rv-opttime{flex:none;font-size:11px;color:var(--dim)}
|
|
186
|
+
.rv-optacts{flex:none;display:inline-flex;gap:var(--s1);align-items:center;margin-left:auto}
|
|
187
|
+
.rv-optacts > button{padding:0 var(--s1);line-height:22px}
|
|
188
|
+
.rv-optacts > button.on{border-color:var(--accent)}
|
|
189
|
+
.rv-stars{display:inline-flex}
|
|
190
|
+
.rv-stars button{border:0;background:none;padding:0;font-size:16px;line-height:22px;color:var(--dim)}
|
|
191
|
+
.rv-stars button.on{color:var(--warn)}
|
|
192
|
+
.rv-pick{font-weight:700;padding:0 var(--s3)!important;color:var(--ok);border-color:color-mix(in srgb,var(--ok) 55%,var(--line))}
|
|
193
|
+
.rv-pick.on{color:var(--bg);background:var(--ok);border-color:var(--ok)}
|
|
194
|
+
.rv-optnote{margin:var(--s0) 0 0 var(--s4);font-size:12.5px;color:var(--ink)}
|
|
195
|
+
.rv-opt .rv-word{margin:var(--s1) 0 0 var(--s4);width:calc(100% - var(--s4))}
|
|
196
|
+
.rv-optbody{margin-top:var(--s2)}
|
|
162
197
|
|
|
163
198
|
/* ── the answer ── */
|
|
164
199
|
.rv-form{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
|
|
165
|
-
.rv-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.rv-
|
|
169
|
-
.rv-
|
|
170
|
-
.rv-
|
|
200
|
+
.rv-title{margin:0;font-size:16px;line-height:1.3;color:var(--ink)}
|
|
201
|
+
/* the card's header: the title, one tight line of state, then what the agent says at full ink; --dim holds 5.6:1 or
|
|
202
|
+
more on --bg and --panel in both themes */
|
|
203
|
+
.rv-head{display:flex;flex-direction:column;gap:var(--s1)}
|
|
204
|
+
.rv-meta{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;font-size:12px;color:var(--dim);line-height:18px}
|
|
205
|
+
.rv-meta .rv-badge{font-size:10px;line-height:18px;max-width:none}
|
|
206
|
+
.rv-copyid{display:inline-flex;align-items:center;padding:var(--s0);color:var(--dim)}
|
|
207
|
+
.rv-copyid:hover{color:var(--ink)}
|
|
208
|
+
.rv-toast{position:fixed;left:50%;bottom:var(--s4);transform:translateX(-50%);z-index:40;max-width:calc(100% - 2 * var(--s4));padding:var(--s1) var(--s3);border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px;box-shadow:0 6px 20px var(--shadow)}
|
|
209
|
+
.rv-what{font-size:13.5px;line-height:1.45;color:var(--ink)}
|
|
210
|
+
.rv-what ul{margin:var(--s0) 0;padding-left:var(--s4)}
|
|
211
|
+
.rv-what p{margin:var(--s0) 0}
|
|
212
|
+
.rv-why{color:var(--dim);font-size:12px}
|
|
213
|
+
.rv-headverdicts{display:flex;flex-wrap:wrap;gap:var(--s1);align-items:center;margin-top:var(--s1)}
|
|
214
|
+
.rv-headverdicts .rv-verdict{padding:0 var(--s2);line-height:24px;font-size:12.5px}
|
|
215
|
+
.rv-head .rv-comment{margin-top:var(--s1);width:100%}
|
|
171
216
|
.rv-keeps{color:var(--ok);font-size:13px}
|
|
172
217
|
.rv-facts{display:flex;flex-direction:column}
|
|
173
|
-
.rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:
|
|
174
|
-
.rv-factl{width:64px;flex:none;color:var(--dim);font-size:
|
|
218
|
+
.rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:22px;font-size:12.5px}
|
|
219
|
+
.rv-factl{width:64px;flex:none;color:var(--dim);font-size:12px}
|
|
175
220
|
.rv-factv{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
176
221
|
.rv-linked{padding:var(--s1) var(--s2);border:1px solid var(--line);border-radius:4px;background:var(--panel)}
|
|
177
222
|
.rv-linkedrow{display:flex;gap:var(--s2);align-items:center;font-size:12px}
|
|
@@ -184,9 +229,6 @@
|
|
|
184
229
|
.rv-outbadge.rect{--tone:var(--warn)}
|
|
185
230
|
.rv-outbadge.rect.on{--tone:var(--blue)}
|
|
186
231
|
.rv-x{flex:none;padding:0 var(--s1);border:0}
|
|
187
|
-
.rv-label{font-size:12px;font-weight:600;margin-top:var(--s1)}
|
|
188
|
-
.rv-listenrow{display:flex;gap:var(--s2);align-items:center;font-size:11px}
|
|
189
|
-
.rv-verdicts{display:flex;flex-direction:column;gap:var(--s1)}
|
|
190
232
|
.rv-verdict{text-align:left;padding:var(--s1) var(--s3);font-size:13px;border-color:color-mix(in srgb,var(--tone) 60%,var(--line))}
|
|
191
233
|
.rv-verdict .rv-vglyph{color:var(--tone);font-weight:800}
|
|
192
234
|
.rv-verdict.chosen{background:var(--tone);color:var(--bg);border-color:var(--tone)}
|
|
@@ -208,6 +250,17 @@
|
|
|
208
250
|
.rv-verdictbar{flex:none;display:flex;gap:var(--s1);padding:var(--s2);border-top:1px solid var(--line);background:var(--panel)}
|
|
209
251
|
.rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
|
|
210
252
|
.rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
|
|
253
|
+
.rv-phone .rv-views{gap:var(--s3)}
|
|
254
|
+
.rv-phone .rv-toast{top:var(--s4);bottom:auto}
|
|
255
|
+
/* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
|
|
256
|
+
.rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
|
|
257
|
+
.rv-phone .rv-optlabel{flex:1;max-width:none}
|
|
258
|
+
.rv-phone .rv-strip{order:5;flex:1 1 100%;height:32px}
|
|
259
|
+
.rv-phone .rv-optthumb{order:4}
|
|
260
|
+
.rv-phone .rv-optacts{order:6;flex:1 1 100%;justify-content:space-between}
|
|
261
|
+
.rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
|
|
262
|
+
.rv-phone .rv-optplay{width:40px;height:34px}
|
|
263
|
+
.rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
|
|
211
264
|
|
|
212
265
|
/* ── the trailer page ── */
|
|
213
266
|
.tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
|
|
@@ -323,6 +376,13 @@
|
|
|
323
376
|
.rv-boardrow .rv-linkedtitle{display:block}
|
|
324
377
|
.rv-kindview{display:flex;flex-direction:column;gap:var(--s2);margin:var(--s2) 0}
|
|
325
378
|
.rv-kindtext{font:12px/1.5 ui-monospace,Menlo,monospace;background:var(--panel);border:1px solid var(--line);border-radius:3px;padding:var(--s2) var(--s3);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
379
|
+
.rv-part{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);margin-top:var(--s2);background:var(--panel)}
|
|
380
|
+
.rv-parthead{display:flex;align-items:center;gap:var(--s2);margin-bottom:var(--s2)}
|
|
381
|
+
.rv-particon{width:20px;height:20px;border-radius:5px;flex:none}
|
|
382
|
+
.rv-partbadge{margin-left:auto;font-size:11px;font-weight:700;border:1px solid;border-radius:3px;padding:0 var(--s1)}
|
|
383
|
+
.rv-partbody{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--s3);align-items:start}
|
|
384
|
+
.rv-partbody img,.rv-partbody video{width:100%;max-height:320px;object-fit:contain;border-radius:6px;display:block}
|
|
385
|
+
.rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
326
386
|
.rv-anim select{font-size:12px}
|
|
327
387
|
/* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
|
|
328
388
|
.rv-sound{display:flex;flex-direction:column;gap:var(--s2)}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// the wikis page's state, one per repo: the search bar over the open wiki
|
|
2
|
+
// (its title and full text, graph-pages.ts on the server), the hit under the
|
|
3
|
+
// arrows, and the page open in the preview panel. The graph is an iframe that
|
|
4
|
+
// only draws (wiki/graph-page.ts): a click on a ball arrives as a message,
|
|
5
|
+
// and opening a page answers with a focus message, so the ball and the
|
|
6
|
+
// preview always show the same page.
|
|
7
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
8
|
+
import type { WikiHit, WikiPage } from '../../wiki/graph-pages.ts'
|
|
9
|
+
import type { RepoSt } from './repo-st.ts'
|
|
10
|
+
|
|
11
|
+
const WIDTH_KEY = 'hub:wiki-preview-width'
|
|
12
|
+
const HEIGHT_KEY = 'hub:wiki-preview-height'
|
|
13
|
+
const PLACE_KEY = 'hub:wiki-preview-place'
|
|
14
|
+
export const PREVIEW = { min: 260, max: 1200, def: 460 }
|
|
15
|
+
/** the preview below the graph: its height */
|
|
16
|
+
export const PREVIEW_H = { min: 160, max: 1200, def: 360 }
|
|
17
|
+
/** where the preview sits against the graph, toggled from its own head */
|
|
18
|
+
export const PLACES = ['left', 'right', 'bottom'] as const
|
|
19
|
+
export type PreviewPlace = (typeof PLACES)[number]
|
|
20
|
+
/** the wait after a keystroke before the search asks the server */
|
|
21
|
+
const SEARCH_MS = 120
|
|
22
|
+
|
|
23
|
+
function loadSize(key: string, b: { min: number; max: number; def: number }): number {
|
|
24
|
+
try {
|
|
25
|
+
const n = Number(globalThis.localStorage?.getItem(key))
|
|
26
|
+
return Number.isFinite(n) && n >= b.min && n <= b.max ? n : b.def
|
|
27
|
+
} catch {
|
|
28
|
+
return b.def
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadPlace(): PreviewPlace {
|
|
33
|
+
try {
|
|
34
|
+
const v = globalThis.localStorage?.getItem(PLACE_KEY) ?? ''
|
|
35
|
+
return (PLACES as readonly string[]).includes(v) ? (v as PreviewPlace) : 'left'
|
|
36
|
+
} catch {
|
|
37
|
+
return 'left'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keep(key: string, v: string): void {
|
|
42
|
+
try {
|
|
43
|
+
globalThis.localStorage?.setItem(key, v)
|
|
44
|
+
} catch { /* private window: the choice lasts the tab */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class WikiSt {
|
|
48
|
+
/** the open wiki's route, `/graph/<id>`: every read is under it */
|
|
49
|
+
href: string | null = null
|
|
50
|
+
query = ''
|
|
51
|
+
hits: WikiHit[] = []
|
|
52
|
+
/** the hit under the arrows */
|
|
53
|
+
cursor = 0
|
|
54
|
+
/** the dropdown shows */
|
|
55
|
+
listOpen = false
|
|
56
|
+
page: WikiPage | null = null
|
|
57
|
+
error = ''
|
|
58
|
+
width = loadSize(WIDTH_KEY, PREVIEW)
|
|
59
|
+
height = loadSize(HEIGHT_KEY, PREVIEW_H)
|
|
60
|
+
place: PreviewPlace = loadPlace()
|
|
61
|
+
/** the copy button said copied, for a moment */
|
|
62
|
+
copied = false
|
|
63
|
+
private frame: Window | null = null
|
|
64
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
65
|
+
private asked = 0
|
|
66
|
+
|
|
67
|
+
constructor(private readonly st: RepoSt) {
|
|
68
|
+
makeAutoObservable<this, 'st' | 'frame' | 'timer' | 'asked'>(this, { st: false, frame: false, timer: false, asked: false })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** the wiki shown changed: its search starts over, and its index opens */
|
|
72
|
+
bind(href: string | null): void {
|
|
73
|
+
if (href === this.href) return
|
|
74
|
+
this.href = href
|
|
75
|
+
this.query = ''
|
|
76
|
+
this.hits = []
|
|
77
|
+
this.page = null
|
|
78
|
+
this.error = ''
|
|
79
|
+
this.listOpen = false
|
|
80
|
+
if (href != null) void this.open('')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** the graph's iframe, to send it focus and theme */
|
|
84
|
+
setFrame(w: Window | null): void {
|
|
85
|
+
this.frame = w
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
send(msg: { type: 'wiki-graph:focus'; id: string } | { type: 'wiki-graph:theme'; theme: 'dark' | 'light' }): void {
|
|
89
|
+
this.frame?.postMessage(msg, '*')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setQuery(q: string): void {
|
|
93
|
+
this.query = q
|
|
94
|
+
this.cursor = 0
|
|
95
|
+
this.listOpen = q.trim() !== ''
|
|
96
|
+
if (this.timer != null) clearTimeout(this.timer)
|
|
97
|
+
if (q.trim() === '') {
|
|
98
|
+
this.hits = []
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
this.timer = setTimeout(() => void this.search(q), SEARCH_MS)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async search(q: string): Promise<void> {
|
|
105
|
+
if (this.href == null) return
|
|
106
|
+
const n = ++this.asked
|
|
107
|
+
try {
|
|
108
|
+
const r = await fetch(this.st.url(`${this.href}/search?q=${encodeURIComponent(q)}`))
|
|
109
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
110
|
+
const hits = (await r.json()) as WikiHit[]
|
|
111
|
+
// an answer to an older query never replaces a newer one
|
|
112
|
+
if (n === this.asked) runInAction(() => { this.hits = hits; this.error = '' })
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (n === this.asked) runInAction(() => (this.error = `search failed: ${(e as Error).message}`))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** the search input's keys: up and down walk the hits, enter opens one, escape closes the list */
|
|
119
|
+
key(k: string): boolean {
|
|
120
|
+
if (k === 'Escape') {
|
|
121
|
+
if (!this.listOpen) return false
|
|
122
|
+
this.listOpen = false
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
if (this.hits.length === 0) return false
|
|
126
|
+
if (k === 'ArrowDown') this.cursor = Math.min(this.hits.length - 1, this.cursor + 1)
|
|
127
|
+
else if (k === 'ArrowUp') this.cursor = Math.max(0, this.cursor - 1)
|
|
128
|
+
else if (k === 'Enter') void this.open(this.hits[this.cursor]!.id)
|
|
129
|
+
else return false
|
|
130
|
+
if (k !== 'Enter') this.listOpen = true
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** a page opens in the preview, and its ball is focused in the graph; '' = the wiki's index */
|
|
135
|
+
async open(id: string): Promise<void> {
|
|
136
|
+
const href = this.href
|
|
137
|
+
if (href == null) return
|
|
138
|
+
this.listOpen = false
|
|
139
|
+
if (id !== '') this.send({ type: 'wiki-graph:focus', id })
|
|
140
|
+
try {
|
|
141
|
+
const r = await fetch(this.st.url(`${href}/page?node=${encodeURIComponent(id)}`))
|
|
142
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
143
|
+
const p = (await r.json()) as WikiPage
|
|
144
|
+
// the wiki changed while the page was read: it is not this one's
|
|
145
|
+
if (href !== this.href) return
|
|
146
|
+
runInAction(() => { this.page = p; this.error = '' })
|
|
147
|
+
if (id === '') this.send({ type: 'wiki-graph:focus', id: p.id })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
runInAction(() => (this.error = `${id === '' ? 'the index' : id} would not open: ${(e as Error).message}`))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setPlace(p: PreviewPlace): void {
|
|
154
|
+
this.place = p
|
|
155
|
+
keep(PLACE_KEY, p)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** the open page's path on disk, to the clipboard */
|
|
159
|
+
async copyPath(): Promise<void> {
|
|
160
|
+
if (this.page == null) return
|
|
161
|
+
try {
|
|
162
|
+
await navigator.clipboard.writeText(this.page.file)
|
|
163
|
+
runInAction(() => (this.copied = true))
|
|
164
|
+
setTimeout(() => runInAction(() => (this.copied = false)), 1500)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
runInAction(() => (this.error = `the path was not copied: ${(e as Error).message}`))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close(): void {
|
|
171
|
+
this.page = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** a drag of the preview's separator ended at this size: its width beside the graph, its height under it */
|
|
175
|
+
setSize(px: number): void {
|
|
176
|
+
if (this.place === 'bottom') {
|
|
177
|
+
this.height = Math.round(Math.min(PREVIEW_H.max, Math.max(PREVIEW_H.min, px)))
|
|
178
|
+
keep(HEIGHT_KEY, String(this.height))
|
|
179
|
+
} else {
|
|
180
|
+
this.width = Math.round(Math.min(PREVIEW.max, Math.max(PREVIEW.min, px)))
|
|
181
|
+
keep(WIDTH_KEY, String(this.width))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** a message from the graph: a ball was clicked */
|
|
186
|
+
onMessage(data: unknown): void {
|
|
187
|
+
const m = data as { type?: unknown; id?: unknown } | null
|
|
188
|
+
if (m != null && m.type === 'wiki-graph:pick' && typeof m.id === 'string') void this.open(m.id)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// The wikis page: one tab per mounted markdown wiki, one link graph at a
|
|
2
2
|
// time, embedded from its own /graph/<id> route. A tab is an item of the
|
|
3
3
|
// wikis collection, so the open wiki is in the url like any other page.
|
|
4
|
+
// Over the graph a search bar (title and full text, a dropdown the arrows
|
|
5
|
+
// walk); a hit, or a click on a ball, opens the page in a resizable panel on
|
|
6
|
+
// the right, whose refs open their page in turn and focus its ball (wiki-st.ts).
|
|
4
7
|
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { useEffect, useRef, type MouseEvent } from 'react'
|
|
9
|
+
import { Group, Panel, Separator, type Layout } from 'react-resizable-panels'
|
|
10
|
+
import { renderWikiPage } from './md.ts'
|
|
11
|
+
import { editorHref } from './check-words.ts'
|
|
12
|
+
import { Icon, ICON } from './icons.tsx'
|
|
13
|
+
import { PLACES, PREVIEW, PREVIEW_H, type PreviewPlace, type WikiSt } from './wiki-st.ts'
|
|
5
14
|
import type { RepoSt } from './repo-st.ts'
|
|
6
15
|
import type { Resource } from './hub-types.ts'
|
|
7
16
|
|
|
@@ -33,8 +42,13 @@ export function openWikis(app: RepoSt): void {
|
|
|
33
42
|
else app.showCol(WIKIS)
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
const PANEL = { overflow: 'hidden' } as const
|
|
46
|
+
|
|
36
47
|
export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
37
48
|
const i = app.cols.find(WIKIS, id)
|
|
49
|
+
const w = app.wiki
|
|
50
|
+
const href = i?.href ?? null
|
|
51
|
+
useEffect(() => w.bind(href), [w, href])
|
|
38
52
|
const tabs = (
|
|
39
53
|
<span className="seg">
|
|
40
54
|
{wikiTabs(app).map((t) => <button key={t.id} className={t.on ? 'on' : ''} onClick={t.go}><span className={`tl ${t.state}`}>{t.label}</span></button>)}
|
|
@@ -45,13 +59,128 @@ export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) =>
|
|
|
45
59
|
<div className="graphview">
|
|
46
60
|
<div className="gvhead">
|
|
47
61
|
{tabs}
|
|
62
|
+
{href == null ? null : <WikiSearch w={w} />}
|
|
48
63
|
<span className={`gvtext tl ${i.state}`}>{i.text}</span>
|
|
49
64
|
</div>
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
{w.error !== '' ? <div className="hnote">{w.error}</div> : null}
|
|
66
|
+
{href == null ? <div className="ph">this wiki carries no page to embed</div> : <WikiBody app={app} w={w} href={href} title={i.title} />}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** the graph and, once a page is open, its preview on the right; the preview's width is dragged and kept */
|
|
72
|
+
const WikiBody = observer(({ app, w, href, title }: { app: RepoSt; w: WikiSt; href: string; title: string }) => {
|
|
73
|
+
const el = useRef<HTMLDivElement | null>(null)
|
|
74
|
+
const frame = useRef<HTMLIFrameElement | null>(null)
|
|
75
|
+
const theme = app.home.theme.resolved
|
|
76
|
+
// the theme the graph first paints in rides its url; a later change is a message, never a reload
|
|
77
|
+
const first = useRef(theme)
|
|
78
|
+
useEffect(() => w.send({ type: 'wiki-graph:theme', theme }), [w, theme])
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const on = (ev: MessageEvent): void => {
|
|
81
|
+
if (ev.source != null && ev.source === frame.current?.contentWindow) w.onMessage(ev.data)
|
|
82
|
+
}
|
|
83
|
+
addEventListener('message', on)
|
|
84
|
+
return () => removeEventListener('message', on)
|
|
85
|
+
}, [w])
|
|
86
|
+
const sep = href.includes('?') ? '&' : '?'
|
|
87
|
+
const below = w.place === 'bottom'
|
|
88
|
+
const graph = (
|
|
89
|
+
<Panel key="graph" id="wiki-graph" minSize={below ? 160 : 280} style={PANEL}>
|
|
90
|
+
{/* the spinner sits under the frame: the graph page paints its own background over it the moment it has something to show */}
|
|
91
|
+
<div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span>
|
|
92
|
+
<iframe ref={(f) => { frame.current = f; w.setFrame(f?.contentWindow ?? null) }} src={`${app.url(href)}${sep}theme=${first.current}`} title={title}
|
|
93
|
+
onLoad={() => {
|
|
94
|
+
w.setFrame(frame.current?.contentWindow ?? null)
|
|
95
|
+
w.send({ type: 'wiki-graph:theme', theme: app.home.theme.resolved })
|
|
96
|
+
if (w.page != null) w.send({ type: 'wiki-graph:focus', id: w.page.id })
|
|
97
|
+
}} />
|
|
98
|
+
</div>
|
|
99
|
+
</Panel>
|
|
100
|
+
)
|
|
101
|
+
const preview = w.page == null ? null : (
|
|
102
|
+
<Panel key="preview" id="wiki-preview" defaultSize={below ? w.height : w.width} minSize={below ? PREVIEW_H.min : PREVIEW.min} maxSize={below ? PREVIEW_H.max : PREVIEW.max}
|
|
103
|
+
groupResizeBehavior="preserve-pixel-size" style={PANEL}>
|
|
104
|
+
<WikiPreview w={w} />
|
|
105
|
+
</Panel>
|
|
106
|
+
)
|
|
107
|
+
const line = preview == null ? null : <Separator key="sep" className={below ? 'rsep hsep' : 'rsep'} />
|
|
108
|
+
return (
|
|
109
|
+
// the group is remade when the preview moves: its panels change order and axis
|
|
110
|
+
<Group key={w.place} className="gvbody" orientation={below ? 'vertical' : 'horizontal'} elementRef={el}
|
|
111
|
+
onLayoutChanged={(layout: Layout, meta) => {
|
|
112
|
+
const pct = layout['wiki-preview']
|
|
113
|
+
const r = el.current?.getBoundingClientRect()
|
|
114
|
+
const whole = below ? r?.height : r?.width
|
|
115
|
+
if (meta.isUserInteraction && pct != null && whole != null && whole > 0) w.setSize((pct / 100) * whole)
|
|
116
|
+
}}>
|
|
117
|
+
{w.place === 'left' ? [preview, line, graph] : [graph, line, preview]}
|
|
118
|
+
</Group>
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/** the search bar: the hits drop under it, the arrows walk them, enter or a click opens one */
|
|
123
|
+
const WikiSearch = observer(({ w }: { w: WikiSt }) => (
|
|
124
|
+
<span className="wsearch">
|
|
125
|
+
<input type="search" placeholder="search the wiki: a title, a word…" aria-label="search the wiki" value={w.query}
|
|
126
|
+
onChange={(e) => w.setQuery(e.currentTarget.value)}
|
|
127
|
+
onFocus={() => { if (w.hits.length > 0) w.key('ArrowUp') }}
|
|
128
|
+
onBlur={() => w.key('Escape')}
|
|
129
|
+
onKeyDown={(e) => { if (w.key(e.key)) e.preventDefault() }} />
|
|
130
|
+
{w.listOpen && w.query.trim() !== '' ? (
|
|
131
|
+
<div className="wslist" role="listbox">
|
|
132
|
+
{w.hits.length === 0 ? <div className="wsnone">no page matches</div> : w.hits.map((h, n) => (
|
|
133
|
+
// mouse down, not click: the input's blur would close the list first
|
|
134
|
+
<div key={h.id} role="option" aria-selected={n === w.cursor} className={`wshit${n === w.cursor ? ' on' : ''}`}
|
|
135
|
+
onMouseDown={(e) => { e.preventDefault(); void w.open(h.id) }}>
|
|
136
|
+
<div className="wshead"><b>{h.title}</b><span className="wspath">{h.id}</span></div>
|
|
137
|
+
{h.snippet === '' || h.inTitle && h.snippet === '' ? null : <div className="wssnip">{h.snippet}</div>}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</span>
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
const PLACE_ICON: Record<PreviewPlace, keyof typeof ICON> = { left: 'paneLeft', right: 'paneRight', bottom: 'paneBottom' }
|
|
146
|
+
|
|
147
|
+
/** the open page: its title and path, where the panel sits, the editor and
|
|
148
|
+
* the copy, the close; then the markdown, whose refs open their own page */
|
|
149
|
+
const WikiPreview = observer(({ w }: { w: WikiSt }) => {
|
|
150
|
+
const p = w.page!
|
|
151
|
+
const editor = editorHref('', p.file)
|
|
152
|
+
const click = (e: MouseEvent<HTMLDivElement>): void => {
|
|
153
|
+
const t = e.target as HTMLElement
|
|
154
|
+
const a = t.closest('a[data-wiki]')
|
|
155
|
+
if (a != null) {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
void w.open(a.getAttribute('data-wiki')!)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
// a ref in a code span opens its page by its text
|
|
161
|
+
const code = t.closest('code')
|
|
162
|
+
const to = code == null ? undefined : p.links[code.textContent ?? '']
|
|
163
|
+
if (to != null) void w.open(to)
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<div className="wprev">
|
|
167
|
+
<div className="wphead">
|
|
168
|
+
<b className="wptitle">{p.title}</b>
|
|
169
|
+
<span className="wspath" title={p.file}>{p.id}</span>
|
|
170
|
+
<span className="seg wpplace" aria-label="where the preview sits">
|
|
171
|
+
{PLACES.map((pl) => (
|
|
172
|
+
<button key={pl} className={w.place === pl ? 'on' : ''} aria-label={`the preview on the ${pl}`} title={`the preview on the ${pl}`} onClick={() => w.setPlace(pl)}>
|
|
173
|
+
<Icon d={ICON[PLACE_ICON[pl]]} />
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</span>
|
|
177
|
+
{editor == null ? null : (
|
|
178
|
+
<a className="wpbtn" href={editor} title={`open ${p.file} in VS Code`}><Icon d={ICON.editor} /><span>VS Code</span></a>
|
|
179
|
+
)}
|
|
180
|
+
<button className="wpbtn" title={`copy ${p.file}`} onClick={() => void w.copyPath()}><Icon d={ICON.copy} /><span>{w.copied ? 'copied' : 'copy path'}</span></button>
|
|
181
|
+
<button className="ghost wpclose" aria-label="close the preview" title="close the preview" onClick={() => w.close()}>×</button>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="wpbody md readme-body" onClick={click} dangerouslySetInnerHTML={{ __html: renderWikiPage(p.md, p.links) }} />
|
|
55
184
|
</div>
|
|
56
185
|
)
|
|
57
186
|
})
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -146,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
146
146
|
// agent working here obeys that wiki, so a reader can open it.
|
|
147
147
|
const graphs: GraphMount[] = []
|
|
148
148
|
if (c.serve?.wikiGraph != null)
|
|
149
|
-
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
|
|
149
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, dir: c.serve.wikiGraph.dir ?? root, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
|
|
150
150
|
else if (existsSync(join(root, 'wiki')))
|
|
151
|
-
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
|
|
151
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), dir: root, lint: { root } })
|
|
152
152
|
const baseWiki = baseWikiRoot(root)
|
|
153
153
|
if (baseWiki != null)
|
|
154
|
-
graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
|
|
154
|
+
graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), dir: baseWiki, lint: null })
|
|
155
155
|
const agentRoot = join(root, 'agent')
|
|
156
156
|
if (existsSync(agentRoot))
|
|
157
|
-
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
157
|
+
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), dir: agentRoot, lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
158
158
|
// ONE page for every repo: the studio ALWAYS
|
|
159
159
|
// mounts — kit-less repos get an empty media section, never a second
|
|
160
160
|
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
@@ -30,6 +30,8 @@ export type { Gate }
|
|
|
30
30
|
export const POLL_MS = 5_000
|
|
31
31
|
/** the app icon preview: sharp on a 2x screen at the admin page's 128px */
|
|
32
32
|
const PREVIEW_PX = 256
|
|
33
|
+
/** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
|
|
34
|
+
const RAIL_PX = 96
|
|
33
35
|
/** how long a release answer holds for a checkout whose `.git` is not a
|
|
34
36
|
* folder, since no stat can say when it moved */
|
|
35
37
|
export const RELEASE_TTL_MS = 60_000
|
|
@@ -74,6 +76,8 @@ export type ProjectRow = {
|
|
|
74
76
|
live: string | null
|
|
75
77
|
/** the colours its repo.config.ts declares (SK024), null when it declares none */
|
|
76
78
|
identity: Identity | null
|
|
79
|
+
/** its app icon cropped to the plate (core/app-icon.ts), what the folded rail draws; null with no mark */
|
|
80
|
+
appIcon: string | null
|
|
77
81
|
/** epoch ms of the newest of: the last commit, the last shipkit run, the
|
|
78
82
|
* newest Claude Code transcript. null when none of them exists */
|
|
79
83
|
activity: number | null
|
|
@@ -298,6 +302,15 @@ export function projectGutter(d: GutterDeps) {
|
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
|
|
305
|
+
// the url names the mark's stamp and both colours, so a new mark or a colour change is a new url, cached a day
|
|
306
|
+
const railIconUrl = (r: WiredRepo, id: Identity): string | null => {
|
|
307
|
+
const mark = appIconOf(r.path)
|
|
308
|
+
if (mark == null) return null
|
|
309
|
+
const q = new URLSearchParams({ plate: '1', light: id.colorLight, dark: id.colorDark, v: String(iconStamp(mark)) })
|
|
310
|
+
return `/api/projects/app-icon/${encodeURIComponent(r.name)}?${q}`
|
|
311
|
+
}
|
|
312
|
+
/** drawn plates, by url: a page load asks for every repo's at once */
|
|
313
|
+
const plates = new Map<string, Promise<Buffer>>()
|
|
301
314
|
|
|
302
315
|
async function measure(): Promise<ProjectsAnswer> {
|
|
303
316
|
const t0 = performance.now()
|
|
@@ -334,6 +347,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
334
347
|
release: facts[i]!.release,
|
|
335
348
|
live: liveUrlOf(config),
|
|
336
349
|
identity: identityOfText(config),
|
|
350
|
+
appIcon: railIconUrl(r, identityOfRoot(r.path)),
|
|
337
351
|
activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
|
|
338
352
|
commit: facts[i]!.commitAt,
|
|
339
353
|
}
|
|
@@ -386,12 +400,22 @@ export function projectGutter(d: GutterDeps) {
|
|
|
386
400
|
const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
|
|
387
401
|
if (r == null) return new Response('not a house repo', { status: 404 })
|
|
388
402
|
const icon = appIconOf(r.path)
|
|
389
|
-
if (icon == null) return new Response('no
|
|
403
|
+
if (icon == null) return new Response('no mark: the app wears the default icon', { status: 404 })
|
|
390
404
|
// the colours a picker is on right now, before they are saved; else the config's
|
|
391
405
|
const q = new URL(req.url).searchParams
|
|
392
406
|
const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
|
|
393
407
|
const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
|
|
394
408
|
try {
|
|
409
|
+
if (q.get('plate') === '1') {
|
|
410
|
+
const key = `${r.path}?${q}`
|
|
411
|
+
let png = plates.get(key)
|
|
412
|
+
if (png == null) {
|
|
413
|
+
png = readFile(icon).then(async (b) => (await appIconPngs(b, id, [RAIL_PX], 'plate'))[0]!)
|
|
414
|
+
plates.set(key, png)
|
|
415
|
+
png.catch(() => plates.delete(key))
|
|
416
|
+
}
|
|
417
|
+
return new Response(new Uint8Array(await png), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=86400' } })
|
|
418
|
+
}
|
|
395
419
|
const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
|
|
396
420
|
return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
|
|
397
421
|
} catch (e) {
|
|
@@ -48,7 +48,7 @@ export function houseIndex(repo: string, home: string): string | null {
|
|
|
48
48
|
|
|
49
49
|
/** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
|
|
50
50
|
* icon a repo with no public surface carries, the identity kit writes public/ */
|
|
51
|
-
export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
51
|
+
export const ICON_FILES = ['.shipkit/identity/icon.png', '.shipkit/identity/icon.svg', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
52
52
|
|
|
53
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
54
54
|
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import { basename } from 'path'
|
|
6
6
|
import { reclaimPreviousImage } from '../core/instance.ts'
|
|
7
7
|
import { isLinkedWorktree } from '../core/git-root.ts'
|
|
8
|
+
import { heldNow, heldText, OWN_LIB, setHeld } from '../core/last-good.ts'
|
|
8
9
|
import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
|
|
10
|
+
import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
|
|
11
|
+
import { LibWatch } from '../home/lib-watch.ts'
|
|
12
|
+
import type { LoadVerdict } from './stale-restart.ts'
|
|
9
13
|
import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
|
|
10
14
|
import { attachToHome } from '../home/attach.ts'
|
|
11
15
|
import { daemonUp, WORKER_ENV } from '../home/home-paths.ts'
|
|
@@ -21,6 +25,14 @@ export type { ServeIo }
|
|
|
21
25
|
/** how often a hub of its own looks at its plan */
|
|
22
26
|
export const PLAN_TICK_MS = 2_000
|
|
23
27
|
|
|
28
|
+
/** does the lib this hub would restart onto load: probed again after every
|
|
29
|
+
* write to its tree, not only when the version moves */
|
|
30
|
+
export function serveProbe(lib: string): { loads: () => LoadVerdict; stop: () => void } {
|
|
31
|
+
const w = new LibWatch(lib)
|
|
32
|
+
const p = new TreeProbe(() => probeLoad(lib, bootEntries(lib, 'serve')), () => `${w.seq}|${diskLibVersion()}`)
|
|
33
|
+
return { loads: () => p.loads(), stop: () => w.close() }
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
/** what a `shipkit serve` does with its hub. On a machine with a home, no
|
|
25
37
|
* hub runs outside it: a home that answers takes it, and an installed one
|
|
26
38
|
* that does not is started first. `serve` only inside the home, a linked
|
|
@@ -104,6 +116,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
104
116
|
// a file table the watch restarts leaked: it re-executes itself in
|
|
105
117
|
// place (same pid, argv, port) once no run is in flight. Only a
|
|
106
118
|
// restart that throws is said, in the hub's own status
|
|
119
|
+
const probe = serveProbe(OWN_LIB)
|
|
107
120
|
const stale = new StaleRestart({
|
|
108
121
|
boot: LIB_VERSION,
|
|
109
122
|
disk: diskLibVersion,
|
|
@@ -114,6 +127,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
114
127
|
if (blocked != null) throw new Error(blocked)
|
|
115
128
|
io.print(`ok: ${why}: restarting in place`)
|
|
116
129
|
stale.stop()
|
|
130
|
+
probe.stop()
|
|
117
131
|
clearInterval(planClock)
|
|
118
132
|
rt.stop()
|
|
119
133
|
hub.stop()
|
|
@@ -126,6 +140,16 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
126
140
|
}
|
|
127
141
|
},
|
|
128
142
|
say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
|
|
143
|
+
// a version move restarts only onto code that loads; the held line is the hub's (serve-hub.ts)
|
|
144
|
+
loads: () => probe.loads(),
|
|
145
|
+
hold: (v) => {
|
|
146
|
+
if (v == null) return setHeld(null)
|
|
147
|
+
const was = heldNow()
|
|
148
|
+
if (was?.file === v.file && was.error === v.error) return
|
|
149
|
+
const h = { since: was?.since ?? Date.now(), file: v.file, error: v.error, from: OWN_LIB, copyAt: null }
|
|
150
|
+
io.printErr(`WARN ${heldText(h)}`)
|
|
151
|
+
setHeld(h)
|
|
152
|
+
},
|
|
129
153
|
})
|
|
130
154
|
stale.start()
|
|
131
155
|
// a watch restart that leaked sheds it before the first run spawns anything
|
|
@@ -140,6 +164,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
140
164
|
for (const sig of ['SIGINT', 'SIGTERM'] as const)
|
|
141
165
|
process.on(sig, () => {
|
|
142
166
|
stale.stop()
|
|
167
|
+
probe.stop()
|
|
143
168
|
clearInterval(planClock)
|
|
144
169
|
plan.stop()
|
|
145
170
|
rt.stop()
|