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,243 @@
1
+ /**
2
+ * Notification-center dialog: a persistent log of every settled install /
3
+ * remove task — successes and failures alike.
4
+ *
5
+ * Records are written to localStorage at settle time (see lib/failures.ts),
6
+ * so a result is never lost even when the dialog was dismissed or the user
7
+ * was away. Each entry carries a circular status badge (green check for
8
+ * success, red cross for failure) with white glyph and message text;
9
+ * failures keep their copy / fix / file-an-issue actions. Opened from the
10
+ * header entry button.
11
+ */
12
+ import { createElement as h } from 'react'
13
+ import type { MouseEvent } from 'react'
14
+ import styles from '../styles/Modal.module.css'
15
+ import type { EnvInfo, Translate } from '../types.ts'
16
+ import type { NotificationRecord } from '../lib/failures.ts'
17
+ import { classifyFailure } from '../lib/failures.ts'
18
+ import type { PendingRestart, QueueTask } from '../hooks/useTaskQueue.ts'
19
+ import { pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
20
+ import { CloseIcon } from './icons.tsx'
21
+
22
+ /** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
23
+ function fmtTime(at: number): string {
24
+ const d = new Date(at)
25
+ const pad = (n: number) => String(n).padStart(2, '0')
26
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
27
+ }
28
+
29
+ /** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
30
+ function BadgeGlyph({ ok }: { ok: boolean }) {
31
+ return h('svg', {
32
+ className: styles.noticeBadgeIcon,
33
+ viewBox: '0 0 16 16',
34
+ width: 12,
35
+ height: 12,
36
+ fill: 'none',
37
+ 'aria-hidden': 'true',
38
+ }, ok
39
+ ? h('path', {
40
+ d: 'M3 8.5l3.5 3.5 6.5-6.5',
41
+ stroke: '#ffffff',
42
+ strokeWidth: 2,
43
+ strokeLinecap: 'round',
44
+ strokeLinejoin: 'round',
45
+ })
46
+ : h('path', {
47
+ d: 'M4 4l8 8M12 4L4 12',
48
+ stroke: '#ffffff',
49
+ strokeWidth: 2,
50
+ strokeLinecap: 'round',
51
+ }))
52
+ }
53
+
54
+ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, cancelTask, restarting, onRestart }: {
55
+ records: NotificationRecord[]
56
+ /** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
57
+ tasks: QueueTask[]
58
+ /** 待重启插件:装完没重启的常驻提醒 */
59
+ pendingRestarts: PendingRestart[]
60
+ t: Translate
61
+ env: EnvInfo | null
62
+ onClose: () => void
63
+ onCopy: (text: string) => void
64
+ onClear: () => void
65
+ /** 删除单条通知记录 */
66
+ onRemove: (id: number) => void
67
+ cancelTask: (id: number) => void
68
+ restarting: boolean
69
+ onRestart: () => void
70
+ }) {
71
+ return h('div', {
72
+ className: styles.overlay,
73
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
74
+ if (e.target === e.currentTarget) onClose()
75
+ },
76
+ },
77
+ h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
78
+ h('div', { className: styles.modalHead },
79
+ h('div', { className: styles.modalTitle }, t('notifications')),
80
+ h('button', {
81
+ className: styles.modalClose,
82
+ 'aria-label': t('errorClose'),
83
+ onClick: onClose,
84
+ }, h(CloseIcon)),
85
+ ),
86
+ h('div', { className: styles.modalBody },
87
+ h('div', { className: styles.errorHint }, t('notificationsDesc')),
88
+ // 进行中的安装/卸载任务实时进度也进通知中心:与队列弹窗同一套行结构,
89
+ // 关掉任务弹窗后仍可在这里盯着进度;待重启插件可在此直接触发重启
90
+ (tasks.length > 0 || pendingRestarts.length > 0)
91
+ ? h('div', null, [
92
+ pendingRestarts.length > 0
93
+ ? h('div', { className: styles.queueSection },
94
+ h('div', { className: styles.queueSectionTitle }, t('sectionPendingRestart')),
95
+ pendingRestarts.map((p) => h('div', { key: `restart-${p.target}`, className: styles.queueRow },
96
+ h('div', { className: styles.queueRowHead },
97
+ h('span', {
98
+ className: `${styles.failKind} ${p.kind === 'uninstall' ? styles.failKindUninstall : styles.failKindInstall}`,
99
+ }, p.kind === 'uninstall' ? t('uninstall') : t('install')),
100
+ h('span', { className: styles.queueRowTarget, title: p.target }, p.target),
101
+ ),
102
+ p.desc ? h('div', { className: styles.queueRowDesc, title: p.desc }, p.desc) : null,
103
+ h('div', { className: styles.queueRowBody },
104
+ h('span', { className: styles.pendingRowStatus },
105
+ p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
106
+ h('span', { className: styles.pendingRowActions },
107
+ h('button', {
108
+ className: styles.restartLater,
109
+ disabled: restarting,
110
+ onClick: onClose,
111
+ }, t('restartLater')),
112
+ h('button', {
113
+ className: styles.restartNow,
114
+ disabled: restarting,
115
+ onClick: onRestart,
116
+ }, restarting ? t('restarting') : t('restartNow')),
117
+ ),
118
+ ),
119
+ )),
120
+ )
121
+ : null,
122
+ tasks.length > 0
123
+ ? h('div', { className: styles.queueSection },
124
+ h('div', { className: styles.queueSectionTitle }, t('sectionInProgress')),
125
+ tasks.map((q) => h('div', { key: q.id, className: styles.queueRow },
126
+ h('div', { className: styles.queueRowHead },
127
+ h('span', {
128
+ className: `${styles.failKind} ${q.kind === 'install' ? styles.failKindInstall : styles.failKindUninstall}`,
129
+ }, q.kind === 'install' ? t('install') : t('uninstall')),
130
+ h('span', { className: styles.queueRowTarget, title: q.target }, q.target),
131
+ h('button', {
132
+ className: styles.stripCancel,
133
+ disabled: q.status === 'cancelling',
134
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); cancelTask(q.id) },
135
+ }, t('cancelTask')),
136
+ ),
137
+ q.desc ? h('div', { className: styles.queueRowDesc, title: q.desc }, q.desc) : null,
138
+ h('div', { className: styles.queueRowBody },
139
+ h('span', { className: styles.queueRowStatus },
140
+ q.status === 'running'
141
+ ? (q.kind === 'install' ? t('installing') : t('uninstalling'))
142
+ : q.status === 'cancelling'
143
+ ? t('cancelling')
144
+ : (q.kind === 'install' ? t('queuedTitle') : t('queuedUninstallTitle'))),
145
+ h('div', { className: `${styles.progressTrack} ${styles.queueRowTrack}` },
146
+ h('div', { className: styles.progressFill, style: { width: `${q.progress}%` } }),
147
+ ),
148
+ h('span', { className: styles.queueRowPct }, `${q.progress}%`),
149
+ ),
150
+ )),
151
+ )
152
+ : null,
153
+ ])
154
+ : null,
155
+ records.length === 0
156
+ ? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
157
+ : h('div', { className: styles.noticeList },
158
+ records.map((r) => {
159
+ return h('div', { key: r.id, className: styles.noticeRow },
160
+ h('div', { className: styles.noticeRowMain },
161
+ h('div', {
162
+ className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
163
+ }, h(BadgeGlyph, { ok: r.ok })),
164
+ h('div', { className: styles.noticeMain },
165
+ h('div', { className: styles.noticeHead },
166
+ h('span', {
167
+ className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
168
+ }, r.ok
169
+ ? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
170
+ : (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
171
+ r.repo
172
+ ? h('a', {
173
+ className: styles.failRepo,
174
+ // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
175
+ href: pluginSiteUrl(r.repo),
176
+ target: '_blank',
177
+ rel: 'noopener noreferrer',
178
+ title: r.repo,
179
+ }, r.repo)
180
+ : null,
181
+ !r.ok && h('button', {
182
+ className: styles.failCopy,
183
+ onClick: () => onCopy(r.message),
184
+ }, t('failCopy')),
185
+ // 每条通知右侧的删除按钮:单独移除这一条
186
+ h('button', {
187
+ className: styles.noticeRemove,
188
+ 'aria-label': t('removeNotification'),
189
+ title: t('removeNotification'),
190
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove(r.id) },
191
+ }, h(CloseIcon)),
192
+ ),
193
+ // 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
194
+ // 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
195
+ !r.ok && (() => {
196
+ const kind = classifyFailure(r.message)
197
+ if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
198
+ // [packaging](预检/装后校验拦截:git 分发缺产物)与
199
+ // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
200
+ return h('div', null, [
201
+ h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
202
+ ? t('failIgnoredBuild')
203
+ : /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
204
+ r.repo ? h('a', {
205
+ className: styles.failBigIssue,
206
+ href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
207
+ target: '_blank',
208
+ rel: 'noopener noreferrer',
209
+ title: t('failIssueHint'),
210
+ }, t('failIssueBig')) : null,
211
+ ])
212
+ }
213
+ return r.repo ? h('a', {
214
+ className: styles.failBigIssue,
215
+ href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
216
+ target: '_blank',
217
+ rel: 'noopener noreferrer',
218
+ title: t('failIssueHint'),
219
+ }, t('failIssueBig')) : null
220
+ })(),
221
+ ),
222
+ ),
223
+ // 每条通知右下角:完整年月日时分秒时间戳(独立一行,不参与徽标垂直居中)
224
+ h('div', { className: styles.noticeFoot },
225
+ h('span', {
226
+ className: styles.noticeTime,
227
+ title: new Date(r.at).toLocaleString(),
228
+ }, fmtTime(r.at)),
229
+ ),
230
+ )
231
+ }),
232
+ ),
233
+ h('div', { className: styles.modalActions },
234
+ records.length > 0 ? h('button', {
235
+ className: styles.failClear,
236
+ onClick: onClear,
237
+ }, t('notificationsClear')) : null,
238
+ h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
239
+ ),
240
+ ),
241
+ ),
242
+ )
243
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * One plugin card in the catalog list: name/category/verified badge,
3
+ * description + topics, star/fork/date stats, and the detail/install/
4
+ * uninstall actions.
5
+ */
6
+ import { createElement as h } from 'react'
7
+ import styles from '../styles/List.module.css'
8
+ import type { HubPlugin, LocaleId, Translate } from '../types.ts'
9
+ import { CATEGORY_LABELS, categoryLabel, pluginDetailUrl } from '../lib/catalog.ts'
10
+ import { fmtStars, relTime } from '../lib/format.ts'
11
+
12
+ export function PluginCard({ plugin: p, copied, installedName, installedVersion, hasUpdate, t, langKey, langPath, onInstall, onUninstall }: {
13
+ plugin: HubPlugin
14
+ copied: string | null
15
+ installedName: (p: HubPlugin) => string | null
16
+ /** 已安装版本号(安装时从目录信号记录;无 release 的仓库为空,无版本号可展示) */
17
+ installedVersion: (p: HubPlugin) => string | null
18
+ /** 已安装且目录有更新(有版本比版本、无版本比仓库更新时间) */
19
+ hasUpdate: (p: HubPlugin) => boolean
20
+ t: Translate
21
+ langKey: LocaleId
22
+ langPath: string
23
+ /** 第二个参数标记「更新」(已安装目标的覆盖安装):按钮/弹窗据此走更新语义 */
24
+ onInstall: (p: HubPlugin, opts?: { update?: boolean }) => void
25
+ onUninstall: (p: HubPlugin) => void
26
+ }) {
27
+ const repo = p.source?.repo ?? ''
28
+ const isCopied = copied === repo
29
+ const pkg = installedName(p)
30
+ const isInstalled = pkg !== null
31
+ const update = hasUpdate(p)
32
+ // 版本号展示:已安装 → 当前已装版本(最能反映用户状态);未安装 → 目录最新版本
33
+ const versionShown = isInstalled ? installedVersion(p) : p.version
34
+
35
+ return h('div', { className: styles.card },
36
+ h('div', { className: styles.cardMain },
37
+ h('div', { className: styles.cardHead },
38
+ h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
39
+ // 版本号紧跟插件名称:有版本才显示(无 release 的仓库不展示版本号)
40
+ versionShown ? h('span', { className: styles.versionBadge, title: t('version') }, versionShown) : null,
41
+ // 已安装且有更新:醒目提示(有 release 比版本号、无 release 比仓库最近更新时间)
42
+ update ? h('span', { className: styles.updateBadge, title: t('updateAvailableHint') }, t('updateAvailable')) : null,
43
+ p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
44
+ p.compatibility?.status === 'verified'
45
+ ? h('span', { className: styles.verified }, t('verified'))
46
+ : null,
47
+ ),
48
+ // 描述始终显示:站点英文数据缺翻译时 description 回退成中文也照常展示,
49
+ // 描述是了解插件的第一入口,比隐藏更实用(不再为求语言一致而砍内容)。
50
+ p.description ? h('p', { className: styles.desc }, p.description) : null,
51
+ (p.topics?.length ?? 0) > 0
52
+ ? h('div', { className: styles.topics },
53
+ p.topics!.slice(0, 3).map((topic) => h('span', { key: topic, className: styles.topic }, topic)),
54
+ )
55
+ : null,
56
+ ),
57
+ h('div', { className: styles.cardSide },
58
+ h('div', { className: styles.stats },
59
+ h('span', { className: styles.star }, '\u2605 ', fmtStars(p.stats?.stargazers_count)),
60
+ h('span', { className: styles.fork }, t('fork'), ' ', fmtStars(p.stats?.forks_count)),
61
+ h('span', { className: styles.date }, relTime(p.dates?.repoUpdatedAt, t)),
62
+ ),
63
+ repo
64
+ ? h('div', { className: styles.actions },
65
+ // 查看详情:未安装/已安装都始终显示,跳转官网详情页
66
+ h('a', {
67
+ className: styles.detailBtn,
68
+ // 两级路径:/plugins/{ownerSlug}/{slug};旧数据缺 ownerSlug 时从 repo 推导
69
+ href: pluginDetailUrl(p, langPath),
70
+ target: '_blank',
71
+ rel: 'noopener noreferrer',
72
+ title: p.slug,
73
+ }, t('detail')),
74
+ isInstalled
75
+ // 已安装且有更新:按钮变「可更新」,点击直接覆盖安装(安装完成自动记录新版本)
76
+ ? (update
77
+ ? h('button', {
78
+ className: styles.installBtnUpdate,
79
+ title: t('updateAvailableHint'),
80
+ onClick: () => onInstall(p, { update: true }),
81
+ }, t('update'))
82
+ : h('button', {
83
+ className: styles.installBtnInstalled,
84
+ disabled: true,
85
+ title: t('installed'),
86
+ }, t('installed')))
87
+ : h('button', {
88
+ className: isCopied ? styles.installBtnCopied : styles.installBtn,
89
+ // 文字恒定避免按钮宽度变化导致卡片跳动;点击先弹信任确认,
90
+ // 弹窗内可选择复制命令或直接安装
91
+ onClick: () => onInstall(p),
92
+ }, t('install')),
93
+ isInstalled
94
+ // 已安装:卸载按钮(危险色),保留明确状态语义
95
+ ? h('button', {
96
+ className: styles.uninstallBtn,
97
+ onClick: () => onUninstall(p),
98
+ }, t('uninstall'))
99
+ : null,
100
+ )
101
+ : null,
102
+ ),
103
+ )
104
+ }