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.
- package/README.en.md +49 -26
- package/README.md +49 -26
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- 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
|
+
}
|