dsh-plugin 0.1.1 → 1.0.0

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 (64) hide show
  1. package/README.en.md +49 -26
  2. package/README.md +49 -26
  3. package/client/client.js +2148 -229
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +396 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +51 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/pending-restart.js +34 -0
  12. package/lib/services/preflight.js +94 -0
  13. package/lib/services/profile.js +88 -0
  14. package/lib/services/progress.js +50 -0
  15. package/lib/services/task-queue.js +417 -0
  16. package/lib/types/http/routes.d.ts +23 -0
  17. package/lib/types/index.d.ts +27 -0
  18. package/lib/types/services/install-types.d.ts +63 -0
  19. package/lib/types/services/install.d.ts +18 -0
  20. package/lib/types/services/installed-versions.d.ts +14 -0
  21. package/lib/types/services/loader.d.ts +43 -0
  22. package/lib/types/services/pending-restart.d.ts +13 -0
  23. package/lib/types/services/preflight.d.ts +6 -0
  24. package/lib/types/services/profile.d.ts +24 -0
  25. package/lib/types/services/progress.d.ts +17 -0
  26. package/lib/types/services/task-queue.d.ts +76 -0
  27. package/package.json +27 -6
  28. package/src/client/components/CatalogControls.tsx +104 -0
  29. package/src/client/components/CatalogHeader.tsx +78 -0
  30. package/src/client/components/CatalogList.tsx +75 -0
  31. package/src/client/components/CategoryTabs.tsx +35 -0
  32. package/src/client/components/Dropdown.tsx +80 -0
  33. package/src/client/components/NotificationsModal.tsx +230 -0
  34. package/src/client/components/PluginCard.tsx +104 -0
  35. package/src/client/components/PluginHubSection.tsx +339 -0
  36. package/src/client/components/ProgressView.tsx +23 -0
  37. package/src/client/components/icons.tsx +125 -0
  38. package/src/client/components/modals.tsx +365 -0
  39. package/src/client/hooks/useCatalog.ts +192 -0
  40. package/src/client/hooks/useTaskQueue.ts +405 -0
  41. package/src/client/index.tsx +8 -422
  42. package/src/client/lib/catalog.ts +204 -0
  43. package/src/client/lib/env.ts +16 -0
  44. package/src/client/lib/failures.ts +153 -0
  45. package/src/client/lib/format.ts +22 -0
  46. package/src/client/locales.ts +186 -18
  47. package/src/client/styles/Dropdown.module.css +123 -0
  48. package/src/client/styles/Header.module.css +432 -0
  49. package/src/client/styles/List.module.css +324 -0
  50. package/src/client/styles/Modal.module.css +965 -0
  51. package/src/client/styles/tokens.module.css +86 -0
  52. package/src/client/types.ts +80 -0
  53. package/src/server/http/routes.ts +397 -0
  54. package/src/server/index.ts +35 -0
  55. package/src/server/services/install-types.ts +63 -0
  56. package/src/server/services/install.ts +18 -0
  57. package/src/server/services/installed-versions.ts +65 -0
  58. package/src/server/services/loader.ts +110 -0
  59. package/src/server/services/pending-restart.ts +36 -0
  60. package/src/server/services/preflight.ts +97 -0
  61. package/src/server/services/profile.ts +89 -0
  62. package/src/server/services/progress.ts +48 -0
  63. package/src/server/services/task-queue.ts +450 -0
  64. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Dialog layer for the Plugin Hub: the install-confirm dialog, the uninstall
3
+ * dialog and the global toast. Both dialogs lock themselves while a mutation
4
+ * or restart is running, then switch to a result view offering an immediate
5
+ * restart or a "later" deferral.
6
+ */
7
+ import { createElement as h } from 'react'
8
+ import type { MouseEvent } from 'react'
9
+ import styles from '../styles/Modal.module.css'
10
+ import type { EnvInfo, HubPlugin, TaskState, ToastState, Translate } from '../types.ts'
11
+ import { CloseIcon, CopyIcon, LinkIcon } from './icons.tsx'
12
+ import { ProgressView } from './ProgressView.tsx'
13
+ import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
14
+ import { classifyFailure } from '../lib/failures.ts'
15
+
16
+ /** 完成结果视图:绿色对勾 + 标题/描述 + 「稍后重启 / 立即重启」按钮对(部分插件需重启后才会挂载) */
17
+ function ResultView({
18
+ title, desc, t, restarting, onRestart, onClose,
19
+ }: {
20
+ title: string
21
+ desc: string
22
+ t: Translate
23
+ restarting: boolean
24
+ onRestart: () => void
25
+ onClose: () => void
26
+ }) {
27
+ return h('div', { className: styles.result },
28
+ h('div', { className: styles.resultCheck },
29
+ h('svg', {
30
+ className: styles.resultCheckIcon,
31
+ viewBox: '0 0 16 16',
32
+ width: 20,
33
+ height: 20,
34
+ fill: 'none',
35
+ 'aria-hidden': 'true',
36
+ }, h('path', {
37
+ d: 'M2.5 8.5l3.5 3.5 7.5-7.5',
38
+ stroke: 'currentColor',
39
+ strokeWidth: 2,
40
+ strokeLinecap: 'round',
41
+ strokeLinejoin: 'round',
42
+ })),
43
+ ),
44
+ h('div', { className: styles.resultTitle }, title),
45
+ h('div', { className: styles.resultDesc }, desc),
46
+ h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
47
+ h('div', { className: styles.modalActions },
48
+ h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
49
+ h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
50
+ restarting ? t('restarting') : t('restartNow')),
51
+ ),
52
+ )
53
+ }
54
+
55
+ export interface InstallModalProps {
56
+ plugin: HubPlugin
57
+ done: boolean
58
+ task: TaskState | null
59
+ t: Translate
60
+ langPath: string
61
+ restarting: boolean
62
+ /** 已安装插件的覆盖更新(走同一条 add 命令原位重装,文案区分安装/更新) */
63
+ update: boolean
64
+ /** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
65
+ submitting: boolean
66
+ onClose: () => void
67
+ onCopy: () => void
68
+ onInstall: () => void
69
+ onRestart: () => void
70
+ }
71
+
72
+ /**
73
+ * 信任确认弹窗:安装进入后台队列后弹窗仍可关闭(任务继续),
74
+ * 只在本任务执行中展示实时进度;完成后切换为结果视图,与卸载一致。
75
+ */
76
+ export function InstallModal(props: InstallModalProps) {
77
+ const { plugin, done, task, t, langPath, restarting, submitting, update, onClose, onCopy, onInstall, onRestart } = props
78
+ const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
79
+ // 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分
80
+ const name = plugin.displayName ?? plugin.slug
81
+ const busyTitle = (label: string) => langPath === 'zh/' ? `${name} 插件${label}` : `${label} ${name}`
82
+ const title = busy
83
+ ? task && task.status === 'pending'
84
+ ? busyTitle(update ? t('queuedUpdateTitle') : t('queuedTitle'))
85
+ : busyTitle(update ? t('updating') : t('installing'))
86
+ : done
87
+ ? update ? t('updateResultTitle') : t('installResultTitle')
88
+ : update ? t('confirmUpdateTitle') : t('confirmTitle')
89
+ return h('div', {
90
+ className: styles.overlay,
91
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
92
+ if (e.target === e.currentTarget) onClose()
93
+ },
94
+ },
95
+ h('div', { className: styles.modal, role: 'dialog', 'aria-modal': 'true' },
96
+ h('div', { className: styles.modalHead },
97
+ h('div', {
98
+ className: busy
99
+ ? `${styles.modalTitle} ${task && task.status === 'pending' ? styles.modalTitleQueued : styles.modalTitleBusy}`
100
+ : styles.modalTitle,
101
+ }, title),
102
+ h('button', {
103
+ className: styles.modalClose,
104
+ 'aria-label': t('confirmCancel'),
105
+ onClick: () => onClose(),
106
+ }, h(CloseIcon)),
107
+ ),
108
+ done
109
+ // 安装完成:结果视图(成功即生效,仅「完成」关闭);更新时标题/描述区分语义
110
+ ? h(ResultView, {
111
+ title: update ? t('updateResultTitle') : t('installResultTitle'),
112
+ desc: update ? t('updateResultDesc') : t('installResultDesc'),
113
+ t,
114
+ restarting,
115
+ onRestart,
116
+ onClose,
117
+ })
118
+ // 确认/进行中:来源行 + 安装命令 + 实时进度 + 操作按钮
119
+ : h('div', { className: styles.modalBody },
120
+ h('div', { className: styles.trustHint }, t('confirmDesc')),
121
+ h('div', { className: styles.modalRow },
122
+ h('span', { className: styles.modalLabel }, t('confirmPlugin')),
123
+ h('span', { className: styles.modalValue, title: plugin.displayName ?? plugin.slug },
124
+ plugin.displayName ?? plugin.slug),
125
+ ),
126
+ plugin.source?.repo ? h('div', { className: styles.modalRow },
127
+ h('span', { className: styles.modalLabel }, t('confirmSource')),
128
+ // 来源仓库:可点击跳转官网收录详情页(新窗口打开)
129
+ h('a', {
130
+ className: styles.modalLink,
131
+ href: pluginDetailUrl(plugin, langPath),
132
+ target: '_blank',
133
+ rel: 'noopener noreferrer',
134
+ title: plugin.source.repo,
135
+ }, h(LinkIcon), plugin.source.repo),
136
+ ) : null,
137
+ // 手动命令条:整条点击即复制,右侧再放一个显式复制按钮
138
+ h('div', {
139
+ className: styles.modalCmd,
140
+ onClick: onCopy,
141
+ title: t('copyInstallCommand'),
142
+ role: 'button',
143
+ tabIndex: 0,
144
+ },
145
+ h('span', { className: styles.modalCmdText }, `dsh plugin add github:${plugin.source?.repo ?? ''}`),
146
+ h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
147
+ ),
148
+ task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
149
+ task ? h(ProgressView, { task }) : null,
150
+ h('div', { className: styles.modalActions },
151
+ h('button', {
152
+ className: styles.modalCopy,
153
+ disabled: busy,
154
+ onClick: onCopy,
155
+ }, t('copyInstallCommand')),
156
+ h('button', {
157
+ className: styles.modalInstall,
158
+ disabled: busy,
159
+ onClick: onInstall,
160
+ }, busy
161
+ ? (task && task.status === 'pending'
162
+ ? (update ? t('queuedUpdateTitle') : t('queuedTitle'))
163
+ : (update ? t('updating') : t('installing')))
164
+ : (update ? t('updateNow') : t('installNow'))),
165
+ ),
166
+ ),
167
+ ),
168
+ )
169
+ }
170
+
171
+ export interface UninstallModalProps {
172
+ plugin: HubPlugin
173
+ done: boolean
174
+ task: TaskState | null
175
+ t: Translate
176
+ langPath: string
177
+ restarting: boolean
178
+ /** 卸载请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
179
+ submitting: boolean
180
+ onClose: () => void
181
+ onCancel: () => void
182
+ onCopyCommand: () => void
183
+ onConfirm: () => void
184
+ onRestart: () => void
185
+ }
186
+
187
+ /** 卸载确认弹窗:确认/进行中(后台队列,可关闭);完成后切换为结果视图(成功即生效,仅「完成」关闭)。 */
188
+ export function UninstallModal(props: UninstallModalProps) {
189
+ const { plugin, done, task, t, langPath, restarting, submitting, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
190
+ const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
191
+ // 进行中标题带上插件名,与安装弹窗一致,并用状态色区分
192
+ const name = plugin.displayName ?? plugin.slug
193
+ const busyTitle = (label: string) => langPath === 'zh/' ? `${name} 插件${label}` : `${label} ${name}`
194
+ const title = busy
195
+ ? task && task.status === 'pending' ? busyTitle(t('queuedUninstallTitle')) : busyTitle(t('uninstalling'))
196
+ : done ? t('uninstallResultTitle') : t('uninstallTitle')
197
+ return h('div', {
198
+ className: styles.overlay,
199
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
200
+ if (e.target === e.currentTarget) onClose()
201
+ },
202
+ },
203
+ h('div', { className: styles.modal, role: 'dialog', 'aria-modal': 'true' },
204
+ h('div', { className: styles.modalHead },
205
+ h('div', {
206
+ className: busy
207
+ // 注意 task 判空:submitting(请求在途)时任务尚未入队,task 为 null,不能直接解引用
208
+ ? `${styles.modalTitle} ${task !== null && task.status === 'pending' ? styles.modalTitleQueued : styles.modalTitleBusy}`
209
+ : styles.modalTitle,
210
+ }, title),
211
+ h('button', {
212
+ className: styles.modalClose,
213
+ 'aria-label': t('confirmCancel'),
214
+ onClick: () => onClose(),
215
+ }, h(CloseIcon)),
216
+ ),
217
+ done
218
+ // 卸载完成:结果视图(成功即生效,仅「完成」关闭)
219
+ ? h(ResultView, {
220
+ title: t('uninstallResultTitle'),
221
+ desc: t('uninstallResultDesc'),
222
+ t,
223
+ restarting,
224
+ onRestart,
225
+ onClose,
226
+ })
227
+ // 确认/进行中:来源行 + 实时进度 + 操作按钮
228
+ : h('div', { className: styles.modalBody },
229
+ h('div', { className: styles.modalDesc }, t('uninstallDesc')),
230
+ h('div', { className: styles.modalRow },
231
+ h('span', { className: styles.modalLabel }, t('confirmPlugin')),
232
+ h('span', { className: styles.modalValue, title: plugin.displayName ?? plugin.slug },
233
+ plugin.displayName ?? plugin.slug),
234
+ ),
235
+ // 显示仓库来源:github 用户名 + 仓库名,让用户确认卸载的是哪一个仓库
236
+ plugin.source?.repo ? h('div', { className: styles.modalRow },
237
+ h('span', { className: styles.modalLabel }, t('confirmSource')),
238
+ // 来源仓库:可点击跳转官网收录详情页(新窗口打开)
239
+ h('a', {
240
+ className: styles.modalLink,
241
+ href: pluginDetailUrl(plugin, langPath),
242
+ target: '_blank',
243
+ rel: 'noopener noreferrer',
244
+ title: plugin.source.repo,
245
+ }, h(LinkIcon), plugin.source.repo),
246
+ ) : null,
247
+ task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
248
+ task ? h(ProgressView, { task }) : null,
249
+ h('div', { className: styles.modalActions },
250
+ h('button', {
251
+ className: styles.modalCancel,
252
+ onClick: onCancel,
253
+ }, t('confirmCancel')),
254
+ // 复制卸载命令:万一直接卸载失败,可去终端手动执行
255
+ h('button', {
256
+ className: styles.modalCopy,
257
+ disabled: busy,
258
+ onClick: onCopyCommand,
259
+ }, t('copyUninstallCommand')),
260
+ h('button', {
261
+ className: styles.uninstallConfirm,
262
+ disabled: busy,
263
+ onClick: onConfirm,
264
+ }, busy ? (task && task.status === 'pending' ? t('queuedUninstallTitle') : t('uninstalling')) : t('uninstall')),
265
+ ),
266
+ ),
267
+ ),
268
+ )
269
+ }
270
+
271
+ /** 预填插件仓库的 GitHub Issue 链接:标题带插件名,正文附完整错误信息,方便用户一键反馈。 */
272
+ /** 安装/卸载失败弹窗:布局与失败记录一致(类型徽标 + 仓库超链接 + 隐蔽复制按钮),报错完整展示,底部一键提交 Issue。 */
273
+ export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
274
+ message: string
275
+ repo: string | null
276
+ kind: 'install' | 'uninstall'
277
+ t: Translate
278
+ env: EnvInfo | null
279
+ onCopy: (text: string) => void
280
+ onClose: () => void
281
+ }) {
282
+ // 失败归类:用官方默认安装方式装不上 = 插件仓库的问题,一律引导提 Issue
283
+ const failureKind = classifyFailure(message)
284
+ return h('div', {
285
+ className: styles.overlay,
286
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
287
+ if (e.target === e.currentTarget) onClose()
288
+ },
289
+ },
290
+ h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
291
+ h('div', { className: styles.modalHead },
292
+ // 标题按操作类型明确区分:安装失败 / 卸载失败
293
+ h('div', { className: styles.errorTitle }, kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall')),
294
+ h('button', {
295
+ className: styles.modalClose,
296
+ 'aria-label': t('errorClose'),
297
+ onClick: onClose,
298
+ }, h(CloseIcon)),
299
+ ),
300
+ h('div', { className: styles.modalBody },
301
+ // 与失败记录一致的布局:类型徽标 + 仓库超链接 + 顶部隐蔽复制按钮
302
+ h('div', { className: styles.failRow },
303
+ h('div', { className: styles.failHead },
304
+ h('span', {
305
+ className: kind === 'install' ? styles.failKindInstall : styles.failKindUninstall,
306
+ }, kind === 'install' ? t('install') : t('uninstall')),
307
+ // 仓库地址(用户名/仓库)统一显示为可点击超链接,跳转到官网详情页(含插件收录信息),而非直接跳 GitHub
308
+ repo ? h('a', {
309
+ className: styles.failRepo,
310
+ href: pluginSiteUrl(repo),
311
+ target: '_blank',
312
+ rel: 'noopener noreferrer',
313
+ title: repo,
314
+ }, repo) : null,
315
+ // 复制按钮放最上面、浅灰隐蔽;报错正文不允许鼠标选择复制,只能点这里
316
+ h('button', { className: styles.errorCopySoft, onClick: () => onCopy(message) }, t('errorCopy')),
317
+ ),
318
+ // 报错信息完整展示(可滚动),比失败记录的预览看得更多
319
+ h('pre', { className: styles.errorBox }, message),
320
+ // 插件问题(prepare 构建失败 / 原生依赖构建被拦截 / git 分发缺产物):都是插件打包分发问题 —— 先说明原因,按钮在下方引导提 Issue
321
+ failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
322
+ ? h('div', null, [
323
+ // [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
324
+ // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
325
+ h('div', { className: styles.failPrepareHint }, failureKind === 'pnpmIgnoredBuild'
326
+ ? t('failIgnoredBuild')
327
+ : /\[packaging\]/i.test(message) ? t('failPackagingHint') : t('failPrepareHint')),
328
+ repo ? h('a', {
329
+ className: styles.failBigIssue,
330
+ href: pluginIssueUrl(repo, message, env),
331
+ target: '_blank',
332
+ rel: 'noopener noreferrer',
333
+ title: t('failIssueHint'),
334
+ }, t('failIssueBig')) : null,
335
+ ])
336
+ : repo ? h('a', {
337
+ className: styles.failBigIssue,
338
+ href: pluginIssueUrl(repo, message, env),
339
+ target: '_blank',
340
+ rel: 'noopener noreferrer',
341
+ title: t('failIssueHint'),
342
+ }, t('failIssueBig')) : null,
343
+ ),
344
+ h('div', { className: styles.modalActions },
345
+ h('button', { className: styles.restartLater, onClick: onClose }, t('errorClose')),
346
+ ),
347
+ ),
348
+ ),
349
+ )
350
+ }
351
+
352
+ /** 全局反馈 Toast:复制成功(反色)/ 安装完成(反色)/ 安装失败(红色)/ 卸载结果 */
353
+ export function Toast({ toast, t }: { toast: ToastState; t: Translate }) {
354
+ const text = toast.kind === 'copied' ? t('toastCopied')
355
+ : toast.kind === 'errCopied' ? t('errCopied')
356
+ : toast.kind === 'done' ? t('installDone')
357
+ : toast.kind === 'fail' ? t('installFail')
358
+ : toast.kind === 'removed' ? t('uninstallDone')
359
+ : t('uninstallFail')
360
+ const fail = toast.kind === 'fail' || toast.kind === 'removeFail'
361
+ return h('div', {
362
+ key: toast.id,
363
+ className: fail ? `${styles.toast} ${styles.toastFail}` : styles.toast,
364
+ }, text)
365
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Catalog data + view state for the Plugin Hub section.
3
+ *
4
+ * Owns the online-data pipeline (live fetch from dsh-plugin.org), the local
5
+ * installed-plugin table, and the filter/search/sort/install-status view
6
+ * state, exposing the derived visible list and per-category counts.
7
+ */
8
+ import { useEffect, useMemo, useState } from 'react'
9
+ import type { HubPlugin, LocaleId } from '../types.ts'
10
+ import { normalize } from '../lib/catalog.ts'
11
+ import type { SortKey } from '../lib/catalog.ts'
12
+
13
+ const PLUGINS_URL = (lang: LocaleId) => `https://dsh-plugin.org/api/plugins.${lang}.json`
14
+ const STATS_URL = 'https://dsh-plugin.org/api/stats.json'
15
+
16
+ export function useCatalog(lang: LocaleId) {
17
+ /** 目录插件(仅保留人工验证通过的条目) */
18
+ const [plugins, setPlugins] = useState<HubPlugin[] | null>(null)
19
+ /** 收录/精选统计(官网 /api/stats.json 实时拉取) */
20
+ const [stats, setStats] = useState<{ total: number; verified: number } | null>(null)
21
+ const [failed, setFailed] = useState(false)
22
+ const [reloadKey, setReloadKey] = useState(0)
23
+ const [category, setCategory] = useState('all')
24
+ const [query, setQuery] = useState('')
25
+ const [sort, setSort] = useState<SortKey>('sortStars')
26
+ /** 列表安装状态筛选:全部 / 已安装 / 未安装(分段按钮,单列表内切换,不引入第二个列表) */
27
+ const [installedFilter, setInstalledFilter] = useState<'all' | 'installed' | 'notInstalled'>('all')
28
+ /** 当前 profile 已安装插件:npm 包名 -> manifest spec(来自宿主本地路由) */
29
+ const [installed, setInstalled] = useState<Record<string, string>>({})
30
+ /** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由) */
31
+ const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string }>>({})
32
+
33
+ // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
34
+ useEffect(() => {
35
+ let cancelled = false
36
+ setPlugins(null)
37
+ setStats(null)
38
+ setFailed(false)
39
+ const fetchData = (url: string): Promise<unknown> =>
40
+ fetch(url, { cache: 'no-store' }).then((res) => {
41
+ if (!res.ok) throw new Error(String(res.status))
42
+ return res.json()
43
+ })
44
+ fetchData(PLUGINS_URL(lang)).then((data) => {
45
+ if (cancelled) return
46
+ const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
47
+ setPlugins(list.filter((p) => p.compatibility?.status === 'verified'))
48
+ }).catch(() => {
49
+ if (!cancelled) setFailed(true)
50
+ })
51
+ fetchData(STATS_URL).then((s) => {
52
+ const stats = s as { total?: number; verified?: number }
53
+ if (!cancelled && stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
54
+ setStats({ total: stats.total, verified: stats.verified })
55
+ }
56
+ }).catch(() => {})
57
+ return () => { cancelled = true }
58
+ // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
59
+ // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
60
+ }, [reloadKey, lang])
61
+
62
+ /** 刷新当前 profile 已安装插件表;宿主未挂本地路由时静默降级为空表。 */
63
+ const refreshInstalled = async () => {
64
+ try {
65
+ const res = await fetch('/dsh-plugin-hub/installed', { cache: 'no-store' })
66
+ if (!res.ok) return
67
+ const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string }> }
68
+ setInstalled(data.installed ?? {})
69
+ setVersions(data.versions ?? {})
70
+ } catch {
71
+ // host without the plugin's server routes — keep the empty table
72
+ }
73
+ }
74
+
75
+ // 首次进入拉取已安装表(依赖宿主 webServer 服务)
76
+ useEffect(() => { refreshInstalled() }, [])
77
+
78
+ /** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`;命中返回 npm 包名。 */
79
+ const installedName = (p: HubPlugin): string | null => {
80
+ const repo = p.source?.repo
81
+ if (!repo) return null
82
+ const needle = `github:${repo.toLowerCase()}`
83
+ for (const [name, spec] of Object.entries(installed)) {
84
+ if (spec.toLowerCase().includes(needle)) return name
85
+ }
86
+ return null
87
+ }
88
+
89
+ /** 该插件安装时记录的目录版本(无记录/未安装 → null)。 */
90
+ const installedVersion = (p: HubPlugin): string | null => {
91
+ const repo = p.source?.repo
92
+ if (!repo) return null
93
+ return versions[repo.toLowerCase()]?.version ?? null
94
+ }
95
+
96
+ /**
97
+ * 是否有更新:仅对已安装插件有意义。
98
+ * 双信号判定——有 release 版本的比版本;无版本(repo 不打 tag)的比仓库最近更新时间
99
+ * (repoUpdatedAt,ISO 字符串字典序 = 时间序),更新时间变新说明有新提交。
100
+ */
101
+ const hasUpdate = (p: HubPlugin): boolean => {
102
+ if (installedName(p) === null) return false
103
+ const repo = p.source?.repo
104
+ if (!repo) return false
105
+ const rec = versions[repo.toLowerCase()]
106
+ if (!rec) return false
107
+ if (p.version) return p.version !== rec.version
108
+ const current = p.dates?.repoUpdatedAt
109
+ return Boolean(current && rec.updatedAt && current > rec.updatedAt)
110
+ }
111
+
112
+ /** 当前分类下的插件(「全部」时为整个目录)。 */
113
+ const categoryPlugins = useMemo(() => {
114
+ if (!plugins) return []
115
+ if (category === 'all') return plugins
116
+ return plugins.filter((p) => p.category === category)
117
+ }, [plugins, category])
118
+
119
+ /** 当前分类下已安装插件数:已安装/未安装按钮上的计数跟随分类,不再用全局口径。 */
120
+ const installedCountInCategory = useMemo(() => {
121
+ return categoryPlugins.reduce((n, p) => n + (installedName(p) !== null ? 1 : 0), 0)
122
+ }, [categoryPlugins, installed])
123
+
124
+ /** 当前分类下未安装插件数。 */
125
+ const notInstalledCountInCategory = Math.max(0, categoryPlugins.length - installedCountInCategory)
126
+
127
+ // 自动重置:切换分类(或已安装表变化)后,若当前安装状态筛选在新分类下无结果,
128
+ // 自动退回「全部」,避免列表空置死胡同、也避免 0 计数按钮被误触。
129
+ useEffect(() => {
130
+ if (installedFilter === 'all') return
131
+ const count = installedFilter === 'installed'
132
+ ? installedCountInCategory
133
+ : notInstalledCountInCategory
134
+ if (count === 0) setInstalledFilter('all')
135
+ }, [installedFilter, installedCountInCategory, notInstalledCountInCategory])
136
+
137
+ const visible = useMemo(() => {
138
+ if (!plugins) return []
139
+ const q = query.trim().toLowerCase()
140
+ const list = plugins.filter((p) => {
141
+ if (category !== 'all' && p.category !== category) return false
142
+ if (installedFilter === 'installed' && installedName(p) === null) return false
143
+ if (installedFilter === 'notInstalled' && installedName(p) !== null) return false
144
+ if (!q) return true
145
+ return (
146
+ (p.displayName ?? '').toLowerCase().includes(q) ||
147
+ (p.description ?? '').toLowerCase().includes(q) ||
148
+ (p.topics ?? []).some((topic) => topic.toLowerCase().includes(q))
149
+ )
150
+ })
151
+ return [...list].sort((a, b) => {
152
+ if (sort === 'sortStars') return (b.stats?.stargazers_count ?? 0) - (a.stats?.stargazers_count ?? 0)
153
+ if (sort === 'sortForks') return (b.stats?.forks_count ?? 0) - (a.stats?.forks_count ?? 0)
154
+ if (sort === 'sortNewest') return (b.dates?.addedAt ?? '').localeCompare(a.dates?.addedAt ?? '')
155
+ return (b.dates?.repoUpdatedAt ?? '').localeCompare(a.dates?.repoUpdatedAt ?? '')
156
+ })
157
+ }, [plugins, category, query, sort, installed, installedFilter])
158
+
159
+ /** Per-category plugin counts shown on the category chips. */
160
+ const categoryCounts = useMemo(() => {
161
+ const counts: Record<string, number> = {}
162
+ for (const p of plugins ?? []) {
163
+ if (p.category) counts[p.category] = (counts[p.category] ?? 0) + 1
164
+ }
165
+ return counts
166
+ }, [plugins])
167
+
168
+ return {
169
+ plugins,
170
+ stats,
171
+ failed,
172
+ reload: () => setReloadKey((k) => k + 1),
173
+ installed,
174
+ installedName,
175
+ installedVersion,
176
+ hasUpdate,
177
+ refreshInstalled,
178
+ category,
179
+ setCategory,
180
+ query,
181
+ setQuery,
182
+ sort,
183
+ setSort,
184
+ installedFilter,
185
+ setInstalledFilter,
186
+ visible,
187
+ total: plugins?.length ?? 0,
188
+ installedCountInCategory,
189
+ notInstalledCountInCategory,
190
+ categoryCounts,
191
+ }
192
+ }