@krapwoo/ds-viewer 0.4.5 → 0.4.6
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 +11 -3
- package/dist/cli/dev.js +31 -6
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/main.js +4 -2
- package/dist/cli/updateCheck.js +174 -43
- package/dist/cli/workspace.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +11 -0
- package/native/catalog/CatalogShell.tsx +47 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/types.ts +20 -0
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -400,9 +400,17 @@ markers or its own file.
|
|
|
400
400
|
|
|
401
401
|
## Updating
|
|
402
402
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
The sidebar footer always shows the installed version ("DS Viewer 0.4.5") and opens the update
|
|
404
|
+
page (`#ds-viewer-update`). With no newer version known, that page says which situation you're in —
|
|
405
|
+
on the latest version, automatic checks off, or npm unreachable — and offers **Check now** (asks npm
|
|
406
|
+
right away, even with automatic checks off) and a **Check for updates automatically** switch. The
|
|
407
|
+
switch saves your own choice for this project on this computer (in your user settings folder, not in
|
|
408
|
+
the project), overriding the project's `updateCheck` for you only; `DS_VIEWER_NO_UPDATE_CHECK=1`
|
|
409
|
+
(CI) still wins over both. `doctor` follows the same setting.
|
|
410
|
+
|
|
411
|
+
When a new version is published, the footer turns into "Update available · 0.5.0" — a banner too,
|
|
412
|
+
for a major version, dismissible per version. Either opens the same page, which then shows what would
|
|
413
|
+
change and one **Update now** button:
|
|
406
414
|
|
|
407
415
|
```
|
|
408
416
|
0.4.0 → 0.5.0 Minor
|
package/dist/cli/dev.js
CHANGED
|
@@ -9,7 +9,7 @@ import { sync } from "./sync.js";
|
|
|
9
9
|
import { writeWorkspace } from "./workspace.js";
|
|
10
10
|
import { findFreePort } from "./port.js";
|
|
11
11
|
import { globBaseFolder } from "./glob.js";
|
|
12
|
-
import {
|
|
12
|
+
import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
|
|
13
13
|
import { readOwnVersion } from "./packageVersion.js";
|
|
14
14
|
import { createUpdateEndpoint } from "./endpoint.js";
|
|
15
15
|
import { buildUpdatePlan } from "./updatePlan.js";
|
|
@@ -70,10 +70,20 @@ export function reloadWorkspace(projectRoot, updateEndpoint) {
|
|
|
70
70
|
* the same way `reloadWorkspace` already is, so this plan never needs to test `dev()` itself
|
|
71
71
|
* (which spawns a real Expo process) to prove this one behavior. */
|
|
72
72
|
export async function refreshUpdateFile(config, ownVersion, options = {}) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const personal = readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot);
|
|
75
|
+
const autoCheck = resolveAutoCheck(config, env, personal);
|
|
76
|
+
// An injected `checkForUpdate` (tests) stands in for the real check; it can't report "couldn't
|
|
77
|
+
// reach npm", so it always counts as answered.
|
|
78
|
+
const injected = options.checkForUpdate;
|
|
79
|
+
const status = injected
|
|
80
|
+
? autoCheck.enabled
|
|
81
|
+
? statusFromCheck(ownVersion, autoCheck, { result: await injected(ownVersion), reached: true })
|
|
82
|
+
: { current: ownVersion, autoCheck, lastOutcome: 'not-run', update: null }
|
|
83
|
+
: await buildVersionStatus(ownVersion, autoCheck);
|
|
84
|
+
writeUpdateFile(config.projectRoot, status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined);
|
|
85
|
+
writeVersionFile(config.projectRoot, status);
|
|
86
|
+
return status;
|
|
77
87
|
}
|
|
78
88
|
// Promisified, not `execFileSync` — install/migrate/doctor each take real seconds, and this now
|
|
79
89
|
// runs from inside the endpoint's own request-handling process, which must keep answering
|
|
@@ -284,8 +294,19 @@ export async function performRestart(handoff, deps = {}) {
|
|
|
284
294
|
export async function dev(initialConfig, devOptions = {}) {
|
|
285
295
|
assertLocalInstall(initialConfig.projectRoot);
|
|
286
296
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
287
|
-
await refreshUpdateFile(initialConfig, ownVersion);
|
|
288
297
|
let config = initialConfig;
|
|
298
|
+
const version = createVersionController({
|
|
299
|
+
ownVersion,
|
|
300
|
+
projectRoot: initialConfig.projectRoot,
|
|
301
|
+
prefsDir: defaultPreferencesDir(),
|
|
302
|
+
env: process.env,
|
|
303
|
+
initial: await refreshUpdateFile(initialConfig, ownVersion),
|
|
304
|
+
getConfig: () => config,
|
|
305
|
+
// **Check now** always asks npm, even with automatic checks off: you asked for this one check.
|
|
306
|
+
// The result lives only in this process; `update.json` and `version.json` are rewritten at
|
|
307
|
+
// the next start (rewriting them now would reload the page).
|
|
308
|
+
runCheck: () => runUpdateCheck(ownVersion, { force: true, timeoutMs: 5000 }),
|
|
309
|
+
});
|
|
289
310
|
sync(config);
|
|
290
311
|
// Restart handoff step 2/4: a status file left by the *previous* `dev` process (right before it
|
|
291
312
|
// exited to restart) is read once, then deleted — a plain refresh afterward shows "idle"/"no
|
|
@@ -336,6 +357,9 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
336
357
|
});
|
|
337
358
|
},
|
|
338
359
|
getStatus: () => updateStatus,
|
|
360
|
+
getVersionStatus: version.getVersionStatus,
|
|
361
|
+
checkNow: version.checkNow,
|
|
362
|
+
setAutoCheck: version.setAutoCheck,
|
|
339
363
|
};
|
|
340
364
|
const endpointServer = createUpdateEndpoint(endpointDeps);
|
|
341
365
|
await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
|
|
@@ -374,6 +398,7 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
374
398
|
configDebounceTimer = setTimeout(() => {
|
|
375
399
|
try {
|
|
376
400
|
config = reloadWorkspace(config.projectRoot, updateEndpoint);
|
|
401
|
+
version.refreshAutoCheck();
|
|
377
402
|
}
|
|
378
403
|
catch (error) {
|
|
379
404
|
console.warn(`Could not reload ds-viewer.config.ts after a change: ${error.message}`);
|
package/dist/cli/endpoint.js
CHANGED
|
@@ -14,6 +14,38 @@ function secretMatches(received, expected) {
|
|
|
14
14
|
const expectedBuf = Buffer.from(expected);
|
|
15
15
|
return receivedBuf.length === expectedBuf.length && timingSafeEqual(receivedBuf, expectedBuf);
|
|
16
16
|
}
|
|
17
|
+
/** Reads a small JSON request body (the switch's `{ enabled }`); anything over 1 KB is refused. */
|
|
18
|
+
function readJsonBody(req) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let body = '';
|
|
21
|
+
let tooLarge = false;
|
|
22
|
+
req.setEncoding('utf8');
|
|
23
|
+
// Keep draining past the cap (without storing it) so the handler can still answer 400 —
|
|
24
|
+
// destroying the socket would drop that answer.
|
|
25
|
+
req.on('data', (chunk) => {
|
|
26
|
+
if (tooLarge)
|
|
27
|
+
return;
|
|
28
|
+
body += chunk;
|
|
29
|
+
if (body.length > 1024) {
|
|
30
|
+
tooLarge = true;
|
|
31
|
+
body = '';
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
if (tooLarge) {
|
|
36
|
+
reject(new Error('Body too large.'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
resolve(JSON.parse(body || 'null'));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
reject(error);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
17
49
|
function sendJson(res, status, body, allowedOrigin) {
|
|
18
50
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
|
|
19
51
|
res.end(JSON.stringify(body));
|
|
@@ -33,6 +65,7 @@ export function createUpdateEndpoint(deps) {
|
|
|
33
65
|
// every real caller), so resetting this flag right after is safe and lets a later retry (after a
|
|
34
66
|
// `'failure'`, in the same process) through again.
|
|
35
67
|
let starting = false;
|
|
68
|
+
let checking = false;
|
|
36
69
|
return http.createServer(async (req, res) => {
|
|
37
70
|
// Controller decision 1 (required, not optional): reject a request whose `Host` header is not
|
|
38
71
|
// this very socket's own `127.0.0.1:<port>` — a DNS-rebinding defence, since `req.socket`'s own
|
|
@@ -55,7 +88,7 @@ export function createUpdateEndpoint(deps) {
|
|
|
55
88
|
res.writeHead(204, {
|
|
56
89
|
'Access-Control-Allow-Origin': deps.allowedOrigin,
|
|
57
90
|
'Access-Control-Allow-Methods': 'POST, GET',
|
|
58
|
-
'Access-Control-Allow-Headers': DS_VIEWER_SECRET_HEADER,
|
|
91
|
+
'Access-Control-Allow-Headers': `${DS_VIEWER_SECRET_HEADER}, Content-Type`,
|
|
59
92
|
});
|
|
60
93
|
res.end();
|
|
61
94
|
return;
|
|
@@ -137,6 +170,48 @@ export function createUpdateEndpoint(deps) {
|
|
|
137
170
|
sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
|
|
138
171
|
return;
|
|
139
172
|
}
|
|
173
|
+
if (req.method === 'GET' && req.url === '/version' && deps.getVersionStatus) {
|
|
174
|
+
sendJson(res, 200, deps.getVersionStatus(), deps.allowedOrigin);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (req.method === 'POST' && req.url === '/version/check' && deps.checkNow) {
|
|
178
|
+
if (checking) {
|
|
179
|
+
sendJson(res, 409, { error: 'A check is already running.' }, deps.allowedOrigin);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
checking = true;
|
|
183
|
+
try {
|
|
184
|
+
sendJson(res, 200, await deps.checkNow(), deps.allowedOrigin);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
sendJson(res, 502, { error: error.message }, deps.allowedOrigin);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
checking = false;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (req.method === 'POST' && req.url === '/version/auto-check' && deps.setAutoCheck) {
|
|
195
|
+
let body;
|
|
196
|
+
try {
|
|
197
|
+
body = await readJsonBody(req);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
body = undefined;
|
|
201
|
+
}
|
|
202
|
+
const enabled = body?.enabled;
|
|
203
|
+
if (typeof enabled !== 'boolean') {
|
|
204
|
+
sendJson(res, 400, { error: 'Expected { "enabled": true | false }.' }, deps.allowedOrigin);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
sendJson(res, 200, deps.setAutoCheck(enabled), deps.allowedOrigin);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
sendJson(res, 500, { error: error.message }, deps.allowedOrigin);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
140
215
|
res.writeHead(404).end('Not found.');
|
|
141
216
|
});
|
|
142
217
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -8,7 +8,7 @@ import { sync } from "./sync.js";
|
|
|
8
8
|
import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
|
|
9
9
|
import { explainPage, formatExplain, parseHeights } from "./explain.js";
|
|
10
10
|
import { readOwnVersion } from "./packageVersion.js";
|
|
11
|
-
import { checkForUpdate,
|
|
11
|
+
import { checkForUpdate, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck } from "./updateCheck.js";
|
|
12
12
|
import { runKitDiffCommand } from "./kitDiff.js";
|
|
13
13
|
import { formatMigrateHuman, runMigrate } from "./migrate.js";
|
|
14
14
|
import { runUpdate } from "./update.js";
|
|
@@ -119,7 +119,9 @@ export async function runDoctorCommand(projectRoot, rest, options = {}) {
|
|
|
119
119
|
const result = runDoctor(config);
|
|
120
120
|
// Design §4 "`--ci`: ... no update check." — `--ci` skips this whole block even when
|
|
121
121
|
// `updateCheck` is otherwise enabled; `result.update` stays `null`.
|
|
122
|
-
|
|
122
|
+
// Same precedence as the viewer: CI's environment variable, your own switch, the project config.
|
|
123
|
+
const autoCheck = resolveAutoCheck(config, options.env ?? process.env, readPersonalAutoCheck(options.prefsDir ?? defaultPreferencesDir(), config.projectRoot));
|
|
124
|
+
if (!rest.includes('--ci') && autoCheck.enabled) {
|
|
123
125
|
const ownVersion = readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
124
126
|
const updateResult = await (options.checkForUpdate ?? checkForUpdate)(ownVersion);
|
|
125
127
|
if (updateResult) {
|
package/dist/cli/updateCheck.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// cli/updateCheck.ts
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
5
5
|
const CACHE_FILE_NAME = 'update-check.json';
|
|
@@ -79,59 +79,163 @@ export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
|
|
|
79
79
|
clearTimeout(timer);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/** One update check, with whether npm actually answered — `checkForUpdate` alone can't tell "no
|
|
83
|
+
* newer version" from "couldn't reach npm", which the update page must say differently. Never
|
|
84
|
+
* throws. `cached` is the cache as it stands afterwards (the last successful check on this
|
|
85
|
+
* computer), when there is one. */
|
|
86
|
+
export async function runUpdateCheck(currentVersion, options = {}) {
|
|
87
|
+
const now = options.now ?? Date.now;
|
|
88
|
+
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
89
|
+
const cached = readCache(cacheDir);
|
|
90
|
+
// `parseVersion` (inside `toResultOrUndefined`) throws on a non-semver `latest`, whether from a
|
|
91
|
+
// malformed cache file or an unexpected registry body; this function must never throw.
|
|
92
|
+
const safeResult = (entry) => {
|
|
93
|
+
try {
|
|
94
|
+
return toResultOrUndefined(entry, currentVersion);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (cached && !options.force && isCacheFresh(cached, now()))
|
|
101
|
+
return { result: safeResult(cached), reached: true, cached };
|
|
102
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
103
|
+
const timeoutMs = options.timeoutMs ?? 900;
|
|
104
|
+
let latest;
|
|
105
|
+
try {
|
|
106
|
+
const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
|
|
107
|
+
latest = registryResponse.version;
|
|
108
|
+
parseVersion(latest);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A stale cache is still better than nothing once offline — still run through the same
|
|
112
|
+
// not-actually-newer guard, never a raw passthrough.
|
|
113
|
+
return { result: cached ? safeResult(cached) : undefined, reached: false, cached };
|
|
114
|
+
}
|
|
115
|
+
let summary = [];
|
|
116
|
+
let releasedAt;
|
|
117
|
+
try {
|
|
118
|
+
const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
|
|
119
|
+
summary = release.body ? extractSummaryBullets(release.body) : [];
|
|
120
|
+
releasedAt = release.published_at;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// The version comparison is the half that matters for the footer/banner; a missing release
|
|
124
|
+
// summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
|
|
125
|
+
// empty "What's new" list, never a failed check.
|
|
126
|
+
}
|
|
127
|
+
const fresh = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
|
|
128
|
+
// A registry that (briefly, during its own propagation) reports an older "latest" than what is
|
|
129
|
+
// already cached never regresses the cache's version, but the check time still moves forward.
|
|
130
|
+
let stored = fresh;
|
|
131
|
+
try {
|
|
132
|
+
if (cached && compareVersions(parseVersion(fresh.latest), parseVersion(cached.latest)) < 0)
|
|
133
|
+
stored = { ...cached, checkedAt: fresh.checkedAt };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// An unreadable cached version is simply replaced.
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
writeCache(cacheDir, stored);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A read-only cache folder must not turn a successful check into a failure.
|
|
143
|
+
}
|
|
144
|
+
return { result: safeResult(fresh), reached: true, cached: stored };
|
|
145
|
+
}
|
|
82
146
|
/** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
|
|
83
147
|
* reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
|
|
84
148
|
* usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
|
|
85
149
|
* outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
|
|
86
150
|
export async function checkForUpdate(currentVersion, options = {}) {
|
|
87
|
-
// M5 (Minor, Fable correction pass): this function is documented — and relied on by `dev()`,
|
|
88
|
-
// which `await`s it before starting anything else — as never throwing. `parseVersion` (inside
|
|
89
|
-
// `toResultOrUndefined`) throws on a non-semver `latest`, whether from a parseable-but-malformed
|
|
90
|
-
// cache file or an unexpected 200 body with no real `version`; either would otherwise stop `dev`
|
|
91
|
-
// from starting at all.
|
|
92
151
|
try {
|
|
93
|
-
|
|
94
|
-
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
95
|
-
const cached = readCache(cacheDir);
|
|
96
|
-
if (cached && isCacheFresh(cached, now()))
|
|
97
|
-
return toResultOrUndefined(cached, currentVersion);
|
|
98
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
99
|
-
const timeoutMs = options.timeoutMs ?? 900;
|
|
100
|
-
let latest;
|
|
101
|
-
try {
|
|
102
|
-
const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
|
|
103
|
-
latest = registryResponse.version;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// A stale cache is still better than nothing once offline — still run through the same
|
|
107
|
-
// not-actually-newer guard, never a raw passthrough.
|
|
108
|
-
return cached ? toResultOrUndefined(cached, currentVersion) : undefined;
|
|
109
|
-
}
|
|
110
|
-
let summary = [];
|
|
111
|
-
let releasedAt;
|
|
112
|
-
try {
|
|
113
|
-
const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
|
|
114
|
-
summary = release.body ? extractSummaryBullets(release.body) : [];
|
|
115
|
-
releasedAt = release.published_at;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
// The version comparison is the half that matters for the footer/banner; a missing release
|
|
119
|
-
// summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
|
|
120
|
-
// empty "What's new" list, never a failed check.
|
|
121
|
-
}
|
|
122
|
-
const cachedResult = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
|
|
123
|
-
// compareVersions is used only to decide *whether* this is worth caching as "the latest" at all
|
|
124
|
-
// — a registry that (briefly, during its own propagation) reports an older "latest" than what
|
|
125
|
-
// is already cached never regresses the cache.
|
|
126
|
-
if (!cached || compareVersions(parseVersion(cachedResult.latest), parseVersion(cached.latest)) >= 0) {
|
|
127
|
-
writeCache(cacheDir, cachedResult);
|
|
128
|
-
}
|
|
129
|
-
return toResultOrUndefined(cachedResult, currentVersion);
|
|
152
|
+
return (await runUpdateCheck(currentVersion, options)).result;
|
|
130
153
|
}
|
|
131
154
|
catch {
|
|
132
155
|
return undefined;
|
|
133
156
|
}
|
|
134
157
|
}
|
|
158
|
+
/** A settings folder, not the cache folder (which the OS may clear): the personal switch must
|
|
159
|
+
* survive. */
|
|
160
|
+
export function preferencesDirFor(platform, env, homedir) {
|
|
161
|
+
if (platform === 'darwin')
|
|
162
|
+
return path.posix.join(homedir, 'Library', 'Application Support', 'ds-viewer');
|
|
163
|
+
if (platform === 'win32')
|
|
164
|
+
return path.win32.join(env.APPDATA ?? path.win32.join(homedir, 'AppData', 'Roaming'), 'ds-viewer');
|
|
165
|
+
return path.posix.join(env.XDG_CONFIG_HOME ?? path.posix.join(homedir, '.config'), 'ds-viewer');
|
|
166
|
+
}
|
|
167
|
+
export function defaultPreferencesDir() {
|
|
168
|
+
return preferencesDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
169
|
+
}
|
|
170
|
+
const PREFERENCES_FILE_NAME = 'preferences.json';
|
|
171
|
+
function readPreferences(dir) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, PREFERENCES_FILE_NAME), 'utf8'));
|
|
174
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Your own "check automatically" switch for one project on this computer, or `undefined` when you
|
|
181
|
+
* haven't set it (the project config then decides). */
|
|
182
|
+
export function readPersonalAutoCheck(dir, projectRoot) {
|
|
183
|
+
const value = readPreferences(dir).projects?.[path.resolve(projectRoot)]?.autoCheck;
|
|
184
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
185
|
+
}
|
|
186
|
+
export function writePersonalAutoCheck(dir, projectRoot, enabled) {
|
|
187
|
+
const prefs = readPreferences(dir);
|
|
188
|
+
const projects = { ...(prefs.projects ?? {}) };
|
|
189
|
+
projects[path.resolve(projectRoot)] = { ...projects[path.resolve(projectRoot)], autoCheck: enabled };
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
// Write then rename, so a crash mid-write never leaves a half file that would read as "no
|
|
192
|
+
// settings" and silently reset every project's switch.
|
|
193
|
+
const target = path.join(dir, PREFERENCES_FILE_NAME);
|
|
194
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
195
|
+
writeFileSync(temp, `${JSON.stringify({ ...prefs, projects }, null, 2)}\n`);
|
|
196
|
+
renameSync(temp, target);
|
|
197
|
+
}
|
|
198
|
+
/** Which setting decides automatic checks, strongest first: CI's environment variable, your own
|
|
199
|
+
* switch, the project config, then on by default. */
|
|
200
|
+
export function resolveAutoCheck(config, env, personal) {
|
|
201
|
+
if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
|
|
202
|
+
return { enabled: false, source: 'env' };
|
|
203
|
+
if (personal !== undefined)
|
|
204
|
+
return { enabled: personal, source: 'personal' };
|
|
205
|
+
if (config.updateCheck !== undefined)
|
|
206
|
+
return { enabled: config.updateCheck, source: 'project' };
|
|
207
|
+
return { enabled: true, source: 'default' };
|
|
208
|
+
}
|
|
209
|
+
function toNotice(result) {
|
|
210
|
+
if (!result)
|
|
211
|
+
return null;
|
|
212
|
+
return { current: result.current, latest: result.latest, breaking: result.breaking, summary: result.summary, releasedAt: result.releasedAt };
|
|
213
|
+
}
|
|
214
|
+
/** The version status `dev` starts with: runs the (cached) check only when automatic checks are on. */
|
|
215
|
+
export async function buildVersionStatus(currentVersion, autoCheck, options = {}) {
|
|
216
|
+
if (!autoCheck.enabled) {
|
|
217
|
+
const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
|
|
218
|
+
return { current: currentVersion, autoCheck, lastCheckedAt: readCache(cacheDir)?.checkedAt, lastOutcome: 'not-run', update: null };
|
|
219
|
+
}
|
|
220
|
+
return statusFromCheck(currentVersion, autoCheck, await runUpdateCheck(currentVersion, options));
|
|
221
|
+
}
|
|
222
|
+
/** Folds one check's outcome into a status (startup, or **Check now**). */
|
|
223
|
+
export function statusFromCheck(currentVersion, autoCheck, outcome) {
|
|
224
|
+
return {
|
|
225
|
+
current: currentVersion,
|
|
226
|
+
autoCheck,
|
|
227
|
+
lastCheckedAt: outcome.cached?.checkedAt,
|
|
228
|
+
lastOutcome: outcome.reached ? 'ok' : 'unreachable',
|
|
229
|
+
update: toNotice(outcome.result),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** `dev` writes this next to `update.json`; the generated entry file imports it. */
|
|
233
|
+
export function writeVersionFile(projectRoot, status) {
|
|
234
|
+
const file = path.join(projectRoot, '.ds-viewer', 'version.json');
|
|
235
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
236
|
+
writeFileSync(file, JSON.stringify(status));
|
|
237
|
+
return file;
|
|
238
|
+
}
|
|
135
239
|
/** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
|
|
136
240
|
* writes something — `null` when there is no result — so the generated entry file (Task 17) can
|
|
137
241
|
* unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
|
|
@@ -141,3 +245,30 @@ export function writeUpdateFile(projectRoot, result) {
|
|
|
141
245
|
writeFileSync(file, JSON.stringify(result ?? null));
|
|
142
246
|
return file;
|
|
143
247
|
}
|
|
248
|
+
/** The live version status behind the local endpoint (`GET /version`, **Check now**, the switch),
|
|
249
|
+
* kept outside `dev()` so its ordering rules are testable. */
|
|
250
|
+
export function createVersionController(deps) {
|
|
251
|
+
let status = deps.initial;
|
|
252
|
+
const resolveNow = () => resolveAutoCheck(deps.getConfig(), deps.env, readPersonalAutoCheck(deps.prefsDir, deps.projectRoot));
|
|
253
|
+
return {
|
|
254
|
+
getVersionStatus: () => status,
|
|
255
|
+
/** **Check now**: asks npm ignoring the cache. Reads the switch *after* the check, so a change
|
|
256
|
+
* made while it ran is kept. */
|
|
257
|
+
checkNow: async () => {
|
|
258
|
+
const outcome = await deps.runCheck();
|
|
259
|
+
status = statusFromCheck(deps.ownVersion, status.autoCheck, outcome);
|
|
260
|
+
return status;
|
|
261
|
+
},
|
|
262
|
+
/** The switch: your own setting for this project on this computer. Takes effect for automatic
|
|
263
|
+
* checks from the next start; the current status keeps any update already found. */
|
|
264
|
+
setAutoCheck: (enabled) => {
|
|
265
|
+
writePersonalAutoCheck(deps.prefsDir, deps.projectRoot, enabled);
|
|
266
|
+
status = { ...status, autoCheck: resolveNow() };
|
|
267
|
+
return status;
|
|
268
|
+
},
|
|
269
|
+
/** After `ds-viewer.config.ts` changes while `dev` runs. */
|
|
270
|
+
refreshAutoCheck: () => {
|
|
271
|
+
status = { ...status, autoCheck: resolveNow() };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
package/dist/cli/workspace.js
CHANGED
|
@@ -65,13 +65,14 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint
|
|
|
65
65
|
// (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
|
|
66
66
|
// already relies on, extended to a whole object instead of one string.
|
|
67
67
|
const endpointConst = `const updateEndpoint = ${updateEndpoint ? JSON.stringify(updateEndpoint) : 'undefined'};\n`;
|
|
68
|
-
const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} updateEndpoint={updateEndpoint} />`;
|
|
68
|
+
const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} versionStatus={versionStatus} updateEndpoint={updateEndpoint} />`;
|
|
69
69
|
const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
|
|
70
70
|
return `import { registerRootComponent } from 'expo';
|
|
71
71
|
import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
|
|
72
72
|
${safeAreaImport}${logoImport}import pages from './generated/pages';
|
|
73
73
|
import components from './generated/components.json';
|
|
74
74
|
import updateNotice from './update.json';
|
|
75
|
+
import versionStatus from './version.json';
|
|
75
76
|
${endpointConst}
|
|
76
77
|
function App() {
|
|
77
78
|
const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});
|
|
@@ -129,3 +129,14 @@ export function footerLabel(update) {
|
|
|
129
129
|
return undefined;
|
|
130
130
|
return `Update available · ${update.latest}`;
|
|
131
131
|
}
|
|
132
|
+
/** The sidebar's footer line: the blue "Update available" notice when a newer version is known
|
|
133
|
+
* (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
|
|
134
|
+
* workspace generated before version status existed, which keeps the old notice-only footer. */
|
|
135
|
+
export function footerLink(update, status) {
|
|
136
|
+
const notice = footerLabel(update) ?? footerLabel(status?.update);
|
|
137
|
+
if (notice)
|
|
138
|
+
return { label: notice, kind: 'update' };
|
|
139
|
+
if (status)
|
|
140
|
+
return { label: `DS Viewer ${status.current}`, kind: 'version' };
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
@@ -5,8 +5,8 @@ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIU
|
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
7
|
import { UpdatePanel } from './UpdatePanel';
|
|
8
|
-
import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner,
|
|
9
|
-
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice } from './types';
|
|
8
|
+
import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
|
+
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
|
|
10
10
|
|
|
11
11
|
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
12
12
|
* constraint and a hardcoded string literal type at once, which is why this is its own alias. */
|
|
@@ -111,6 +111,7 @@ export function CatalogShell<TId extends string>({
|
|
|
111
111
|
sections,
|
|
112
112
|
defaultPreviewWidths,
|
|
113
113
|
update,
|
|
114
|
+
versionStatus,
|
|
114
115
|
updateEndpoint,
|
|
115
116
|
}: {
|
|
116
117
|
/** Short product/app name — the sidebar logo and the breadcrumb root. */
|
|
@@ -129,6 +130,12 @@ export function CatalogShell<TId extends string>({
|
|
|
129
130
|
* or already up to date; the viewer treats all three identically: no footer, no banner, no
|
|
130
131
|
* update page content). */
|
|
131
132
|
update?: UpdateNotice | null;
|
|
133
|
+
/** The installed version, whether automatic checks are on (and why), and the last check —
|
|
134
|
+
* `dev` writes it at startup (`.ds-viewer/version.json`), and the shell refreshes it from the
|
|
135
|
+
* local endpoint. With it, the sidebar always shows a version line and the update page offers
|
|
136
|
+
* **Check now** and the automatic-check switch. Without it (an older generated workspace), the
|
|
137
|
+
* footer appears only when an update is known, as before. */
|
|
138
|
+
versionStatus?: VersionStatus | null;
|
|
132
139
|
/** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
|
|
133
140
|
* `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
|
|
134
141
|
* disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
|
|
@@ -198,28 +205,56 @@ export function CatalogShell<TId extends string>({
|
|
|
198
205
|
|
|
199
206
|
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
200
207
|
|
|
208
|
+
// The live version status: starts from what `dev` wrote at startup, then follows the local
|
|
209
|
+
// endpoint (a reload after **Check now** must not fall back to the startup snapshot).
|
|
210
|
+
const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!updateEndpoint || !isWeb()) return;
|
|
213
|
+
let cancelled = false;
|
|
214
|
+
fetch(`${updateEndpoint.baseUrl}/version`, { headers: { [DS_VIEWER_SECRET_HEADER]: updateEndpoint.secret } })
|
|
215
|
+
.then((res) => (res.ok ? (res.json() as Promise<VersionStatus>) : undefined))
|
|
216
|
+
.then((next) => {
|
|
217
|
+
if (next && !cancelled) setLiveStatus(next);
|
|
218
|
+
})
|
|
219
|
+
.catch(() => {
|
|
220
|
+
// The startup snapshot stays; the endpoint may be restarting.
|
|
221
|
+
});
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
}, [updateEndpoint]);
|
|
226
|
+
// With a live status, it is the truth (it includes a **Check now** result); otherwise the
|
|
227
|
+
// startup notice, as before.
|
|
228
|
+
const effectiveUpdate: UpdateNotice | null = liveStatus ? liveStatus.update : update ?? null;
|
|
229
|
+
|
|
201
230
|
const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
|
|
202
231
|
isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
|
|
203
232
|
);
|
|
204
233
|
const dismissMajorBanner = useCallback(() => {
|
|
205
|
-
if (!
|
|
206
|
-
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major',
|
|
207
|
-
setDismissedMajor(
|
|
208
|
-
}, [
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
: undefined;
|
|
234
|
+
if (!effectiveUpdate) return;
|
|
235
|
+
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', effectiveUpdate.latest);
|
|
236
|
+
setDismissedMajor(effectiveUpdate.latest);
|
|
237
|
+
}, [effectiveUpdate]);
|
|
238
|
+
const link = footerLink(effectiveUpdate, liveStatus);
|
|
239
|
+
const footer = link ? { ...link, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) } : undefined;
|
|
212
240
|
// Design: the banner shows on every *normal* page; once you're already looking at the update
|
|
213
241
|
// page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
|
|
214
242
|
// the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
|
|
215
|
-
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(
|
|
243
|
+
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
|
|
216
244
|
|
|
217
245
|
return (
|
|
218
246
|
<View style={styles.root}>
|
|
219
247
|
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
|
|
220
248
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
221
249
|
{active === UPDATE_PAGE_ID ? (
|
|
222
|
-
<UpdatePanel
|
|
250
|
+
<UpdatePanel
|
|
251
|
+
update={effectiveUpdate}
|
|
252
|
+
versionStatus={liveStatus}
|
|
253
|
+
onVersionStatus={setLiveStatus}
|
|
254
|
+
endpoint={updateEndpoint}
|
|
255
|
+
appName={appName}
|
|
256
|
+
headingRef={headingRef}
|
|
257
|
+
/>
|
|
223
258
|
) : activeDef ? (
|
|
224
259
|
<SectionBlock
|
|
225
260
|
key={activeDef.id}
|
|
@@ -229,7 +264,7 @@ export function CatalogShell<TId extends string>({
|
|
|
229
264
|
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
230
265
|
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
231
266
|
// correction pass).
|
|
232
|
-
banner={showBanner &&
|
|
267
|
+
banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
|
|
233
268
|
pager={{
|
|
234
269
|
previousId: neighbors(order, activeDef.id).previous,
|
|
235
270
|
nextId: neighbors(order, activeDef.id).next,
|
|
@@ -43,7 +43,7 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
46
|
+
function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
|
|
47
47
|
const [hovered, setHovered] = useState(false);
|
|
48
48
|
const [focused, setFocused] = useState(false);
|
|
49
49
|
return (
|
|
@@ -63,7 +63,8 @@ function FooterLink({ label, active, onPress }: { label: string; active: boolean
|
|
|
63
63
|
focused && styles.focusRing,
|
|
64
64
|
]}
|
|
65
65
|
>
|
|
66
|
-
|
|
66
|
+
{/* The dot marks an available update; the everyday version line is quiet. */}
|
|
67
|
+
{kind === 'update' && <View style={styles.footerDot} />}
|
|
67
68
|
<Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
|
|
68
69
|
</Pressable>
|
|
69
70
|
);
|
|
@@ -96,10 +97,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
96
97
|
labels?: ReadonlyMap<string, string>;
|
|
97
98
|
active: string | undefined;
|
|
98
99
|
onPress: (id: TId) => void;
|
|
99
|
-
/** The
|
|
100
|
-
*
|
|
101
|
-
* gets the same active styling a selected nav item would. */
|
|
102
|
-
footer?: { label: string; active: boolean; onPress: () => void };
|
|
100
|
+
/** The link below the nav list that opens the update page: the installed version (`'version'`),
|
|
101
|
+
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
102
|
+
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
103
|
+
footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
|
|
103
104
|
}) {
|
|
104
105
|
const [query, setQuery] = useState('');
|
|
105
106
|
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
@@ -201,7 +202,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
201
202
|
</ScrollView>
|
|
202
203
|
{footer && (
|
|
203
204
|
<View style={styles.footerContainer}>
|
|
204
|
-
<FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
|
|
205
|
+
<FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
|
|
205
206
|
</View>
|
|
206
207
|
)}
|
|
207
208
|
</View>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
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
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
7
|
+
} from './updatePanelState';
|
|
8
|
+
import type { UpdateNotice, VersionStatus } from './types';
|
|
7
9
|
|
|
8
10
|
function isWeb(): boolean {
|
|
9
11
|
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
@@ -25,7 +27,7 @@ function VersionsHeader({ current, latest, breaking, releasedAt }: { current: st
|
|
|
25
27
|
<Text style={styles.arrow}>→</Text>
|
|
26
28
|
<Text style={styles.versionsText}>{latest}</Text>
|
|
27
29
|
<View style={[styles.pill, breaking && styles.pillMajor]}>
|
|
28
|
-
<Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{
|
|
30
|
+
<Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{bumpLabel(current, latest, breaking)}</Text>
|
|
29
31
|
</View>
|
|
30
32
|
</View>
|
|
31
33
|
<Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
|
|
@@ -190,6 +192,140 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The update page when no newer version is known (approved mockup
|
|
197
|
+
* docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
|
|
198
|
+
* installed version and which situation applies, when it last checked, the automatic-check
|
|
199
|
+
* switch, and **Check now**. Without an endpoint (a static build) it shows the status only.
|
|
200
|
+
*/
|
|
201
|
+
function VersionDetails({
|
|
202
|
+
status,
|
|
203
|
+
endpoint,
|
|
204
|
+
onStatus,
|
|
205
|
+
}: {
|
|
206
|
+
status: VersionStatus;
|
|
207
|
+
endpoint?: Endpoint;
|
|
208
|
+
onStatus: (next: VersionStatus) => void;
|
|
209
|
+
}) {
|
|
210
|
+
const [checking, setChecking] = useState(false);
|
|
211
|
+
const [justChecked, setJustChecked] = useState(false);
|
|
212
|
+
const [saving, setSaving] = useState(false);
|
|
213
|
+
const [localError, setLocalError] = useState<string | undefined>(undefined);
|
|
214
|
+
const live = Boolean(endpoint) && isWeb();
|
|
215
|
+
const overview = versionOverview(status, justChecked, Date.now());
|
|
216
|
+
const help = autoCheckHelp(status.autoCheck);
|
|
217
|
+
|
|
218
|
+
const checkNow = async () => {
|
|
219
|
+
if (!endpoint || checking) return;
|
|
220
|
+
setChecking(true);
|
|
221
|
+
setJustChecked(false);
|
|
222
|
+
setLocalError(undefined);
|
|
223
|
+
try {
|
|
224
|
+
const res = await authedFetch(endpoint, '/version/check', { method: 'POST' });
|
|
225
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
226
|
+
const next = (await res.json()) as VersionStatus;
|
|
227
|
+
// A newer version moves the whole page into the update flow (the parent re-renders with it).
|
|
228
|
+
setJustChecked(next.lastOutcome === 'ok' && !next.update);
|
|
229
|
+
onStatus(next);
|
|
230
|
+
} catch {
|
|
231
|
+
setLocalError('Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
|
|
232
|
+
} finally {
|
|
233
|
+
setChecking(false);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const setAutoCheck = async (enabled: boolean) => {
|
|
238
|
+
if (!endpoint || saving) return;
|
|
239
|
+
setSaving(true);
|
|
240
|
+
setLocalError(undefined);
|
|
241
|
+
try {
|
|
242
|
+
const res = await authedFetch(endpoint, '/version/auto-check', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({ enabled }),
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
248
|
+
onStatus((await res.json()) as VersionStatus);
|
|
249
|
+
} catch {
|
|
250
|
+
setLocalError('Couldn’t save the setting. Try again.');
|
|
251
|
+
} finally {
|
|
252
|
+
setSaving(false);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const unreachable = status.lastOutcome === 'unreachable';
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<View style={styles.versionsRow}>
|
|
260
|
+
<Text style={styles.versionsText}>{status.current}</Text>
|
|
261
|
+
{overview.pill === 'latest' && (
|
|
262
|
+
<View style={[styles.pill, styles.pillSuccess]}>
|
|
263
|
+
<Text style={[styles.pillText, styles.pillTextSuccess]}>Latest</Text>
|
|
264
|
+
</View>
|
|
265
|
+
)}
|
|
266
|
+
{overview.pill === 'off' && (
|
|
267
|
+
<View style={[styles.pill, styles.pillOff]}>
|
|
268
|
+
<Text style={[styles.pillText, styles.pillTextOff]}>Checks off</Text>
|
|
269
|
+
</View>
|
|
270
|
+
)}
|
|
271
|
+
</View>
|
|
272
|
+
<Text style={styles.muted}>{overview.line}</Text>
|
|
273
|
+
{overview.note && (
|
|
274
|
+
<NoteBox variant={overview.note.variant}>
|
|
275
|
+
{overview.note.bold ? <Text style={styles.noteBoxBold}>{overview.note.bold}</Text> : null}
|
|
276
|
+
{overview.note.text}
|
|
277
|
+
</NoteBox>
|
|
278
|
+
)}
|
|
279
|
+
{!unreachable && (
|
|
280
|
+
<View style={styles.facts}>
|
|
281
|
+
<View style={styles.factRow}>
|
|
282
|
+
<Text style={styles.factLabel}>Last checked</Text>
|
|
283
|
+
<Text style={styles.factValue}>{checkedAgoLabel(status.lastCheckedAt, Date.now())}</Text>
|
|
284
|
+
</View>
|
|
285
|
+
<View style={styles.factRow}>
|
|
286
|
+
<View style={styles.switchText}>
|
|
287
|
+
<Text style={styles.switchLabel}>Check for updates automatically</Text>
|
|
288
|
+
<Text style={styles.switchHelp}>{help.text}</Text>
|
|
289
|
+
</View>
|
|
290
|
+
<Switch
|
|
291
|
+
value={status.autoCheck.enabled}
|
|
292
|
+
onValueChange={setAutoCheck}
|
|
293
|
+
disabled={!live || help.locked || saving || checking}
|
|
294
|
+
// React Native web passes only the label to the real input (no description), so the
|
|
295
|
+
// helper text rides in the accessible name: a screen reader hears why it's on or off.
|
|
296
|
+
accessibilityLabel={`Check for updates automatically. ${help.text}`}
|
|
297
|
+
trackColor={{ false: '#c9c9c9', true: CATALOG_COLOR.accent }}
|
|
298
|
+
thumbColor="#ffffff"
|
|
299
|
+
// The approved mockup's 44 × 26 switch; React Native web's default is 40 × 20, below
|
|
300
|
+
// the 24px minimum hit target.
|
|
301
|
+
style={styles.switch}
|
|
302
|
+
{...({ activeThumbColor: '#ffffff' } as Record<string, unknown>)}
|
|
303
|
+
/>
|
|
304
|
+
</View>
|
|
305
|
+
</View>
|
|
306
|
+
)}
|
|
307
|
+
{localError && <NoteBox variant="err">{localError}</NoteBox>}
|
|
308
|
+
{live && (
|
|
309
|
+
<View style={styles.actions}>
|
|
310
|
+
<Pressable
|
|
311
|
+
onPress={checkNow}
|
|
312
|
+
disabled={checking}
|
|
313
|
+
accessibilityRole="button"
|
|
314
|
+
accessibilityState={{ disabled: checking, busy: checking }}
|
|
315
|
+
style={[styles.secondaryButton, styles.buttonRow, checking && styles.busyButton]}
|
|
316
|
+
>
|
|
317
|
+
{checking && <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} />}
|
|
318
|
+
<Text style={[styles.secondaryButtonText, checking && styles.busyButtonText]}>
|
|
319
|
+
{checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
|
|
320
|
+
</Text>
|
|
321
|
+
</Pressable>
|
|
322
|
+
{unreachable && !checking && <Text style={styles.muted}>or run <Code>npm view @krapwoo/ds-viewer version</Code></Text>}
|
|
323
|
+
</View>
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
193
329
|
/**
|
|
194
330
|
* Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
|
|
195
331
|
* Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
|
|
@@ -203,11 +339,16 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
|
|
|
203
339
|
*/
|
|
204
340
|
export function UpdatePanel({
|
|
205
341
|
update,
|
|
342
|
+
versionStatus,
|
|
343
|
+
onVersionStatus,
|
|
206
344
|
endpoint,
|
|
207
345
|
appName,
|
|
208
346
|
headingRef,
|
|
209
347
|
}: {
|
|
210
348
|
update: UpdateNotice | null;
|
|
349
|
+
/** When present, the no-update view shows the version, the switch and **Check now**. */
|
|
350
|
+
versionStatus?: VersionStatus | null;
|
|
351
|
+
onVersionStatus?: (next: VersionStatus) => void;
|
|
211
352
|
endpoint?: Endpoint;
|
|
212
353
|
appName: string;
|
|
213
354
|
headingRef?: React.Ref<View>;
|
|
@@ -325,6 +466,8 @@ export function UpdatePanel({
|
|
|
325
466
|
content =
|
|
326
467
|
status.phase === 'success' ? (
|
|
327
468
|
<SuccessContent status={status} />
|
|
469
|
+
) : versionStatus ? (
|
|
470
|
+
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
|
|
328
471
|
) : (
|
|
329
472
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
330
473
|
);
|
|
@@ -486,7 +629,7 @@ export function UpdatePanel({
|
|
|
486
629
|
{/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
|
|
487
630
|
* the same cast `SectionBlock.tsx`'s own heading target already uses. */}
|
|
488
631
|
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
489
|
-
<Text style={styles.title}>
|
|
632
|
+
<Text style={styles.title}>DS Viewer updates</Text>
|
|
490
633
|
</View>
|
|
491
634
|
{content}
|
|
492
635
|
</View>
|
|
@@ -517,6 +660,19 @@ const styles = StyleSheet.create({
|
|
|
517
660
|
pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
|
|
518
661
|
pillTextMajor: { color: CATALOG_COLOR.warning },
|
|
519
662
|
pillTextSuccess: { color: CATALOG_COLOR.success },
|
|
663
|
+
pillOff: { backgroundColor: CATALOG_COLOR.chip },
|
|
664
|
+
pillTextOff: { color: CATALOG_COLOR.textMuted },
|
|
665
|
+
facts: { marginTop: 18, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
666
|
+
factRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: CATALOG_SPACE.md, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
|
|
667
|
+
factLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
|
|
668
|
+
factValue: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
|
|
669
|
+
switchText: { flex: 1, gap: 2 },
|
|
670
|
+
switchLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
671
|
+
switch: { width: 44, height: 26 },
|
|
672
|
+
switchHelp: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
673
|
+
buttonRow: { flexDirection: 'row', gap: CATALOG_SPACE.sm },
|
|
674
|
+
busyButton: { borderColor: CATALOG_COLOR.border },
|
|
675
|
+
busyButtonText: { color: CATALOG_COLOR.textMuted },
|
|
520
676
|
muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
|
|
521
677
|
block: { marginTop: 18 },
|
|
522
678
|
h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
|
|
@@ -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, UpdateNotice } from './types.ts';
|
|
6
|
+
import type { NavGroup, UpdateNotice, VersionStatus } 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[] {
|
|
@@ -149,3 +149,16 @@ export function footerLabel(update: UpdateNotice | null | undefined): string | u
|
|
|
149
149
|
if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
|
|
150
150
|
return `Update available · ${update.latest}`;
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/** The sidebar's footer line: the blue "Update available" notice when a newer version is known
|
|
154
|
+
* (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
|
|
155
|
+
* workspace generated before version status existed, which keeps the old notice-only footer. */
|
|
156
|
+
export function footerLink(
|
|
157
|
+
update: UpdateNotice | null | undefined,
|
|
158
|
+
status: VersionStatus | null | undefined,
|
|
159
|
+
): { label: string; kind: 'update' | 'version' } | undefined {
|
|
160
|
+
const notice = footerLabel(update) ?? footerLabel(status?.update);
|
|
161
|
+
if (notice) return { label: notice, kind: 'update' };
|
|
162
|
+
if (status) return { label: `DS Viewer ${status.current}`, kind: 'version' };
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
package/native/catalog/types.ts
CHANGED
|
@@ -256,6 +256,26 @@ export interface UpdateNotice {
|
|
|
256
256
|
releasedAt?: string;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** Where the "check automatically" setting in effect comes from, strongest first: the
|
|
260
|
+
* `DS_VIEWER_NO_UPDATE_CHECK=1` environment variable (CI), your own switch on the update page
|
|
261
|
+
* (saved per project on this computer), the project's `updateCheck` config, or the default (on). */
|
|
262
|
+
export type AutoCheckSource = 'env' | 'personal' | 'project' | 'default';
|
|
263
|
+
|
|
264
|
+
/** What the update page and the sidebar's version line show. Written by `dev` at startup
|
|
265
|
+
* (`.ds-viewer/version.json`) and kept current by the local endpoint (`GET /version`,
|
|
266
|
+
* `POST /version/check`, `POST /version/auto-check`). */
|
|
267
|
+
export interface VersionStatus {
|
|
268
|
+
/** The installed ds-viewer version. */
|
|
269
|
+
current: string;
|
|
270
|
+
autoCheck: { enabled: boolean; source: AutoCheckSource };
|
|
271
|
+
/** The last successful check on this computer (any project), when known. */
|
|
272
|
+
lastCheckedAt?: string;
|
|
273
|
+
/** This run's most recent check: answered, npm unreachable, or not run (checks off). */
|
|
274
|
+
lastOutcome: 'ok' | 'unreachable' | 'not-run';
|
|
275
|
+
/** A newer version, when one is known. */
|
|
276
|
+
update: UpdateNotice | null;
|
|
277
|
+
}
|
|
278
|
+
|
|
259
279
|
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
|
260
280
|
export interface TokenSection {
|
|
261
281
|
/** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AutoCheckSource, VersionStatus } from './types.ts';
|
|
2
|
+
|
|
1
3
|
/** The same shape as `cli/updatePlan.ts`'s `UpdatePlan` — duplicated, not imported: `native/catalog/`
|
|
2
4
|
* is the published package root and never imports from `cli/` (the reverse is normal: `cli/`
|
|
3
5
|
* already imports `native/catalog/` throughout). The endpoint's `POST /plan` response is this
|
|
@@ -72,3 +74,73 @@ export function daysAgoLabel(releasedAt: string, nowMs: number): string {
|
|
|
72
74
|
if (days === 1) return 'Released 1 day ago.';
|
|
73
75
|
return `Released ${days} days ago.`;
|
|
74
76
|
}
|
|
77
|
+
|
|
78
|
+
/** "Last checked" on the update page. */
|
|
79
|
+
export function checkedAgoLabel(at: string | undefined, nowMs: number): string {
|
|
80
|
+
if (!at) return 'Never';
|
|
81
|
+
const minutes = Math.floor((nowMs - Date.parse(at)) / 60_000);
|
|
82
|
+
if (minutes < 1) return 'Just now';
|
|
83
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
84
|
+
const hours = Math.floor(minutes / 60);
|
|
85
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
86
|
+
const days = Math.floor(hours / 24);
|
|
87
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface VersionOverview {
|
|
91
|
+
pill?: 'latest' | 'off';
|
|
92
|
+
line: string;
|
|
93
|
+
note?: { variant: 'ok' | 'err'; bold?: string; text: string };
|
|
94
|
+
/** **Check now**, or **Try again** after npm didn't answer. */
|
|
95
|
+
action: 'check' | 'retry';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The update page when no newer version is known: which of "latest", "checks off", "not checked
|
|
99
|
+
* yet" and "couldn't reach npm" applies. Only a check that actually answered may say "latest".
|
|
100
|
+
* `justChecked` is true right after **Check now** found nothing newer. */
|
|
101
|
+
export function versionOverview(status: VersionStatus, justChecked: boolean, nowMs: number): VersionOverview {
|
|
102
|
+
if (status.lastOutcome === 'unreachable') {
|
|
103
|
+
const last = status.lastCheckedAt ? ` The last successful check, ${checkedAgoLabel(status.lastCheckedAt, nowMs).toLowerCase()}, found no newer version.` : '';
|
|
104
|
+
return { line: `You’re on ${status.current}.`, action: 'retry', note: { variant: 'err', bold: 'Couldn’t reach npm.', text: ` You may be offline.${last}` } };
|
|
105
|
+
}
|
|
106
|
+
if (status.lastOutcome === 'not-run') {
|
|
107
|
+
if (!status.autoCheck.enabled) {
|
|
108
|
+
return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off, so this can’t tell whether a newer version exists.`, action: 'check' };
|
|
109
|
+
}
|
|
110
|
+
return { line: `You’re on ${status.current}. Not checked yet: automatic checks run when the viewer starts.`, action: 'check' };
|
|
111
|
+
}
|
|
112
|
+
// Turned off after a check answered (approved mockup state 7b); a fresh Check now still wins.
|
|
113
|
+
if (!status.autoCheck.enabled && !justChecked) {
|
|
114
|
+
return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off.`, action: 'check' };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
pill: 'latest',
|
|
118
|
+
line: 'You’re on the latest version.',
|
|
119
|
+
action: 'check',
|
|
120
|
+
...(justChecked ? { note: { variant: 'ok' as const, text: 'Checked just now. No newer version.' } } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The switch's helper text: where the setting in effect comes from. Only CI's environment
|
|
125
|
+
* variable locks the switch. */
|
|
126
|
+
export function autoCheckHelp(autoCheck: { enabled: boolean; source: AutoCheckSource }): { text: string; locked: boolean } {
|
|
127
|
+
if (autoCheck.source === 'env') return { text: 'Off in this environment (DS_VIEWER_NO_UPDATE_CHECK=1).', locked: true };
|
|
128
|
+
if (autoCheck.source === 'personal') {
|
|
129
|
+
return { text: autoCheck.enabled ? 'Once a day, when the viewer starts. Saved for you on this computer.' : 'Turned off by you on this computer.', locked: false };
|
|
130
|
+
}
|
|
131
|
+
if (!autoCheck.enabled) {
|
|
132
|
+
return { text: 'Off for this project (updateCheck: false in ds-viewer.config.ts). Turning it on here applies to you only.', locked: false };
|
|
133
|
+
}
|
|
134
|
+
return { text: 'Once a day, when the viewer starts.', locked: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The update's size pill. Breaking updates are always "Major" (the CLI decides that); otherwise
|
|
138
|
+
* a change in the second number is "Minor" and anything smaller "Patch". */
|
|
139
|
+
export function bumpLabel(current: string, latest: string, breaking: boolean): 'Major' | 'Minor' | 'Patch' {
|
|
140
|
+
if (breaking) return 'Major';
|
|
141
|
+
const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number);
|
|
142
|
+
const a = parts(current);
|
|
143
|
+
const b = parts(latest);
|
|
144
|
+
if (!a || !b) return 'Minor';
|
|
145
|
+
return a[0] === b[0] && a[1] === b[1] ? 'Patch' : 'Minor';
|
|
146
|
+
}
|