@krapwoo/ds-viewer 0.4.8 → 0.4.9
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/dist/cli/dev.js +66 -37
- package/dist/cli/endpoint.js +33 -0
- package/native/catalog/UpdatePanel.tsx +114 -103
- package/native/catalog/updatePanelState.ts +68 -1
- package/package.json +1 -1
package/dist/cli/dev.js
CHANGED
|
@@ -106,6 +106,7 @@ function packageManagerLabel(pm) {
|
|
|
106
106
|
* (the endpoint, Task 12, refuses `POST /update` itself); this function only ever runs a plan it
|
|
107
107
|
* has already been told is safe to apply. */
|
|
108
108
|
export async function performUpdate(config, currentVersion, plan, deps) {
|
|
109
|
+
const startAt = deps.from === 'doctor' ? 3 : deps.from === 'migrate' ? 2 : 1;
|
|
109
110
|
const migrationCount = plan.files.filter((f) => f.reason.startsWith('migration:')).length;
|
|
110
111
|
const pm = detectPackageManager(config.projectRoot);
|
|
111
112
|
// Names the detected package manager and the real migration count (Important finding, Fable
|
|
@@ -133,30 +134,34 @@ export async function performUpdate(config, currentVersion, plan, deps) {
|
|
|
133
134
|
state: i < completedCount ? 'done' : i === completedCount ? 'now' : 'todo',
|
|
134
135
|
})),
|
|
135
136
|
});
|
|
136
|
-
report(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
137
|
+
report(startAt);
|
|
138
|
+
if (startAt <= 1) {
|
|
139
|
+
const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
|
|
140
|
+
try {
|
|
141
|
+
await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label, step: 'install' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
report(2);
|
|
148
148
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
if (startAt <= 2) {
|
|
150
|
+
try {
|
|
151
|
+
await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label, step: 'migrate' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
report(3);
|
|
152
158
|
}
|
|
153
|
-
report(3);
|
|
154
159
|
let doctorOutput;
|
|
155
160
|
try {
|
|
156
161
|
doctorOutput = await deps.execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
|
|
157
162
|
}
|
|
158
163
|
catch (error) {
|
|
159
|
-
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label });
|
|
164
|
+
deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label, step: 'doctor' });
|
|
160
165
|
return;
|
|
161
166
|
}
|
|
162
167
|
report(4);
|
|
@@ -331,32 +336,56 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
331
336
|
let child;
|
|
332
337
|
const secret = randomBytes(24).toString('hex');
|
|
333
338
|
const allowedOrigin = `http://localhost:${port}`;
|
|
339
|
+
// The plan the last update ran with, so a failed migrate or doctor step can be resumed.
|
|
340
|
+
let lastPlan;
|
|
341
|
+
const runUpdate = (plan, from) => {
|
|
342
|
+
lastPlan = plan;
|
|
343
|
+
watchersPaused = true;
|
|
344
|
+
void performUpdate(config, ownVersion, plan, {
|
|
345
|
+
execImpl: defaultExecImpl,
|
|
346
|
+
from,
|
|
347
|
+
onStatus: (status) => {
|
|
348
|
+
updateStatus = status;
|
|
349
|
+
if (status.phase === 'failure')
|
|
350
|
+
watchersPaused = false; // the update stopped — keep developing on the old version.
|
|
351
|
+
},
|
|
352
|
+
restart: async (result) => {
|
|
353
|
+
writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
|
|
354
|
+
await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
|
|
355
|
+
},
|
|
356
|
+
}).catch((error) => {
|
|
357
|
+
// `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
|
|
358
|
+
// them (e.g. writing the status file) lands here instead of as an unhandled rejection.
|
|
359
|
+
updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating', step: 'other' };
|
|
360
|
+
watchersPaused = false;
|
|
361
|
+
});
|
|
362
|
+
};
|
|
334
363
|
const endpointDeps = {
|
|
335
364
|
secret,
|
|
336
365
|
allowedOrigin,
|
|
337
366
|
buildPlan: () => buildUpdatePlan(config, ownVersion),
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
// Install, migrate, doctor, restart; `from` resumes after a failed migrate or doctor step.
|
|
368
|
+
startUpdate: (plan) => runUpdate(plan),
|
|
369
|
+
getStatus: () => updateStatus,
|
|
370
|
+
dismissStatus: () => {
|
|
371
|
+
updateStatus = { phase: 'idle' };
|
|
372
|
+
},
|
|
373
|
+
resumeUpdate: (step) => {
|
|
374
|
+
if (lastPlan) {
|
|
375
|
+
runUpdate(lastPlan, step);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// No plan in memory (this process didn't run the failed update): rebuild it first.
|
|
379
|
+
updateStatus = { phase: 'updating', steps: [{ label: 'Preparing', state: 'now' }] };
|
|
380
|
+
void buildUpdatePlan(config, ownVersion).then((plan) => {
|
|
381
|
+
if ('error' in plan)
|
|
382
|
+
updateStatus = { phase: 'failure', log: plan.error, failedStep: 'Preparing', step: 'other' };
|
|
383
|
+
else
|
|
384
|
+
runUpdate(plan, step);
|
|
385
|
+
}, (error) => {
|
|
386
|
+
updateStatus = { phase: 'failure', log: error.message, failedStep: 'Preparing', step: 'other' };
|
|
357
387
|
});
|
|
358
388
|
},
|
|
359
|
-
getStatus: () => updateStatus,
|
|
360
389
|
getVersionStatus: version.getVersionStatus,
|
|
361
390
|
checkNow: version.checkNow,
|
|
362
391
|
setAutoCheck: version.setAutoCheck,
|
package/dist/cli/endpoint.js
CHANGED
|
@@ -166,6 +166,39 @@ export function createUpdateEndpoint(deps) {
|
|
|
166
166
|
});
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
|
+
if (req.method === 'POST' && req.url === '/update/dismiss' && deps.dismissStatus) {
|
|
170
|
+
const phase = deps.getStatus().phase;
|
|
171
|
+
if (starting || phase === 'updating' || phase === 'restarting') {
|
|
172
|
+
sendJson(res, 409, { error: 'An update is running.' }, deps.allowedOrigin);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
deps.dismissStatus();
|
|
176
|
+
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (req.method === 'POST' && req.url === '/update/resume' && deps.resumeUpdate) {
|
|
180
|
+
const status = deps.getStatus();
|
|
181
|
+
const step = status.phase === 'failure' ? status.step : undefined;
|
|
182
|
+
if (starting || (step !== 'migrate' && step !== 'doctor')) {
|
|
183
|
+
sendJson(res, 409, { error: 'There is no failed migrate or doctor step to resume.' }, deps.allowedOrigin);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Same reasoning as POST /update: answer first, then run the work on the next tick.
|
|
187
|
+
starting = true;
|
|
188
|
+
sendJson(res, 202, { started: true }, deps.allowedOrigin);
|
|
189
|
+
setImmediate(() => {
|
|
190
|
+
try {
|
|
191
|
+
deps.resumeUpdate(step);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.warn('Update could not resume: ' + error.message);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
starting = false;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
169
202
|
if (req.method === 'GET' && req.url === '/update/status') {
|
|
170
203
|
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
171
204
|
return;
|
|
@@ -3,7 +3,7 @@ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch,
|
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
|
|
5
5
|
import {
|
|
6
|
-
autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
6
|
+
autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
7
7
|
} from './updatePanelState';
|
|
8
8
|
import type { UpdateNotice, VersionStatus } from './types';
|
|
9
9
|
|
|
@@ -51,20 +51,6 @@ function FilesList({ files }: { files: UpdatePlanView['files'] }) {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** Same shape as `FilesList`, but with no right-hand reason column — the `success` phase's
|
|
55
|
-
* `status.files` carries plain paths, never a reason (Task 16 Step 2 table: a dedicated,
|
|
56
|
-
* smaller render here is simplest rather than over-generalizing `FilesList` for one caller). */
|
|
57
|
-
function PlainFilesList({ files }: { files: string[] }) {
|
|
58
|
-
return (
|
|
59
|
-
<View style={styles.filesBox}>
|
|
60
|
-
{files.map((path, i) => (
|
|
61
|
-
<View key={path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
|
|
62
|
-
<Text style={styles.filePath}>{path}</Text>
|
|
63
|
-
</View>
|
|
64
|
-
))}
|
|
65
|
-
</View>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
54
|
|
|
69
55
|
const NOTE_BOX_VARIANT = {
|
|
70
56
|
muted: { box: { backgroundColor: CATALOG_COLOR.surfaceMuted }, text: { color: CATALOG_COLOR.text } },
|
|
@@ -139,59 +125,66 @@ function Code({ children }: { children: string }) {
|
|
|
139
125
|
return <Text style={styles.code}>{children}</Text>;
|
|
140
126
|
}
|
|
141
127
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The result of the last update, as a note at the top of the update page (approved mockup
|
|
132
|
+
* 2026-10-11): a bold header, a description, one detail line, and a bottom-left button in the
|
|
133
|
+
* note's colour. The rest of the page stays usable below it.
|
|
134
|
+
*/
|
|
135
|
+
function ResultNoteBox({
|
|
136
|
+
status,
|
|
137
|
+
current,
|
|
138
|
+
latest,
|
|
139
|
+
busy,
|
|
140
|
+
onAction,
|
|
141
|
+
}: {
|
|
142
|
+
status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
|
|
143
|
+
current: string;
|
|
144
|
+
latest?: string;
|
|
145
|
+
busy: boolean;
|
|
146
|
+
onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
|
|
147
|
+
}) {
|
|
148
|
+
const [showLog, setShowLog] = useState(false);
|
|
149
|
+
const note = resultNote(status, current, latest);
|
|
150
|
+
const ok = note.tone === 'ok';
|
|
151
|
+
const tone = ok ? CATALOG_COLOR.success : CATALOG_COLOR.danger;
|
|
148
152
|
return (
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
153
|
+
<View style={[styles.resultNote, ok ? styles.resultNoteOk : styles.resultNoteErr]} role="status">
|
|
154
|
+
<Text style={[styles.resultTitle, { color: tone }]}>{note.title}</Text>
|
|
155
|
+
<Text style={[styles.resultDescription, { color: tone }]}>{note.description}</Text>
|
|
156
|
+
<Text style={[styles.resultDetail, { color: tone }]}>
|
|
157
|
+
{note.command ? (
|
|
158
|
+
<>
|
|
159
|
+
{'You can also run '}
|
|
160
|
+
<Code>{note.command}</Code>
|
|
161
|
+
{' in your terminal.'}
|
|
162
|
+
</>
|
|
163
|
+
) : (
|
|
164
|
+
note.detail
|
|
165
|
+
)}
|
|
166
|
+
</Text>
|
|
167
|
+
<View style={styles.resultRow}>
|
|
168
|
+
<Pressable
|
|
169
|
+
onPress={() => onAction(note.action.kind)}
|
|
170
|
+
disabled={busy}
|
|
171
|
+
accessibilityRole="button"
|
|
172
|
+
accessibilityState={{ disabled: busy }}
|
|
173
|
+
style={({ pressed }) => [styles.resultButton, { backgroundColor: tone }, (pressed || busy) && styles.resultButtonPressed]}
|
|
174
|
+
>
|
|
175
|
+
<Text style={styles.resultButtonText}>{note.action.label}</Text>
|
|
176
|
+
</Pressable>
|
|
177
|
+
{note.hasLog && status.phase === 'failure' && (
|
|
178
|
+
<Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
|
|
179
|
+
<Text style={[styles.resultLink, { color: tone }]}>{showLog ? 'Hide log' : 'Show log'}</Text>
|
|
180
|
+
</Pressable>
|
|
181
|
+
)}
|
|
167
182
|
</View>
|
|
168
|
-
|
|
183
|
+
{showLog && status.phase === 'failure' && <LogBlock>{status.log}</LogBlock>}
|
|
184
|
+
</View>
|
|
169
185
|
);
|
|
170
186
|
}
|
|
171
187
|
|
|
172
|
-
/** The `failure` phase's headline/body copy, keyed off which step failed — `runUpdate`/
|
|
173
|
-
* `performUpdate` each name their own distinct recovery command (Important finding, Fable
|
|
174
|
-
* correction pass: one hardcoded headline/body for every failure was wrong for two of the
|
|
175
|
-
* three). */
|
|
176
|
-
function failureCopy(failedStep: string, current: string): { headline: string; body: React.ReactNode } {
|
|
177
|
-
if (/install/i.test(failedStep)) {
|
|
178
|
-
return {
|
|
179
|
-
headline: 'The update stopped while installing.',
|
|
180
|
-
body: <>Your files weren’t migrated. Recover with <Code>npx ds-viewer update</Code> in your terminal.</>,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (/migrat/i.test(failedStep)) {
|
|
184
|
-
return {
|
|
185
|
-
headline: 'The update installed, but migrating failed.',
|
|
186
|
-
body: <>Recover with <Code>{`npx ds-viewer migrate --from ${current}`}</Code> in your terminal.</>,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
headline: 'The update installed and migrated, but doctor failed to run.',
|
|
191
|
-
body: <>Recover with <Code>npx ds-viewer doctor</Code> in your terminal.</>,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
188
|
/**
|
|
196
189
|
* The update page when no newer version is known (approved mockup
|
|
197
190
|
* docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
|
|
@@ -202,10 +195,13 @@ function VersionDetails({
|
|
|
202
195
|
status,
|
|
203
196
|
endpoint,
|
|
204
197
|
onStatus,
|
|
198
|
+
onCheck,
|
|
205
199
|
}: {
|
|
206
200
|
status: VersionStatus;
|
|
207
201
|
endpoint?: Endpoint;
|
|
208
202
|
onStatus: (next: VersionStatus) => void;
|
|
203
|
+
/** Called as Check now starts (clears an update result note). */
|
|
204
|
+
onCheck?: () => void;
|
|
209
205
|
}) {
|
|
210
206
|
const [checking, setChecking] = useState(false);
|
|
211
207
|
const [justChecked, setJustChecked] = useState(false);
|
|
@@ -217,6 +213,7 @@ function VersionDetails({
|
|
|
217
213
|
|
|
218
214
|
const checkNow = async () => {
|
|
219
215
|
if (!endpoint || checking) return;
|
|
216
|
+
onCheck?.();
|
|
220
217
|
setChecking(true);
|
|
221
218
|
setJustChecked(false);
|
|
222
219
|
setLocalError(undefined);
|
|
@@ -395,7 +392,20 @@ export function UpdatePanel({
|
|
|
395
392
|
});
|
|
396
393
|
}, [fetchStatus, fetchPlan, update]);
|
|
397
394
|
|
|
398
|
-
|
|
395
|
+
// A finished update (success or failure) shows as a note at the top; the page under it behaves
|
|
396
|
+
// as if idle, so Check now, the switch and a newer update all keep working.
|
|
397
|
+
const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
|
|
398
|
+
const phase = resolvePanelPhase(result ? { phase: 'idle' } : status, planResult);
|
|
399
|
+
const [noteBusy, setNoteBusy] = useState(false);
|
|
400
|
+
const dismissResult = useCallback(async () => {
|
|
401
|
+
if (!endpoint || !isWeb()) return;
|
|
402
|
+
try {
|
|
403
|
+
const res = await authedFetch(endpoint, '/update/dismiss', { method: 'POST' });
|
|
404
|
+
if (res.ok) setStatus((await res.json()) as UpdateStatusView);
|
|
405
|
+
} catch {
|
|
406
|
+
// The note stays; the next status poll or reload settles it.
|
|
407
|
+
}
|
|
408
|
+
}, [endpoint]);
|
|
399
409
|
|
|
400
410
|
// Polls `/update/status` at a plain interval while an update is in flight — design §5 never
|
|
401
411
|
// asks for push/websocket progress, only "the panel shows progress."
|
|
@@ -463,14 +473,14 @@ export function UpdatePanel({
|
|
|
463
473
|
if (!update) {
|
|
464
474
|
// Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
|
|
465
475
|
// reflects a just-finished update read from the (surviving) endpoint across the reload.
|
|
466
|
-
content =
|
|
467
|
-
|
|
468
|
-
<SuccessContent status={status} />
|
|
469
|
-
) : versionStatus ? (
|
|
470
|
-
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
|
|
476
|
+
content = versionStatus ? (
|
|
477
|
+
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} onCheck={result ? dismissResult : undefined} />
|
|
471
478
|
) : (
|
|
472
479
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
473
480
|
);
|
|
481
|
+
} else if (result?.phase === 'failure') {
|
|
482
|
+
// The failure note's own button is the recovery; don't offer Update now twice.
|
|
483
|
+
content = <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />;
|
|
474
484
|
} else if (phase === 'ready' && plan) {
|
|
475
485
|
content = (
|
|
476
486
|
<>
|
|
@@ -587,40 +597,7 @@ export function UpdatePanel({
|
|
|
587
597
|
<Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
|
|
588
598
|
</>
|
|
589
599
|
);
|
|
590
|
-
|
|
591
|
-
content = <SuccessContent status={status} />;
|
|
592
|
-
} else if (phase === 'failure' && status.phase === 'failure') {
|
|
593
|
-
const { headline, body } = failureCopy(status.failedStep, update.current);
|
|
594
|
-
const failedLabel = `${status.failedStep} failed`;
|
|
595
|
-
content = (
|
|
596
|
-
<>
|
|
597
|
-
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
598
|
-
<NoteBox variant="err">
|
|
599
|
-
<Text style={styles.noteBoxBold}>{headline}</Text>
|
|
600
|
-
{' '}
|
|
601
|
-
{body}
|
|
602
|
-
</NoteBox>
|
|
603
|
-
<Steps
|
|
604
|
-
steps={[
|
|
605
|
-
{ label: `Downloaded ${update.latest}`, state: 'done' },
|
|
606
|
-
{ label: failedLabel, state: 'todo' },
|
|
607
|
-
]}
|
|
608
|
-
failedStep={failedLabel}
|
|
609
|
-
/>
|
|
610
|
-
<LogBlock>{status.log}</LogBlock>
|
|
611
|
-
<View style={styles.actions}>
|
|
612
|
-
<Pressable
|
|
613
|
-
onPress={() => {
|
|
614
|
-
if (isWeb()) navigator.clipboard?.writeText(status.log);
|
|
615
|
-
}}
|
|
616
|
-
accessibilityRole="button"
|
|
617
|
-
style={styles.secondaryButton}
|
|
618
|
-
>
|
|
619
|
-
<Text style={styles.secondaryButtonText}>Copy log</Text>
|
|
620
|
-
</Pressable>
|
|
621
|
-
</View>
|
|
622
|
-
</>
|
|
623
|
-
);
|
|
600
|
+
|
|
624
601
|
}
|
|
625
602
|
|
|
626
603
|
return (
|
|
@@ -631,6 +608,28 @@ export function UpdatePanel({
|
|
|
631
608
|
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
632
609
|
<Text style={styles.title}>DS Viewer updates</Text>
|
|
633
610
|
</View>
|
|
611
|
+
{result && (
|
|
612
|
+
<ResultNoteBox
|
|
613
|
+
status={result}
|
|
614
|
+
current={update?.current ?? versionStatus?.current ?? ''}
|
|
615
|
+
latest={update?.latest}
|
|
616
|
+
busy={noteBusy || starting}
|
|
617
|
+
onAction={async (kind) => {
|
|
618
|
+
if (!endpoint) return;
|
|
619
|
+
setNoteBusy(true);
|
|
620
|
+
try {
|
|
621
|
+
if (kind === 'dismiss') await dismissResult();
|
|
622
|
+
else if (kind === 'retry') await startUpdate();
|
|
623
|
+
else {
|
|
624
|
+
await authedFetch(endpoint, '/update/resume', { method: 'POST' });
|
|
625
|
+
await fetchStatus();
|
|
626
|
+
}
|
|
627
|
+
} finally {
|
|
628
|
+
setNoteBusy(false);
|
|
629
|
+
}
|
|
630
|
+
}}
|
|
631
|
+
/>
|
|
632
|
+
)}
|
|
634
633
|
{content}
|
|
635
634
|
</View>
|
|
636
635
|
);
|
|
@@ -638,6 +637,18 @@ export function UpdatePanel({
|
|
|
638
637
|
|
|
639
638
|
const styles = StyleSheet.create({
|
|
640
639
|
wrap: { maxWidth: CATALOG_LAYOUT.updateContentMaxWidth },
|
|
640
|
+
resultNote: { borderRadius: CATALOG_RADIUS.md, padding: 14, marginTop: 12, marginBottom: 18, gap: 4 },
|
|
641
|
+
resultNoteOk: { backgroundColor: CATALOG_COLOR.successSubtle },
|
|
642
|
+
resultNoteErr: { backgroundColor: CATALOG_COLOR.dangerSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.dangerBorder },
|
|
643
|
+
resultTitle: { fontSize: CATALOG_TYPE.lg, fontWeight: '800' },
|
|
644
|
+
resultDescription: { fontSize: CATALOG_TYPE.panelHeading, lineHeight: 19 },
|
|
645
|
+
resultDetail: { fontSize: CATALOG_TYPE.sm, lineHeight: 18 },
|
|
646
|
+
resultRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 8 },
|
|
647
|
+
// The catalog's smallest control size (the major banner's close button), filled in the note's colour.
|
|
648
|
+
resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
|
|
649
|
+
resultButtonPressed: { opacity: 0.8 },
|
|
650
|
+
resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
|
|
651
|
+
resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
|
|
641
652
|
crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
642
653
|
headingTarget: { marginTop: 4, marginBottom: 6 },
|
|
643
654
|
title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
@@ -32,7 +32,7 @@ export type UpdateStatusView =
|
|
|
32
32
|
| { phase: 'updating'; steps: { label: string; state: 'done' | 'now' | 'todo' }[] }
|
|
33
33
|
| { phase: 'restarting' }
|
|
34
34
|
| { phase: 'success'; doctorSummary: string; files: string[]; latest: string }
|
|
35
|
-
| { phase: 'failure'; log: string; failedStep: string };
|
|
35
|
+
| { phase: 'failure'; log: string; failedStep: string; step?: 'install' | 'migrate' | 'doctor' | 'other' };
|
|
36
36
|
|
|
37
37
|
export type UpdatePhase = 'checking' | 'ready' | 'dirty' | 'offline' | 'updating' | 'restarting' | 'success' | 'failure';
|
|
38
38
|
|
|
@@ -144,3 +144,70 @@ export function bumpLabel(current: string, latest: string, breaking: boolean): '
|
|
|
144
144
|
if (!a || !b) return 'Minor';
|
|
145
145
|
return a[0] === b[0] && a[1] === b[1] ? 'Patch' : 'Minor';
|
|
146
146
|
}
|
|
147
|
+
|
|
148
|
+
export interface ResultNote {
|
|
149
|
+
tone: 'ok' | 'err';
|
|
150
|
+
title: string;
|
|
151
|
+
description: string;
|
|
152
|
+
detail: string;
|
|
153
|
+
/** The terminal fallback, shown as code inside `detail` (failures only). */
|
|
154
|
+
command?: string;
|
|
155
|
+
/** At most two words. `dismiss` clears the note; `retry` runs the whole update again; `resume`
|
|
156
|
+
* continues from the failed migrate or doctor step. */
|
|
157
|
+
action: { label: string; kind: 'dismiss' | 'retry' | 'resume' };
|
|
158
|
+
hasLog: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The note shown at the top of the update page after an update finished (approved mockup
|
|
162
|
+
* 2026-10-11): a header, a description, one detail line, and a bottom-left button. */
|
|
163
|
+
export function resultNote(
|
|
164
|
+
status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>,
|
|
165
|
+
current: string,
|
|
166
|
+
latest?: string,
|
|
167
|
+
): ResultNote {
|
|
168
|
+
if (status.phase === 'success') {
|
|
169
|
+
return {
|
|
170
|
+
tone: 'ok',
|
|
171
|
+
title: `Updated to ${status.latest}`,
|
|
172
|
+
description: "The changes aren't committed yet, so review them in your editor.",
|
|
173
|
+
// Doctor's own summary line continues after an em dash with component counts; the note keeps
|
|
174
|
+
// only the error and warning counts.
|
|
175
|
+
detail: `Changed: ${status.files.join(', ') || 'nothing'} · Doctor: ${status.doctorSummary.split(' — ')[0]}`,
|
|
176
|
+
action: { label: 'Got it', kind: 'dismiss' },
|
|
177
|
+
hasLog: false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const failure = (title: string, description: string, command: string, action: ResultNote['action']): ResultNote => ({
|
|
181
|
+
tone: 'err', title, description, detail: `You can also run ${command} in your terminal.`, command, action, hasLog: true,
|
|
182
|
+
});
|
|
183
|
+
switch (status.step) {
|
|
184
|
+
case 'migrate':
|
|
185
|
+
return failure(
|
|
186
|
+
'The update installed, but migrating failed',
|
|
187
|
+
`Your package is on ${latest ?? 'the new version'}, but your files still match ${current}.`,
|
|
188
|
+
`npx ds-viewer migrate --from ${current}`,
|
|
189
|
+
{ label: 'Finish migration', kind: 'resume' },
|
|
190
|
+
);
|
|
191
|
+
case 'doctor':
|
|
192
|
+
return failure(
|
|
193
|
+
'The update installed, but doctor failed to run',
|
|
194
|
+
'The update itself is done. Only the page check is missing.',
|
|
195
|
+
'npx ds-viewer doctor',
|
|
196
|
+
{ label: 'Rerun doctor', kind: 'resume' },
|
|
197
|
+
);
|
|
198
|
+
case 'install':
|
|
199
|
+
return failure(
|
|
200
|
+
'The update stopped while installing',
|
|
201
|
+
'Nothing was migrated. Retrying starts again from the download.',
|
|
202
|
+
'npx ds-viewer update',
|
|
203
|
+
{ label: 'Retry update', kind: 'retry' },
|
|
204
|
+
);
|
|
205
|
+
default:
|
|
206
|
+
return failure(
|
|
207
|
+
'The update stopped',
|
|
208
|
+
'Something unexpected went wrong. Retrying starts again from the download.',
|
|
209
|
+
'npx ds-viewer update',
|
|
210
|
+
{ label: 'Retry update', kind: 'retry' },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|