dsh-plugin 1.1.2 → 1.2.1

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 (110) hide show
  1. package/client/client.js +4465 -1442
  2. package/client/client.js.map +1 -1
  3. package/lib/http/routes.js +438 -22
  4. package/lib/index.js +1 -1
  5. package/lib/services/install/install.js +4 -0
  6. package/lib/services/install/npm-check.js +55 -0
  7. package/lib/services/{npm-resolve.js → install/npm-resolve.js} +7 -5
  8. package/lib/services/{preflight.js → install/preflight.js} +15 -4
  9. package/lib/services/{task-queue.js → install/task-queue.js} +155 -66
  10. package/lib/services/loader.js +56 -1
  11. package/lib/services/log.js +138 -0
  12. package/lib/services/probe.js +157 -0
  13. package/lib/services/{profile.js → profile/profile.js} +29 -1
  14. package/lib/services/settings.js +60 -0
  15. package/lib/types/http/routes.d.ts +1 -1
  16. package/lib/types/index.d.ts +1 -1
  17. package/lib/types/services/{install-types.d.ts → install/install-types.d.ts} +3 -0
  18. package/lib/types/services/{install.d.ts → install/install.d.ts} +5 -5
  19. package/lib/types/services/install/npm-check.d.ts +12 -0
  20. package/lib/types/services/{npm-resolve.d.ts → install/npm-resolve.d.ts} +2 -1
  21. package/lib/types/services/{task-queue.d.ts → install/task-queue.d.ts} +5 -1
  22. package/lib/types/services/loader.d.ts +14 -8
  23. package/lib/types/services/log.d.ts +49 -0
  24. package/lib/types/services/probe.d.ts +30 -0
  25. package/lib/types/services/{pending-restart.d.ts → profile/pending-restart.d.ts} +1 -1
  26. package/lib/types/services/{profile.d.ts → profile/profile.d.ts} +2 -0
  27. package/lib/types/services/settings.d.ts +24 -0
  28. package/package.json +1 -1
  29. package/src/client/components/PluginHubSection.tsx +341 -120
  30. package/src/client/components/{CatalogList.tsx → catalog/CatalogList.tsx} +11 -7
  31. package/src/client/components/{PluginCard.tsx → catalog/PluginCard.tsx} +5 -4
  32. package/src/client/components/layout/CatalogControls.tsx +80 -0
  33. package/src/client/components/{CatalogHeader.tsx → layout/CatalogHeader.tsx} +4 -4
  34. package/src/client/components/{CategoryTabs.tsx → layout/CategoryTabs.tsx} +3 -3
  35. package/src/client/components/layout/SectionTabs.tsx +60 -0
  36. package/src/client/components/{AboutModal.tsx → modals/AboutModal.tsx} +7 -7
  37. package/src/client/components/{HubUpdateModal.tsx → modals/HubUpdateModal.tsx} +5 -5
  38. package/src/client/components/modals/InstalledDetailModal.tsx +193 -0
  39. package/src/client/components/modals/LogsModal.tsx +301 -0
  40. package/src/client/components/modals/LogsPathModal.tsx +161 -0
  41. package/src/client/components/{NotificationsModal.tsx → modals/NotificationsModal.tsx} +47 -20
  42. package/src/client/components/{ProgressView.tsx → modals/ProgressView.tsx} +2 -2
  43. package/src/client/components/{modals.tsx → modals/modals.tsx} +117 -40
  44. package/src/client/components/{Dropdown.tsx → ui/Dropdown.tsx} +14 -6
  45. package/src/client/components/ui/Toggle.tsx +28 -0
  46. package/src/client/components/ui/icons.tsx +343 -0
  47. package/src/client/components/views/CustomInstallView.tsx +250 -0
  48. package/src/client/components/views/DiagnosticsView.tsx +172 -0
  49. package/src/client/components/views/InstalledView.tsx +328 -0
  50. package/src/client/components/views/LogsView.tsx +150 -0
  51. package/src/client/components/views/MarketView.tsx +66 -0
  52. package/src/client/components/views/SettingsView.tsx +273 -0
  53. package/src/client/data/catalog.ts +38 -0
  54. package/src/client/data/host.ts +107 -0
  55. package/src/client/data/hub.ts +41 -0
  56. package/src/client/hooks/useCatalog.ts +71 -115
  57. package/src/client/hooks/useIncrementalList.ts +46 -0
  58. package/src/client/hooks/useLanguage.ts +40 -0
  59. package/src/client/hooks/useSettings.ts +90 -0
  60. package/src/client/hooks/useTaskQueue.ts +156 -39
  61. package/src/client/locales.ts +358 -38
  62. package/src/client/logic/constants.ts +86 -0
  63. package/src/client/{lib → logic}/failures.ts +95 -74
  64. package/src/client/logic/install-command.ts +56 -0
  65. package/src/client/logic/installed.ts +207 -0
  66. package/src/client/logic/normalize.ts +52 -0
  67. package/src/client/logic/urls.ts +125 -0
  68. package/src/client/styles/CustomInstallView.module.css +191 -0
  69. package/src/client/styles/DiagnosticsView.module.css +231 -0
  70. package/src/client/styles/Dropdown.module.css +42 -41
  71. package/src/client/styles/Header.module.css +100 -132
  72. package/src/client/styles/InstalledView.module.css +447 -0
  73. package/src/client/styles/List.module.css +6 -0
  74. package/src/client/styles/LogsView.module.css +503 -0
  75. package/src/client/styles/Modal.module.css +352 -70
  76. package/src/client/styles/SectionTabs.module.css +142 -0
  77. package/src/client/styles/SettingsView.module.css +232 -0
  78. package/src/client/styles/Toggle.module.css +60 -0
  79. package/src/client/styles/tokens.module.css +1 -0
  80. package/src/client/types.ts +23 -3
  81. package/src/server/http/routes.ts +414 -22
  82. package/src/server/index.ts +1 -1
  83. package/src/server/services/{install-types.ts → install/install-types.ts} +3 -1
  84. package/src/server/services/{install.ts → install/install.ts} +5 -5
  85. package/src/server/services/install/npm-check.ts +53 -0
  86. package/src/server/services/{npm-resolve.ts → install/npm-resolve.ts} +7 -5
  87. package/src/server/services/{preflight.ts → install/preflight.ts} +16 -4
  88. package/src/server/services/{task-queue.ts → install/task-queue.ts} +157 -63
  89. package/src/server/services/loader.ts +55 -1
  90. package/src/server/services/log.ts +167 -0
  91. package/src/server/services/probe.ts +157 -0
  92. package/src/server/services/{pending-restart.ts → profile/pending-restart.ts} +1 -1
  93. package/src/server/services/{profile.ts → profile/profile.ts} +31 -1
  94. package/src/server/services/settings.ts +79 -0
  95. package/lib/services/install.js +0 -4
  96. package/src/client/components/CatalogControls.tsx +0 -108
  97. package/src/client/components/icons.tsx +0 -129
  98. package/src/client/lib/catalog.ts +0 -293
  99. package/src/client/lib/env.ts +0 -20
  100. /package/lib/services/{install-types.js → install/install-types.js} +0 -0
  101. /package/lib/services/{installed-versions.js → profile/installed-versions.js} +0 -0
  102. /package/lib/services/{pending-restart.js → profile/pending-restart.js} +0 -0
  103. /package/lib/services/{progress.js → profile/progress.js} +0 -0
  104. /package/lib/types/services/{preflight.d.ts → install/preflight.d.ts} +0 -0
  105. /package/lib/types/services/{installed-versions.d.ts → profile/installed-versions.d.ts} +0 -0
  106. /package/lib/types/services/{progress.d.ts → profile/progress.d.ts} +0 -0
  107. /package/src/client/{lib → logic}/format.ts +0 -0
  108. /package/src/client/{lib → logic}/renderMarkdown.ts +0 -0
  109. /package/src/server/services/{installed-versions.ts → profile/installed-versions.ts} +0 -0
  110. /package/src/server/services/{progress.ts → profile/progress.ts} +0 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Built-in log viewer dialog: no external software needed — the app opens
7
+ * its own window and loads the local system log (JSONL at
8
+ * ~/.dsh/profiles/<profile>/hub.log) straight from the server, one page at
9
+ * a time.
10
+ *
11
+ * Every line is categorized twice: a function area (install / uninstall /
12
+ * update / diagnostics / settings / system) and a severity level (debug /
13
+ * info / success / warn / error). Filtering on both axes lets you target,
14
+ * say, only install errors — so the exact incompatible step of an install
15
+ * (or uninstall) is easy to spot. Search narrows by event code or message.
16
+ *
17
+ * Server pagination: GET /dsh-plugin-hub/logs?offset=&limit=&category=
18
+ * &level=&query= → { entries, total, hasMore, path }. The footer shows the
19
+ * real file location and reveals it in the system file manager.
20
+ */
21
+ import { createElement as h, useCallback, useEffect, useRef, useState } from 'react'
22
+ import type { MouseEvent } from 'react'
23
+ import styles from '../../styles/Modal.module.css'
24
+ import view from '../../styles/LogsView.module.css'
25
+ import type { Translate } from '../../types.ts'
26
+ import { openLogFile } from '../../data/host.ts'
27
+ import { CloseIcon } from '../ui/icons.tsx'
28
+
29
+ /** 与服务端 /logs 返回的 LogEntry 对齐的客户端投影。 */
30
+ export interface LogEntryView {
31
+ /** 毫秒时间戳 */
32
+ at: number
33
+ level: 'debug' | 'info' | 'success' | 'warn' | 'error'
34
+ category: 'install' | 'uninstall' | 'update' | 'diagnostics' | 'settings' | 'system'
35
+ /** 机器可读事件码,如 install.start */
36
+ event: string
37
+ message: string
38
+ }
39
+
40
+ export type LogCategoryFilter = 'all' | LogEntryView['category']
41
+ export type LogLevelFilter = 'all' | LogEntryView['level']
42
+
43
+ const PAGE = 100
44
+
45
+ /** 类别筛选标签:值 → 语言包 key(弹窗内 chip 与行内类别徽章共用)。 */
46
+ const CAT_LABEL: Record<LogEntryView['category'], string> = {
47
+ install: 'logCatInstall',
48
+ uninstall: 'logCatUninstall',
49
+ update: 'logCatUpdate',
50
+ diagnostics: 'logCatDiagnostics',
51
+ settings: 'logCatSettings',
52
+ system: 'logCatSystem',
53
+ }
54
+
55
+ const CAT_FILTERS: Array<{ value: LogCategoryFilter; key: string }> = [
56
+ { value: 'all', key: 'logCatAll' },
57
+ { value: 'install', key: 'logCatInstall' },
58
+ { value: 'uninstall', key: 'logCatUninstall' },
59
+ { value: 'update', key: 'logCatUpdate' },
60
+ { value: 'diagnostics', key: 'logCatDiagnostics' },
61
+ { value: 'settings', key: 'logCatSettings' },
62
+ { value: 'system', key: 'logCatSystem' },
63
+ ]
64
+
65
+ const LEVEL_FILTERS: Array<{ value: LogLevelFilter; key: string }> = [
66
+ { value: 'all', key: 'logLvAll' },
67
+ { value: 'error', key: 'logLvError' },
68
+ { value: 'warn', key: 'logLvWarn' },
69
+ { value: 'success', key: 'logLvSuccess' },
70
+ { value: 'info', key: 'logLvInfo' },
71
+ { value: 'debug', key: 'logLvDebug' },
72
+ ]
73
+
74
+ /** 级别徽章文案:弹窗 chip 与行内级别徽章共用同一语言包 key。 */
75
+ const LEVEL_LABEL: Record<LogEntryView['level'], string> = {
76
+ debug: 'logLvDebug',
77
+ info: 'logLvInfo',
78
+ success: 'logLvSuccess',
79
+ warn: 'logLvWarn',
80
+ error: 'logLvError',
81
+ }
82
+
83
+ /** 时间:YYYY-MM-DD HH:mm:ss(每条都精确到秒)。 */
84
+ export function fmtLogTime(at: number): string {
85
+ const d = new Date(at)
86
+ const pad = (n: number) => String(n).padStart(2, '0')
87
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
88
+ }
89
+
90
+ /** 导出文件名时间戳:2026-08-24_21-35-00。 */
91
+ function fmtFileStamp(at: number): string {
92
+ const d = new Date(at)
93
+ const pad = (n: number) => String(n).padStart(2, '0')
94
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`
95
+ }
96
+
97
+ /** 单条日志行:时间 + 级别徽章 + 类别徽章 + 事件码 + 描述。查看器弹窗与设置页预览共用。 */
98
+ export function LogEntryRow({ e, t }: { e: LogEntryView; t: Translate }) {
99
+ return h('div', { className: view.entry, title: e.message },
100
+ h('span', { className: view.time }, fmtLogTime(e.at)),
101
+ h('span', { className: `${view.badge} ${view[`badge${e.level}`]}` }, t(LEVEL_LABEL[e.level])),
102
+ h('span', { className: `${view.catBadge} ${view[`cat${e.category}`]}` }, t(CAT_LABEL[e.category])),
103
+ h('span', { className: view.event }, e.event),
104
+ h('span', { className: view.message }, e.message),
105
+ )
106
+ }
107
+
108
+ export function LogsModal({ t, onClose }: {
109
+ t: Translate
110
+ onClose: () => void
111
+ }) {
112
+ const [category, setCategory] = useState<LogCategoryFilter>('all')
113
+ const [level, setLevel] = useState<LogLevelFilter>('all')
114
+ const [query, setQuery] = useState('')
115
+ const [entries, setEntries] = useState<LogEntryView[]>([])
116
+ const [total, setTotal] = useState(0)
117
+ const [hasMore, setHasMore] = useState(false)
118
+ const [path, setPath] = useState('')
119
+ const [loading, setLoading] = useState(false)
120
+ const [error, setError] = useState(false)
121
+ const [opening, setOpening] = useState(false)
122
+ const [openFailed, setOpenFailed] = useState(false)
123
+ const offsetRef = useRef(0)
124
+
125
+ /** 拉一页:过滤参数取当前 state,偏移量由调用方给出。 */
126
+ const fetchPage = useCallback(async (offset: number): Promise<{ entries: LogEntryView[]; total: number; hasMore: boolean; path: string } | null> => {
127
+ try {
128
+ const qs = new URLSearchParams({ offset: String(offset), limit: String(PAGE) })
129
+ if (category !== 'all') qs.set('category', category)
130
+ if (level !== 'all') qs.set('level', level)
131
+ const q = query.trim()
132
+ if (q !== '') qs.set('query', q)
133
+ const res = await fetch(`/dsh-plugin-hub/logs?${qs.toString()}`, { cache: 'no-store' })
134
+ if (!res.ok) return null
135
+ return await res.json() as { entries: LogEntryView[]; total: number; hasMore: boolean; path: string }
136
+ } catch {
137
+ return null
138
+ }
139
+ }, [category, level, query])
140
+
141
+ /** 从第一页重新加载(换筛选/搜索时)。 */
142
+ const reload = useCallback(async () => {
143
+ setLoading(true)
144
+ const data = await fetchPage(0)
145
+ if (data === null) {
146
+ setError(true)
147
+ setLoading(false)
148
+ return
149
+ }
150
+ offsetRef.current = data.entries.length
151
+ setEntries(data.entries)
152
+ setTotal(data.total)
153
+ setHasMore(data.hasMore)
154
+ if (data.path !== '') setPath(data.path)
155
+ setError(false)
156
+ setLoading(false)
157
+ }, [fetchPage])
158
+
159
+ /** 追加下一页(「加载更多」)。 */
160
+ const loadMore = useCallback(async () => {
161
+ if (loading) return
162
+ setLoading(true)
163
+ const data = await fetchPage(offsetRef.current)
164
+ if (data === null) {
165
+ setError(true)
166
+ setLoading(false)
167
+ return
168
+ }
169
+ offsetRef.current += data.entries.length
170
+ setEntries((prev) => [...prev, ...data.entries])
171
+ setTotal(data.total)
172
+ setHasMore(data.hasMore)
173
+ setLoading(false)
174
+ }, [fetchPage, loading])
175
+
176
+ // 首次进入 + 类别/级别/搜索变化(搜索 280ms 防抖)都重查第一页
177
+ const first = useRef(true)
178
+ useEffect(() => {
179
+ if (first.current) {
180
+ first.current = false
181
+ void reload()
182
+ return
183
+ }
184
+ const id = window.setTimeout(() => { void reload() }, 280)
185
+ return () => window.clearTimeout(id)
186
+ }, [reload])
187
+
188
+ /** 在系统文件管理器中定位日志文件(服务端 spawn open)。 */
189
+ const openFile = async () => {
190
+ if (opening) return
191
+ setOpening(true)
192
+ setOpenFailed(false)
193
+ const ok = await openLogFile()
194
+ setOpening(false)
195
+ if (!ok) setOpenFailed(true)
196
+ }
197
+
198
+ /** 导出:拼纯文本 → Blob → 触发浏览器下载 .log 文件 */
199
+ const exportFile = () => {
200
+ const text = entries.map((e) => `[${fmtLogTime(e.at)}] [${e.level}] [${e.category}] [${e.event}] ${e.message}`).join('\n')
201
+ if (text === '') return
202
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
203
+ const url = URL.createObjectURL(blob)
204
+ const a = document.createElement('a')
205
+ a.href = url
206
+ a.download = `dsh-plugin-hub-log-${fmtFileStamp(Date.now())}.log`
207
+ document.body.appendChild(a)
208
+ a.click()
209
+ a.remove()
210
+ URL.revokeObjectURL(url)
211
+ }
212
+
213
+ return h('div', {
214
+ className: styles.overlay,
215
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
216
+ if (e.target === e.currentTarget) onClose()
217
+ },
218
+ },
219
+ h('div', { className: `${styles.errorModal} ${styles.logModal}`, role: 'dialog', 'aria-modal': 'true' },
220
+ h('div', { className: styles.modalHead },
221
+ h('div', { className: styles.modalTitle }, t('logModalTitle')),
222
+ h('button', {
223
+ className: styles.modalClose,
224
+ 'aria-label': t('errorClose'),
225
+ onClick: onClose,
226
+ }, h(CloseIcon)),
227
+ ),
228
+ h('div', { className: styles.modalBody },
229
+ // 类别筛选行:安装/卸载/更新… 功能域区分
230
+ h('div', { className: view.filterBar },
231
+ CAT_FILTERS.map((f) => h('button', {
232
+ key: f.value,
233
+ type: 'button',
234
+ className: category === f.value ? `${view.filterChip} ${view.filterChipActive}` : view.filterChip,
235
+ onClick: () => setCategory(f.value),
236
+ }, t(f.key))),
237
+ ),
238
+ // 级别筛选行 + 搜索框:按级别区分报错与正常,便于针对性排查
239
+ h('div', { className: view.filterBar },
240
+ LEVEL_FILTERS.map((f) => h('button', {
241
+ key: f.value,
242
+ type: 'button',
243
+ className: level === f.value ? `${view.filterChip} ${view.filterChipActive}` : view.filterChip,
244
+ onClick: () => setLevel(f.value),
245
+ }, t(f.key))),
246
+ h('div', { className: view.search },
247
+ h('input', {
248
+ className: view.searchInput,
249
+ type: 'search',
250
+ value: query,
251
+ placeholder: t('logSearchPlaceholder'),
252
+ 'aria-label': t('logSearchPlaceholder'),
253
+ onChange: (e) => setQuery((e.target as HTMLInputElement).value),
254
+ }),
255
+ ),
256
+ ),
257
+ h('div', { className: view.logList },
258
+ error
259
+ ? h('div', { className: view.empty }, t('logsLoadFail'))
260
+ : loading && entries.length === 0
261
+ ? h('div', { className: view.empty }, t('logsLoading'))
262
+ : entries.length === 0
263
+ ? h('div', { className: view.empty }, t('logEmptyFilter'))
264
+ : entries.map((e, i) => h(LogEntryRow, { key: `${e.at}-${i}`, e, t })),
265
+ // 分页:还有就「加载更多」,拉完了给个总数收尾
266
+ entries.length > 0 && (hasMore
267
+ ? h('button', {
268
+ type: 'button',
269
+ className: view.more,
270
+ disabled: loading,
271
+ onClick: () => void loadMore(),
272
+ }, loading ? t('logsLoading') : t('logLoadMore'))
273
+ : h('div', { className: view.moreEnd }, t('logNoMore', { n: total }))),
274
+ ),
275
+ ),
276
+ // 底部:日志真实路径 + 打开文件 / 导出日志
277
+ h('div', { className: view.foot },
278
+ h('span', { className: view.footPath, title: path },
279
+ `${t('logPathLabel')}: ${path === '' ? '…' : path}`),
280
+ openFailed
281
+ ? h('span', { className: view.footFail }, t('logOpenFileFail'))
282
+ : h('span', { className: view.footCount }, t('logCount', { n: total })),
283
+ h('div', { className: view.footActions },
284
+ h('button', {
285
+ type: 'button',
286
+ className: view.btn,
287
+ disabled: opening || path === '',
288
+ title: t('logOpenFileTip'),
289
+ onClick: () => void openFile(),
290
+ }, t('logOpenFile')),
291
+ h('button', {
292
+ type: 'button',
293
+ className: `${view.btn} ${view.btnPrimary}`,
294
+ disabled: entries.length === 0,
295
+ onClick: exportFile,
296
+ }, t('logsExport')),
297
+ ),
298
+ ),
299
+ ),
300
+ )
301
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 日志存放位置弹窗:设置页不内联长表单(挡视线),点「修改」弹窗编辑。
7
+ * 两种输入方式并存 —— 手输目录 / .log 文件路径,或点「选择目录」调系统
8
+ * 原生文件夹对话框(服务端 spawn osascript / FolderBrowserDialog / zenity)。
9
+ * 文本框预填当前生效位置,目录选择器也从该位置打开 —— 默认地址是动态的
10
+ * (随用户主目录/平台变化,如 ~/.dsh/profiles/<profile>/hub.log),必须实时展示。
11
+ * 留空或点「恢复默认」= 回到默认;保存前服务端预验证可写。
12
+ */
13
+ import { createElement as h, useEffect, useState } from 'react'
14
+ import styles from '../../styles/Modal.module.css'
15
+ import view from '../../styles/LogsView.module.css'
16
+ import type { Translate } from '../../types.ts'
17
+ import { chooseLogDir } from '../../data/host.ts'
18
+ import { CloseIcon } from '../ui/icons.tsx'
19
+
20
+ export function LogsPathModal({ t, defaultPath, currentPath, onSaved, onClose }: {
21
+ t: Translate
22
+ /** 真正默认位置(服务端动态算出的 ~/.dsh/profiles/<profile>/hub.log),跨机器/平台不同 */
23
+ defaultPath: string
24
+ /** 当前生效的真实路径(可能已被自定义覆盖) */
25
+ currentPath: string
26
+ /** 保存成功回调(写入设置;父级据此刷新生效路径与日志列表) */
27
+ onSaved: (value: string) => void
28
+ onClose: () => void
29
+ }) {
30
+ // 打开即显示当前生效位置 —— 用户一眼看到日志现在存在哪,也方便在此基础上微调
31
+ const [draft, setDraft] = useState(currentPath)
32
+ const [saving, setSaving] = useState(false)
33
+ const [fail, setFail] = useState(false)
34
+ const [browsing, setBrowsing] = useState(false)
35
+ const [browseFail, setBrowseFail] = useState(false)
36
+
37
+ // Esc 关闭:与其它弹窗交互一致
38
+ useEffect(() => {
39
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
40
+ window.addEventListener('keydown', onKey)
41
+ return () => window.removeEventListener('keydown', onKey)
42
+ }, [onClose])
43
+
44
+ /** 保存:空串 = 回默认;填回默认位置同样按「未自定义」处理(设置里保持干净)。
45
+ * 服务端预建目录验证可写,失败给出反馈。 */
46
+ const save = async () => {
47
+ if (saving) return
48
+ setSaving(true)
49
+ setFail(false)
50
+ try {
51
+ const value = draft.trim()
52
+ const normalized = value === '' || value === defaultPath ? '' : value
53
+ const res = await fetch('/dsh-plugin-hub/settings', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json' },
56
+ body: JSON.stringify({ logPath: normalized }),
57
+ cache: 'no-store',
58
+ })
59
+ if (!res.ok) {
60
+ setFail(true)
61
+ return
62
+ }
63
+ // 保存成功即关:主界面路径行随即刷新为新位置,本身就是反馈
64
+ onSaved(normalized)
65
+ onClose()
66
+ } catch {
67
+ setFail(true)
68
+ } finally {
69
+ setSaving(false)
70
+ }
71
+ }
72
+
73
+ /** 选择目录:系统对话框从默认位置打开,选中路径回填输入框(仍可手改)。 */
74
+ const browse = async () => {
75
+ if (browsing) return
76
+ setBrowsing(true)
77
+ setBrowseFail(false)
78
+ const picked = await chooseLogDir()
79
+ setBrowsing(false)
80
+ if (picked !== null) {
81
+ setDraft(picked)
82
+ setFail(false)
83
+ } else {
84
+ setBrowseFail(true)
85
+ }
86
+ }
87
+
88
+ return h('div', {
89
+ className: styles.overlay,
90
+ onClick: (e: { target: unknown; currentTarget: unknown }) => {
91
+ if (e.target === e.currentTarget) onClose()
92
+ },
93
+ },
94
+ h('div', { className: `${styles.modal} ${styles.modalWide}`, role: 'dialog', 'aria-modal': 'true' },
95
+ h('div', { className: styles.modalHead },
96
+ h('div', { className: styles.modalTitle }, t('logPathSettingTitle')),
97
+ h('button', {
98
+ className: styles.modalClose,
99
+ 'aria-label': t('errorClose'),
100
+ onClick: onClose,
101
+ }, h(CloseIcon)),
102
+ ),
103
+ h('div', { className: styles.modalBody },
104
+ h('div', { className: view.pathDialog },
105
+ h('div', { className: view.pathDialogDesc }, t('logPathSettingDesc')),
106
+ // 手输路径 + 系统目录选择器两条路并行
107
+ h('div', { className: view.pathDialogRow },
108
+ h('input', {
109
+ type: 'text',
110
+ className: view.pathDraft,
111
+ value: draft,
112
+ placeholder: t('logPathPlaceholder'),
113
+ spellCheck: false,
114
+ onChange: (e: { currentTarget: HTMLInputElement }) => {
115
+ setDraft(e.currentTarget.value)
116
+ setFail(false)
117
+ setBrowseFail(false)
118
+ },
119
+ }),
120
+ h('button', {
121
+ type: 'button',
122
+ className: view.btn,
123
+ disabled: browsing,
124
+ title: t('logPathBrowse'),
125
+ onClick: () => void browse(),
126
+ }, browsing ? t('logPathSaving') : t('logPathBrowse')),
127
+ ),
128
+ // 提示 + 「恢复默认」:提示左、动作右,一行内不挤
129
+ h('div', { className: view.pathDialogReset },
130
+ h('span', { className: view.pathDialogHint }, t('logPathResetHint')),
131
+ h('button', {
132
+ type: 'button',
133
+ className: view.linkBtn,
134
+ onClick: () => {
135
+ setDraft(defaultPath)
136
+ setFail(false)
137
+ setBrowseFail(false)
138
+ },
139
+ }, t('logPathReset')),
140
+ ),
141
+ browseFail && h('div', { className: view.footFail }, t('logPathBrowseFail')),
142
+ fail && h('div', { className: view.footFail }, t('logPathSaveFail')),
143
+ h('div', { className: view.pathDialogFoot },
144
+ h('button', {
145
+ type: 'button',
146
+ className: view.btn,
147
+ disabled: saving,
148
+ onClick: onClose,
149
+ }, t('confirmCancel')),
150
+ h('button', {
151
+ type: 'button',
152
+ className: `${view.btn} ${view.btnPrimary}`,
153
+ disabled: saving,
154
+ onClick: () => void save(),
155
+ }, saving ? t('logPathSaving') : t('logPathSave')),
156
+ ),
157
+ ),
158
+ ),
159
+ ),
160
+ )
161
+ }
@@ -6,22 +6,22 @@
6
6
  * Notification-center dialog: a persistent log of every settled install /
7
7
  * remove task — successes and failures alike.
8
8
  *
9
- * Records are written to localStorage at settle time (see lib/failures.ts),
9
+ * Records are written to localStorage at settle time (see logic/failures.ts),
10
10
  * so a result is never lost even when the dialog was dismissed or the user
11
11
  * was away. Each entry carries a circular status badge (green check for
12
12
  * success, red cross for failure) with white glyph and message text;
13
13
  * failures keep their copy / fix / file-an-issue actions. Opened from the
14
- * header entry button.
14
+ * header entry button (after the Settings tab).
15
15
  */
16
16
  import { createElement as h } from 'react'
17
17
  import type { MouseEvent } from 'react'
18
- import styles from '../styles/Modal.module.css'
19
- import type { EnvInfo, Translate } from '../types.ts'
20
- import type { NotificationRecord } from '../lib/failures.ts'
21
- import { classifyFailure } from '../lib/failures.ts'
22
- import type { PendingRestart, QueueTask } from '../hooks/useTaskQueue.ts'
23
- import { pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
24
- import { CloseIcon } from './icons.tsx'
18
+ import styles from '../../styles/Modal.module.css'
19
+ import type { EnvInfo, Translate } from '../../types.ts'
20
+ import type { NotificationRecord } from '../../logic/failures.ts'
21
+ import { classifyFailure, npmTooLowVersion } from '../../logic/failures.ts'
22
+ import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
23
+ import { pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
24
+ import { CloseIcon } from '../ui/icons.tsx'
25
25
 
26
26
  /** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
27
27
  function fmtTime(at: number): string {
@@ -55,7 +55,7 @@ function BadgeGlyph({ ok }: { ok: boolean }) {
55
55
  }))
56
56
  }
57
57
 
58
- export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, cancelTask, restarting, onRestart }: {
58
+ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, onUpdate, cancelTask, restarting, onRestart }: {
59
59
  records: NotificationRecord[]
60
60
  /** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
61
61
  tasks: QueueTask[]
@@ -68,6 +68,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
68
68
  onClear: () => void
69
69
  /** 删除单条通知记录 */
70
70
  onRemove: (id: number) => void
71
+ /** 更新提醒通知点击:跳去该插件的更新确认弹窗 */
72
+ onUpdate: (repo: string) => void
71
73
  cancelTask: (id: number) => void
72
74
  restarting: boolean
73
75
  onRestart: () => void
@@ -160,9 +162,14 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
160
162
  ? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
161
163
  : h('div', { className: styles.noticeList },
162
164
  records.map((r) => {
165
+ const isUpdate = r.kind === 'update'
163
166
  return h('div', {
164
167
  key: r.id,
165
- className: r.ok ? `${styles.noticeRow} ${styles.noticeRowOk}` : styles.noticeRow,
168
+ // 更新提醒:整行可点击直达更新确认弹窗;install/uninstall 记录维持原交互
169
+ className: r.ok
170
+ ? `${styles.noticeRow} ${styles.noticeRowOk}${isUpdate ? ` ${styles.noticeRowUpdate}` : ''}`
171
+ : styles.noticeRow,
172
+ ...(isUpdate ? { onClick: () => onUpdate(r.repo), title: t('updateNoticeGo') } : {}),
166
173
  },
167
174
  h('div', { className: styles.noticeRowMain },
168
175
  h('div', {
@@ -173,17 +180,23 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
173
180
  h('span', {
174
181
  className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
175
182
  }, r.ok
176
- ? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
183
+ ? (isUpdate ? t('updateNoticeTitle') : (r.kind === 'install' ? t('installDone') : t('uninstallDone')))
177
184
  : (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
185
+ // 更新提醒:新版本号紧随标题展示;repo 为纯文本(整行点击即去更新,不跳官网外链)
186
+ isUpdate && r.version
187
+ ? h('span', { className: styles.noticeVersion, title: r.version }, `v${r.version}`)
188
+ : null,
178
189
  r.repo
179
- ? h('a', {
180
- className: styles.failRepo,
181
- // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
182
- href: pluginSiteUrl(r.repo),
183
- target: '_blank',
184
- rel: 'noopener noreferrer',
185
- title: r.repo,
186
- }, r.repo)
190
+ ? (isUpdate
191
+ ? h('span', { className: styles.failRepo, title: r.repo }, r.repo)
192
+ : h('a', {
193
+ className: styles.failRepo,
194
+ // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
195
+ href: pluginSiteUrl(r.repo),
196
+ target: '_blank',
197
+ rel: 'noopener noreferrer',
198
+ title: r.repo,
199
+ }, r.repo))
187
200
  : null,
188
201
  !r.ok && h('button', {
189
202
  className: styles.failCopy,
@@ -197,6 +210,13 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
197
210
  title: new Date(r.at).toLocaleString(),
198
211
  }, fmtTime(r.at))
199
212
  : null,
213
+ // 更新提醒:行尾「去更新」按钮直达更新确认弹窗(整行也可点击)
214
+ isUpdate
215
+ ? h('button', {
216
+ className: styles.noticeUpdateGo,
217
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onUpdate(r.repo) },
218
+ }, t('updateNoticeGo'))
219
+ : null,
200
220
  // 每条通知右侧的删除按钮:单独移除这一条
201
221
  h('button', {
202
222
  className: styles.noticeRemove,
@@ -209,6 +229,13 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
209
229
  // 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
210
230
  !r.ok && (() => {
211
231
  const kind = classifyFailure(r.message)
232
+ if (kind === 'npmTooOld') {
233
+ // npm 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题 ——
234
+ // 直接给升级指引,不引导提 Issue
235
+ const v = npmTooLowVersion(r.message)
236
+ return h('div', { className: styles.failPrepareHint },
237
+ t(v ? 'failNpmTooLowV' : 'failNpmTooLow', v ? { v } : undefined))
238
+ }
212
239
  if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
213
240
  // [packaging](预检/装后校验拦截:git 分发缺产物)与
214
241
  // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
@@ -9,8 +9,8 @@
9
9
  * empty for most of the run; failures surface their full output in ErrorModal.
10
10
  */
11
11
  import { createElement as h } from 'react'
12
- import styles from '../styles/Modal.module.css'
13
- import type { TaskState } from '../types.ts'
12
+ import styles from '../../styles/Modal.module.css'
13
+ import type { TaskState } from '../../types.ts'
14
14
 
15
15
  export function ProgressView({ task }: { task: TaskState }) {
16
16
  return h('div', { className: styles.progress },