@krapwoo/ds-viewer 0.3.0 → 0.4.1
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/README.md +80 -0
- package/config/index.ts +4 -2
- package/dist/cli/dev.js +315 -15
- package/dist/cli/doctor.js +10 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/githubAction.js +8 -11
- package/dist/cli/init.js +3 -3
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +77 -18
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/migrateEntry.js +19 -0
- package/dist/cli/migrations/index.js +14 -0
- package/dist/cli/migrations/types.js +9 -0
- package/dist/cli/packageManager.js +40 -0
- package/dist/cli/packageVersion.js +24 -0
- package/dist/cli/semver.js +26 -0
- package/dist/cli/update.js +96 -0
- package/dist/cli/updateCheck.js +143 -0
- package/dist/cli/updatePlan.js +211 -0
- package/dist/cli/workspace.js +10 -5
- package/dist/native/catalog/catalogNavigation.js +129 -0
- package/dist/native/catalog/tokens.js +15 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/index.ts +3 -0
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +11 -0
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
|
|
5
|
+
import { daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, type UpdatePlanView, type UpdateStatusView } from './updatePanelState';
|
|
6
|
+
import type { UpdateNotice } from './types';
|
|
7
|
+
|
|
8
|
+
function isWeb(): boolean {
|
|
9
|
+
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Endpoint = { baseUrl: string; secret: string };
|
|
13
|
+
|
|
14
|
+
async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
|
|
15
|
+
return fetch(`${endpoint.baseUrl}${path}`, { ...init, headers: { ...init.headers, [DS_VIEWER_SECRET_HEADER]: endpoint.secret } });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Everything visually shared across phases: the versions row, the file list, a colored note box,
|
|
19
|
+
* a step list, and a dark log block — one definition each, reused by every `case` below. */
|
|
20
|
+
function VersionsHeader({ current, latest, breaking, releasedAt }: { current: string; latest: string; breaking: boolean; releasedAt?: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<View>
|
|
23
|
+
<View style={styles.versionsRow}>
|
|
24
|
+
<Text style={styles.versionsText}>{current}</Text>
|
|
25
|
+
<Text style={styles.arrow}>→</Text>
|
|
26
|
+
<Text style={styles.versionsText}>{latest}</Text>
|
|
27
|
+
<View style={[styles.pill, breaking && styles.pillMajor]}>
|
|
28
|
+
<Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{breaking ? 'Major' : 'Minor'}</Text>
|
|
29
|
+
</View>
|
|
30
|
+
</View>
|
|
31
|
+
<Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function FilesList({ files }: { files: UpdatePlanView['files'] }) {
|
|
37
|
+
return (
|
|
38
|
+
<View style={styles.block}>
|
|
39
|
+
<Text style={styles.h3}>Files that would change</Text>
|
|
40
|
+
<View style={styles.filesBox}>
|
|
41
|
+
{files.map((file, i) => (
|
|
42
|
+
<View key={file.path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
|
|
43
|
+
<Text style={styles.filePath}>{file.path}</Text>
|
|
44
|
+
<Text style={[styles.fileReason, file.dirty && styles.fileReasonDirty]}>{file.dirty ? 'uncommitted' : file.reason}</Text>
|
|
45
|
+
</View>
|
|
46
|
+
))}
|
|
47
|
+
</View>
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Same shape as `FilesList`, but with no right-hand reason column — the `success` phase's
|
|
53
|
+
* `status.files` carries plain paths, never a reason (Task 16 Step 2 table: a dedicated,
|
|
54
|
+
* smaller render here is simplest rather than over-generalizing `FilesList` for one caller). */
|
|
55
|
+
function PlainFilesList({ files }: { files: string[] }) {
|
|
56
|
+
return (
|
|
57
|
+
<View style={styles.filesBox}>
|
|
58
|
+
{files.map((path, i) => (
|
|
59
|
+
<View key={path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
|
|
60
|
+
<Text style={styles.filePath}>{path}</Text>
|
|
61
|
+
</View>
|
|
62
|
+
))}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NOTE_BOX_VARIANT = {
|
|
68
|
+
muted: { box: { backgroundColor: CATALOG_COLOR.surfaceMuted }, text: { color: CATALOG_COLOR.text } },
|
|
69
|
+
warn: { box: { backgroundColor: CATALOG_COLOR.warningSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.warningBorder }, text: { color: CATALOG_COLOR.warning } },
|
|
70
|
+
err: { box: { backgroundColor: CATALOG_COLOR.dangerSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.dangerBorder }, text: { color: CATALOG_COLOR.danger } },
|
|
71
|
+
ok: { box: { backgroundColor: CATALOG_COLOR.successSubtle }, text: { color: CATALOG_COLOR.success } },
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
function NoteBox({ variant, children }: { variant: keyof typeof NOTE_BOX_VARIANT; children: React.ReactNode }) {
|
|
75
|
+
const v = NOTE_BOX_VARIANT[variant];
|
|
76
|
+
return (
|
|
77
|
+
<View style={[styles.noteBox, v.box]}>
|
|
78
|
+
<Text style={[styles.noteBoxText, v.text]}>{children}</Text>
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `state` is a three-way `'done' | 'now' | 'todo'` (Important finding, Fable correction pass —
|
|
84
|
+
* the approved mockup's own `updating` state shows a step mid-flight as a distinct spinner-style
|
|
85
|
+
* icon, `ic now`, never collapsed into the same boolean `done`/not-`done` the `failure` phase's
|
|
86
|
+
* own two-item list still uses below). `failedStep` marks one step `fail` regardless of its own
|
|
87
|
+
* `state` — the failure phase's steps list is built fresh, inline, never reusing `status.steps`
|
|
88
|
+
* from a prior `updating` snapshot. */
|
|
89
|
+
function Steps({ steps, failedStep }: { steps: { label: string; state: 'done' | 'now' | 'todo' }[]; failedStep?: string }) {
|
|
90
|
+
return (
|
|
91
|
+
<View style={styles.steps}>
|
|
92
|
+
{steps.map((step) => {
|
|
93
|
+
const failed = failedStep !== undefined && step.label === failedStep;
|
|
94
|
+
const icon = failed ? 'fail' : step.state;
|
|
95
|
+
return (
|
|
96
|
+
<View key={step.label} style={styles.stepRow}>
|
|
97
|
+
<View
|
|
98
|
+
style={[
|
|
99
|
+
styles.stepIcon,
|
|
100
|
+
icon === 'done' && styles.stepIconDone,
|
|
101
|
+
icon === 'now' && styles.stepIconNow,
|
|
102
|
+
icon === 'todo' && styles.stepIconTodo,
|
|
103
|
+
icon === 'fail' && styles.stepIconFail,
|
|
104
|
+
]}
|
|
105
|
+
>
|
|
106
|
+
<Text style={styles.stepIconGlyph}>{icon === 'done' ? '✓' : icon === 'fail' ? '!' : ''}</Text>
|
|
107
|
+
</View>
|
|
108
|
+
<Text style={failed ? styles.stepLabelFail : icon === 'now' ? styles.stepLabelNow : styles.stepLabel}>
|
|
109
|
+
{/* Errata 10: the in-flight step reads like the mockup's "Applying 1 migration…". */}
|
|
110
|
+
{icon === 'now' && !step.label.endsWith('…') ? `${step.label}…` : step.label}
|
|
111
|
+
</Text>
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function LogBlock({ children }: { children: string }) {
|
|
120
|
+
// Real npm logs run to dozens of lines; cap the height so "Copy log" stays in view. The region
|
|
121
|
+
// is keyboard-focusable on web so the overflow can be scrolled without a pointer.
|
|
122
|
+
return (
|
|
123
|
+
<ScrollView
|
|
124
|
+
style={styles.logBox}
|
|
125
|
+
accessibilityLabel="Update log"
|
|
126
|
+
{...(Platform.OS === 'web' ? ({ tabIndex: 0 } as Record<string, unknown>) : {})}
|
|
127
|
+
>
|
|
128
|
+
<Text style={styles.logText}>{children}</Text>
|
|
129
|
+
</ScrollView>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** An inline, code-styled chip for a terminal command (the approved mockup's own `code.cmd`) —
|
|
134
|
+
* used wherever this panel's copy names a recovery command (Minor finding, Fable correction
|
|
135
|
+
* pass: the plan previously rendered every such command as plain muted text). */
|
|
136
|
+
function Code({ children }: { children: string }) {
|
|
137
|
+
return <Text style={styles.code}>{children}</Text>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The `success` phase's content, shared between the `update === null` path (Errata 1c — the
|
|
141
|
+
* normal case, since `update.json` becomes `null` once latest equals current) and the defensive
|
|
142
|
+
* `update` still non-null path. Reads `status.latest`/`status.files`/`status.doctorSummary`,
|
|
143
|
+
* never `update.latest`/`plan.files` — `plan` is gone by the time this renders (the page has
|
|
144
|
+
* reloaded onto the new version). */
|
|
145
|
+
function SuccessContent({ status }: { status: Extract<UpdateStatusView, { phase: 'success' }> }) {
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
<View style={styles.versionsRow}>
|
|
149
|
+
<Text style={styles.versionsText}>{status.latest}</Text>
|
|
150
|
+
<View style={[styles.pill, styles.pillSuccess]}>
|
|
151
|
+
<Text style={[styles.pillText, styles.pillTextSuccess]}>Updated</Text>
|
|
152
|
+
</View>
|
|
153
|
+
</View>
|
|
154
|
+
<NoteBox variant="ok">
|
|
155
|
+
<Text style={styles.noteBoxBold}>{`Updated to ${status.latest}.`}</Text>
|
|
156
|
+
{' Changes are not committed — review them in your editor.'}
|
|
157
|
+
</NoteBox>
|
|
158
|
+
<View style={styles.block}>
|
|
159
|
+
<Text style={styles.h3}>Changed</Text>
|
|
160
|
+
<PlainFilesList files={status.files} />
|
|
161
|
+
</View>
|
|
162
|
+
<View style={styles.block}>
|
|
163
|
+
<Text style={styles.h3}>Doctor</Text>
|
|
164
|
+
<Text style={styles.muted}>{status.doctorSummary}</Text>
|
|
165
|
+
</View>
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The `failure` phase's headline/body copy, keyed off which step failed — `runUpdate`/
|
|
171
|
+
* `performUpdate` each name their own distinct recovery command (Important finding, Fable
|
|
172
|
+
* correction pass: one hardcoded headline/body for every failure was wrong for two of the
|
|
173
|
+
* three). */
|
|
174
|
+
function failureCopy(failedStep: string, current: string): { headline: string; body: React.ReactNode } {
|
|
175
|
+
if (/install/i.test(failedStep)) {
|
|
176
|
+
return {
|
|
177
|
+
headline: 'The update stopped while installing.',
|
|
178
|
+
body: <>Your files weren’t migrated. Recover with <Code>npx ds-viewer update</Code> in your terminal.</>,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (/migrat/i.test(failedStep)) {
|
|
182
|
+
return {
|
|
183
|
+
headline: 'The update installed, but migrating failed.',
|
|
184
|
+
body: <>Recover with <Code>{`npx ds-viewer migrate --from ${current}`}</Code> in your terminal.</>,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
headline: 'The update installed and migrated, but doctor failed to run.',
|
|
189
|
+
body: <>Recover with <Code>npx ds-viewer doctor</Code> in your terminal.</>,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
|
|
195
|
+
* Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
|
|
196
|
+
* of the mockup's 10 states to render from those two pieces of data, polled (never pushed) at a
|
|
197
|
+
* plain interval while an update is in flight.
|
|
198
|
+
*
|
|
199
|
+
* Errata 1c: `update` is `UpdateNotice | null` — `update.json` becomes `null` once latest equals
|
|
200
|
+
* current, which is exactly what happens the moment a successful update's reload lands on the new
|
|
201
|
+
* version. When `update` is `null`, the plan is never fetched; the panel shows the success state
|
|
202
|
+
* read from `status` alone when one is in flight/just finished, else a plain up-to-date note.
|
|
203
|
+
*/
|
|
204
|
+
export function UpdatePanel({
|
|
205
|
+
update,
|
|
206
|
+
endpoint,
|
|
207
|
+
appName,
|
|
208
|
+
headingRef,
|
|
209
|
+
}: {
|
|
210
|
+
update: UpdateNotice | null;
|
|
211
|
+
endpoint?: Endpoint;
|
|
212
|
+
appName: string;
|
|
213
|
+
headingRef?: React.Ref<View>;
|
|
214
|
+
}) {
|
|
215
|
+
const [status, setStatus] = useState<UpdateStatusView>({ phase: 'idle' });
|
|
216
|
+
const [planResult, setPlanResult] = useState<UpdatePlanView | { error: string } | undefined>(undefined);
|
|
217
|
+
// Critical finding, Fable correction pass: while this is true, the endpoint's own `/update` POST
|
|
218
|
+
// is in flight (re-planning before it can even start) — **Update now** must disable itself for
|
|
219
|
+
// that whole window, not just once the server reports `'updating'`, or a second click fires a
|
|
220
|
+
// second `POST /update` the endpoint's own `starting` flag would otherwise have to refuse.
|
|
221
|
+
const [starting, setStarting] = useState(false);
|
|
222
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
223
|
+
|
|
224
|
+
const fetchStatus = useCallback(async () => {
|
|
225
|
+
if (!endpoint || !isWeb()) return;
|
|
226
|
+
try {
|
|
227
|
+
const res = await authedFetch(endpoint, '/update/status');
|
|
228
|
+
setStatus((await res.json()) as UpdateStatusView);
|
|
229
|
+
} catch {
|
|
230
|
+
// Errata 2a: the endpoint's own process exits mid-restart — a failed poll while an update
|
|
231
|
+
// was in flight or already restarting means "still restarting," not "idle again."
|
|
232
|
+
setStatus((prev) => (prev.phase === 'updating' || prev.phase === 'restarting' ? { phase: 'restarting' } : prev));
|
|
233
|
+
}
|
|
234
|
+
}, [endpoint]);
|
|
235
|
+
|
|
236
|
+
const fetchPlan = useCallback(async () => {
|
|
237
|
+
if (!endpoint || !isWeb()) return;
|
|
238
|
+
// Important finding, Fable correction pass: an unhandled rejection here (the endpoint
|
|
239
|
+
// unreachable, CORS-blocked, or mid-restart) previously left `planResult` `undefined` forever
|
|
240
|
+
// — the panel sat on "Checking…" with no way out. The same error shape `buildUpdatePlan`
|
|
241
|
+
// itself returns on failure drives the existing `offline` phase.
|
|
242
|
+
try {
|
|
243
|
+
const res = await authedFetch(endpoint, '/plan', { method: 'POST' });
|
|
244
|
+
setPlanResult((await res.json()) as UpdatePlanView | { error: string });
|
|
245
|
+
} catch {
|
|
246
|
+
setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
|
|
247
|
+
}
|
|
248
|
+
}, [endpoint]);
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
// Errata 1c: never call `fetchPlan` when there is no update to plan for.
|
|
252
|
+
fetchStatus().then(() => {
|
|
253
|
+
if (update) fetchPlan();
|
|
254
|
+
});
|
|
255
|
+
}, [fetchStatus, fetchPlan, update]);
|
|
256
|
+
|
|
257
|
+
const phase = resolvePanelPhase(status, planResult);
|
|
258
|
+
|
|
259
|
+
// Polls `/update/status` at a plain interval while an update is in flight — design §5 never
|
|
260
|
+
// asks for push/websocket progress, only "the panel shows progress."
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
clearInterval(pollRef.current);
|
|
263
|
+
if (phase === 'updating' || phase === 'restarting') pollRef.current = setInterval(fetchStatus, 700);
|
|
264
|
+
return () => clearInterval(pollRef.current);
|
|
265
|
+
}, [phase, fetchStatus]);
|
|
266
|
+
|
|
267
|
+
// Restart handoff step 4 (design §5): once the server reports "restarting", Metro itself (not
|
|
268
|
+
// this endpoint — the endpoint's own process is about to exit) is polled on the *page's own
|
|
269
|
+
// origin* until it answers again, then the page reloads onto the new version. Errata 2b: reload
|
|
270
|
+
// only once at least one probe has already failed, so the old Metro answering briefly (before
|
|
271
|
+
// it actually goes down) can't cause a reload loop back onto the old version.
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (phase !== 'restarting' || !isWeb()) return;
|
|
274
|
+
let hadFailure = false;
|
|
275
|
+
const interval = setInterval(() => {
|
|
276
|
+
fetch(window.location.origin, { method: 'HEAD' })
|
|
277
|
+
.then((res) => {
|
|
278
|
+
if (res.ok && hadFailure) {
|
|
279
|
+
clearInterval(interval);
|
|
280
|
+
window.location.reload();
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.catch(() => {
|
|
284
|
+
hadFailure = true; // still restarting — keep polling.
|
|
285
|
+
});
|
|
286
|
+
}, 500);
|
|
287
|
+
return () => clearInterval(interval);
|
|
288
|
+
}, [phase]);
|
|
289
|
+
|
|
290
|
+
const startUpdate = useCallback(async () => {
|
|
291
|
+
if (!endpoint) return;
|
|
292
|
+
setStarting(true);
|
|
293
|
+
try {
|
|
294
|
+
const res = await authedFetch(endpoint, '/update', { method: 'POST' });
|
|
295
|
+
// A non-202 (409 dirty/already-running, or 502 "couldn't prepare") means a file was dirtied
|
|
296
|
+
// — or the plan went stale — after this panel's own `ready` plan was fetched; re-fetching the
|
|
297
|
+
// plan surfaces the `dirty`/`offline` phase instead of silently staying on `ready` forever
|
|
298
|
+
// (Minor finding, Fable correction pass).
|
|
299
|
+
if (res.status !== 202) {
|
|
300
|
+
await fetchPlan();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await fetchStatus();
|
|
304
|
+
} catch {
|
|
305
|
+
// Important finding, Fable correction pass: same reasoning as `fetchPlan` above — a failed
|
|
306
|
+
// `POST /update` must not leave the panel stuck mid-click with no way forward.
|
|
307
|
+
setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
|
|
308
|
+
} finally {
|
|
309
|
+
setStarting(false);
|
|
310
|
+
}
|
|
311
|
+
}, [endpoint, fetchPlan, fetchStatus]);
|
|
312
|
+
|
|
313
|
+
const plan = planResult && !('error' in planResult) ? planResult : undefined;
|
|
314
|
+
|
|
315
|
+
// The breadcrumb and heading render for every phase (Important finding, Fable correction pass:
|
|
316
|
+
// the approved mockup's own `frame()` always renders `<crumb>{appName} / DS Viewer</crumb>` and
|
|
317
|
+
// the title "Update DS Viewer" above the 512px column — this plan previously rendered neither,
|
|
318
|
+
// which also left `check:catalog`'s own "every page has a heading" check with nothing to find
|
|
319
|
+
// once the footer link existed). `content` is computed per phase below, then wrapped once.
|
|
320
|
+
let content: React.ReactNode = null;
|
|
321
|
+
|
|
322
|
+
if (!update) {
|
|
323
|
+
// Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
|
|
324
|
+
// reflects a just-finished update read from the (surviving) endpoint across the reload.
|
|
325
|
+
content =
|
|
326
|
+
status.phase === 'success' ? (
|
|
327
|
+
<SuccessContent status={status} />
|
|
328
|
+
) : (
|
|
329
|
+
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
330
|
+
);
|
|
331
|
+
} else if (phase === 'ready' && plan) {
|
|
332
|
+
content = (
|
|
333
|
+
<>
|
|
334
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
335
|
+
{update.summary.length > 0 && (
|
|
336
|
+
<View style={styles.block}>
|
|
337
|
+
<Text style={styles.h3}>What’s new</Text>
|
|
338
|
+
{update.summary.map((line) => (
|
|
339
|
+
<Text key={line} style={styles.bullet}>{`• ${line}`}</Text>
|
|
340
|
+
))}
|
|
341
|
+
</View>
|
|
342
|
+
)}
|
|
343
|
+
<FilesList files={plan.files} />
|
|
344
|
+
{plan.kitFilesDiffering > 0 && (
|
|
345
|
+
<Text style={styles.muted}>
|
|
346
|
+
{`${plan.kitFilesDiffering} starter-kit file${plan.kitFilesDiffering === 1 ? '' : 's'} differ from ${update.latest}’s kit. ` +
|
|
347
|
+
'They’re yours and are never changed. Compare with npx ds-viewer kit diff <Component>.'}
|
|
348
|
+
</Text>
|
|
349
|
+
)}
|
|
350
|
+
{plan.outsideGitRepo && (
|
|
351
|
+
<Text style={styles.muted}>Not a git repository, so uncommitted changes couldn’t be checked.</Text>
|
|
352
|
+
)}
|
|
353
|
+
<View style={styles.actions}>
|
|
354
|
+
<Pressable
|
|
355
|
+
onPress={startUpdate}
|
|
356
|
+
disabled={starting}
|
|
357
|
+
accessibilityRole="button"
|
|
358
|
+
accessibilityState={{ disabled: starting }}
|
|
359
|
+
style={[styles.primaryButton, starting && styles.disabledButton]}
|
|
360
|
+
>
|
|
361
|
+
<Text style={[styles.primaryButtonText, starting && styles.disabledButtonText]}>Update now</Text>
|
|
362
|
+
</Pressable>
|
|
363
|
+
{starting ? <Text style={styles.muted}>Starting…</Text> : <Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>}
|
|
364
|
+
</View>
|
|
365
|
+
</>
|
|
366
|
+
);
|
|
367
|
+
} else if (phase === 'checking') {
|
|
368
|
+
content = (
|
|
369
|
+
<>
|
|
370
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
371
|
+
<NoteBox variant="muted">Checking what this update would change…</NoteBox>
|
|
372
|
+
{update.summary.length > 0 && (
|
|
373
|
+
<View style={styles.block}>
|
|
374
|
+
<Text style={styles.h3}>What’s new</Text>
|
|
375
|
+
{update.summary.map((line) => (
|
|
376
|
+
<Text key={line} style={styles.bullet}>{`• ${line}`}</Text>
|
|
377
|
+
))}
|
|
378
|
+
</View>
|
|
379
|
+
)}
|
|
380
|
+
</>
|
|
381
|
+
);
|
|
382
|
+
} else if (phase === 'dirty' && plan) {
|
|
383
|
+
content = (
|
|
384
|
+
<>
|
|
385
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
386
|
+
<NoteBox variant="warn">
|
|
387
|
+
<Text style={styles.noteBoxBold}>{`${plan.dirtyFiles.length} file${plan.dirtyFiles.length === 1 ? '' : 's'} ${plan.dirtyFiles.length === 1 ? 'has' : 'have'} uncommitted changes.`}</Text>
|
|
388
|
+
{' Commit or stash them, then check again.'}
|
|
389
|
+
</NoteBox>
|
|
390
|
+
<FilesList files={plan.files} />
|
|
391
|
+
<View style={styles.actions}>
|
|
392
|
+
<Pressable
|
|
393
|
+
disabled
|
|
394
|
+
accessibilityRole="button"
|
|
395
|
+
accessibilityState={{ disabled: true }}
|
|
396
|
+
style={[styles.primaryButton, styles.disabledButton]}
|
|
397
|
+
>
|
|
398
|
+
<Text style={[styles.primaryButtonText, styles.disabledButtonText]}>Update now</Text>
|
|
399
|
+
</Pressable>
|
|
400
|
+
<Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
|
|
401
|
+
<Text style={styles.secondaryButtonText}>Check again</Text>
|
|
402
|
+
</Pressable>
|
|
403
|
+
</View>
|
|
404
|
+
</>
|
|
405
|
+
);
|
|
406
|
+
} else if (phase === 'offline') {
|
|
407
|
+
content = isAlreadyUpToDateError(planResult!) ? (
|
|
408
|
+
<>
|
|
409
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
410
|
+
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
411
|
+
</>
|
|
412
|
+
) : (
|
|
413
|
+
<>
|
|
414
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
415
|
+
<NoteBox variant="err">
|
|
416
|
+
<Text style={styles.noteBoxBold}>Couldn’t prepare the update.</Text>
|
|
417
|
+
{' You may be offline, or npm didn’t respond. Nothing was changed.'}
|
|
418
|
+
</NoteBox>
|
|
419
|
+
<View style={styles.actions}>
|
|
420
|
+
<Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
|
|
421
|
+
<Text style={styles.secondaryButtonText}>Try again</Text>
|
|
422
|
+
</Pressable>
|
|
423
|
+
<Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>
|
|
424
|
+
</View>
|
|
425
|
+
</>
|
|
426
|
+
);
|
|
427
|
+
} else if (phase === 'updating' && status.phase === 'updating') {
|
|
428
|
+
content = (
|
|
429
|
+
<>
|
|
430
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
431
|
+
<Text style={[styles.h3, styles.block]}>Updating</Text>
|
|
432
|
+
<Steps steps={status.steps} />
|
|
433
|
+
<Text style={styles.muted}>Keep this tab open. The viewer restarts on its own when the update finishes.</Text>
|
|
434
|
+
</>
|
|
435
|
+
);
|
|
436
|
+
} else if (phase === 'restarting') {
|
|
437
|
+
content = (
|
|
438
|
+
<>
|
|
439
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
440
|
+
<NoteBox variant="muted">
|
|
441
|
+
<Text style={styles.noteBoxBold}>{`Restarting the viewer on ${update.latest}…`}</Text>
|
|
442
|
+
{' This page reloads by itself.'}
|
|
443
|
+
</NoteBox>
|
|
444
|
+
<Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
|
|
445
|
+
</>
|
|
446
|
+
);
|
|
447
|
+
} else if (phase === 'success' && status.phase === 'success') {
|
|
448
|
+
content = <SuccessContent status={status} />;
|
|
449
|
+
} else if (phase === 'failure' && status.phase === 'failure') {
|
|
450
|
+
const { headline, body } = failureCopy(status.failedStep, update.current);
|
|
451
|
+
const failedLabel = `${status.failedStep} failed`;
|
|
452
|
+
content = (
|
|
453
|
+
<>
|
|
454
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
455
|
+
<NoteBox variant="err">
|
|
456
|
+
<Text style={styles.noteBoxBold}>{headline}</Text>
|
|
457
|
+
{' '}
|
|
458
|
+
{body}
|
|
459
|
+
</NoteBox>
|
|
460
|
+
<Steps
|
|
461
|
+
steps={[
|
|
462
|
+
{ label: `Downloaded ${update.latest}`, state: 'done' },
|
|
463
|
+
{ label: failedLabel, state: 'todo' },
|
|
464
|
+
]}
|
|
465
|
+
failedStep={failedLabel}
|
|
466
|
+
/>
|
|
467
|
+
<LogBlock>{status.log}</LogBlock>
|
|
468
|
+
<View style={styles.actions}>
|
|
469
|
+
<Pressable
|
|
470
|
+
onPress={() => {
|
|
471
|
+
if (isWeb()) navigator.clipboard?.writeText(status.log);
|
|
472
|
+
}}
|
|
473
|
+
accessibilityRole="button"
|
|
474
|
+
style={styles.secondaryButton}
|
|
475
|
+
>
|
|
476
|
+
<Text style={styles.secondaryButtonText}>Copy log</Text>
|
|
477
|
+
</Pressable>
|
|
478
|
+
</View>
|
|
479
|
+
</>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<View style={styles.wrap}>
|
|
485
|
+
<Text style={styles.crumb}>{`${appName} / DS Viewer`}</Text>
|
|
486
|
+
{/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
|
|
487
|
+
* the same cast `SectionBlock.tsx`'s own heading target already uses. */}
|
|
488
|
+
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
489
|
+
<Text style={styles.title}>Update DS Viewer</Text>
|
|
490
|
+
</View>
|
|
491
|
+
{content}
|
|
492
|
+
</View>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const styles = StyleSheet.create({
|
|
497
|
+
wrap: { maxWidth: CATALOG_LAYOUT.updateContentMaxWidth },
|
|
498
|
+
crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
499
|
+
headingTarget: { marginTop: 4, marginBottom: 6 },
|
|
500
|
+
title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
501
|
+
code: {
|
|
502
|
+
fontFamily: 'Menlo, monospace',
|
|
503
|
+
fontSize: CATALOG_TYPE.sm,
|
|
504
|
+
backgroundColor: CATALOG_COLOR.surfaceMuted,
|
|
505
|
+
borderWidth: 1,
|
|
506
|
+
borderColor: CATALOG_COLOR.border,
|
|
507
|
+
borderRadius: 6,
|
|
508
|
+
paddingHorizontal: 6,
|
|
509
|
+
paddingVertical: 2,
|
|
510
|
+
},
|
|
511
|
+
versionsRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
512
|
+
versionsText: { fontSize: 22, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
513
|
+
arrow: { fontSize: 22, color: CATALOG_COLOR.textMuted },
|
|
514
|
+
pill: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, backgroundColor: CATALOG_COLOR.accentSubtle },
|
|
515
|
+
pillMajor: { backgroundColor: CATALOG_COLOR.warningSubtle },
|
|
516
|
+
pillSuccess: { backgroundColor: CATALOG_COLOR.successSubtle },
|
|
517
|
+
pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
|
|
518
|
+
pillTextMajor: { color: CATALOG_COLOR.warning },
|
|
519
|
+
pillTextSuccess: { color: CATALOG_COLOR.success },
|
|
520
|
+
muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
|
|
521
|
+
block: { marginTop: 18 },
|
|
522
|
+
h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
|
|
523
|
+
bullet: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, marginVertical: 2 },
|
|
524
|
+
filesBox: { borderWidth: 1, borderColor: CATALOG_COLOR.border, borderRadius: CATALOG_RADIUS.md },
|
|
525
|
+
fileRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, paddingHorizontal: 12 },
|
|
526
|
+
fileRowBorder: { borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
527
|
+
filePath: { fontFamily: 'Menlo, monospace', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.text },
|
|
528
|
+
fileReason: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
529
|
+
fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
|
|
530
|
+
actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
|
|
531
|
+
primaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, backgroundColor: CATALOG_COLOR.text, alignItems: 'center', justifyContent: 'center' },
|
|
532
|
+
primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: CATALOG_TYPE.md },
|
|
533
|
+
secondaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, alignItems: 'center', justifyContent: 'center' },
|
|
534
|
+
secondaryButtonText: { fontWeight: '700', fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
|
|
535
|
+
disabledButton: { backgroundColor: '#e6e6e6', borderColor: '#e6e6e6' },
|
|
536
|
+
disabledButtonText: { color: '#8a8a8a' },
|
|
537
|
+
noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
|
|
538
|
+
noteBoxText: { fontSize: CATALOG_TYPE.md },
|
|
539
|
+
noteBoxBold: { fontWeight: '700' },
|
|
540
|
+
steps: { marginTop: 12 },
|
|
541
|
+
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 6 },
|
|
542
|
+
stepIcon: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
|
543
|
+
stepIconDone: { backgroundColor: CATALOG_COLOR.successSubtle },
|
|
544
|
+
// The mockup's own `.ic.now` is a CSS-animated half-ring spinner (`border-right-color:
|
|
545
|
+
// transparent`); a static accent-colored ring reads clearly enough here without introducing
|
|
546
|
+
// this file's first animation for a step that is, in practice, on screen for a few seconds.
|
|
547
|
+
stepIconNow: { borderWidth: 2, borderColor: CATALOG_COLOR.accent },
|
|
548
|
+
stepIconTodo: { borderWidth: 2, borderColor: CATALOG_COLOR.borderStrong },
|
|
549
|
+
stepIconFail: { backgroundColor: CATALOG_COLOR.dangerSubtle },
|
|
550
|
+
stepIconGlyph: { fontSize: 12, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
551
|
+
stepLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
|
|
552
|
+
stepLabelNow: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
|
|
553
|
+
stepLabelFail: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
|
|
554
|
+
logBox: { backgroundColor: '#1d1d1f', borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 8, maxHeight: 240 },
|
|
555
|
+
logText: { color: '#e8e8e8', fontSize: 12, fontFamily: 'Menlo, monospace' },
|
|
556
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
|
|
4
4
|
* sidebar order, previous/next order, and fragment fallback can never drift apart.
|
|
5
5
|
*/
|
|
6
|
-
import type { NavGroup } from './types.ts';
|
|
6
|
+
import type { NavGroup, UpdateNotice } from './types.ts';
|
|
7
7
|
|
|
8
8
|
/** THE canonical within-group ordering of section ids. */
|
|
9
9
|
export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
|
|
@@ -75,3 +75,72 @@ export function idFromHash<TId extends string>(hash: string, order: readonly TId
|
|
|
75
75
|
const match = order.find((id) => id === decoded);
|
|
76
76
|
return match ?? order[0];
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Design §5: the update panel is its own catalog page at this fragment, "not listed in the
|
|
80
|
+
* sidebar groups" — it is therefore never a member of `order`, so `idFromHash` alone would treat
|
|
81
|
+
* it as an unknown fragment and fall back to the first real page. */
|
|
82
|
+
export const UPDATE_PAGE_ID = 'ds-viewer-update';
|
|
83
|
+
|
|
84
|
+
/** The request header both the local endpoint (Task 12, which checks it) and the panel's own
|
|
85
|
+
* fetch calls (Task 16, which send it) use for the per-`dev`-run secret (design §5 "Local
|
|
86
|
+
* endpoint safeguards"). Defined once, here, rather than as a literal string repeated in both the
|
|
87
|
+
* `cli/` and `native/catalog/` halves of the package, which may never import from each other in
|
|
88
|
+
* the other direction (`cli/` imports `native/catalog/`; `native/catalog/` imports nothing from
|
|
89
|
+
* `cli/`). */
|
|
90
|
+
export const DS_VIEWER_SECRET_HEADER = 'x-ds-viewer-secret';
|
|
91
|
+
|
|
92
|
+
/** A minimal `major.minor.patch` comparison, duplicated from `cli/semver.ts`'s own
|
|
93
|
+
* `compareVersions` rather than imported — `native/catalog/` is the published package root and
|
|
94
|
+
* never imports from `cli/` (the same reasoning `native/catalog/updatePanelState.ts`'s own
|
|
95
|
+
* `UpdatePlanView`/`UpdateStatusView`, Task 15, already established for duplicated types). Used
|
|
96
|
+
* only as a defense-in-depth guard (Critical finding, Fable correction pass) alongside
|
|
97
|
+
* `checkForUpdate`'s own newer-than-current check (Task 4) — not a general-purpose semver
|
|
98
|
+
* comparator, the same scope note `cli/semver.ts`'s own `Version` interface already carries.
|
|
99
|
+
* Errata 5: parses with a regex rather than `split('.').map(Number)` so a prerelease suffix on
|
|
100
|
+
* either string (e.g. `0.4.1-verify`) doesn't turn the patch component into `NaN`; a string that
|
|
101
|
+
* doesn't match at all is treated as not newer. */
|
|
102
|
+
function isNewerVersion(a: string, b: string): boolean {
|
|
103
|
+
const ma = a.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
104
|
+
const mb = b.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
105
|
+
if (!ma || !mb) return false;
|
|
106
|
+
for (let i = 1; i <= 3; i += 1) {
|
|
107
|
+
const na = Number(ma[i]);
|
|
108
|
+
const nb = Number(mb[i]);
|
|
109
|
+
if (na !== nb) return na > nb;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveActiveFromHash<TId extends string>(
|
|
115
|
+
hash: string,
|
|
116
|
+
order: readonly TId[],
|
|
117
|
+
): TId | typeof UPDATE_PAGE_ID | undefined {
|
|
118
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
119
|
+
let decoded = '';
|
|
120
|
+
try {
|
|
121
|
+
decoded = decodeURIComponent(raw);
|
|
122
|
+
} catch {
|
|
123
|
+
decoded = '';
|
|
124
|
+
}
|
|
125
|
+
if (decoded === UPDATE_PAGE_ID) return UPDATE_PAGE_ID;
|
|
126
|
+
return idFromHash(hash, order);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
|
|
130
|
+
* is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
|
|
131
|
+
* any value other than the *current* `update.latest` (including `null`, or an older dismissed
|
|
132
|
+
* major) still shows the banner, so dismissing 1.0.0 does not also suppress a later 2.0.0. */
|
|
133
|
+
export function shouldShowMajorBanner(update: UpdateNotice | null | undefined, dismissedVersion: string | null): boolean {
|
|
134
|
+
if (!update || !update.breaking) return false;
|
|
135
|
+
if (!isNewerVersion(update.latest, update.current)) return false;
|
|
136
|
+
return update.latest !== dismissedVersion;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The quiet footer line's exact copy (design §5: `"Update available · 0.5.0"`), or `undefined`
|
|
140
|
+
* when there is nothing to show. Independent of `shouldShowMajorBanner` — design: the footer line
|
|
141
|
+
* shows for *every* available update, minor or major, and stays even once the major banner is
|
|
142
|
+
* dismissed. */
|
|
143
|
+
export function footerLabel(update: UpdateNotice | null | undefined): string | undefined {
|
|
144
|
+
if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
|
|
145
|
+
return `Update available · ${update.latest}`;
|
|
146
|
+
}
|
package/native/catalog/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type { ComponentManifestEntry, ManifestExample } from './manifest';
|
|
|
34
34
|
export { defineCatalogPage, buildCatalogSections } from './pageApi';
|
|
35
35
|
export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
|
|
36
36
|
export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
|
|
37
|
+
export { UpdatePanel } from './UpdatePanel';
|
|
38
|
+
export { UPDATE_PAGE_ID } from './catalogNavigation';
|
|
39
|
+
export type { UpdateNotice } from './types';
|
|
37
40
|
export type { ListGroup, ListItem } from './comparison';
|
|
38
41
|
export {
|
|
39
42
|
CATALOG_TYPE,
|