dsh-plugin 0.1.1 → 1.1.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 (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -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 +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,368 @@
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 { installCommandOf, 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
+ desc ? h('div', { className: styles.resultDesc }, desc) : null,
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 }, installCommandOf(plugin)),
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, command, attempts, t, env, onCopy, onClose }: {
274
+ message: string
275
+ repo: string | null
276
+ kind: 'install' | 'uninstall'
277
+ command?: string
278
+ /** 尝试过的安装方式(npm 反查 + 实际执行命令):issue 预填一并贴给作者,便于反推正确的 npm 包名 */
279
+ attempts?: string[]
280
+ t: Translate
281
+ env: EnvInfo | null
282
+ onCopy: (text: string) => void
283
+ onClose: () => void
284
+ }) {
285
+ // 失败归类:当前安装通道(npm/git)装不上 = 插件分发/依赖的问题,一律引导提 Issue
286
+ const failureKind = classifyFailure(message)
287
+ return h('div', {
288
+ className: styles.overlay,
289
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
290
+ if (e.target === e.currentTarget) onClose()
291
+ },
292
+ },
293
+ h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
294
+ h('div', { className: styles.modalHead },
295
+ // 标题按操作类型明确区分:安装失败 / 卸载失败
296
+ h('div', { className: styles.errorTitle }, kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall')),
297
+ h('button', {
298
+ className: styles.modalClose,
299
+ 'aria-label': t('errorClose'),
300
+ onClick: onClose,
301
+ }, h(CloseIcon)),
302
+ ),
303
+ h('div', { className: styles.modalBody },
304
+ // 与失败记录一致的布局:类型徽标 + 仓库超链接 + 顶部隐蔽复制按钮
305
+ h('div', { className: styles.failRow },
306
+ h('div', { className: styles.failHead },
307
+ h('span', {
308
+ className: kind === 'install' ? styles.failKindInstall : styles.failKindUninstall,
309
+ }, kind === 'install' ? t('install') : t('uninstall')),
310
+ // 仓库地址(用户名/仓库)统一显示为可点击超链接,跳转到官网详情页(含插件收录信息),而非直接跳 GitHub
311
+ repo ? h('a', {
312
+ className: styles.failRepo,
313
+ href: pluginSiteUrl(repo),
314
+ target: '_blank',
315
+ rel: 'noopener noreferrer',
316
+ title: repo,
317
+ }, repo) : null,
318
+ // 复制按钮放最上面、浅灰隐蔽;报错正文不允许鼠标选择复制,只能点这里
319
+ h('button', { className: styles.errorCopySoft, onClick: () => onCopy(message) }, t('errorCopy')),
320
+ ),
321
+ // 报错信息完整展示(可滚动),比失败记录的预览看得更多
322
+ h('pre', { className: styles.errorBox }, message),
323
+ // 插件问题(prepare 构建失败 / 原生依赖构建被拦截 / git 分发缺产物):都是插件打包分发问题 —— 先说明原因,按钮在下方引导提 Issue
324
+ failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
325
+ ? h('div', null, [
326
+ // [packaging](预检/装后校验拦截:git 分发缺产物)与
327
+ // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
328
+ h('div', { className: styles.failPrepareHint }, failureKind === 'pnpmIgnoredBuild'
329
+ ? t('failIgnoredBuild')
330
+ : /\[packaging\]/i.test(message) ? t('failPackagingHint') : t('failPrepareHint')),
331
+ repo ? h('a', {
332
+ className: styles.failBigIssue,
333
+ href: pluginIssueUrl(repo, message, env, command, attempts),
334
+ target: '_blank',
335
+ rel: 'noopener noreferrer',
336
+ title: t('failIssueHint'),
337
+ }, t('failIssueBig')) : null,
338
+ ])
339
+ : repo ? h('a', {
340
+ className: styles.failBigIssue,
341
+ href: pluginIssueUrl(repo, message, env, command, attempts),
342
+ target: '_blank',
343
+ rel: 'noopener noreferrer',
344
+ title: t('failIssueHint'),
345
+ }, t('failIssueBig')) : null,
346
+ ),
347
+ h('div', { className: styles.modalActions },
348
+ h('button', { className: styles.restartLater, onClick: onClose }, t('errorClose')),
349
+ ),
350
+ ),
351
+ ),
352
+ )
353
+ }
354
+
355
+ /** 全局反馈 Toast:复制成功(反色)/ 安装完成(反色)/ 安装失败(红色)/ 卸载结果 */
356
+ export function Toast({ toast, t }: { toast: ToastState; t: Translate }) {
357
+ const text = toast.kind === 'copied' ? t('toastCopied')
358
+ : toast.kind === 'errCopied' ? t('errCopied')
359
+ : toast.kind === 'done' ? t('installDone')
360
+ : toast.kind === 'fail' ? t('installFail')
361
+ : toast.kind === 'removed' ? t('uninstallDone')
362
+ : t('uninstallFail')
363
+ const fail = toast.kind === 'fail' || toast.kind === 'removeFail'
364
+ return h('div', {
365
+ key: toast.id,
366
+ className: fail ? `${styles.toast} ${styles.toastFail}` : styles.toast,
367
+ }, text)
368
+ }
@@ -0,0 +1,202 @@
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
+ * npmPackage 为 npm 优先通道反查命中的包名映射(目录数据未下发时客户端靠它把依赖 key 匹配回仓库) */
32
+ const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string; npmPackage?: string }>>({})
33
+
34
+ // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
35
+ useEffect(() => {
36
+ let cancelled = false
37
+ setPlugins(null)
38
+ setStats(null)
39
+ setFailed(false)
40
+ const fetchData = (url: string): Promise<unknown> =>
41
+ fetch(url, { cache: 'no-store' }).then((res) => {
42
+ if (!res.ok) throw new Error(String(res.status))
43
+ return res.json()
44
+ })
45
+ fetchData(PLUGINS_URL(lang)).then((data) => {
46
+ if (cancelled) return
47
+ const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
48
+ setPlugins(list.filter((p) => p.compatibility?.status === 'verified'))
49
+ }).catch(() => {
50
+ if (!cancelled) setFailed(true)
51
+ })
52
+ fetchData(STATS_URL).then((s) => {
53
+ const stats = s as { total?: number; verified?: number }
54
+ if (!cancelled && stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
55
+ setStats({ total: stats.total, verified: stats.verified })
56
+ }
57
+ }).catch(() => {})
58
+ return () => { cancelled = true }
59
+ // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
60
+ // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
61
+ }, [reloadKey, lang])
62
+
63
+ /** 刷新当前 profile 已安装插件表;宿主未挂本地路由时静默降级为空表。 */
64
+ const refreshInstalled = async () => {
65
+ try {
66
+ const res = await fetch('/dsh-plugin-hub/installed', { cache: 'no-store' })
67
+ if (!res.ok) return
68
+ const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string; npmPackage?: string }> }
69
+ setInstalled(data.installed ?? {})
70
+ setVersions(data.versions ?? {})
71
+ } catch {
72
+ // host without the plugin's server routes — keep the empty table
73
+ }
74
+ }
75
+
76
+ // 首次进入拉取已安装表(依赖宿主 webServer 服务)
77
+ useEffect(() => { refreshInstalled() }, [])
78
+
79
+ /** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
80
+ const installedName = (p: HubPlugin): string | null => {
81
+ const repo = p.source?.repo
82
+ if (!repo) return null
83
+ const needle = `github:${repo.toLowerCase()}`
84
+ for (const [name, spec] of Object.entries(installed)) {
85
+ if (spec.toLowerCase().includes(needle)) return name
86
+ }
87
+ // npm 通道安装:profile 依赖 key 直接是 npm 包名。包名来源两处——目录数据(npmPackage),
88
+ // 或服务端 npm 优先反查持久化的映射(versions[repo].npmPackage,覆盖组织 scope 与 GitHub
89
+ // 用户名不一致、目录未下发包名的场景);命中任一并依赖 key 真实存在即视为已安装
90
+ const pkg = ((p.source?.npmPackage || versions[repo.toLowerCase()]?.npmPackage) ?? '').toLowerCase()
91
+ if (pkg) {
92
+ for (const name of Object.keys(installed)) {
93
+ if (name.toLowerCase() === pkg) return name
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ /** 该插件安装时记录的目录版本(无记录/未安装 → null)。 */
100
+ const installedVersion = (p: HubPlugin): string | null => {
101
+ const repo = p.source?.repo
102
+ if (!repo) return null
103
+ return versions[repo.toLowerCase()]?.version ?? null
104
+ }
105
+
106
+ /**
107
+ * 是否有更新:仅对已安装插件有意义。
108
+ * 双信号判定——有 release 版本的比版本;无版本(repo 不打 tag)的比仓库最近更新时间
109
+ * (repoUpdatedAt,ISO 字符串字典序 = 时间序),更新时间变新说明有新提交。
110
+ */
111
+ const hasUpdate = (p: HubPlugin): boolean => {
112
+ if (installedName(p) === null) return false
113
+ const repo = p.source?.repo
114
+ if (!repo) return false
115
+ const rec = versions[repo.toLowerCase()]
116
+ if (!rec) return false
117
+ if (p.version) return p.version !== rec.version
118
+ const current = p.dates?.repoUpdatedAt
119
+ return Boolean(current && rec.updatedAt && current > rec.updatedAt)
120
+ }
121
+
122
+ /** 当前分类下的插件(「全部」时为整个目录)。 */
123
+ const categoryPlugins = useMemo(() => {
124
+ if (!plugins) return []
125
+ if (category === 'all') return plugins
126
+ return plugins.filter((p) => p.category === category)
127
+ }, [plugins, category])
128
+
129
+ /** 当前分类下已安装插件数:已安装/未安装按钮上的计数跟随分类,不再用全局口径。 */
130
+ const installedCountInCategory = useMemo(() => {
131
+ return categoryPlugins.reduce((n, p) => n + (installedName(p) !== null ? 1 : 0), 0)
132
+ }, [categoryPlugins, installed])
133
+
134
+ /** 当前分类下未安装插件数。 */
135
+ const notInstalledCountInCategory = Math.max(0, categoryPlugins.length - installedCountInCategory)
136
+
137
+ // 自动重置:切换分类(或已安装表变化)后,若当前安装状态筛选在新分类下无结果,
138
+ // 自动退回「全部」,避免列表空置死胡同、也避免 0 计数按钮被误触。
139
+ useEffect(() => {
140
+ if (installedFilter === 'all') return
141
+ const count = installedFilter === 'installed'
142
+ ? installedCountInCategory
143
+ : notInstalledCountInCategory
144
+ if (count === 0) setInstalledFilter('all')
145
+ }, [installedFilter, installedCountInCategory, notInstalledCountInCategory])
146
+
147
+ const visible = useMemo(() => {
148
+ if (!plugins) return []
149
+ const q = query.trim().toLowerCase()
150
+ const list = plugins.filter((p) => {
151
+ if (category !== 'all' && p.category !== category) return false
152
+ if (installedFilter === 'installed' && installedName(p) === null) return false
153
+ if (installedFilter === 'notInstalled' && installedName(p) !== null) return false
154
+ if (!q) return true
155
+ return (
156
+ (p.displayName ?? '').toLowerCase().includes(q) ||
157
+ (p.description ?? '').toLowerCase().includes(q) ||
158
+ (p.topics ?? []).some((topic) => topic.toLowerCase().includes(q))
159
+ )
160
+ })
161
+ return [...list].sort((a, b) => {
162
+ if (sort === 'sortStars') return (b.stats?.stargazers_count ?? 0) - (a.stats?.stargazers_count ?? 0)
163
+ if (sort === 'sortForks') return (b.stats?.forks_count ?? 0) - (a.stats?.forks_count ?? 0)
164
+ if (sort === 'sortNewest') return (b.dates?.addedAt ?? '').localeCompare(a.dates?.addedAt ?? '')
165
+ return (b.dates?.repoUpdatedAt ?? '').localeCompare(a.dates?.repoUpdatedAt ?? '')
166
+ })
167
+ }, [plugins, category, query, sort, installed, installedFilter])
168
+
169
+ /** Per-category plugin counts shown on the category chips. */
170
+ const categoryCounts = useMemo(() => {
171
+ const counts: Record<string, number> = {}
172
+ for (const p of plugins ?? []) {
173
+ if (p.category) counts[p.category] = (counts[p.category] ?? 0) + 1
174
+ }
175
+ return counts
176
+ }, [plugins])
177
+
178
+ return {
179
+ plugins,
180
+ stats,
181
+ failed,
182
+ reload: () => setReloadKey((k) => k + 1),
183
+ installed,
184
+ installedName,
185
+ installedVersion,
186
+ hasUpdate,
187
+ refreshInstalled,
188
+ category,
189
+ setCategory,
190
+ query,
191
+ setQuery,
192
+ sort,
193
+ setSort,
194
+ installedFilter,
195
+ setInstalledFilter,
196
+ visible,
197
+ total: plugins?.length ?? 0,
198
+ installedCountInCategory,
199
+ notInstalledCountInCategory,
200
+ categoryCounts,
201
+ }
202
+ }