dsh-tokenlens 1.2.2 → 1.3.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/client.js CHANGED
@@ -68,6 +68,14 @@ window.__ModuleLoader__.load({
68
68
  const MANAGEMENT_SYNC_MS = 15000
69
69
  /** Retry delay of a 明细-window save blocked by an in-flight settings request (v1.2.1). */
70
70
  const DETAILS_WINDOW_SAVE_RETRY_MS = 200
71
+ /**
72
+ * Delays of the network-free providers re-reads while 「启动时检查更新」 is on
73
+ * and no result exists yet: the Host's own startup / toggle check runs in
74
+ * the background, so hello or a settings answer can win the race. Two
75
+ * attempts cover a normal registry round-trip without ever asking the
76
+ * registry twice; the management page's silent check covers the rest.
77
+ */
78
+ const UPDATE_RESULT_REFRESH_MS = [1200, 4000]
71
79
  /** How many per-provider / per-model stats rows the dialog lists. */
72
80
  const MAX_PROVIDER_ROWS = 5
73
81
  const MAX_MODEL_ROWS = 3
@@ -137,7 +145,13 @@ window.__ModuleLoader__.load({
137
145
  defaultScope: 'provider',
138
146
  detailsExpanded: 'auto',
139
147
  detailsWindow: 'total',
148
+ // 「启动时检查更新」 is persisted too. ON by default (v1.3.1) so users learn
149
+ // about new releases; switching it off keeps the plugin fully local-only.
150
+ // Every other value (an old Host omitting the field included) means ON.
151
+ updateCheck: true,
140
152
  }
153
+ /** The npm package page the 「打开 npm」 update links point at. */
154
+ const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
141
155
 
142
156
  const zh = {
143
157
  title: '词元透镜(dsh-TokenLens)',
@@ -340,6 +354,23 @@ window.__ModuleLoader__.load({
340
354
  settingsDetailsCollapsed: '收起',
341
355
  settingsResetDefaults: '恢复默认设置',
342
356
  settingsResetDefaultsHint: '恢复为插件默认的显示设置',
357
+ updateCheckLabel: '启动时检查更新',
358
+ updateCheckHint: '默认开启:开启时访问 npm registry 检查新版本;关闭后插件完全本地运行',
359
+ updateCheckOff: '关闭',
360
+ updateCheckOn: '开启',
361
+ updateChecking: '正在检查…',
362
+ updateLatest: '已是最新版本 v{version}',
363
+ updateAvailable: '发现新版本 v{latest}(当前 v{current})',
364
+ updateCheckFailed: '检查失败',
365
+ updateCheckNow: '立即检查',
366
+ updateCheckNever: '尚未检查',
367
+ updateOpenNpm: '打开 npm',
368
+ // The update status now lives on the plugin-version line of the dialog
369
+ // body (footer line removed); these two key the version-line suffix.
370
+ updateVersionNew: '有新版本 v{latest}',
371
+ updateVersionLatest: '已是最新版本',
372
+ // Kept for compatibility (the footer line is no longer rendered).
373
+ updateDialogLine: '发现新版本 v{latest} · 打开 npm',
343
374
  providerBothLabels: '短标签:{short} · 全名:{full}',
344
375
  providerShortLabel: '短标签',
345
376
  providerShortLabelHint: '1–12 字符',
@@ -568,6 +599,23 @@ window.__ModuleLoader__.load({
568
599
  settingsDetailsCollapsed: 'Collapsed',
569
600
  settingsResetDefaults: 'Restore defaults',
570
601
  settingsResetDefaultsHint: 'Restore the plugin default display settings',
602
+ updateCheckLabel: 'Check for updates at startup',
603
+ updateCheckHint: 'On by default: contacts the npm registry to check for a new version; switch it off to keep the plugin fully local',
604
+ updateCheckOff: 'Off',
605
+ updateCheckOn: 'On',
606
+ updateChecking: 'Checking…',
607
+ updateLatest: 'Up to date: v{version}',
608
+ updateAvailable: 'New version v{latest} available (current v{current})',
609
+ updateCheckFailed: 'Update check failed',
610
+ updateCheckNow: 'Check now',
611
+ updateCheckNever: 'Not checked yet',
612
+ updateOpenNpm: 'Open on npm',
613
+ // The update status now lives on the plugin-version line of the dialog
614
+ // body (footer line removed); these two key the version-line suffix.
615
+ updateVersionNew: 'New version v{latest}',
616
+ updateVersionLatest: 'Up to date',
617
+ // Kept for compatibility (the footer line is no longer rendered).
618
+ updateDialogLine: 'New version v{latest} · Open on npm',
571
619
  providerBothLabels: 'Short label: {short} · Full name: {full}',
572
620
  providerShortLabel: 'Short label',
573
621
  providerShortLabelHint: '1–12 characters',
@@ -1111,9 +1159,9 @@ window.__ModuleLoader__.load({
1111
1159
  }
1112
1160
 
1113
1161
  /**
1114
- * The five display settings of the `providers` payload (v0.29.0; the 明细
1115
- * donut window joined them in v1.2.1), every field normalized to a usable
1116
- * value so old Hosts keep the previous defaults.
1162
+ * The five display settings plus 「启动时检查更新」 of the `providers`
1163
+ * payload, every field normalized to a usable value so old Hosts keep the
1164
+ * previous defaults.
1117
1165
  */
1118
1166
  function normalizePluginSettings(raw) {
1119
1167
  const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
@@ -1123,7 +1171,49 @@ window.__ModuleLoader__.load({
1123
1171
  defaultScope: normalizeDisplayScope(source.defaultScope),
1124
1172
  detailsExpanded: normalizeDetailsExpanded(source.detailsExpanded),
1125
1173
  detailsWindow: normalizeDetailsWindow(source.detailsWindow),
1174
+ // The startup update check is ON by default (v1.3.1): only an explicit
1175
+ // boolean is honored, so an old Host that omits the field stays on.
1176
+ updateCheck:
1177
+ typeof source.updateCheck === 'boolean' ? source.updateCheck : DEFAULT_PLUGIN_SETTINGS.updateCheck,
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * The optional `update` object of the hello / providers envelope: the last
1183
+ * known update check result, or null when the Host never checked.
1184
+ *
1185
+ * Defensive by design — the update UI must never throw on hostile wire
1186
+ * data. String fields collapse to `''`/null, `available` is only claimed
1187
+ * when it is literally `true` *and* names a version, and a wire error keeps
1188
+ * the shared normalized error shape.
1189
+ */
1190
+ function normalizeUpdate(raw) {
1191
+ if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return null
1192
+ const current = stringOrEmpty(raw.current).trim()
1193
+ const latestText = stringOrEmpty(raw.latest).trim()
1194
+ const latest = latestText === '' ? null : latestText
1195
+ return {
1196
+ current,
1197
+ latest,
1198
+ available: raw.available === true && latest !== null,
1199
+ checkedAt: stringOrEmpty(raw.checkedAt).trim(),
1200
+ error: raw.error === null || raw.error === undefined ? null : normalizeError(raw.error),
1201
+ }
1202
+ }
1203
+
1204
+ /** Whether two normalized update infos are render-identical (avoids store churn). */
1205
+ function sameUpdate(a, b) {
1206
+ if (a === b) return true
1207
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
1208
+ if (a.current !== b.current || a.latest !== b.latest || a.available !== b.available || a.checkedAt !== b.checkedAt) {
1209
+ return false
1126
1210
  }
1211
+ if (a.error === null || b.error === null) return a.error === b.error
1212
+ return (
1213
+ a.error.code === b.error.code &&
1214
+ a.error.message === b.error.message &&
1215
+ a.error.retryable === b.error.retryable
1216
+ )
1127
1217
  }
1128
1218
 
1129
1219
  /**
@@ -1184,6 +1274,10 @@ window.__ModuleLoader__.load({
1184
1274
  ? raw.dshProviders.map((entry) => normalizeDshProviderEntry(entry)).filter((entry) => entry !== null)
1185
1275
  : []
1186
1276
  const settings = raw.settings !== null && typeof raw.settings === 'object' ? raw.settings : {}
1277
+ // The update check result travels with every providers payload; `hasUpdate`
1278
+ // distinguishes "the Host said null" from "an old Host omitted the field",
1279
+ // so a hello-provided result is never wiped by a payload without it.
1280
+ const hasUpdate = Object.prototype.hasOwnProperty.call(raw, 'update')
1187
1281
  return {
1188
1282
  // v0.17.0 identity: dedupe by providerId, last-wins, so the filter tabs
1189
1283
  // never list the same provider twice even if the Host sends a duplicate.
@@ -1196,6 +1290,10 @@ window.__ModuleLoader__.load({
1196
1290
  storeFile: stringOrEmpty(raw.storeFile).trim(),
1197
1291
  storeFileDisplay: stringOrEmpty(raw.storeFileDisplay).trim(),
1198
1292
  settings: normalizePluginSettings(settings),
1293
+ update: hasUpdate ? normalizeUpdate(raw.update) : null,
1294
+ hasUpdate,
1295
+ // Optional: the Host may advertise the npm page its links should open.
1296
+ updatePackageUrl: stringOrEmpty(raw.updatePackageUrl).trim(),
1199
1297
  labelCatalog,
1200
1298
  }
1201
1299
  }
@@ -1773,6 +1871,16 @@ window.__ModuleLoader__.load({
1773
1871
  labelCatalog: {},
1774
1872
  // Plugin version from the hello envelope (dialog footnote).
1775
1873
  pluginVersion: '',
1874
+ // 「启动时检查更新」 (persisted setting) and the last check result the
1875
+ // hello / providers envelope reported, plus the in-flight flag of an
1876
+ // explicit 「立即检查」. The flag is deliberately independent from
1877
+ // `management.settingsBusy`: a check never blocks a settings save.
1878
+ updateCheck: DEFAULT_PLUGIN_SETTINGS.updateCheck,
1879
+ update: null,
1880
+ updateChecking: false,
1881
+ // The canonical npm page the update links open; the Host may advertise
1882
+ // its own (newer bundles may move the package), this is the fallback.
1883
+ updatePackageUrl: NPM_PACKAGE_URL,
1776
1884
  // Extra DSH-only detail blocks (provider split / top models). Collapsed by
1777
1885
  // default on short screens so the dialog fits without a scrollbar.
1778
1886
  showDetails:
@@ -1808,8 +1916,8 @@ window.__ModuleLoader__.load({
1808
1916
  let usageEpoch = 0
1809
1917
 
1810
1918
  /**
1811
- * The five display settings currently in effect (v0.29.0; the 明细 donut
1812
- * window joined them in v1.2.1).
1919
+ * The display settings currently in effect: the five v0.29.0 defaults (the
1920
+ * v1.2.1 明细 donut window included) plus 「启动时检查更新」.
1813
1921
  */
1814
1922
  function currentPluginSettings() {
1815
1923
  const state = store.get()
@@ -1819,6 +1927,7 @@ window.__ModuleLoader__.load({
1819
1927
  defaultScope: normalizeDisplayScope(state.defaultScope),
1820
1928
  detailsExpanded: normalizeDetailsExpanded(state.detailsExpanded),
1821
1929
  detailsWindow: normalizeDetailsWindow(state.detailsWindow),
1930
+ updateCheck: state.updateCheck === true,
1822
1931
  }
1823
1932
  }
1824
1933
 
@@ -2065,6 +2174,18 @@ window.__ModuleLoader__.load({
2065
2174
  if (options.adoptDetailsWindow === true && current.detailsWindow !== settings.detailsWindow) {
2066
2175
  patch.detailsWindow = settings.detailsWindow
2067
2176
  }
2177
+ // 「启动时检查更新」 is adopted on every answer (it is the persisted value
2178
+ // echoing back); the answered checkbox state must win after a toggle.
2179
+ if (current.updateCheck !== settings.updateCheck) patch.updateCheck = settings.updateCheck
2180
+ // A payload that carries `update` (even as null) is authoritative for the
2181
+ // last check result; one that omits it (old Host) keeps what hello said.
2182
+ if (parsed.hasUpdate === true && !sameUpdate(current.update, parsed.update)) {
2183
+ patch.update = parsed.update
2184
+ }
2185
+ // The Host-advertised npm page travels with the same payload.
2186
+ if (parsed.updatePackageUrl !== '' && current.updatePackageUrl !== parsed.updatePackageUrl) {
2187
+ patch.updatePackageUrl = parsed.updatePackageUrl
2188
+ }
2068
2189
  if (parsed.labelCatalog !== null && typeof parsed.labelCatalog === 'object') {
2069
2190
  const catalog = { ...current.labelCatalog, ...parsed.labelCatalog }
2070
2191
  if (Object.keys(catalog).length > 0) patch.labelCatalog = catalog
@@ -2123,6 +2244,9 @@ window.__ModuleLoader__.load({
2123
2244
  storeFileDisplay: parsed !== null ? parsed.storeFileDisplay : '',
2124
2245
  loadedAt: Date.now(),
2125
2246
  })
2247
+ // 「启动时检查更新」: the first management-page load runs one silent
2248
+ // check when the setting is on and no result is known yet.
2249
+ maybeAutoUpdateCheck()
2126
2250
  return { kind: 'ok' }
2127
2251
  }
2128
2252
  patchManagement({ inFlight: false, phase: 'error', error: normalizeError(data.error) })
@@ -2273,6 +2397,10 @@ window.__ModuleLoader__.load({
2273
2397
  }
2274
2398
  : {}),
2275
2399
  })
2400
+ // Turning 「启动时检查更新」 on makes the Host run one check right away;
2401
+ // its answer may still be in flight, so read the local result shortly
2402
+ // after (no extra registry request).
2403
+ if (store.get().updateCheck === true && store.get().update === null) scheduleUpdateResultRefresh()
2276
2404
  return { kind: 'ok' }
2277
2405
  }
2278
2406
  patchManagement({ settingsBusy: false, settingsError: normalizeError(data.error) })
@@ -2331,6 +2459,128 @@ window.__ModuleLoader__.load({
2331
2459
  void requestSettingsUpdate({ ...DEFAULT_PLUGIN_SETTINGS })
2332
2460
  }
2333
2461
 
2462
+ /** Guards the one silent check the management page may start after opening. */
2463
+ let autoUpdateCheckStarted = false
2464
+ /** Whether the settings error banner currently shows an update-check failure. */
2465
+ let updateCheckErrorShown = false
2466
+ /** One delayed retry when the Host says "a check is already running". */
2467
+ const UPDATE_CHECK_RETRY_MS = 1200
2468
+ let updateCheckRetryTimer = null
2469
+
2470
+ /**
2471
+ * Run one update check (「立即检查」, and the management page's one silent
2472
+ * startup check when 「启动时检查更新」 is on).
2473
+ *
2474
+ * `updateChecking` is an independent flag, never `settingsBusy`: a check may
2475
+ * not block a settings save (and vice versa). A Host-level failure travels
2476
+ * inside `update.error` (the status row renders 检查失败); a transport or
2477
+ * envelope failure uses the management page's existing error banner. Never
2478
+ * throws.
2479
+ */
2480
+ async function requestUpdateCheck(options = {}) {
2481
+ const retried = options.retried === true
2482
+ if (store.get().updateChecking === true) return { kind: 'busy' }
2483
+ store.set({ updateChecking: true })
2484
+ let data
2485
+ try {
2486
+ data = await call({ action: 'updateCheck' })
2487
+ } catch (error) {
2488
+ store.set({ updateChecking: false })
2489
+ updateCheckErrorShown = true
2490
+ patchManagement({ settingsError: managementErrorOf(error) })
2491
+ return { kind: 'transport' }
2492
+ }
2493
+ if (data !== null && typeof data === 'object' && typeof data.ok === 'boolean') {
2494
+ if (data.ok === true) {
2495
+ const update = normalizeUpdate(data.update)
2496
+ if (update !== null) {
2497
+ store.set({ updateChecking: false, update })
2498
+ // A successful retry retires the banner of the failed one instead of
2499
+ // leaving a stale check failure above a fresh result.
2500
+ if (updateCheckErrorShown === true) {
2501
+ updateCheckErrorShown = false
2502
+ patchManagement({ settingsError: null })
2503
+ }
2504
+ return { kind: 'ok' }
2505
+ }
2506
+ // A first-ever check that is already in flight comes back as
2507
+ // `{ok:true, update:null}` (the Host's single-flight returns the last
2508
+ // result). Retry once after a short delay instead of reporting a bad
2509
+ // response; a result that arrived meanwhile (management sync) retires
2510
+ // the retry.
2511
+ if (!retried) {
2512
+ if (updateCheckRetryTimer !== null) window.clearTimeout(updateCheckRetryTimer)
2513
+ updateCheckRetryTimer = window.setTimeout(() => {
2514
+ updateCheckRetryTimer = null
2515
+ store.set({ updateChecking: false })
2516
+ if (store.get().update !== null) return
2517
+ void requestUpdateCheck({ retried: true })
2518
+ }, UPDATE_CHECK_RETRY_MS)
2519
+ return { kind: 'pending' }
2520
+ }
2521
+ store.set({ updateChecking: false })
2522
+ return { kind: 'pending' }
2523
+ }
2524
+ store.set({ updateChecking: false })
2525
+ updateCheckErrorShown = true
2526
+ patchManagement({ settingsError: normalizeError(data.error) })
2527
+ return { kind: 'envelope' }
2528
+ }
2529
+ store.set({ updateChecking: false })
2530
+ updateCheckErrorShown = true
2531
+ patchManagement({
2532
+ settingsError: { code: 'internal', message: 'unexpected updateCheck response', retryable: true },
2533
+ })
2534
+ return { kind: 'transport' }
2535
+ }
2536
+
2537
+ /**
2538
+ * The management page's ONE silent check: only while the setting is on and
2539
+ * no result is known yet. The module-level flag makes a re-open, a tab
2540
+ * switch or a provider-list refresh impossible to turn into a second check.
2541
+ */
2542
+ function maybeAutoUpdateCheck() {
2543
+ if (autoUpdateCheckStarted === true) return
2544
+ const state = store.get()
2545
+ if (state.updateCheck !== true || state.update !== null) return
2546
+ autoUpdateCheckStarted = true
2547
+ void requestUpdateCheck()
2548
+ }
2549
+
2550
+ /** The pending network-free providers re-read (see UPDATE_RESULT_REFRESH_MS). */
2551
+ let updateResultRefreshTimer = null
2552
+ let updateResultRefreshAttempt = 0
2553
+
2554
+ /**
2555
+ * Read the local providers payload once more after the Host's background
2556
+ * update check had time to finish (`updateCheck` on, no result known yet).
2557
+ *
2558
+ * This never asks the npm registry itself — it only re-reads the Host state
2559
+ * that the Host's own startup / toggle check fills in. No-op once a result
2560
+ * exists or the setting is off, so it can be armed from several places.
2561
+ */
2562
+ function scheduleUpdateResultRefresh() {
2563
+ if (updateResultRefreshTimer !== null) return
2564
+ const delay = UPDATE_RESULT_REFRESH_MS[updateResultRefreshAttempt]
2565
+ if (delay === undefined) return
2566
+ updateResultRefreshAttempt += 1
2567
+ updateResultRefreshTimer = window.setTimeout(() => {
2568
+ updateResultRefreshTimer = null
2569
+ const state = store.get()
2570
+ if (state.updateCheck !== true || state.update !== null) {
2571
+ updateResultRefreshAttempt = 0
2572
+ return
2573
+ }
2574
+ void bootstrapProviders().then(() => {
2575
+ if (store.get().update !== null) {
2576
+ updateResultRefreshAttempt = 0
2577
+ return
2578
+ }
2579
+ scheduleUpdateResultRefresh()
2580
+ })
2581
+ }, delay)
2582
+ }
2583
+
2334
2584
  /** Force one upstream read for a provider and keep the row outcome. Never throws. */
2335
2585
  async function requestProviderTest(providerId) {
2336
2586
  const before = store.get().management
@@ -2519,6 +2769,9 @@ window.__ModuleLoader__.load({
2519
2769
  const current = store.get().management
2520
2770
  const stale = current.loadedAt === 0 || Date.now() - current.loadedAt >= PROVIDERS_TTL_MS
2521
2771
  if (current.inFlight !== true && stale) void requestProviders()
2772
+ // A fresh check when the setting is already known and no result exists
2773
+ // (the requestProviders answer triggers the same one-shot path otherwise).
2774
+ maybeAutoUpdateCheck()
2522
2775
  }
2523
2776
 
2524
2777
  /** Switch back to the usage page (the modal chrome stays). */
@@ -2633,12 +2886,20 @@ window.__ModuleLoader__.load({
2633
2886
  void requestProviderUpdate(provider.providerId, { shortLabel: '', label: '' })
2634
2887
  }
2635
2888
 
2636
- /** Store the plugin version reported by the hello envelope (dialog footnote). */
2889
+ /** Store the plugin version and the last update check result of the hello envelope. */
2637
2890
  function applyHelloInfo(data) {
2638
2891
  if (data === null || typeof data !== 'object') return
2892
+ const patch = {}
2639
2893
  if (typeof data.version === 'string' && data.version !== '') {
2640
- if (store.get().pluginVersion !== data.version) store.set({ pluginVersion: data.version })
2894
+ if (store.get().pluginVersion !== data.version) patch.pluginVersion = data.version
2895
+ }
2896
+ // `update` is optional on the wire: only an explicit field (null included)
2897
+ // replaces what the store already knows.
2898
+ if (Object.prototype.hasOwnProperty.call(data, 'update')) {
2899
+ const update = normalizeUpdate(data.update)
2900
+ if (!sameUpdate(store.get().update, update)) patch.update = update
2641
2901
  }
2902
+ if (Object.keys(patch).length > 0) store.set(patch)
2642
2903
  }
2643
2904
 
2644
2905
  /** Announce this browser half to the Host (also records the current provider). */
@@ -2772,6 +3033,11 @@ window.__ModuleLoader__.load({
2772
3033
  // v0.10.0: learn the persisted chip label style + wire labels once, without
2773
3034
  // touching the management page state (silent when an old Host answers nothing).
2774
3035
  void bootstrapProviders()
3036
+ // The Host runs its optional startup update check in the background; the
3037
+ // hello / bootstrap answers above can win the race and carry no result yet.
3038
+ // Re-read the (local, no-network) providers payload shortly after so the
3039
+ // dialog footer can show the result without opening the management page.
3040
+ scheduleUpdateResultRefresh()
2775
3041
  pollTimer = window.setInterval(() => {
2776
3042
  void poll()
2777
3043
  }, POLL_INTERVAL_MS)
@@ -2783,6 +3049,16 @@ window.__ModuleLoader__.load({
2783
3049
  disposed = true
2784
3050
  if (pollTimer !== null) window.clearInterval(pollTimer)
2785
3051
  if (retryTimer !== null) window.clearTimeout(retryTimer)
3052
+ // Pending update-check timers must not fire into a disposed controller.
3053
+ updateResultRefreshAttempt = 0
3054
+ if (updateResultRefreshTimer !== null) {
3055
+ window.clearTimeout(updateResultRefreshTimer)
3056
+ updateResultRefreshTimer = null
3057
+ }
3058
+ if (updateCheckRetryTimer !== null) {
3059
+ window.clearTimeout(updateCheckRetryTimer)
3060
+ updateCheckRetryTimer = null
3061
+ }
2786
3062
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
2787
3063
  document.removeEventListener('visibilitychange', onVisibilityChange)
2788
3064
  }
@@ -2974,7 +3250,11 @@ window.__ModuleLoader__.load({
2974
3250
  .dsh-tokenlens-chart-drill-title{color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
2975
3251
  /* 明细 donut window switch (v1.2.0): one pill row under the details title. */
2976
3252
  .dsh-tokenlens-details-window{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
2977
- .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75}
3253
+ .dsh-tokenlens-version{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;text-align:right;opacity:.75;overflow-wrap:anywhere}
3254
+ /* Update status on the plugin-version line (dialog body): wraps on narrow screens. */
3255
+ .dsh-tokenlens-version-update{white-space:normal;overflow-wrap:anywhere}
3256
+ .dsh-tokenlens-version .dsh-tokenlens-update-link{color:inherit;font-size:inherit;vertical-align:baseline}
3257
+ .dsh-tokenlens-version .dsh-tokenlens-update-link .dsh-tokenlens-icon{margin-left:2px;vertical-align:-1px}
2978
3258
  .dsh-tokenlens-cards{gap:6px;flex-wrap:wrap;display:flex}
2979
3259
  .dsh-tokenlens-cards-caption{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;margin-bottom:-3px}
2980
3260
  .dsh-tokenlens-harness-breakdown{display:flex;flex-direction:column;gap:4px}
@@ -3118,6 +3398,20 @@ window.__ModuleLoader__.load({
3118
3398
  .dsh-tokenlens-settings-options .dsh-tokenlens-settings-option{display:inline-flex;align-items:center;gap:4px}
3119
3399
  .dsh-tokenlens-settings-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px;min-width:0}
3120
3400
  @media (max-width:560px){.dsh-tokenlens-settings-row{grid-template-columns:minmax(0,1fr)}}
3401
+ /* Update check block (update UI): toggle row, privacy hint, status line + 立即检查. */
3402
+ .dsh-tokenlens-update-section{display:flex;flex-direction:column;gap:6px;min-width:0;margin-top:2px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.18))}
3403
+ .dsh-tokenlens-update-hint{opacity:.8;font-size:11px}
3404
+ .dsh-tokenlens-update-status{align-items:center;flex-wrap:wrap;display:flex;gap:5px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;overflow-wrap:anywhere}
3405
+ .dsh-tokenlens-update-status[data-kind=error]{color:var(--dsw-alias-label-error,#d9534f)}
3406
+ .dsh-tokenlens-update-status[data-kind=available]{color:var(--dsw-alias-label-primary)}
3407
+ .dsh-tokenlens-update-error-detail{opacity:.85;min-width:0;overflow-wrap:anywhere}
3408
+ .dsh-tokenlens-update-retry{font:inherit;font-size:11px;line-height:inherit;padding:0;border:0;background:0 0;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
3409
+ .dsh-tokenlens-update-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;min-width:0}
3410
+ .dsh-tokenlens-update-link{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:inherit;text-decoration:underline;text-underline-offset:2px;min-width:0}
3411
+ .dsh-tokenlens-update-link:hover,.dsh-tokenlens-update-retry:hover{color:var(--dsw-alias-label-primary)}
3412
+ .dsh-tokenlens-update-link:focus-visible,.dsh-tokenlens-update-retry:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5));border-radius:2px}
3413
+ .dsh-tokenlens-update-line{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 6px;min-width:0;margin-right:auto;color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4}
3414
+ .dsh-tokenlens-update-line .dsh-tokenlens-update-link{color:inherit}
3121
3415
  .dsh-tokenlens-provider-labels{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow-wrap:anywhere;display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px}
3122
3416
  .dsh-tokenlens-provider-labels .dsh-tokenlens-icon{margin-top:1px}
3123
3417
  /* Inline icons: currentColor stroke, never a text node (keeps textOf stable). */
@@ -5891,11 +6185,13 @@ window.__ModuleLoader__.load({
5891
6185
  h(
5892
6186
  'button',
5893
6187
  {
5894
- key: option.value,
6188
+ key: String(option.value),
5895
6189
  type: 'button',
5896
6190
  className: `dsh-tokenlens-scope-option dsh-tokenlens-settings-option${field === 'labelStyle' ? ' dsh-tokenlens-label-style-option' : ''}`,
5897
6191
  'data-setting': field,
5898
- 'data-value': option.value,
6192
+ // A boolean row (updateCheck) is stringified so `data-*` matches
6193
+ // the real DOM; the string rows keep their wire value verbatim.
6194
+ 'data-value': typeof option.value === 'string' ? option.value : String(option.value),
5899
6195
  'data-label-style': field === 'labelStyle' ? option.value : undefined,
5900
6196
  'data-active': active === option.value ? 'true' : 'false',
5901
6197
  'aria-pressed': active === option.value ? 'true' : 'false',
@@ -5913,6 +6209,127 @@ window.__ModuleLoader__.load({
5913
6209
  )
5914
6210
  }
5915
6211
 
6212
+ /** The npm page the update links open (the Host-advertised one when present). */
6213
+ function updatePackageUrlOf(state) {
6214
+ return typeof state?.updatePackageUrl === 'string' && state.updatePackageUrl !== ''
6215
+ ? state.updatePackageUrl
6216
+ : NPM_PACKAGE_URL
6217
+ }
6218
+
6219
+ /**
6220
+ * The 「更新检查」 status line: checking → failed → update available → up to
6221
+ * date → never checked. `data-update-status` names the branch for tests and
6222
+ * styling; the error branch keeps a 重试 action only when retryable.
6223
+ */
6224
+ function buildUpdateStatusLine(state, t) {
6225
+ const update = state.update
6226
+ const packageUrl = updatePackageUrlOf(state)
6227
+ const line = (kind, children) =>
6228
+ h(
6229
+ 'div',
6230
+ {
6231
+ key: 'update-status',
6232
+ className: 'dsh-tokenlens-update-status',
6233
+ 'data-update-status': kind,
6234
+ 'data-kind': kind,
6235
+ role: 'status',
6236
+ },
6237
+ Icon(kind === 'error' ? 'alert' : kind === 'available' ? 'bolt' : kind === 'latest' ? 'check' : 'info', 12),
6238
+ children,
6239
+ )
6240
+ if (state.updateChecking === true) {
6241
+ return line('checking', h('span', { key: 'text' }, t('updateChecking')))
6242
+ }
6243
+ if (update !== null && update.error !== null) {
6244
+ return line('error', [
6245
+ h('span', { key: 'text' }, t('updateCheckFailed')),
6246
+ h('span', { key: 'detail', className: 'dsh-tokenlens-update-error-detail', title: update.error.message }, managementErrorText(update.error, t)),
6247
+ update.error.retryable === true
6248
+ ? h(
6249
+ 'button',
6250
+ {
6251
+ key: 'retry',
6252
+ type: 'button',
6253
+ className: 'dsh-tokenlens-update-retry',
6254
+ 'data-update-retry': 'true',
6255
+ onClick: () => {
6256
+ void requestUpdateCheck()
6257
+ },
6258
+ },
6259
+ t('retry'),
6260
+ )
6261
+ : null,
6262
+ ])
6263
+ }
6264
+ if (update !== null && update.available === true) {
6265
+ return line('available', [
6266
+ h('span', { key: 'text' }, t('updateAvailable', { latest: update.latest, current: update.current })),
6267
+ h(
6268
+ 'a',
6269
+ {
6270
+ key: 'link',
6271
+ className: 'dsh-tokenlens-update-link',
6272
+ 'data-update-link': 'true',
6273
+ href: packageUrl,
6274
+ target: '_blank',
6275
+ rel: 'noreferrer noopener',
6276
+ title: packageUrl,
6277
+ onClick: (event) => openConsole(packageUrl, event),
6278
+ },
6279
+ Icon('external', 12),
6280
+ t('updateOpenNpm'),
6281
+ ),
6282
+ ])
6283
+ }
6284
+ if (update !== null && update.latest !== null && update.available === false) {
6285
+ return line('latest', h('span', { key: 'text' }, t('updateLatest', { version: update.latest })))
6286
+ }
6287
+ return line('never', h('span', { key: 'text' }, t('updateCheckNever')))
6288
+ }
6289
+
6290
+ /**
6291
+ * The 「更新检查」 block of 插件设置: the startup toggle (the shared settings
6292
+ * row), the registry-privacy hint, the last check result and 「立即检查」.
6293
+ * The check button works whether the startup toggle is on or off, and its
6294
+ * `updateChecking` flag is independent from `settingsBusy`.
6295
+ */
6296
+ function buildUpdateSection(state, t) {
6297
+ const checking = state.updateChecking === true
6298
+ return h(
6299
+ 'div',
6300
+ { key: 'update-section', className: 'dsh-tokenlens-update-section', 'data-update-section': 'true' },
6301
+ buildSettingsRow(state, t, 'updateCheck', t('updateCheckLabel'), [
6302
+ { value: false, label: t('updateCheckOff'), icon: 'ban' },
6303
+ { value: true, label: t('updateCheckOn'), icon: 'check' },
6304
+ ]),
6305
+ h(
6306
+ 'div',
6307
+ { key: 'hint', className: 'dsh-tokenlens-subtitle dsh-tokenlens-update-hint', 'data-update-hint': 'true' },
6308
+ t('updateCheckHint'),
6309
+ ),
6310
+ buildUpdateStatusLine(state, t),
6311
+ h(
6312
+ 'div',
6313
+ { key: 'actions', className: 'dsh-tokenlens-update-actions' },
6314
+ h(
6315
+ Button,
6316
+ {
6317
+ variant: 'outline',
6318
+ className: 'dsh-tokenlens-update-check',
6319
+ 'data-update-check-now': 'true',
6320
+ title: t('updateCheckNow'),
6321
+ disabled: checking,
6322
+ onClick: () => {
6323
+ void requestUpdateCheck()
6324
+ },
6325
+ },
6326
+ Icon('spinner', 12),
6327
+ checking ? t('updateChecking') : t('updateCheckNow'),
6328
+ ),
6329
+ ),
6330
+ )
6331
+ }
6332
+
5916
6333
  /** The 插件设置 tab (v0.29.0): display defaults, reset button and store path. */
5917
6334
  function buildSettingsPanel(state, t) {
5918
6335
  const management = state.management
@@ -5937,6 +6354,9 @@ window.__ModuleLoader__.load({
5937
6354
  { value: 'collapsed', label: t('settingsDetailsCollapsed'), icon: 'list' },
5938
6355
  ]),
5939
6356
  ]
6357
+ // 「更新检查」 is its own block (own status + own in-flight flag), kept
6358
+ // apart from the display-defaults rows and their reset button.
6359
+ children.push(buildUpdateSection(state, t))
5940
6360
  const actions = h(
5941
6361
  'div',
5942
6362
  { key: 'actions', className: 'dsh-tokenlens-settings-actions' },
@@ -6381,6 +6801,69 @@ window.__ModuleLoader__.load({
6381
6801
  })
6382
6802
  }
6383
6803
 
6804
+ /**
6805
+ * The update status appended to the plugin-version line of the usage dialog
6806
+ * body, or null when it must stay silent.
6807
+ *
6808
+ * Rules (a check result only): an available release renders as
6809
+ * 「 · 有新版本 vX」 with the version phrase linking to the npm page; a
6810
+ * successful check without a newer release renders 「 · 已是最新版本」.
6811
+ * No result, a failed check or an explicitly disabled setting adds nothing
6812
+ * (never show a misleading status).
6813
+ */
6814
+ function buildVersionUpdateStatus(state, t) {
6815
+ if (state.updateCheck !== true) return null
6816
+ const update = state.update
6817
+ if (update === null || update.error !== null) return null
6818
+ if (update.available === true && update.latest !== null) {
6819
+ const packageUrl = updatePackageUrlOf(state)
6820
+ // The version NUMBER inside the sentence is the real npm link; the i18n
6821
+ // sentence itself stays a single string (`updateVersionNew`).
6822
+ const sentence = t('updateVersionNew', { latest: update.latest })
6823
+ const versionText = `v${update.latest}`
6824
+ const index = sentence.indexOf(versionText)
6825
+ const link = h(
6826
+ 'a',
6827
+ {
6828
+ className: 'dsh-tokenlens-update-link dsh-tokenlens-version-link',
6829
+ 'data-update-link': 'true',
6830
+ 'data-update-version-link': 'true',
6831
+ href: packageUrl,
6832
+ target: '_blank',
6833
+ rel: 'noreferrer noopener',
6834
+ title: packageUrl,
6835
+ onClick: (event) => openConsole(packageUrl, event),
6836
+ },
6837
+ index >= 0 ? versionText : `v${update.latest}`,
6838
+ Icon('external', 12),
6839
+ )
6840
+ return h(
6841
+ 'span',
6842
+ {
6843
+ key: 'update-status',
6844
+ className: 'dsh-tokenlens-version-update',
6845
+ 'data-update-version-status': 'available',
6846
+ },
6847
+ ' · ',
6848
+ index >= 0 ? sentence.slice(0, index) : `${sentence} `,
6849
+ link,
6850
+ )
6851
+ }
6852
+ if (update.latest !== null) {
6853
+ return h(
6854
+ 'span',
6855
+ {
6856
+ key: 'update-status',
6857
+ className: 'dsh-tokenlens-version-update',
6858
+ 'data-update-version-status': 'latest',
6859
+ },
6860
+ ' · ',
6861
+ t('updateVersionLatest'),
6862
+ )
6863
+ }
6864
+ return null
6865
+ }
6866
+
6384
6867
  /** The dialog contents (body + footer); may throw, the caller guards it. */
6385
6868
  function buildDialog(state, now, t) {
6386
6869
  if (state.view === 'management') return buildManagementDialog(state, t)
@@ -6412,6 +6895,9 @@ window.__ModuleLoader__.load({
6412
6895
  'div',
6413
6896
  { key: 'version', className: 'dsh-tokenlens-version' },
6414
6897
  t('pluginVersion', { version: state.pluginVersion }),
6898
+ // Update status sits right next to the plugin version: 有新版本 vX
6899
+ // (link to the npm page) or 已是最新版本, only for a successful check.
6900
+ buildVersionUpdateStatus(state, t),
6415
6901
  ),
6416
6902
  )
6417
6903
  }