dsh-plugin 0.1.0 → 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 +52 -27
  2. package/README.md +52 -27
  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 -569
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Custom dropdown (native <select> replacement).
3
+ *
4
+ * macOS/浏览器原生下拉无法跟随宿主主题,这里自绘:触发器按钮 + 绝对定位面板,
5
+ * 点外部/Esc 关闭;选项 hover 加重底色、激活项深色实心高亮,全部走 --dsw-alias-* 主题变量。
6
+ */
7
+ import { createElement as h, useEffect, useRef, useState } from 'react'
8
+ import type { JSX } from 'react'
9
+ import styles from '../styles/Dropdown.module.css'
10
+ import { ChevronDownIcon } from './icons.tsx'
11
+
12
+ export interface DropdownOption<T extends string> {
13
+ value: T
14
+ label: string
15
+ /** 可选计数徽标(如「已安装 12」) */
16
+ count?: number
17
+ }
18
+
19
+ interface DropdownProps<T extends string> {
20
+ value: T
21
+ options: DropdownOption<T>[]
22
+ onChange: (value: T) => void
23
+ title?: string
24
+ }
25
+
26
+ export function Dropdown<T extends string>({ value, options, onChange, title }: DropdownProps<T>): JSX.Element {
27
+ const [open, setOpen] = useState(false)
28
+ const rootRef = useRef<HTMLDivElement | null>(null)
29
+
30
+ // 面板展开时:点面板外部或按 Esc 关闭。
31
+ useEffect(() => {
32
+ if (!open) return
33
+ const onDoc = (e: MouseEvent) => {
34
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
35
+ }
36
+ const onKey = (e: KeyboardEvent) => {
37
+ if (e.key === 'Escape') setOpen(false)
38
+ }
39
+ document.addEventListener('mousedown', onDoc)
40
+ document.addEventListener('keydown', onKey)
41
+ return () => {
42
+ document.removeEventListener('mousedown', onDoc)
43
+ document.removeEventListener('keydown', onKey)
44
+ }
45
+ }, [open])
46
+
47
+ const current = options.find((o) => o.value === value)
48
+
49
+ return h('div', { className: styles.dropdown, ref: rootRef },
50
+ h('button', {
51
+ className: styles.dropdownBtn,
52
+ type: 'button',
53
+ title,
54
+ 'aria-haspopup': 'listbox',
55
+ 'aria-expanded': open,
56
+ onClick: () => setOpen((v) => !v),
57
+ },
58
+ h('span', { className: styles.dropdownLabel }, current?.label ?? value),
59
+ h('span', { className: open ? styles.dropdownArrowOpen : styles.dropdownArrow }, h(ChevronDownIcon)),
60
+ ),
61
+ open && h('div', { className: styles.dropdownPanel, role: 'listbox' },
62
+ options.map((o) => h('button', {
63
+ key: o.value,
64
+ type: 'button',
65
+ role: 'option',
66
+ 'aria-selected': o.value === value,
67
+ className: o.value === value ? styles.dropdownItemActive : styles.dropdownItem,
68
+ onClick: () => {
69
+ onChange(o.value)
70
+ setOpen(false)
71
+ },
72
+ },
73
+ h('span', { className: styles.dropdownItemLabel }, o.label),
74
+ typeof o.count === 'number'
75
+ ? h('span', { className: o.value === value ? styles.dropdownCountActive : styles.dropdownCount }, o.count)
76
+ : null,
77
+ )),
78
+ ),
79
+ )
80
+ }
@@ -0,0 +1,230 @@
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
+ /** 记录时间紧凑展示:今年内 MM-DD HH:mm,跨年补年份前缀。 */
23
+ function fmtTime(at: number): string {
24
+ const d = new Date(at)
25
+ const pad = (n: number) => String(n).padStart(2, '0')
26
+ const mmdd = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
27
+ return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`
28
+ }
29
+
30
+ /** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
31
+ function BadgeGlyph({ ok }: { ok: boolean }) {
32
+ return h('svg', {
33
+ className: styles.noticeBadgeIcon,
34
+ viewBox: '0 0 16 16',
35
+ width: 12,
36
+ height: 12,
37
+ fill: 'none',
38
+ 'aria-hidden': 'true',
39
+ }, ok
40
+ ? h('path', {
41
+ d: 'M3 8.5l3.5 3.5 6.5-6.5',
42
+ stroke: '#ffffff',
43
+ strokeWidth: 2,
44
+ strokeLinecap: 'round',
45
+ strokeLinejoin: 'round',
46
+ })
47
+ : h('path', {
48
+ d: 'M4 4l8 8M12 4L4 12',
49
+ stroke: '#ffffff',
50
+ strokeWidth: 2,
51
+ strokeLinecap: 'round',
52
+ }))
53
+ }
54
+
55
+ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, cancelTask, restarting, onRestart }: {
56
+ records: NotificationRecord[]
57
+ /** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
58
+ tasks: QueueTask[]
59
+ /** 待重启插件:装完没重启的常驻提醒 */
60
+ pendingRestarts: PendingRestart[]
61
+ t: Translate
62
+ env: EnvInfo | null
63
+ onClose: () => void
64
+ onCopy: (text: string) => void
65
+ onClear: () => void
66
+ cancelTask: (id: number) => void
67
+ restarting: boolean
68
+ onRestart: () => void
69
+ }) {
70
+ return h('div', {
71
+ className: styles.overlay,
72
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
73
+ if (e.target === e.currentTarget) onClose()
74
+ },
75
+ },
76
+ h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
77
+ h('div', { className: styles.modalHead },
78
+ h('div', { className: styles.modalTitle }, t('notifications')),
79
+ h('button', {
80
+ className: styles.modalClose,
81
+ 'aria-label': t('errorClose'),
82
+ onClick: onClose,
83
+ }, h(CloseIcon)),
84
+ ),
85
+ h('div', { className: styles.modalBody },
86
+ h('div', { className: styles.errorHint }, t('notificationsDesc')),
87
+ // 进行中的安装/卸载任务实时进度也进通知中心:与队列弹窗同一套行结构,
88
+ // 关掉任务弹窗后仍可在这里盯着进度;待重启插件可在此直接触发重启
89
+ (tasks.length > 0 || pendingRestarts.length > 0)
90
+ ? h('div', null, [
91
+ pendingRestarts.length > 0
92
+ ? h('div', { className: styles.queueSection },
93
+ h('div', { className: styles.queueSectionTitle }, t('sectionPendingRestart')),
94
+ pendingRestarts.map((p) => h('div', { key: `restart-${p.target}`, className: styles.queueRow },
95
+ h('div', { className: styles.queueRowHead },
96
+ h('span', {
97
+ className: `${styles.failKind} ${p.kind === 'uninstall' ? styles.failKindUninstall : styles.failKindInstall}`,
98
+ }, p.kind === 'uninstall' ? t('uninstall') : t('install')),
99
+ h('span', { className: styles.queueRowTarget, title: p.target }, p.target),
100
+ ),
101
+ p.desc ? h('div', { className: styles.queueRowDesc, title: p.desc }, p.desc) : null,
102
+ h('div', { className: styles.queueRowBody },
103
+ h('span', { className: styles.pendingRowStatus },
104
+ p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
105
+ h('span', { className: styles.pendingRowActions },
106
+ h('button', {
107
+ className: styles.restartLater,
108
+ disabled: restarting,
109
+ onClick: onClose,
110
+ }, t('restartLater')),
111
+ h('button', {
112
+ className: styles.restartNow,
113
+ disabled: restarting,
114
+ onClick: onRestart,
115
+ }, restarting ? t('restarting') : t('restartNow')),
116
+ ),
117
+ ),
118
+ )),
119
+ )
120
+ : null,
121
+ tasks.length > 0
122
+ ? h('div', { className: styles.queueSection },
123
+ h('div', { className: styles.queueSectionTitle }, t('sectionInProgress')),
124
+ tasks.map((q) => h('div', { key: q.id, className: styles.queueRow },
125
+ h('div', { className: styles.queueRowHead },
126
+ h('span', {
127
+ className: `${styles.failKind} ${q.kind === 'install' ? styles.failKindInstall : styles.failKindUninstall}`,
128
+ }, q.kind === 'install' ? t('install') : t('uninstall')),
129
+ h('span', { className: styles.queueRowTarget, title: q.target }, q.target),
130
+ h('button', {
131
+ className: styles.stripCancel,
132
+ disabled: q.status === 'cancelling',
133
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); cancelTask(q.id) },
134
+ }, t('cancelTask')),
135
+ ),
136
+ q.desc ? h('div', { className: styles.queueRowDesc, title: q.desc }, q.desc) : null,
137
+ h('div', { className: styles.queueRowBody },
138
+ h('span', { className: styles.queueRowStatus },
139
+ q.status === 'running'
140
+ ? (q.kind === 'install' ? t('installing') : t('uninstalling'))
141
+ : q.status === 'cancelling'
142
+ ? t('cancelling')
143
+ : (q.kind === 'install' ? t('queuedTitle') : t('queuedUninstallTitle'))),
144
+ h('div', { className: `${styles.progressTrack} ${styles.queueRowTrack}` },
145
+ h('div', { className: styles.progressFill, style: { width: `${q.progress}%` } }),
146
+ ),
147
+ h('span', { className: styles.queueRowPct }, `${q.progress}%`),
148
+ ),
149
+ )),
150
+ )
151
+ : null,
152
+ ])
153
+ : null,
154
+ records.length === 0
155
+ ? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
156
+ : h('div', { className: styles.noticeList },
157
+ records.map((r) => {
158
+ return h('div', { key: r.id, className: styles.noticeRow },
159
+ h('div', {
160
+ className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
161
+ }, h(BadgeGlyph, { ok: r.ok })),
162
+ h('div', { className: styles.noticeMain },
163
+ h('div', { className: styles.noticeHead },
164
+ h('span', {
165
+ className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
166
+ }, r.ok
167
+ ? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
168
+ : (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
169
+ r.repo
170
+ ? h('a', {
171
+ className: styles.failRepo,
172
+ // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
173
+ href: pluginSiteUrl(r.repo),
174
+ target: '_blank',
175
+ rel: 'noopener noreferrer',
176
+ title: r.repo,
177
+ }, r.repo)
178
+ : null,
179
+ h('span', {
180
+ className: styles.failTime,
181
+ title: new Date(r.at).toLocaleString(),
182
+ }, fmtTime(r.at)),
183
+ !r.ok && h('button', {
184
+ className: styles.failCopy,
185
+ onClick: () => onCopy(r.message),
186
+ }, t('failCopy')),
187
+ ),
188
+ // 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
189
+ // 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
190
+ !r.ok && (() => {
191
+ const kind = classifyFailure(r.message)
192
+ if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
193
+ // [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
194
+ // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
195
+ return h('div', null, [
196
+ h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
197
+ ? t('failIgnoredBuild')
198
+ : /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
199
+ r.repo ? h('a', {
200
+ className: styles.failBigIssue,
201
+ href: pluginIssueUrl(r.repo, r.message, env),
202
+ target: '_blank',
203
+ rel: 'noopener noreferrer',
204
+ title: t('failIssueHint'),
205
+ }, t('failIssueBig')) : null,
206
+ ])
207
+ }
208
+ return r.repo ? h('a', {
209
+ className: styles.failBigIssue,
210
+ href: pluginIssueUrl(r.repo, r.message, env),
211
+ target: '_blank',
212
+ rel: 'noopener noreferrer',
213
+ title: t('failIssueHint'),
214
+ }, t('failIssueBig')) : null
215
+ })(),
216
+ ),
217
+ )
218
+ }),
219
+ ),
220
+ h('div', { className: styles.modalActions },
221
+ records.length > 0 ? h('button', {
222
+ className: styles.failClear,
223
+ onClick: onClear,
224
+ }, t('notificationsClear')) : null,
225
+ h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
226
+ ),
227
+ ),
228
+ ),
229
+ )
230
+ }
@@ -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
+ }