rvlib-shipkit 1.1.1 → 1.3.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 +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +235 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// the machine, as a page on the home sees it: every repo's hub state and the
|
|
2
|
+
// active flags from the daemon's /api/home.json, the widgets from its
|
|
3
|
+
// /api/home/widgets.json, the global processes and pm's children from pm's
|
|
4
|
+
// /api/pm/status.json. A node of the page tree
|
|
5
|
+
// (home-st.ts): the root reads it for the list's dots, and says loudly when the
|
|
6
|
+
// daemon behind the page does not answer. A hub serving on its own has none
|
|
7
|
+
// of this (HomeSt.machine is null).
|
|
8
|
+
import { makeAutoObservable, observableRef, runInAction } from 'mobx'
|
|
9
|
+
import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
|
|
10
|
+
import type { WorkerView } from '../../home/hubs.ts'
|
|
11
|
+
import type { ResourceView } from '../../home/resources.ts'
|
|
12
|
+
import type { PmAnswer, Role } from '../../home/pm.ts'
|
|
13
|
+
import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
|
|
14
|
+
import { ClockSvc } from './clock-svc.ts'
|
|
15
|
+
import type { HomeSt } from './home-st.ts'
|
|
16
|
+
import type { HubDot } from './projects-svc.ts'
|
|
17
|
+
|
|
18
|
+
export const MACHINE_POLL_MS = 1_500
|
|
19
|
+
/** past the web's own wait on the daemon (serve-web.ts MACHINE_ANSWER_MS), so
|
|
20
|
+
* a hung daemon reads in its words, and a hung web still reads as down */
|
|
21
|
+
export const MACHINE_TIMEOUT_MS = 8_000
|
|
22
|
+
|
|
23
|
+
/** where this browser keeps the machine as it last read it, painted at once
|
|
24
|
+
* on the next load (the overview) while the daemon and pm are asked again */
|
|
25
|
+
export const MACHINE_KEY = 'hub:machine'
|
|
26
|
+
|
|
27
|
+
/** how long a copied pid says so */
|
|
28
|
+
export const COPIED_MS = 1_200
|
|
29
|
+
|
|
30
|
+
/** how open opens a global process's url, kept per browser */
|
|
31
|
+
export const OPEN_HOW_KEY = 'hub:open-how'
|
|
32
|
+
export type OpenHow = 'browser' | 'app'
|
|
33
|
+
|
|
34
|
+
function loadOpenHow(): OpenHow {
|
|
35
|
+
try {
|
|
36
|
+
return globalThis.localStorage?.getItem(OPEN_HOW_KEY) === 'app' ? 'app' : 'browser'
|
|
37
|
+
} catch {
|
|
38
|
+
return 'browser'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Kept = { data: HomeAnswer | null; pm: PmAnswer | null; widgets: WidgetsAnswer | null }
|
|
43
|
+
|
|
44
|
+
function loadKept(): Kept | null {
|
|
45
|
+
try {
|
|
46
|
+
const v = JSON.parse(globalThis.localStorage?.getItem(MACHINE_KEY) ?? 'null') as Kept | null
|
|
47
|
+
return v != null && typeof v === 'object' && (v.data == null || Array.isArray(v.data.workers)) ? v : null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** the poll failed because nothing behind the page answers, not on an error of an answer */
|
|
54
|
+
class DownError extends Error {}
|
|
55
|
+
|
|
56
|
+
export type MachineOpts = { reload?: () => void; timeoutMs?: number }
|
|
57
|
+
|
|
58
|
+
export class MachineSt {
|
|
59
|
+
data: HomeAnswer | null = null
|
|
60
|
+
/** pm's answer: the resources and the daemon and web states */
|
|
61
|
+
pm: PmAnswer | null = null
|
|
62
|
+
/** every house repo's widgets, as the daemon last rendered them */
|
|
63
|
+
widgets: WidgetsAnswer | null = null
|
|
64
|
+
/** why pm did not answer the last poll, or '' while it does */
|
|
65
|
+
pmDown = ''
|
|
66
|
+
/** the last action that failed */
|
|
67
|
+
error = ''
|
|
68
|
+
/** where open sends a global process's url: the default browser, or a
|
|
69
|
+
* shipkit app window */
|
|
70
|
+
openHow: OpenHow = loadOpenHow()
|
|
71
|
+
/** why the daemon did not answer the last poll, or '' while it does */
|
|
72
|
+
down = ''
|
|
73
|
+
/** the daemon answered once since the load: until then data, pm and
|
|
74
|
+
* widgets are what this browser kept, shown but never acted on */
|
|
75
|
+
fresh = false
|
|
76
|
+
/** the one second the process ages move on, ticking while the machine polls */
|
|
77
|
+
clock: ClockSvc
|
|
78
|
+
/** the pid a click just copied, which says so for COPIED_MS */
|
|
79
|
+
copiedPid: number | null = null
|
|
80
|
+
private copiedTimer: ReturnType<typeof setTimeout> | null = null
|
|
81
|
+
/** what was last kept, so an unchanged machine is not written again */
|
|
82
|
+
private kept = ''
|
|
83
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
84
|
+
private wasDown = false
|
|
85
|
+
private polling = false
|
|
86
|
+
private widgetsPolling = false
|
|
87
|
+
/** `error` holds a poll's error, which the next good poll clears */
|
|
88
|
+
private pollError = false
|
|
89
|
+
|
|
90
|
+
constructor(private st: HomeSt, private o: MachineOpts = {}) {
|
|
91
|
+
this.clock = new ClockSvc()
|
|
92
|
+
const k = loadKept()
|
|
93
|
+
if (k != null) {
|
|
94
|
+
this.data = k.data
|
|
95
|
+
this.pm = k.pm
|
|
96
|
+
this.widgets = k.widgets
|
|
97
|
+
}
|
|
98
|
+
makeAutoObservable<this, 'st' | 'timer' | 'copiedTimer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, clock: false, timer: false, copiedTimer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** the repo the page shows, null on the overview and the plan */
|
|
102
|
+
get repo(): string | null {
|
|
103
|
+
return this.st.page === 'repo' ? this.st.selected : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
worker(name: string | null): WorkerView | null {
|
|
107
|
+
return name == null ? null : this.data?.workers.find((w) => w.repo === name) ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get shown(): WorkerView | null {
|
|
111
|
+
return this.worker(this.repo)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** the processes table: shipkit's own first, then one group per repo in
|
|
115
|
+
* the project list's order, one row per process (process-rows.ts, what
|
|
116
|
+
* `shipkit global-processes` prints) */
|
|
117
|
+
get processGroups(): ProcessGroup[] {
|
|
118
|
+
return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
resource(id: string): ResourceView | null {
|
|
122
|
+
return this.pm?.resources.find((r) => r.id === id) ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dotOf(name: string): HubDot {
|
|
126
|
+
const s = this.worker(name)?.status
|
|
127
|
+
return s === 'starting' || s === 'crashed' ? s : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** the resources this repo uses that are not up, as one line */
|
|
131
|
+
warnOf(name: string): string | null {
|
|
132
|
+
const off = (this.worker(name)?.uses ?? []).filter((id) => this.resource(id)?.status !== 'up')
|
|
133
|
+
return off.length === 0 ? null : `needs ${off.join(', ')}, which ${off.length === 1 ? 'is' : 'are'} off`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
isActive(name: string): boolean {
|
|
137
|
+
return this.data?.active.includes(name) === true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
boot(): void {
|
|
141
|
+
void this.load().then(() => {
|
|
142
|
+
// a repo page asks its hub to load when it is down
|
|
143
|
+
if (this.repo != null && this.data != null) void this.hub(this.repo, 'ensure')
|
|
144
|
+
})
|
|
145
|
+
this.timer = setInterval(() => void this.load(), MACHINE_POLL_MS)
|
|
146
|
+
this.clock.start()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
stop(): void {
|
|
150
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
151
|
+
this.clock.stop()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** pm is polled beside the daemon: either one down leaves the other shown */
|
|
155
|
+
private async loadPm(): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetch('/api/pm/status.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
|
|
158
|
+
if (!r.ok) throw new Error(await r.text())
|
|
159
|
+
const j = (await r.json()) as PmAnswer
|
|
160
|
+
runInAction(() => {
|
|
161
|
+
this.pm = j
|
|
162
|
+
this.pmDown = ''
|
|
163
|
+
})
|
|
164
|
+
} catch (e) {
|
|
165
|
+
runInAction(() => {
|
|
166
|
+
this.pm = null
|
|
167
|
+
this.pmDown = (e as Error).message
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** a widget poll that fails keeps the last html shown: the daemon's own
|
|
173
|
+
* poll says why */
|
|
174
|
+
private async loadWidgets(): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
const r = await fetch('/api/home/widgets.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
|
|
177
|
+
if (!r.ok) return
|
|
178
|
+
const j = (await r.json()) as WidgetsAnswer
|
|
179
|
+
runInAction(() => { this.widgets = j })
|
|
180
|
+
} catch {
|
|
181
|
+
// the daemon poll beside it reports a daemon that does not answer
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async load(): Promise<void> {
|
|
186
|
+
// a stalled poll is not stacked on
|
|
187
|
+
if (this.polling) return
|
|
188
|
+
this.polling = true
|
|
189
|
+
const pm = this.loadPm()
|
|
190
|
+
// never held on: a widget that answers late only shows late
|
|
191
|
+
if (!this.widgetsPolling) {
|
|
192
|
+
this.widgetsPolling = true
|
|
193
|
+
void this.loadWidgets().finally(() => { this.widgetsPolling = false })
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
let r: Response
|
|
197
|
+
try {
|
|
198
|
+
r = await fetch('/api/home.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw new DownError(`the home does not answer: ${(e as Error).message}`)
|
|
201
|
+
}
|
|
202
|
+
if (r.headers.get('x-shipkit-daemon') === 'down') throw new DownError(await r.text())
|
|
203
|
+
if (!r.ok) throw new Error(`/api/home.json answered ${r.status}: ${await r.text()}`)
|
|
204
|
+
const j = (await r.json()) as HomeAnswer
|
|
205
|
+
const back = this.wasDown
|
|
206
|
+
const cleared = this.pollError
|
|
207
|
+
this.wasDown = false
|
|
208
|
+
this.pollError = false
|
|
209
|
+
runInAction(() => {
|
|
210
|
+
this.data = j
|
|
211
|
+
this.down = ''
|
|
212
|
+
this.fresh = true
|
|
213
|
+
if (cleared) this.error = ''
|
|
214
|
+
})
|
|
215
|
+
// the shown repo starts once its hub answers
|
|
216
|
+
this.st.wake()
|
|
217
|
+
// and every other one is read ahead behind it
|
|
218
|
+
void this.st.prefetch()
|
|
219
|
+
// what the page loaded while the daemon was away failed: start over
|
|
220
|
+
if (back) this.reload()
|
|
221
|
+
} catch (e) {
|
|
222
|
+
const msg = (e as Error).message
|
|
223
|
+
if (e instanceof DownError) {
|
|
224
|
+
this.wasDown = true
|
|
225
|
+
runInAction(() => { this.down = msg })
|
|
226
|
+
} else {
|
|
227
|
+
this.pollError = true
|
|
228
|
+
runInAction(() => {
|
|
229
|
+
this.down = ''
|
|
230
|
+
this.error = msg
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
await pm
|
|
235
|
+
this.polling = false
|
|
236
|
+
this.keep()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** the machine as read now, kept for the next load. Only a change is written */
|
|
241
|
+
private keep(): void {
|
|
242
|
+
if (!this.fresh) return
|
|
243
|
+
try {
|
|
244
|
+
// the log tails change on every poll and only show on a hover: not kept
|
|
245
|
+
const pm = this.pm == null ? null : { ...this.pm, children: this.pm.children.map((c) => ({ ...c, tail: [] })), resources: this.pm.resources.map((r) => ({ ...r, tail: [] })) }
|
|
246
|
+
const s = JSON.stringify({ data: this.data, pm, widgets: this.widgets } satisfies Kept)
|
|
247
|
+
if (s === this.kept) return
|
|
248
|
+
globalThis.localStorage?.setItem(MACHINE_KEY, s)
|
|
249
|
+
this.kept = s
|
|
250
|
+
} catch { /* private window or blocked site data: the next load waits on the daemon */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private reload(): void {
|
|
254
|
+
if (this.o.reload != null) this.o.reload()
|
|
255
|
+
else window.location.reload()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async post(path: string, body: unknown): Promise<boolean> {
|
|
259
|
+
try {
|
|
260
|
+
const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
261
|
+
if (!r.ok) throw new Error(await r.text())
|
|
262
|
+
this.pollError = false
|
|
263
|
+
runInAction(() => { this.error = '' })
|
|
264
|
+
void this.load()
|
|
265
|
+
return true
|
|
266
|
+
} catch (e) {
|
|
267
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
|
|
273
|
+
return this.post('/api/home/hub', { name, act })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
setActive(name: string, on: boolean): Promise<boolean> {
|
|
277
|
+
return this.post('/api/home/active', { name, on })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** a global process: start, stop, restart, take over the copy started
|
|
281
|
+
* elsewhere, open its url in the default browser or as an app window
|
|
282
|
+
* (started first when it is down), or run one of its actions */
|
|
283
|
+
processAct(id: string, act: 'start' | 'stop' | 'restart' | 'takeover' | 'open' | 'app' | 'action', action?: string): Promise<boolean> {
|
|
284
|
+
return this.post('/api/pm/resource', { id, act, action })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** the url of a global process, the way this browser picked (started
|
|
288
|
+
* first when it is down) */
|
|
289
|
+
openProcess(id: string): Promise<boolean> {
|
|
290
|
+
return this.processAct(id, this.openHow === 'app' ? 'app' : 'open')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** a process's pid to the clipboard, and the "copied" flash on it; a
|
|
294
|
+
* refusal (plain http, an unfocused page) is said in the strip */
|
|
295
|
+
async copyPid(pid: number): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
|
|
298
|
+
await navigator.clipboard.writeText(String(pid))
|
|
299
|
+
} catch (e) {
|
|
300
|
+
runInAction(() => { this.error = `could not copy pid ${pid}: ${(e as Error).message}` })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
runInAction(() => { this.copiedPid = pid })
|
|
304
|
+
if (this.copiedTimer != null) clearTimeout(this.copiedTimer)
|
|
305
|
+
this.copiedTimer = setTimeout(() => runInAction(() => { this.copiedPid = null }), COPIED_MS)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
setOpenHow(how: OpenHow): void {
|
|
309
|
+
this.openHow = how
|
|
310
|
+
try {
|
|
311
|
+
globalThis.localStorage?.setItem(OPEN_HOW_KEY, how)
|
|
312
|
+
} catch { /* private window or blocked site data: the pick lasts the tab */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** restart the daemon or the web through pm */
|
|
316
|
+
restartChild(id: Role): Promise<boolean> {
|
|
317
|
+
return this.post('/api/pm/child', { id, act: 'restart' })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
modernize(name: string): Promise<boolean> {
|
|
321
|
+
return this.post('/api/home/modernize', { name })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** one more window on this page. In a gui window (a Chrome app) a popup
|
|
325
|
+
* opens as its own app window */
|
|
326
|
+
newWindow(): void {
|
|
327
|
+
window.open(`${window.location.origin}${window.location.pathname}`, '_blank', 'popup,width=1400,height=900')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
openInCode(name: string): void {
|
|
331
|
+
void this.st.projects.open(name)
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/serve/hub-app/md.ts
CHANGED
|
@@ -8,13 +8,15 @@ import sanitizeHtml from 'sanitize-html'
|
|
|
8
8
|
|
|
9
9
|
marked.use({ gfm: true, breaks: false, async: false })
|
|
10
10
|
|
|
11
|
+
const CARD_TAGS = [
|
|
12
|
+
'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
|
|
13
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
14
|
+
'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
|
|
15
|
+
]
|
|
16
|
+
|
|
11
17
|
export const renderMd = (src: string): string =>
|
|
12
18
|
sanitizeHtml(marked.parse(src) as string, {
|
|
13
|
-
allowedTags:
|
|
14
|
-
'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
|
|
15
|
-
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
16
|
-
'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
|
|
17
|
-
],
|
|
19
|
+
allowedTags: CARD_TAGS,
|
|
18
20
|
allowedAttributes: { a: ['href', 'target', 'rel'] },
|
|
19
21
|
allowedSchemes: ['http', 'https'],
|
|
20
22
|
allowProtocolRelative: false,
|
|
@@ -37,3 +39,34 @@ export const renderMdInline = (src: string): string =>
|
|
|
37
39
|
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
38
40
|
},
|
|
39
41
|
})
|
|
42
|
+
|
|
43
|
+
/** where a README image loads from: an http(s) url as written, a path
|
|
44
|
+
* relative to the repo root through `fileUrl`, anything else (data:, file:,
|
|
45
|
+
* `//host`) nowhere */
|
|
46
|
+
export const readmeImageSrc = (src: string, fileUrl: (rel: string) => string): string | null => {
|
|
47
|
+
const s = src.trim()
|
|
48
|
+
if (/^https?:\/\//i.test(s)) return s
|
|
49
|
+
if (s === '' || s.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(s)) return null
|
|
50
|
+
const rel = s.replace(/[?#].*$/, '').replace(/^(\.\/|\/)+/, '')
|
|
51
|
+
return rel === '' ? null : fileUrl(rel.split('/').map(encodeURIComponent).join('/'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** a whole README: the card allowlist plus images and the few tags READMEs
|
|
55
|
+
* lean on. A relative image is rewritten to the repo's file route, an image
|
|
56
|
+
* with no loadable source is dropped */
|
|
57
|
+
export const renderReadme = (src: string, fileUrl: (rel: string) => string): string =>
|
|
58
|
+
sanitizeHtml(marked.parse(src) as string, {
|
|
59
|
+
allowedTags: [...CARD_TAGS, 'h5', 'h6', 'img', 'details', 'summary', 'kbd', 'sup', 'sub', 'del'],
|
|
60
|
+
allowedAttributes: { a: ['href', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height'] },
|
|
61
|
+
allowedSchemes: ['http', 'https'],
|
|
62
|
+
allowProtocolRelative: false,
|
|
63
|
+
transformTags: {
|
|
64
|
+
a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
|
|
65
|
+
img: (tagName, attribs) => {
|
|
66
|
+
const { src: raw, ...rest } = attribs
|
|
67
|
+
const src = readmeImageSrc(raw ?? '', fileUrl)
|
|
68
|
+
return { tagName, attribs: src == null ? rest : { ...rest, src } }
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
|
|
72
|
+
})
|
|
@@ -4,27 +4,15 @@
|
|
|
4
4
|
// structure is read-only (kit.ts is code-reviewed); this edits WORKSPACE
|
|
5
5
|
// state: picks, alts, prompt overlays, meta sidecars, geometry rects.
|
|
6
6
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
-
import type {
|
|
7
|
+
import type { RepoSt } from './repo-st.ts'
|
|
8
8
|
import type { AssetDetail, CatRow, Rect, SideAsset } from './hub-types.ts'
|
|
9
9
|
|
|
10
|
-
const MEDIA = '/media'
|
|
11
10
|
// thumbs change in place when a pick changes → ~8s cache buckets; roll
|
|
12
11
|
// files are immutable (one name = one file forever), so their URLs carry
|
|
13
12
|
// no bust — an <audio> src that never changes never restarts playback
|
|
14
13
|
const V = (): number => Date.now() >> 13
|
|
15
|
-
export const rollUrl = (id: string, name: string): string => `${MEDIA}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
|
|
16
|
-
export const thumbUrl = (id: string): string => `${MEDIA}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
|
|
17
14
|
export const isAud = (n: string): boolean => ['mp3', 'wav', 'ogg', 'm4a'].includes(String(n ?? '').split('.').pop() ?? '')
|
|
18
15
|
|
|
19
|
-
const j = async (u: string): Promise<any> => {
|
|
20
|
-
const r = await fetch(MEDIA + u)
|
|
21
|
-
if (!r.ok) throw new Error(`${u}: ${r.status}`)
|
|
22
|
-
return r.json()
|
|
23
|
-
}
|
|
24
|
-
const post = async (u: string, b?: unknown): Promise<any> => {
|
|
25
|
-
const r = await fetch(MEDIA + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
|
|
26
|
-
return r.json()
|
|
27
|
-
}
|
|
28
16
|
|
|
29
17
|
export class MediaSvc {
|
|
30
18
|
assets: SideAsset[] = []
|
|
@@ -80,7 +68,7 @@ export class MediaSvc {
|
|
|
80
68
|
/** stage overlay root (the image box) — registered by AssetStage, DOM only */
|
|
81
69
|
overlayHost: HTMLElement | null = null
|
|
82
70
|
|
|
83
|
-
constructor(private st:
|
|
71
|
+
constructor(private st: RepoSt) {
|
|
84
72
|
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'overlayHost'>(
|
|
85
73
|
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, overlayHost: false })
|
|
86
74
|
if (typeof localStorage !== 'undefined') {
|
|
@@ -102,15 +90,41 @@ export class MediaSvc {
|
|
|
102
90
|
}
|
|
103
91
|
|
|
104
92
|
start(): void {
|
|
93
|
+
if (this.hbTimer != null || this.dead != null) return
|
|
105
94
|
void this.heartbeat()
|
|
106
95
|
this.hbTimer = setInterval(() => void this.heartbeat(), 1500)
|
|
107
96
|
}
|
|
108
|
-
|
|
97
|
+
/** polling only: a pending prompt or rect save still lands */
|
|
98
|
+
stop(): void {
|
|
109
99
|
if (this.hbTimer != null) clearInterval(this.hbTimer)
|
|
100
|
+
this.hbTimer = null
|
|
101
|
+
}
|
|
102
|
+
dispose(): void {
|
|
103
|
+
this.stop()
|
|
110
104
|
if (this.rectTimer != null) clearTimeout(this.rectTimer)
|
|
111
105
|
if (this.promptTimer != null) clearTimeout(this.promptTimer)
|
|
112
106
|
}
|
|
113
107
|
|
|
108
|
+
/** the media API, under this repo's base */
|
|
109
|
+
private get api(): string {
|
|
110
|
+
return this.st.url('/media')
|
|
111
|
+
}
|
|
112
|
+
rollUrl(id: string, name: string): string {
|
|
113
|
+
return `${this.api}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
|
|
114
|
+
}
|
|
115
|
+
thumbUrl(id: string): string {
|
|
116
|
+
return `${this.api}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
|
|
117
|
+
}
|
|
118
|
+
private async j(u: string): Promise<any> {
|
|
119
|
+
const r = await fetch(this.api + u)
|
|
120
|
+
if (!r.ok) throw new Error(`${u}: ${r.status}`)
|
|
121
|
+
return r.json()
|
|
122
|
+
}
|
|
123
|
+
private async post(u: string, b?: unknown): Promise<any> {
|
|
124
|
+
const r = await fetch(this.api + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
|
|
125
|
+
return r.json()
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
get curId(): string | null {
|
|
115
129
|
return this.st.sel?.t === 'asset' ? this.st.sel.id : null
|
|
116
130
|
}
|
|
@@ -145,7 +159,7 @@ export class MediaSvc {
|
|
|
145
159
|
if (this.dead != null) return
|
|
146
160
|
let s: any
|
|
147
161
|
try {
|
|
148
|
-
const r = await fetch(
|
|
162
|
+
const r = await fetch(this.api + '/api/state')
|
|
149
163
|
if (r.status === 404) {
|
|
150
164
|
// the mount failed at boot — it will not come back without a
|
|
151
165
|
// restart; say so once, loudly, and stop wasting the wire
|
|
@@ -174,6 +188,7 @@ export class MediaSvc {
|
|
|
174
188
|
})
|
|
175
189
|
if (this.curId != null) await this.refreshRolls()
|
|
176
190
|
if (this.catMode != null) await this.fillCat()
|
|
191
|
+
this.st.cols.mediaListed()
|
|
177
192
|
this.st.afterFirstBeat()
|
|
178
193
|
}
|
|
179
194
|
|
|
@@ -184,7 +199,7 @@ export class MediaSvc {
|
|
|
184
199
|
this.statusLine = ''
|
|
185
200
|
const a = this.assets.find((x) => x.id === id)
|
|
186
201
|
if (a != null) this.openCats.add(a.cat)
|
|
187
|
-
const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
202
|
+
const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
188
203
|
if (this.curId !== id) return // selection changed mid-fetch — never clobber
|
|
189
204
|
runInAction(() => {
|
|
190
205
|
this.detail = r
|
|
@@ -206,7 +221,7 @@ export class MediaSvc {
|
|
|
206
221
|
async refreshRolls(): Promise<void> {
|
|
207
222
|
const id = this.curId
|
|
208
223
|
if (id == null) return
|
|
209
|
-
const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
224
|
+
const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
210
225
|
if (this.curId !== id) return
|
|
211
226
|
runInAction(() => {
|
|
212
227
|
if (this.detail == null) this.detail = r
|
|
@@ -223,23 +238,23 @@ export class MediaSvc {
|
|
|
223
238
|
|
|
224
239
|
async generate(): Promise<void> {
|
|
225
240
|
if (this.curId == null) return
|
|
226
|
-
await post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
|
|
241
|
+
await this.post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
|
|
227
242
|
void this.heartbeat()
|
|
228
243
|
}
|
|
229
244
|
async genCat(cat: string, count: number): Promise<void> {
|
|
230
|
-
const r = await post('/api/generate-cat', { cat, count })
|
|
245
|
+
const r = await this.post('/api/generate-cat', { cat, count })
|
|
231
246
|
if (r?.error != null) runInAction(() => { this.statusLine = r.error })
|
|
232
247
|
void this.heartbeat()
|
|
233
248
|
}
|
|
234
249
|
clearQueue(): void {
|
|
235
|
-
void post('/api/queue/clear')
|
|
250
|
+
void this.post('/api/queue/clear')
|
|
236
251
|
}
|
|
237
252
|
stopCurrent(): void {
|
|
238
|
-
void post('/api/interrupt')
|
|
253
|
+
void this.post('/api/interrupt')
|
|
239
254
|
}
|
|
240
255
|
|
|
241
256
|
async pick(name: string): Promise<void> {
|
|
242
|
-
const res = await post('/api/pick', { id: this.curId, name })
|
|
257
|
+
const res = await this.post('/api/pick', { id: this.curId, name })
|
|
243
258
|
runInAction(() => {
|
|
244
259
|
this.statusLine = res.error != null ? `ship FAILED: ${res.error}` : res.shipped != null ? `shipped → ${res.shipped}` : 'picked (pick.json)'
|
|
245
260
|
this.active = name
|
|
@@ -247,11 +262,11 @@ export class MediaSvc {
|
|
|
247
262
|
await this.refreshRolls()
|
|
248
263
|
}
|
|
249
264
|
async alt(name: string): Promise<void> {
|
|
250
|
-
await post('/api/alt', { id: this.curId, name })
|
|
265
|
+
await this.post('/api/alt', { id: this.curId, name })
|
|
251
266
|
await this.refreshRolls()
|
|
252
267
|
}
|
|
253
268
|
async discard(name: string): Promise<void> {
|
|
254
|
-
await post('/api/discard', { id: this.curId, name })
|
|
269
|
+
await this.post('/api/discard', { id: this.curId, name })
|
|
255
270
|
runInAction(() => {
|
|
256
271
|
if (this.active === name) this.active = null
|
|
257
272
|
// the ✕ sits on the hovered tile and its unmount fires no
|
|
@@ -267,7 +282,7 @@ export class MediaSvc {
|
|
|
267
282
|
const fd = new FormData()
|
|
268
283
|
fd.append('id', this.curId)
|
|
269
284
|
fd.append('file', f)
|
|
270
|
-
const r = await fetch(`${
|
|
285
|
+
const r = await fetch(`${this.api}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
|
|
271
286
|
if (r.error != null) runInAction(() => { this.statusLine = `upload FAIL: ${r.error}` })
|
|
272
287
|
}
|
|
273
288
|
await this.refreshRolls()
|
|
@@ -290,7 +305,7 @@ export class MediaSvc {
|
|
|
290
305
|
const text = this.prompt
|
|
291
306
|
if (text === this.savedPrompt) return // unchanged — not a save
|
|
292
307
|
void (async () => {
|
|
293
|
-
const r = await post('/api/prompt', { id, prompt: text })
|
|
308
|
+
const r = await this.post('/api/prompt', { id, prompt: text })
|
|
294
309
|
runInAction(() => {
|
|
295
310
|
if (this.curId !== id) return // selection moved while the POST flew — a late response must not corrupt the new asset's baseline
|
|
296
311
|
if (r.error != null) {
|
|
@@ -330,7 +345,7 @@ export class MediaSvc {
|
|
|
330
345
|
}
|
|
331
346
|
const id = this.curId
|
|
332
347
|
const name = this.active
|
|
333
|
-
const r = await j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
|
|
348
|
+
const r = await this.j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
|
|
334
349
|
if (this.curId !== id || this.active !== name) return // roll switched mid-fetch
|
|
335
350
|
runInAction(() => {
|
|
336
351
|
this.rect = r?.rect ?? null
|
|
@@ -349,7 +364,7 @@ export class MediaSvc {
|
|
|
349
364
|
if (this.rectTimer != null) clearTimeout(this.rectTimer)
|
|
350
365
|
this.rectTimer = setTimeout(() => {
|
|
351
366
|
void (async () => {
|
|
352
|
-
const r = await post('/api/rect', { id, name, rect })
|
|
367
|
+
const r = await this.post('/api/rect', { id, name, rect })
|
|
353
368
|
runInAction(() => {
|
|
354
369
|
if (this.curId !== id || this.active !== name) return // selection moved on — never adopt a stale rect
|
|
355
370
|
if (r.rect != null) this.rect = r.rect
|
|
@@ -360,7 +375,7 @@ export class MediaSvc {
|
|
|
360
375
|
}
|
|
361
376
|
async legsDefault(): Promise<void> {
|
|
362
377
|
if (this.curId == null || this.active == null) return
|
|
363
|
-
const r = await post('/api/legs-default', { id: this.curId, name: this.active })
|
|
378
|
+
const r = await this.post('/api/legs-default', { id: this.curId, name: this.active })
|
|
364
379
|
if (r.rect != null) runInAction(() => { this.rect = r.rect })
|
|
365
380
|
}
|
|
366
381
|
removeLegs(): void {
|
|
@@ -374,7 +389,7 @@ export class MediaSvc {
|
|
|
374
389
|
async fillCat(): Promise<void> {
|
|
375
390
|
const mode = this.catMode
|
|
376
391
|
if (mode == null) return
|
|
377
|
-
const r = await j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
|
|
392
|
+
const r = await this.j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
|
|
378
393
|
if (this.catMode !== mode) return // navigated away mid-fetch
|
|
379
394
|
runInAction(() => { this.catRows = r.rows ?? [] })
|
|
380
395
|
}
|
|
@@ -384,10 +399,10 @@ export class MediaSvc {
|
|
|
384
399
|
const nv = Math.max(0.1, v || row.rect.size)
|
|
385
400
|
if (nv === row.rect.size) return // blur without a change is not a save
|
|
386
401
|
row.rect.size = nv
|
|
387
|
-
await post('/api/rect', { id, name, rect: row.rect })
|
|
402
|
+
await this.post('/api/rect', { id, name, rect: row.rect })
|
|
388
403
|
}
|
|
389
404
|
async toggleFlip(id: string, name: string): Promise<void> {
|
|
390
|
-
await post('/api/flip', { id, name })
|
|
405
|
+
await this.post('/api/flip', { id, name })
|
|
391
406
|
await this.fillCat()
|
|
392
407
|
}
|
|
393
408
|
async applyAllSize(): Promise<void> {
|
|
@@ -397,7 +412,7 @@ export class MediaSvc {
|
|
|
397
412
|
// one action for all mutations (post-await writes would land outside
|
|
398
413
|
// it — mobx strict mode), then the POSTs
|
|
399
414
|
runInAction(() => { for (const row of rows) row.rect!.size = v })
|
|
400
|
-
for (const row of rows) await post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
|
|
415
|
+
for (const row of rows) await this.post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
|
|
401
416
|
runInAction(() => { this.allMsg = 'applied' })
|
|
402
417
|
setTimeout(() => runInAction(() => { this.allMsg = '' }), 1200)
|
|
403
418
|
await this.fillCat()
|
|
@@ -433,12 +448,16 @@ export class MediaSvc {
|
|
|
433
448
|
if (kind != null && typeof localStorage !== 'undefined') localStorage.setItem(`hub:tools:${kind}`, [...this.tools].join(','))
|
|
434
449
|
this.syncHash()
|
|
435
450
|
}
|
|
436
|
-
/** the hash carries the working state: #<assetId>?tools=a,b — shareable,
|
|
437
|
-
|
|
438
|
-
|
|
451
|
+
/** the hash carries the working state: #<assetId>?tools=a,b — shareable,
|
|
452
|
+
* restored on load. null while no asset is open */
|
|
453
|
+
get hash(): string | null {
|
|
454
|
+
if (this.curId == null) return null
|
|
439
455
|
const t = [...this.tools].join(',')
|
|
440
|
-
|
|
441
|
-
|
|
456
|
+
return `${this.curId}${t === '' ? '' : `?tools=${t}`}`
|
|
457
|
+
}
|
|
458
|
+
private syncHash(): void {
|
|
459
|
+
const h = this.hash
|
|
460
|
+
if (h != null) this.st.replaceHash(h)
|
|
442
461
|
}
|
|
443
462
|
/** tabs = kit-declared presets first, then autos for tools no tab covers.
|
|
444
463
|
* a tab swaps the enabled tool SET — the canvas underneath never moves */
|
|
@@ -493,12 +512,12 @@ export class MediaSvc {
|
|
|
493
512
|
}
|
|
494
513
|
async loadMeta(): Promise<void> {
|
|
495
514
|
this.jsonMsg = ''
|
|
496
|
-
const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
|
|
515
|
+
const r = this.curId != null && this.active != null ? await this.post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
|
|
497
516
|
runInAction(() => { this.jsonText = r.text ?? '' })
|
|
498
517
|
}
|
|
499
518
|
async saveMeta(): Promise<void> {
|
|
500
519
|
if (this.curId == null || this.active == null) return
|
|
501
|
-
const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
|
|
520
|
+
const r = await this.post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
|
|
502
521
|
runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
|
|
503
522
|
}
|
|
504
523
|
/** frozen per mount: a roll/asset switch remounts with a fresh api */
|
|
@@ -513,14 +532,14 @@ export class MediaSvc {
|
|
|
513
532
|
/** the PICKED roll's name (null before any pick) — editors that
|
|
514
533
|
* target shipped art can warn when the shown roll differs */
|
|
515
534
|
picked: this.detail?.rolls.find((x) => x.picked)?.name ?? null,
|
|
516
|
-
imgUrl: rollUrl(id, name),
|
|
535
|
+
imgUrl: this.rollUrl(id, name),
|
|
517
536
|
getMeta: async () => {
|
|
518
|
-
const r = await post('/api/meta', { id, name })
|
|
537
|
+
const r = await this.post('/api/meta', { id, name })
|
|
519
538
|
if (r.error != null) throw new Error(r.error)
|
|
520
539
|
return r.text ? JSON.parse(r.text) : null
|
|
521
540
|
},
|
|
522
541
|
saveMeta: async (data: unknown) => {
|
|
523
|
-
const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
|
|
542
|
+
const r = await this.post('/api/meta', { id, name, text: JSON.stringify(data) })
|
|
524
543
|
if (r.error != null) throw new Error(r.error)
|
|
525
544
|
},
|
|
526
545
|
// an element covering the DISPLAYED roll image, so an editor can
|