@chatcode/cco-market 1.45.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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
@@ -0,0 +1,621 @@
1
+ /**
2
+ * The market's card on the plugin configuration page (dsh >= 0.1.0-rc.7).
3
+ *
4
+ * It manages the market ITSELF — version, update, remove — when the current
5
+ * profile owns the dependency. A host-provided market keeps only its version
6
+ * display and download-region controls. This page is where a user goes to deal
7
+ * with a plugin,
8
+ * and "which version am I on / update it / get rid of it" is the part of
9
+ * that anybody can act on without knowing how ChatCode CLI is put together.
10
+ *
11
+ * `allowRestart` deliberately does NOT appear here. It exists for hosts
12
+ * where a supervisor (systemd, launchd, pm2, Docker `restart: always`, a
13
+ * desktop wrapper) owns the process, and its audience is whoever wrote that
14
+ * deployment — a person already editing config, not someone browsing
15
+ * settings. As a switch it read as jargon to everyone else, which is worse
16
+ * than absent: a control you cannot evaluate is a control you cannot safely
17
+ * touch. It remains a config option.
18
+ *
19
+ * ## Why the chrome is hand-built (again), and why it now matches
20
+ *
21
+ * The host's own contract is that "a plugin that ships a browser half owns
22
+ * its own card" — the plugins tab only lays out a flex column and dispatches
23
+ * `settings.plugin.item`. So the container IS ours to draw, and a value
24
+ * import from `dsh-client-ui-settings-plugins` would fail the client
25
+ * bundle-purity gate anyway.
26
+ *
27
+ * What the first version got wrong was drawing something of its own
28
+ * invention: a flat, always-expanded box next to rows that collapse and
29
+ * carry a chevron. The fix is not a different component — `DisclosureRow` is
30
+ * 24px chrome for compact flow rows, a different thing — but the same design
31
+ * tokens, laid out the way the host lays out `PluginCard`. Classes below
32
+ * mirror it one for one, so the market stops looking like it wandered in
33
+ * from another product.
34
+ */
35
+
36
+ import { createElement as h, Fragment, useCallback, useEffect, useRef, useState } from 'react'
37
+ import type { ReactElement } from 'react'
38
+ import { Button, IconChevronDownOutline14, IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
39
+ import css from './Market.module.css'
40
+ import { api, applyGithubRouting } from './market-data.ts'
41
+ import type { MarketStatus } from './market-data.ts'
42
+ import type { Translate } from './market-data.ts'
43
+
44
+ /** Keys the market leaves in the browser; cleared when the user purges. */
45
+ const BROWSER_KEYS = ['dshm-webdav', 'dshm-gist-id'] as const
46
+ const SELF_PACKAGE_NAMES = ['@chatcode/cco-market', 'dshmarket', 'dsh-market'] as const
47
+
48
+ export interface SettingsCardProps {
49
+ t: Translate
50
+ /**
51
+ * Retire the market's own entry in the left settings menu.
52
+ *
53
+ * Called once the package is gone. Leaving "插件市场" in the menu after
54
+ * the user removed it is the card asserting something the profile no
55
+ * longer agrees with — and the section behind it can no longer talk to a
56
+ * server that has disposed its routes.
57
+ */
58
+ onRemoved?: () => void
59
+ }
60
+
61
+ type Channel = 'stable' | 'beta' | 'dev'
62
+ type Region = 'global' | 'china'
63
+
64
+ /** What api('/dsh-market/status') tells this card. */
65
+ interface SelfStatus {
66
+ version: string | null
67
+ restart: boolean
68
+ channel: Channel
69
+ /** The channels the SERVER offers; the card never invents its own list. */
70
+ channels: Channel[]
71
+ /** Which mirrors every outbound request uses. */
72
+ region: Region
73
+ /** The regions the SERVER offers, on the same principle as `channels`. */
74
+ regions: Region[]
75
+ /** The region came from the network check, not from the user. */
76
+ regionAuto: boolean
77
+ /** User-maintained fallback prefix, separate from the active learned route. */
78
+ githubProxyCustom: string | null
79
+ /** Environment-owned prefixes are visible but not editable. */
80
+ githubProxyManaged: boolean
81
+ /** Whether the profile package manager owns this market installation. */
82
+ selfManaged: boolean
83
+ }
84
+
85
+ /** The subset of api('/dsh-market/status') this card reads. */
86
+ interface StatusBody {
87
+ version?: string
88
+ restart?: boolean
89
+ channel?: string
90
+ channels?: string[]
91
+ region?: string
92
+ regions?: string[]
93
+ regionAuto?: boolean
94
+ githubProxy?: string | null
95
+ githubRoutes?: MarketStatus['githubRoutes']
96
+ githubProxyCustom?: string | null
97
+ githubProxyManaged?: boolean
98
+ selfManaged?: boolean
99
+ }
100
+
101
+ /** What api('/dsh-market/updates') says about the market's own row. */
102
+ interface SelfUpdate {
103
+ updateAvailable: boolean
104
+ latest: string | null
105
+ /** The channel points at this version, and it is not newer: a switch. */
106
+ channelSwitch: string | null
107
+ /** The current build came from a local package and must switch to the online release. */
108
+ restoreRequired: boolean
109
+ }
110
+
111
+ type Phase = 'idle' | 'confirming' | 'working' | 'removed' | 'updated' | 'failed'
112
+
113
+ /** The market's own row as api('/dsh-market/updates') sends it. */
114
+ interface RawUpdate { updateAvailable?: boolean; latest?: string; channelSwitch?: string; restoreRequired?: boolean }
115
+
116
+ const CHANNELS: Channel[] = ['stable', 'beta', 'dev']
117
+ const asChannel = (value: unknown): Channel | null =>
118
+ CHANNELS.includes(value as Channel) ? (value as Channel) : null
119
+
120
+ const CHANNEL_LABEL: Record<Channel, string> = {
121
+ stable: 'setChannelStable', beta: 'setChannelBeta', dev: 'setChannelDev',
122
+ }
123
+ const CHANNEL_HINT: Record<Channel, string> = {
124
+ stable: 'setChannelStableHint', beta: 'setChannelBetaHint', dev: 'setChannelDevHint',
125
+ }
126
+
127
+ const REGIONS: Region[] = ['global', 'china']
128
+ const asRegion = (value: unknown): Region | null =>
129
+ REGIONS.includes(value as Region) ? (value as Region) : null
130
+
131
+ const REGION_LABEL: Record<Region, string> = {
132
+ global: 'setRegionGlobal', china: 'setRegionChina',
133
+ }
134
+ const REGION_HINT: Record<Region, string> = {
135
+ global: 'setRegionGlobalHint', china: 'setRegionChinaHint',
136
+ }
137
+
138
+ /**
139
+ * Read the server's answer, taking the list of channels FROM it.
140
+ *
141
+ * The card does not decide which channels exist: the server is what accepts
142
+ * or refuses a selection, so a card drawing its own list could only ever
143
+ * disagree with it.
144
+ */
145
+ function readStatus(body: StatusBody): SelfStatus {
146
+ const offered = (body.channels ?? []).map(asChannel).filter((c): c is Channel => c !== null)
147
+ const regions = (body.regions ?? []).map(asRegion).filter((r): r is Region => r !== null)
148
+ return {
149
+ version: body.version ?? null,
150
+ restart: body.restart === true,
151
+ channel: asChannel(body.channel) ?? 'stable',
152
+ // A host too old to send the list still gets the two that predate it.
153
+ channels: offered.length > 0 ? offered : ['stable', 'beta'],
154
+ region: asRegion(body.region) ?? 'global',
155
+ // A host with no region at all is a host from before this existed. It
156
+ // downloads from the official sources, which is what `global` means, so
157
+ // the row tells the truth about that host rather than hiding.
158
+ regions: regions.length > 0 ? regions : REGIONS,
159
+ regionAuto: body.regionAuto === true,
160
+ githubProxyCustom: typeof body.githubProxyCustom === 'string' ? body.githubProxyCustom : null,
161
+ githubProxyManaged: body.githubProxyManaged === true,
162
+ selfManaged: body.selfManaged !== false,
163
+ }
164
+ }
165
+
166
+ function readUpdate(own: RawUpdate): SelfUpdate {
167
+ return {
168
+ updateAvailable: own.updateAvailable === true,
169
+ latest: own.latest ?? null,
170
+ channelSwitch: own.channelSwitch ?? null,
171
+ restoreRequired: own.restoreRequired === true,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Clear the market's browser-side leftovers.
177
+ *
178
+ * These are the only two things the market keeps in the browser, and the
179
+ * server cannot reach either. Neither holds a credential — the WebDAV
180
+ * password is never persisted and a Gist token is read from the environment,
181
+ * never from disk — so this is tidiness, not a security step, and the copy
182
+ * must not imply otherwise.
183
+ */
184
+ export function clearBrowserState(storage: Pick<Storage, 'removeItem'>): void {
185
+ for (const key of BROWSER_KEYS) {
186
+ try { storage.removeItem(key) } catch { /* storage unavailable */ }
187
+ }
188
+ }
189
+
190
+ export function SettingsCard({ t, onRemoved }: SettingsCardProps): ReactElement | null {
191
+ const [open, setOpen] = useState(false)
192
+ const [status, setStatus] = useState<SelfStatus | null>(null)
193
+ const [update, setUpdate] = useState<SelfUpdate | null>(null)
194
+ const [selfPackageName, setSelfPackageName] = useState<string>('@chatcode/cco-market')
195
+ const [phase, setPhase] = useState<Phase>('idle')
196
+ const [restoreConfirming, setRestoreConfirming] = useState(false)
197
+ const [purge, setPurge] = useState(false)
198
+ const [error, setError] = useState<string | null>(null)
199
+ const [proxyEditing, setProxyEditing] = useState(false)
200
+ const [proxyDraft, setProxyDraft] = useState('')
201
+ const [proxySaving, setProxySaving] = useState(false)
202
+ /**
203
+ * The last self-update was refused by pnpm's fresh-release safety wait
204
+ * (#39). Only the market's own card can update the market, so without a
205
+ * retry here there is NO way to take a just-published version — the
206
+ * discover list's retry button covers every plugin except this one (#255).
207
+ */
208
+ const [stale, setStale] = useState(false)
209
+
210
+ // Only once the row is opened: the plugin configuration page renders every
211
+ // card at once, and an update check costs a registry round trip.
212
+ //
213
+ // `open` is the ONLY dependency, and re-entry is guarded by a ref rather
214
+ // than by `status`. Keying it on state the effect itself sets meant the
215
+ // first `setStatus` re-ran the effect, whose cleanup dropped the still
216
+ // in-flight update check — so the update row never appeared. Measured by
217
+ // the spec, not reasoned about.
218
+ const probed = useRef(false)
219
+ useEffect(() => {
220
+ if (!open || probed.current) return
221
+ probed.current = true
222
+ let live = true
223
+ void (async () => {
224
+ try {
225
+ const response = await fetch(api('/dsh-market/status'), { cache: 'no-store' })
226
+ const body = (await response.json()) as StatusBody
227
+ if (live) setStatus(readStatus(body))
228
+ applyGithubRouting(body)
229
+ } catch {
230
+ if (live) {
231
+ setStatus({
232
+ version: null, restart: false, channel: 'stable', channels: ['stable', 'beta'],
233
+ region: 'global', regions: REGIONS, regionAuto: false,
234
+ githubProxyCustom: null, githubProxyManaged: false, selfManaged: true,
235
+ })
236
+ }
237
+ }
238
+ try {
239
+ const response = await fetch(api('/dsh-market/updates'), { cache: 'no-store' })
240
+ const body = (await response.json()) as { updates?: Record<string, RawUpdate> }
241
+ const ownName = SELF_PACKAGE_NAMES.find(name => body.updates?.[name] !== undefined)
242
+ const own = ownName === undefined ? undefined : body.updates?.[ownName]
243
+ if (live && own !== undefined) {
244
+ setSelfPackageName(ownName ?? '@chatcode/cco-market')
245
+ setUpdate(readUpdate(own))
246
+ }
247
+ } catch { /* an update check that fails leaves the row without an offer */ }
248
+ })()
249
+ return () => { live = false }
250
+ }, [open])
251
+
252
+ const post = useCallback(async (path: string, payload: unknown): Promise<{ ok?: boolean; error?: string }> => {
253
+ const response = await fetch(path, {
254
+ method: 'POST',
255
+ headers: { 'content-type': 'application/json' },
256
+ body: JSON.stringify(payload),
257
+ })
258
+ return (await response.json()) as { ok?: boolean; error?: string }
259
+ }, [])
260
+
261
+ const onUpdate = useCallback((force = false) => {
262
+ setRestoreConfirming(false)
263
+ setPhase('working')
264
+ setError(null)
265
+ setStale(false)
266
+ void (async () => {
267
+ try {
268
+ const body = await post(api('/dsh-market/update'), {
269
+ name: selfPackageName,
270
+ ...(update?.restoreRequired === true ? { restore: true } : {}),
271
+ ...(force ? { force: true } : {}),
272
+ }) as {
273
+ ok?: boolean; error?: string; stale?: boolean
274
+ }
275
+ if (body.ok === true) setPhase('updated')
276
+ else {
277
+ setStale(body.stale === true)
278
+ setError(body.error ?? t('setSelfFailed'))
279
+ setPhase('failed')
280
+ }
281
+ } catch (cause) {
282
+ setError(cause instanceof Error ? cause.message : String(cause))
283
+ setPhase('failed')
284
+ }
285
+ })()
286
+ }, [post, selfPackageName, t, update?.restoreRequired])
287
+
288
+ const onRemove = useCallback(() => {
289
+ setPhase('working')
290
+ setError(null)
291
+ void (async () => {
292
+ try {
293
+ const body = await post(api('/dsh-market/self-uninstall'), { confirm: true, purge })
294
+ if (body.ok === true) {
295
+ if (purge) clearBrowserState(localStorage)
296
+ setPhase('removed')
297
+ onRemoved?.()
298
+ } else { setError(body.error ?? t('setSelfFailed')); setPhase('failed') }
299
+ } catch (cause) {
300
+ setError(cause instanceof Error ? cause.message : String(cause))
301
+ setPhase('failed')
302
+ }
303
+ })()
304
+ }, [onRemoved, post, purge, t])
305
+
306
+ const busy = phase === 'working'
307
+ const version = status?.version ?? null
308
+ // A prerelease says so in its own version string; nothing else has to be
309
+ // consulted to know the running build is one.
310
+ const prerelease = version !== null && version.includes('-')
311
+
312
+ /** Re-ask what this channel offers; the previous answer was for another one. */
313
+ const refreshUpdate = useCallback(async (): Promise<void> => {
314
+ const response = await fetch(api('/dsh-market/updates') + '?force=1', { cache: 'no-store' })
315
+ const body = (await response.json()) as { updates?: Record<string, RawUpdate> }
316
+ const ownName = SELF_PACKAGE_NAMES.find(name => body.updates?.[name] !== undefined)
317
+ const own = ownName === undefined ? undefined : body.updates?.[ownName]
318
+ if (ownName !== undefined) setSelfPackageName(ownName)
319
+ setUpdate(own === undefined ? null : readUpdate(own))
320
+ }, [])
321
+
322
+ /**
323
+ * Select a channel — and show the one the SERVER accepted.
324
+ *
325
+ * This used to move the control first and ignore the answer, which was
326
+ * harmless while every channel was permitted. It stopped being harmless
327
+ * the moment one of them can be refused: a 403 would have left "dev"
328
+ * highlighted on a profile that is not on it.
329
+ */
330
+ const onChannel = useCallback((next: Channel) => {
331
+ setError(null)
332
+ void (async () => {
333
+ try {
334
+ const body = await post(api('/dsh-market/channel'), { channel: next }) as { ok?: boolean; error?: string; channel?: string }
335
+ if (body.ok !== true) { setError(body.error ?? t('setSelfFailed')); return }
336
+ setStatus(current => (current === null ? current : { ...current, channel: asChannel(body.channel) ?? next }))
337
+ await refreshUpdate()
338
+ } catch (cause) {
339
+ setError(cause instanceof Error ? cause.message : String(cause))
340
+ }
341
+ })()
342
+ }, [post, refreshUpdate, t])
343
+
344
+ /**
345
+ * Select a download region — and show the one the SERVER accepted, on the
346
+ * same reasoning as the channel above.
347
+ *
348
+ * A hand-made choice retires the one-time notice: the market no longer has
349
+ * anything to explain once the user has answered for themselves.
350
+ */
351
+ const onRegion = useCallback((next: Region) => {
352
+ setError(null)
353
+ void (async () => {
354
+ try {
355
+ const body = await post(api('/dsh-market/region'), { region: next }) as { ok?: boolean; error?: string; region?: string }
356
+ if (body.ok !== true) { setError(body.error ?? t('setSelfFailed')); return }
357
+ const accepted = asRegion(body.region) ?? next
358
+ setStatus(current => (current === null ? current : { ...current, region: accepted, regionAuto: false }))
359
+ // Re-read the resolved proxy rather than deriving it here. The card
360
+ // knows which regions exist because the server told it; it must not
361
+ // start knowing what each one RESOLVES to, or the routing table would
362
+ // have a second copy that can disagree with the first.
363
+ try {
364
+ const fresh = await fetch(api('/dsh-market/status'), { cache: 'no-store' })
365
+ const status = (await fresh.json()) as StatusBody
366
+ applyGithubRouting(status)
367
+ } catch { /* images keep the previous route until the next load */ }
368
+ } catch (cause) {
369
+ setError(cause instanceof Error ? cause.message : String(cause))
370
+ }
371
+ })()
372
+ }, [post, t])
373
+
374
+ /** Save or clear the single user-facing GitHub escape route. */
375
+ const onGithubProxy = useCallback((proxy: string | null) => {
376
+ setError(null)
377
+ setProxySaving(true)
378
+ void (async () => {
379
+ try {
380
+ const body = await post(api('/dsh-market/github-proxy'), { proxy }) as { ok?: boolean; error?: string }
381
+ if (body.ok !== true) { setError(body.error ?? t('setSelfFailed')); return }
382
+ const fresh = await fetch(api('/dsh-market/status'), { cache: 'no-store' })
383
+ const statusBody = (await fresh.json()) as StatusBody
384
+ setStatus(readStatus(statusBody))
385
+ applyGithubRouting(statusBody)
386
+ setProxyEditing(false)
387
+ } catch (cause) {
388
+ setError(cause instanceof Error ? cause.message : String(cause))
389
+ } finally {
390
+ setProxySaving(false)
391
+ }
392
+ })()
393
+ }, [post, t])
394
+
395
+ /** One label + hint block with an optional action, the host's row shape. */
396
+ const row = (label: string, hint: string, action: ReactElement | null): ReactElement =>
397
+ h('div', { className: css.setRow },
398
+ h('div', { className: css.setLabelBox },
399
+ h('div', { className: css.setLabel }, label),
400
+ h('div', { className: css.setHint }, hint),
401
+ ),
402
+ action,
403
+ )
404
+
405
+ // The end state REPLACES the controls rather than sitting beside them: the
406
+ // package is gone from disk, so an update button next to "removed" would
407
+ // offer something that cannot happen.
408
+ const body = phase === 'removed'
409
+ ? row(t('setSelfRemoved'), t('setSelfRemovedHint'), null)
410
+ : h(Fragment, null,
411
+ status?.selfManaged === true ? row(
412
+ // An older offer is not an update, and calling it one would have
413
+ // the user click "更新" to go backwards. It IS what picking an
414
+ // earlier channel asked for, so it is offered — under its own name.
415
+ update?.updateAvailable === true && update.latest !== null
416
+ ? `${t('setSelfUpdateReady')} ${update.latest}`
417
+ : update?.channelSwitch != null
418
+ ? `${t('setChannelSwitch')} ${update.channelSwitch}`
419
+ : t('setSelfUpToDate'),
420
+ phase === 'updated'
421
+ ? t('setSelfUpdatedHint')
422
+ : update?.channelSwitch != null
423
+ ? t('setChannelSwitchHint')
424
+ // Explains what the Update button does — only worth saying
425
+ // when that button is actually on screen. Already up to date,
426
+ // it read as an instruction for an action that wasn't there.
427
+ : update?.updateAvailable === true ? t('setSelfUpdateHint') : t('setSelfUpToDateHint'),
428
+ phase === 'updated'
429
+ ? null
430
+ : update?.updateAvailable === true
431
+ ? h(Button, {
432
+ variant: 'primary',
433
+ size: 'sm',
434
+ disabled: busy,
435
+ onClick: () => {
436
+ if (update.restoreRequired) setRestoreConfirming(true)
437
+ else onUpdate()
438
+ },
439
+ }, update.restoreRequired ? t('restoreOnline') : t('setSelfUpdate'))
440
+ : update?.channelSwitch != null
441
+ ? h(Button, { variant: 'outline', size: 'sm', disabled: busy, onClick: () => onUpdate() }, t('setChannelSwitch'))
442
+ : null,
443
+ ) : null,
444
+ status?.selfManaged === true && restoreConfirming
445
+ ? h('div', { className: css.setConfirm },
446
+ h('div', { className: css.setHint }, t('restoreHint')),
447
+ h('div', { className: css.setActions },
448
+ h(Button, {
449
+ variant: 'ghost', size: 'sm', onClick: () => { setRestoreConfirming(false) },
450
+ }, t('setSelfCancel')),
451
+ h(Button, {
452
+ variant: 'primary', size: 'sm', onClick: () => onUpdate(),
453
+ }, t('restoreContinue')),
454
+ ),
455
+ )
456
+ : null,
457
+ status?.selfManaged === true ? row(t('setChannel'), t(CHANNEL_HINT[status.channel]),
458
+ h('div', { className: css.setSeg },
459
+ // Drawn from what the SERVER says is available, so the control
460
+ // can never offer a channel the route would refuse.
461
+ (status?.channels ?? ['stable', 'beta']).map(id => h('button', {
462
+ key: id,
463
+ type: 'button',
464
+ className: status?.channel === id ? `${css.setSegBtn} ${css.setSegOn}` : css.setSegBtn,
465
+ disabled: busy || status === null,
466
+ // The dev option says what it is on hover rather than being
467
+ // hidden behind a switch. A label a user can read beats a gate
468
+ // plus the machinery that maintains it, and the row's own hint
469
+ // repeats it in full once the channel is selected.
470
+ title: id === 'dev' ? t('setChannelDevHint') : undefined,
471
+ onClick: () => { onChannel(id) },
472
+ }, t(CHANNEL_LABEL[id]))),
473
+ )) : null,
474
+ // Above the channel row and below the update row: this is about
475
+ // getting plugins at all, which is the market's whole job, while the
476
+ // channel is about which build of the market itself arrives.
477
+ row(
478
+ t('setRegion'),
479
+ // The hint carries the explanation of an automatic choice when
480
+ // there is one to make, and the plain description of the selected
481
+ // region otherwise. One line either way — a notice that pushes the
482
+ // description off screen would trade a permanent answer for a
483
+ // temporary one.
484
+ status?.regionAuto === true
485
+ ? `${t(REGION_HINT[status.region])} ${t('setRegionAuto')}`
486
+ : t(REGION_HINT[status?.region ?? 'global']),
487
+ h('div', { className: css.setSeg },
488
+ (status?.regions ?? REGIONS).map(id => h('button', {
489
+ key: id,
490
+ type: 'button',
491
+ className: status?.region === id ? `${css.setSegBtn} ${css.setSegOn}` : css.setSegBtn,
492
+ disabled: busy || status === null,
493
+ onClick: () => { onRegion(id) },
494
+ }, t(REGION_LABEL[id]))),
495
+ )),
496
+ row(
497
+ t('setGithubProxy'),
498
+ status?.githubProxyManaged === true
499
+ ? t('setGithubProxyManagedHint')
500
+ : status?.githubProxyCustom !== null && status?.githubProxyCustom !== undefined
501
+ ? t('setGithubProxyCustomHint')
502
+ : t('setGithubProxyAutoHint'),
503
+ status?.githubProxyManaged === true
504
+ ? null
505
+ : status?.githubProxyCustom !== null && status?.githubProxyCustom !== undefined
506
+ ? h('div', { className: css.setInlineActions },
507
+ h(Button, {
508
+ variant: 'outline', size: 'sm', disabled: proxySaving,
509
+ onClick: () => { setProxyDraft(status.githubProxyCustom ?? ''); setProxyEditing(true) },
510
+ }, t('setGithubProxyEdit')),
511
+ h(Button, {
512
+ variant: 'ghost', size: 'sm', disabled: proxySaving,
513
+ onClick: () => { onGithubProxy(null) },
514
+ }, t('setGithubProxyAuto')),
515
+ )
516
+ : h(Button, {
517
+ variant: 'outline', size: 'sm', disabled: status === null || proxySaving,
518
+ onClick: () => { setProxyDraft(''); setProxyEditing(true) },
519
+ }, t('setGithubProxyCustom')),
520
+ ),
521
+ proxyEditing && status?.githubProxyManaged !== true
522
+ ? h('div', { className: css.setProxyEditor },
523
+ h('label', { className: css.setProxyLabel },
524
+ t('setGithubProxyInput'),
525
+ h('input', {
526
+ className: css.setProxyInput,
527
+ type: 'url',
528
+ value: proxyDraft,
529
+ placeholder: 'https://mirror.example',
530
+ 'aria-label': t('setGithubProxyInput'),
531
+ disabled: proxySaving,
532
+ onChange: (event: { currentTarget: { value: string } }) => { setProxyDraft(event.currentTarget.value) },
533
+ }),
534
+ ),
535
+ h('div', { className: css.setInlineActions },
536
+ h(Button, {
537
+ variant: 'ghost', size: 'sm', disabled: proxySaving,
538
+ onClick: () => { setProxyEditing(false) },
539
+ }, t('setSelfCancel')),
540
+ h(Button, {
541
+ variant: 'primary', size: 'sm', disabled: proxySaving || proxyDraft.trim() === '',
542
+ onClick: () => { onGithubProxy(proxyDraft) },
543
+ }, t('setGithubProxySave')),
544
+ ),
545
+ )
546
+ : null,
547
+ status?.selfManaged === true ? row(t('setSelfRemove'), t('setSelfRemoveHint'),
548
+ phase === 'confirming' || busy
549
+ ? null
550
+ : h(Button, {
551
+ variant: 'outline',
552
+ size: 'sm',
553
+ className: css.setDanger,
554
+ disabled: busy,
555
+ onClick: () => { setPhase('confirming') },
556
+ }, t('setSelfRemove'))) : null,
557
+ status?.selfManaged === true && (phase === 'confirming' || busy)
558
+ ? h('div', { className: css.setConfirm },
559
+ h('div', { className: css.setHint }, t('setSelfConfirm')),
560
+ h('label', { className: css.setCheck },
561
+ h('input', { type: 'checkbox', checked: purge, onChange: () => { setPurge(!purge) } }),
562
+ h('span', null, t('setSelfPurge')),
563
+ ),
564
+ // States the consequence of the CURRENT choice, both ways. The
565
+ // default keeps things, and what "keeping" costs — plugins the
566
+ // market switched off stay off, with the UI that could switch
567
+ // them back on about to disappear — is the part a user cannot
568
+ // work out on their own.
569
+ h('div', { className: css.setHint }, purge ? t('setSelfPurgeOn') : t('setSelfPurgeOff')),
570
+ h('div', { className: css.setActions },
571
+ busy
572
+ ? null
573
+ : h(Button, {
574
+ variant: 'ghost',
575
+ size: 'sm',
576
+ onClick: () => { setPhase('idle'); setPurge(false) },
577
+ }, t('setSelfCancel')),
578
+ h(Button, {
579
+ variant: 'primary',
580
+ size: 'sm',
581
+ className: css.setDanger,
582
+ disabled: busy,
583
+ icon: busy ? h('span', { className: css.spin }, h(IconLoadingOutline16, { size: 16 })) : undefined,
584
+ onClick: onRemove,
585
+ }, busy ? t('setSelfWorking') : t('setSelfRemoveConfirm')),
586
+ ),
587
+ )
588
+ : null,
589
+ error !== null ? h('div', { className: css.err }, error) : null,
590
+ // The one place the market can be forced past pnpm's fresh-release
591
+ // wait. Shown only after that specific refusal, never as a default:
592
+ // the wait exists because a version published minutes ago can still
593
+ // be unpublished (#39).
594
+ stale
595
+ ? h('div', { className: css.setActions },
596
+ h(Button, { variant: 'primary', size: 'sm', onClick: () => onUpdate(true) }, t('updateNow')))
597
+ : null,
598
+ )
599
+
600
+ return h('div', { className: open ? `${css.setCard} ${css.setCardOpen}` : css.setCard },
601
+ h('button', {
602
+ type: 'button',
603
+ className: css.setHeader,
604
+ 'aria-expanded': open,
605
+ onClick: () => { setOpen(!open) },
606
+ },
607
+ h('div', { className: css.setHeadText },
608
+ h('div', { className: css.setName },
609
+ t('nav'),
610
+ version !== null ? h('span', { className: css.version }, ` v${version}`) : null,
611
+ prerelease ? h('span', { className: css.setBetaTag }, t('setChannelBeta')) : null,
612
+ ),
613
+ h('div', { className: css.setDesc }, t('setCardDesc')),
614
+ ),
615
+ h('span', { className: open ? `${css.setChevron} ${css.setChevronOpen}` : css.setChevron },
616
+ h(IconChevronDownOutline14, { size: 14 }),
617
+ ),
618
+ ),
619
+ open ? h('div', { className: css.setBody }, body) : null,
620
+ )
621
+ }
@@ -0,0 +1,21 @@
1
+ .root{height:100%;overflow:auto;padding:4px;color:var(--dsw-alias-label-primary,#1f2328)}
2
+ .head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:8px}
3
+ .head>span{min-width:240px;max-width:420px;flex:1}
4
+ .title{font-size:16px;line-height:24px;font-weight:600;margin:0}
5
+ .subtitle,.workspace,.warning,.meta,.state{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#8b93a1);margin:2px 0}
6
+ .workspace{overflow-wrap:anywhere}
7
+ .warning{padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:8px;margin:10px 0}
8
+ .error,.notice{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;padding:8px 10px;border-radius:8px;margin:10px 0}
9
+ .error{color:var(--dsw-alias-state-danger-primary,#dc2626);background:var(--dsw-alias-state-danger-bg,#fef2f2)}
10
+ .notice{color:var(--dsw-alias-state-success-primary,#16a34a);background:var(--dsw-alias-state-success-bg,#f0fdf4);overflow-wrap:anywhere}
11
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px;margin-top:12px}
12
+ .card{display:flex;flex-direction:column;min-height:150px;padding:12px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff)}
13
+ .cardHead{display:flex;align-items:center;justify-content:space-between;gap:8px}
14
+ .card h3{font-size:14px;line-height:20px;margin:0;overflow-wrap:anywhere}
15
+ .description{font-size:12px;line-height:18px;margin:8px 0;flex:1;color:var(--dsw-alias-label-secondary,#6b7280);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
16
+ .meta{min-height:18px}
17
+ .actions{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}
18
+ .projectInstall.projectInstall{background:var(--dsw-alias-button-primary-fill,#1f2328);color:var(--dsw-alias-label-primary-foreground,#fff)}
19
+ .projectInstall.projectInstall:hover:not(:disabled){background-color:var(--dsw-alias-button-primary-fill,#1f2328)!important;color:var(--dsw-alias-label-primary-foreground,#fff)!important}
20
+ .pager{display:flex;align-items:center;justify-content:center;gap:12px;margin:16px 0;font-size:12px;color:var(--dsw-alias-label-secondary,#6b7280)}
21
+ @media(max-width:640px){.head{flex-direction:column}.head>span{width:100%;max-width:none}.grid{grid-template-columns:1fr}}