rvlib-shipkit 2.2.0 → 2.4.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 +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- 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 +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- 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 +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- 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/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- 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/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// the page's theme, a node of the root (home-st.ts): his pick (dark, light or
|
|
2
|
+
// auto, which follows the system) kept per browser, resolved to dark or light
|
|
3
|
+
// on <html data-theme>, where hub.css swaps its token block. Applied at
|
|
4
|
+
// construction, before the first render, so a reload paints in the theme.
|
|
5
|
+
import { autorun, makeAutoObservable, runInAction } from 'mobx'
|
|
6
|
+
|
|
7
|
+
export const THEMES = ['dark', 'light', 'auto'] as const
|
|
8
|
+
export type ThemePick = (typeof THEMES)[number]
|
|
9
|
+
export type Theme = 'dark' | 'light'
|
|
10
|
+
|
|
11
|
+
const KEY = 'hub:theme'
|
|
12
|
+
|
|
13
|
+
function loadPick(): ThemePick {
|
|
14
|
+
try {
|
|
15
|
+
const v = globalThis.localStorage?.getItem(KEY)
|
|
16
|
+
return (THEMES as readonly string[]).includes(v ?? '') ? (v as ThemePick) : 'auto'
|
|
17
|
+
} catch {
|
|
18
|
+
return 'auto'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ThemeSt {
|
|
23
|
+
pick: ThemePick = loadPick()
|
|
24
|
+
/** what the system asks for, read live */
|
|
25
|
+
system: Theme = 'dark'
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
makeAutoObservable(this)
|
|
29
|
+
const mq = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: light)') : null
|
|
30
|
+
if (mq != null) {
|
|
31
|
+
this.system = mq.matches ? 'light' : 'dark'
|
|
32
|
+
mq.addEventListener('change', (e) => runInAction(() => (this.system = e.matches ? 'light' : 'dark')))
|
|
33
|
+
}
|
|
34
|
+
const root = typeof document !== 'undefined' ? document.documentElement : null
|
|
35
|
+
if (root != null) autorun(() => {
|
|
36
|
+
root.dataset.theme = this.resolved
|
|
37
|
+
root.style.colorScheme = this.resolved
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get resolved(): Theme {
|
|
42
|
+
return this.pick === 'auto' ? this.system : this.pick
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
set(p: ThemePick): void {
|
|
46
|
+
this.pick = p
|
|
47
|
+
try {
|
|
48
|
+
globalThis.localStorage?.setItem(KEY, p)
|
|
49
|
+
} catch { /* private window or blocked site data: the pick lasts the tab */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the foot's button: dark, light, auto, then dark again */
|
|
53
|
+
cycle(): void {
|
|
54
|
+
this.set(THEMES[(THEMES.indexOf(this.pick) + 1) % THEMES.length]!)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -74,6 +74,8 @@ export const KIND_SECTION: Record<ResourceType, SectionId> = {
|
|
|
74
74
|
post: 'media',
|
|
75
75
|
wiki: 'wikis',
|
|
76
76
|
process: 'processes',
|
|
77
|
+
card: 'review',
|
|
78
|
+
cut: 'trailer',
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/** a process's colour: up reads green, crashed red, on its way orange,
|
|
@@ -392,6 +394,8 @@ export const TYPE_MARK: Record<string, TypeMark> = {
|
|
|
392
394
|
test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
|
|
393
395
|
reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
|
|
394
396
|
process: { glyph: '\u223f', hue: 'k1', word: 'a global process this repo declares: its state, its menu, its log' },
|
|
397
|
+
card: { glyph: '\u25a3', hue: 'k2', word: 'a review card waiting for your answer: approve, fix, reject, circle a detail' },
|
|
398
|
+
cut: { glyph: '\u25b6', hue: 'k3', word: 'a trailer cut: play it with its timeline, pin a note on any frame' },
|
|
395
399
|
done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
|
|
396
400
|
}
|
|
397
401
|
|
|
@@ -45,7 +45,7 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
|
|
|
45
45
|
const key = s == null ? null : sectionKeyLabel(id)
|
|
46
46
|
const el = useInSight(t.cursor === row.key)
|
|
47
47
|
return (
|
|
48
|
-
<div ref={el} className={`tsec${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
|
|
48
|
+
<div ref={el} className={`tsec s-${id}${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
|
|
49
49
|
title={s == null ? row.label : `${s.title} (${key})`} onClick={() => t.click(row)}>
|
|
50
50
|
<Fold app={app} row={row} />
|
|
51
51
|
<Icon d={ICON[id]} />
|
|
@@ -144,6 +144,10 @@ export class TreeSvc {
|
|
|
144
144
|
this.cursor = row.key
|
|
145
145
|
this.activate(row)
|
|
146
146
|
}
|
|
147
|
+
/** a click in the page: the arrows are the page's again until the tree is walked or clicked */
|
|
148
|
+
leave(): void {
|
|
149
|
+
this.cursor = null
|
|
150
|
+
}
|
|
147
151
|
/** the chevron: fold or unfold, never open */
|
|
148
152
|
fold(row: TreeRow): void {
|
|
149
153
|
this.cursor = row.key
|
|
@@ -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
|
@@ -58,6 +58,7 @@ import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serv
|
|
|
58
58
|
import type { projectGutter } from './project-gutter-route.ts'
|
|
59
59
|
import { defaultHubPort } from './hub-port.ts'
|
|
60
60
|
import { LAYOUT } from '../core/layout.ts'
|
|
61
|
+
import { storeDir, storeStamp } from '../review/store.ts'
|
|
61
62
|
export { defaultHubPort }
|
|
62
63
|
|
|
63
64
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
@@ -145,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
145
146
|
// agent working here obeys that wiki, so a reader can open it.
|
|
146
147
|
const graphs: GraphMount[] = []
|
|
147
148
|
if (c.serve?.wikiGraph != null)
|
|
148
|
-
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 } })
|
|
149
150
|
else if (existsSync(join(root, 'wiki')))
|
|
150
|
-
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 } })
|
|
151
152
|
const baseWiki = baseWikiRoot(root)
|
|
152
153
|
if (baseWiki != null)
|
|
153
|
-
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 })
|
|
154
155
|
const agentRoot = join(root, 'agent')
|
|
155
156
|
if (existsSync(agentRoot))
|
|
156
|
-
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 } })
|
|
157
158
|
// ONE page for every repo: the studio ALWAYS
|
|
158
159
|
// mounts — kit-less repos get an empty media section, never a second
|
|
159
160
|
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
@@ -347,9 +348,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
347
348
|
graphs,
|
|
348
349
|
studio,
|
|
349
350
|
refresh,
|
|
350
|
-
// the config
|
|
351
|
-
//
|
|
352
|
-
|
|
351
|
+
// the config, the run record and the review store (gitignored, its
|
|
352
|
+
// own watch counts its writes): what the warm rows and the runs page
|
|
353
|
+
// read besides the tracked files the watch sees
|
|
354
|
+
stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}|${storeStamp(storeDir(root))}`,
|
|
353
355
|
runCli,
|
|
354
356
|
// the run happens IN THIS PROCESS, so every check result reaches the
|
|
355
357
|
// page as it finishes. A shelled run could only ever deliver one wall
|
|
@@ -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/same-origin.ts
CHANGED
|
@@ -10,5 +10,9 @@ export function sameOrigin(req: Request): boolean {
|
|
|
10
10
|
if (site != null && site !== 'same-origin' && site !== 'none') return false
|
|
11
11
|
const origin = req.headers.get('origin')
|
|
12
12
|
if (origin == null) return true
|
|
13
|
-
|
|
13
|
+
// behind a proxy that frames the page under its own origin (pkm-multi's admin), the host the browser asked is the
|
|
14
|
+
// proxy's, which it passes on. A browser cannot forge the header: a page of another site that sets it is held at
|
|
15
|
+
// its preflight, which this server never answers
|
|
16
|
+
const host = req.headers.get('x-forwarded-host') ?? new URL(req.url).host
|
|
17
|
+
try { return new URL(origin).host === host } catch { return false }
|
|
14
18
|
}
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
10
|
+
import { pageOf, searchWiki } from 'rvlib-shipkit/src/wiki/graph-pages.ts'
|
|
10
11
|
import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
|
|
11
12
|
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
12
13
|
import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
|
|
@@ -32,6 +33,8 @@ import { sameOrigin } from './same-origin.ts'
|
|
|
32
33
|
import { LAYOUT } from '../core/layout.ts'
|
|
33
34
|
import { WarmCell } from './warm-cell.ts'
|
|
34
35
|
import { readmeRoutes } from './readme-route.ts'
|
|
36
|
+
import { reviewRoutes } from '../review/routes.ts'
|
|
37
|
+
import { embedRoute } from './embed.ts'
|
|
35
38
|
|
|
36
39
|
export { sameOrigin }
|
|
37
40
|
|
|
@@ -338,6 +341,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
338
341
|
// the empty page shows the README, its images served from the repo
|
|
339
342
|
const readme = await readmeRoutes(d.root, path)
|
|
340
343
|
if (readme != null) return readme
|
|
344
|
+
// the review or trailer page alone, for another tool's frame (embed.ts)
|
|
345
|
+
const embed = await embedRoute(path)
|
|
346
|
+
if (embed != null) return embed
|
|
347
|
+
// the one feedback store and the trailer view (review/routes.ts)
|
|
348
|
+
const review = await reviewRoutes(d.root, req, path)
|
|
349
|
+
if (review != null) return review
|
|
341
350
|
if (path === '/api/status.json') {
|
|
342
351
|
const p = join(d.root, LAYOUT.status)
|
|
343
352
|
return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
|
|
@@ -403,6 +412,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
403
412
|
if (path === `/graph/${g.id}`) return page(routes.page())
|
|
404
413
|
if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
|
|
405
414
|
if (path === `/graph/${g.id}/positions`) return routes.save(req)
|
|
415
|
+
// the wikis page's search bar and its preview panel: only a node of the graph is read
|
|
416
|
+
if (path === `/graph/${g.id}/search`) return g.dir == null ? new Response('this wiki names no folder to read', { status: 404 }) : Response.json(searchWiki(held, g.dir, new URL(req.url).searchParams.get('q') ?? ''))
|
|
417
|
+
if (path === `/graph/${g.id}/page`) {
|
|
418
|
+
const p = g.dir == null ? null : pageOf(held, g.dir, new URL(req.url).searchParams.get('node') ?? '')
|
|
419
|
+
return p == null ? new Response('not a page of this wiki', { status: 404 }) : Response.json(p)
|
|
420
|
+
}
|
|
406
421
|
}
|
|
407
422
|
// the media API namespace — the studio route table, ONE prefix strip
|
|
408
423
|
if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
|