dsh-plugin 1.3.1 → 1.3.3

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.
@@ -9,7 +9,7 @@
9
9
  * installed-plugin table, and the filter/search/sort/install-status view
10
10
  * state, exposing the derived visible list and per-category counts.
11
11
  */
12
- import { useEffect, useMemo, useState } from 'react'
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
13
  import type { HubPlugin, LocaleId } from '../types.ts'
14
14
  import { HUB_REPO, PLUGIN_VERSION } from '../logic/constants.ts'
15
15
  import type { HubAboutInfo, HubUpdateInfo } from '../types.ts'
@@ -45,6 +45,14 @@ export function useCatalog(lang: LocaleId) {
45
45
  const [sort, setSort] = useState<SortKey>('sortStars')
46
46
  /** 当前排序方向:点同一个排序按钮切换 正序/倒序;点新排序用该排序的默认方向(与已安装视图一致) */
47
47
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
48
+ /** 目录加载「超时自动强制刷新」重试计数:宿主重启/代理未就绪时请求可能长时间挂起,
49
+ * 界面停在「正在加载插件数据…」假死 —— 超时自动重拉,最多自动重试 2 次后转失败态;
50
+ * 用户手动刷新(reload)时清零重新计数。 */
51
+ const loadRetriesRef = useRef(0)
52
+ /** 目录加载超时阈值:超过即判定挂起,强制刷新一次(服务端代理 curl 上限 20s,客户端 12s 先兜底) */
53
+ const LOAD_TIMEOUT_MS = 12000
54
+ /** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍 12s,2 次后转失败界面可手动重试) */
55
+ const MAX_LOAD_RETRIES = 2
48
56
 
49
57
  /** 点击排序按钮:同一按钮切换正/倒序,新按钮用默认方向 */
50
58
  function toggleSort(key: SortKey) {
@@ -83,15 +91,21 @@ export function useCatalog(lang: LocaleId) {
83
91
  // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
84
92
  useEffect(() => {
85
93
  let cancelled = false
94
+ // 本次加载是否已收尾(成功或失败):超时定时器只对「还在加载中」生效
95
+ let settled = false
96
+ // 中止控制器:请求挂起时(宿主重启/代理抖动)超时自动重试会换新请求,
97
+ // 旧请求在此中止释放连接,避免挂起堆积
98
+ const controller = new AbortController()
86
99
  setPlugins(null)
87
100
  setHubPlugin(null)
88
101
  setStats(null)
89
102
  setFailed(false)
90
103
  // 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口。
91
- const load = () => Promise.all([fetchCatalog(lang), fetchStats()])
104
+ const load = () => Promise.all([fetchCatalog(lang, controller.signal), fetchStats(controller.signal)])
92
105
  load()
93
106
  .then(([list, stats]) => {
94
107
  if (cancelled) return
108
+ settled = true
95
109
  // 插件市场不显示自己:DSH Plugin Hub 从目录里排除自身条目,
96
110
  // 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
97
111
  // 目录数据已排除自身,此处再兜底过滤一次,防旧快照仍含自身条目。
@@ -106,9 +120,27 @@ export function useCatalog(lang: LocaleId) {
106
120
  }
107
121
  })
108
122
  .catch(() => {
109
- if (!cancelled) setFailed(true)
123
+ if (cancelled) return
124
+ settled = true
125
+ setFailed(true)
110
126
  })
111
- return () => { cancelled = true }
127
+ // 加载超时兜底:宿主刚重启/代理未就绪时目录请求可能长时间挂起,界面停在「正在加载插件数据…」
128
+ // 像假死。超过阈值未收尾 → 强制刷新列表(重拉一次,最多 MAX_LOAD_RETRIES 次自动重试),
129
+ // 重试仍失败 → 转失败界面(显示「重试」按钮,手动刷新时计数清零重新开始)。
130
+ const timer = window.setTimeout(() => {
131
+ if (cancelled || settled) return
132
+ if (loadRetriesRef.current < MAX_LOAD_RETRIES) {
133
+ loadRetriesRef.current += 1
134
+ setReloadKey((k) => k + 1)
135
+ } else {
136
+ setFailed(true)
137
+ }
138
+ }, LOAD_TIMEOUT_MS)
139
+ return () => {
140
+ cancelled = true
141
+ window.clearTimeout(timer)
142
+ controller.abort()
143
+ }
112
144
  // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
113
145
  // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
114
146
  }, [reloadKey, lang])
@@ -218,7 +250,7 @@ export function useCatalog(lang: LocaleId) {
218
250
  hubPlugin,
219
251
  stats,
220
252
  failed,
221
- reload: () => setReloadKey((k) => k + 1),
253
+ reload: () => { loadRetriesRef.current = 0; setReloadKey((k) => k + 1) },
222
254
  installed,
223
255
  installedItems,
224
256
  installedName,
@@ -141,6 +141,12 @@ export const zh = {
141
141
  logOpenFile: '打开文件',
142
142
  logOpenFileTip: '在系统文件管理器中定位日志文件',
143
143
  logOpenFileFail: '打开失败,请在文件管理器中手动定位',
144
+ logClearAll: '清空所有日志',
145
+ logClearConfirmTitle: '清空所有日志?',
146
+ logClearConfirmDesc: '将删除全部日志记录,且不可恢复。',
147
+ logClearing: '正在清空…',
148
+ logClearAllTip: '清空全部日志记录',
149
+ logClearFailed: '清空失败,请重试',
144
150
  logPathSettingTitle: '日志存放位置',
145
151
  logPathSettingDesc: '默认存于宿主配置目录,可填目录或 .log 文件路径改存别处',
146
152
  logPathPlaceholder: '留空恢复默认',
@@ -241,7 +247,7 @@ export const zh = {
241
247
  restartNow: '立即重启',
242
248
  restartLater: '稍后重启',
243
249
  restarting: '正在重启…',
244
- restartHint: '部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
250
+ restartHint: '重启会中断正在进行的安装/卸载任务,其进度将丢失。部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
245
251
  done: '完成',
246
252
  // 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
247
253
  restartPendingHint: '安装成功,重启后生效',
@@ -254,7 +260,11 @@ export const zh = {
254
260
  notificationsDesc: '安装、卸载任务与更新提醒都会记录在这里,即使错过提示也能随时回来查看。',
255
261
  notificationsEmpty: '暂无通知记录',
256
262
  notificationsClear: '清空通知',
263
+ notificationsClearConfirmTitle: '清空所有通知?',
264
+ notificationsClearConfirmDesc: '将删除全部通知记录,且不可恢复。',
257
265
  removeNotification: '删除这条通知',
266
+ removeNotificationConfirmTitle: '删除这条通知?',
267
+ removeNotificationConfirmDesc: '该通知将被移除,且不可恢复。',
258
268
  // 串行队列:安装/卸载弹窗内可后台关闭,排队任务自动串行执行
259
269
  queuedTitle: '已加入安装排队',
260
270
  queuedUninstallTitle: '已加入卸载排队',
@@ -458,6 +468,12 @@ export const en = {
458
468
  logOpenFile: 'Reveal',
459
469
  logOpenFileTip: 'Reveal the log file in your system file manager',
460
470
  logOpenFileFail: 'Could not open; locate the file manually',
471
+ logClearAll: 'Clear all logs',
472
+ logClearConfirmTitle: 'Clear all logs?',
473
+ logClearConfirmDesc: 'All log entries will be deleted. This cannot be undone.',
474
+ logClearing: 'Clearing…',
475
+ logClearAllTip: 'Delete all log entries',
476
+ logClearFailed: 'Failed to clear; try again',
461
477
  logPathSettingTitle: 'Log Location',
462
478
  logPathSettingDesc: 'Stored in the host config directory by default — set a directory or a .log file path to store elsewhere',
463
479
  logPathPlaceholder: 'Leave empty for default',
@@ -558,7 +574,7 @@ export const en = {
558
574
  restartNow: 'Restart now',
559
575
  restartLater: 'Later',
560
576
  restarting: 'Restarting…',
561
- restartHint: 'Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
577
+ restartHint: 'Restarting interrupts any in-progress install / uninstall task — its progress will be lost. Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
562
578
  done: 'Done',
563
579
  restartPendingHint: 'Installed — takes effect after restart',
564
580
  restartPendingHintUninstall: 'Removed — cleaned up after restart',
@@ -570,7 +586,11 @@ export const en = {
570
586
  notificationsDesc: 'Every install, remove and update notice is logged here — even if you missed the prompt, you can always check back.',
571
587
  notificationsEmpty: 'No notifications yet',
572
588
  notificationsClear: 'Clear notifications',
589
+ notificationsClearConfirmTitle: 'Clear all notifications?',
590
+ notificationsClearConfirmDesc: 'All notification records will be deleted. This cannot be undone.',
573
591
  removeNotification: 'Remove this notification',
592
+ removeNotificationConfirmTitle: 'Remove this notification?',
593
+ removeNotificationConfirmDesc: 'This notification will be removed. This cannot be undone.',
574
594
  queuedTitle: 'Queued…',
575
595
  queuedUninstallTitle: 'Queued for removal…',
576
596
  queuedHint: 'Queued — starts automatically after earlier tasks finish. You may close this window.',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Self-heal missing plugin CSS tags.
3
+ *
4
+ * CSS injection is a one-shot side effect of the bundle factory (guarded by
5
+ * "tag already present"). When the host restarts the page survives, but the
6
+ * host HMR chain may remove the <style data-plugin> tags while the in-memory
7
+ * module cache still holds the old factory — so the factory never re-runs and
8
+ * the styles never come back until some interaction triggers a reload.
9
+ *
10
+ * The tsdown `dsh-css-modules-inline` build plugin registers every module's
11
+ * { tagId, css } into the global list `__DSH_PLUGIN_CSS__` at factory time.
12
+ * This helper compares that list against the live DOM and re-injects any
13
+ * missing tags, turning the "click around to fix it" self-heal into an
14
+ * automatic one on mount / view switch.
15
+ */
16
+
17
+ interface PluginCssEntry {
18
+ tagId: string
19
+ css: string
20
+ }
21
+
22
+ declare global {
23
+ interface Window {
24
+ __DSH_PLUGIN_CSS__?: PluginCssEntry[]
25
+ }
26
+ }
27
+
28
+ /** Re-inject any registered stylesheet that is missing from the DOM. Idempotent. */
29
+ export function ensurePluginCss(): void {
30
+ if (typeof document === 'undefined') return
31
+ const registry = window.__DSH_PLUGIN_CSS__
32
+ if (!registry || registry.length === 0) return
33
+ for (const { tagId, css } of registry) {
34
+ const selector = 'style[data-plugin-css=' + JSON.stringify(tagId) + ']'
35
+ if (document.querySelector(selector) !== null) continue
36
+ const tag = document.createElement('style')
37
+ tag.dataset.plugin = 'dsh-plugin'
38
+ tag.dataset.pluginCss = tagId
39
+ tag.textContent = css
40
+ document.head.appendChild(tag)
41
+ }
42
+ }
@@ -62,16 +62,37 @@
62
62
  cursor: default;
63
63
  }
64
64
 
65
- /* 主操作:导出日志,品牌色实底 */
66
- .btnPrimary {
67
- color: #ffffff;
68
- background: var(--hub-brand);
69
- border-color: var(--hub-brand);
65
+ /* 弹窗头部动作:清空所有日志(危险操作,二次确认时转红) */
66
+ .headActions {
67
+ flex: 1;
68
+ display: flex;
69
+ justify-content: flex-end;
70
+ margin-right: 4px;
71
+ }
72
+
73
+ .clearBtn {
74
+ flex: none;
75
+ height: 22px;
76
+ padding: 0 10px;
77
+ font-size: 11px;
78
+ font-weight: 500;
79
+ color: var(--hub-text-secondary);
80
+ background: transparent;
81
+ border: 1px solid var(--hub-border-2);
82
+ border-radius: 6px;
83
+ cursor: pointer;
84
+ user-select: none;
85
+ transition: color 0.12s ease, background-color 0.12s ease, border-color 0.12s ease;
70
86
  }
71
87
 
72
- .btnPrimary:hover:not(:disabled) {
73
- background: var(--hub-brand-hover);
74
- border-color: var(--hub-brand-hover);
88
+ .clearBtn:hover:not(:disabled) {
89
+ color: var(--hub-text-primary);
90
+ background: var(--hub-bg-2);
91
+ }
92
+
93
+ .clearBtn:disabled {
94
+ opacity: 0.55;
95
+ cursor: default;
75
96
  }
76
97
 
77
98
  /* 日志列表:一行一条,悬浮高亮;超出限高内部滚动 */
@@ -122,6 +122,83 @@
122
122
  flex-shrink: 0;
123
123
  }
124
124
 
125
+ /* 确认弹窗图标区:图标居中占位,位于标题下方、描述上方 —— 所有询问框都要有对应图标 */
126
+ .confirmIconWrap {
127
+ display: flex;
128
+ justify-content: center;
129
+ padding: 2px 0 6px;
130
+ }
131
+
132
+ /* 确认弹窗图标:圆形徽标(48px),普通确认 = 品牌蓝底白图形 */
133
+ .confirmIcon {
134
+ width: 48px;
135
+ height: 48px;
136
+ flex: none;
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ border-radius: 50%;
141
+ background: var(--hub-brand);
142
+ box-shadow: 0 4px 12px rgba(79, 110, 247, 0.28);
143
+ }
144
+
145
+ /* 危险操作确认图标:红色底白图形 —— 清空/删除/卸载/重置,醒目区分于普通确认 */
146
+ .confirmIconDanger {
147
+ background: var(--hub-danger);
148
+ box-shadow: 0 4px 12px rgba(209, 36, 47, 0.3);
149
+ }
150
+
151
+ /* 危险操作确认按钮:红色实底白字 —— 清空/删除/卸载/重置的最终确认,显著醒目 */
152
+ .dangerConfirm {
153
+ flex: none;
154
+ height: 28px;
155
+ padding: 0 14px;
156
+ font-size: 12px;
157
+ font-weight: 600;
158
+ color: #ffffff;
159
+ background: var(--hub-danger);
160
+ border: 1px solid var(--hub-danger);
161
+ border-radius: 6px;
162
+ cursor: pointer;
163
+ user-select: none;
164
+ transition: background-color 0.12s ease, border-color 0.12s ease;
165
+ }
166
+
167
+ .dangerConfirm:hover:not(:disabled) {
168
+ background: var(--hub-danger-hover);
169
+ border-color: var(--hub-danger-hover);
170
+ }
171
+
172
+ .dangerConfirm:disabled {
173
+ opacity: 0.55;
174
+ cursor: default;
175
+ }
176
+
177
+ /* 普通确认按钮:品牌蓝实底白字 —— 安装/更新/重启/忽略等常规确认 */
178
+ .confirmPrimary {
179
+ flex: none;
180
+ height: 28px;
181
+ padding: 0 14px;
182
+ font-size: 12px;
183
+ font-weight: 600;
184
+ color: #ffffff;
185
+ background: var(--hub-brand);
186
+ border: 1px solid var(--hub-brand);
187
+ border-radius: 6px;
188
+ cursor: pointer;
189
+ user-select: none;
190
+ transition: filter 0.12s ease;
191
+ }
192
+
193
+ .confirmPrimary:hover:not(:disabled) {
194
+ filter: brightness(1.08);
195
+ }
196
+
197
+ .confirmPrimary:disabled {
198
+ opacity: 0.55;
199
+ cursor: default;
200
+ }
201
+
125
202
  .modalDesc {
126
203
  font-size: 12px;
127
204
  line-height: 18px;
@@ -20,7 +20,7 @@ import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
20
20
  import { preflightTarget } from '../services/install/preflight.ts'
21
21
  import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
22
22
  import { loadSettings, saveSettings, resetSettings, type HubSettings } from '../services/settings.ts'
23
- import { appendLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
23
+ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
24
24
 
25
25
  export interface WebRoute {
26
26
  kind: 'exact'
@@ -325,6 +325,25 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
325
325
  }
326
326
  },
327
327
  }),
328
+ webServer.register({
329
+ kind: 'exact',
330
+ path: '/dsh-plugin-hub/clear-log',
331
+ handler: (request, response) => {
332
+ if (!requireTrustedPost(request, response)) return
333
+ // 清空所有日志:截断当前日志文件为空
334
+ try {
335
+ const ok = clearLog(profile)
336
+ if (!ok) {
337
+ sendJson(response, 500, { error: 'failed to clear log' })
338
+ return
339
+ }
340
+ appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' })
341
+ sendJson(response, 200, { ok: true })
342
+ } catch {
343
+ sendJson(response, 500, { error: 'failed to clear log' })
344
+ }
345
+ },
346
+ }),
328
347
  webServer.register({
329
348
  kind: 'exact',
330
349
  path: '/dsh-plugin-hub/open-log',
@@ -659,6 +678,8 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
659
678
  attempts,
660
679
  timeoutMs: COMMAND_TIMEOUT_MS,
661
680
  env: mutationEnv(settings),
681
+ // 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
682
+ updateNpm: mode === 'update',
662
683
  })
663
684
  sendJson(response, 200, { ok: true, task: task.id })
664
685
  } catch (error) {
@@ -177,6 +177,8 @@ export function startPluginMutation(options: {
177
177
  uninstallLoader?: LoaderHandle
178
178
  /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
179
179
  attempts?: string[]
180
+ /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
181
+ updateNpm?: boolean
180
182
  }): InstallTask {
181
183
  const task: InstallTask = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false }
182
184
  nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1
@@ -266,21 +268,29 @@ function spawnMutation(options: {
266
268
  uninstallLoader?: LoaderHandle
267
269
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
268
270
  globalNpm?: string[]
271
+ /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
272
+ updateNpm?: boolean
269
273
  }): Promise<InstallResult> {
270
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm } = options
274
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm, updateNpm } = options
271
275
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
272
276
  const isGlobalNpm = (globalNpm ?? []).length > 0
273
277
  const invocation = isGlobalNpm
274
278
  ? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
275
279
  : cliInvocation()
280
+ // 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
281
+ // (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
282
+ // 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
283
+ const npmUpdateTarget = updateNpm && !isGlobalNpm && githubRepoOf(target) === null
284
+ ? `${target}@latest`
285
+ : target
276
286
  const args = isGlobalNpm
277
287
  ? ['install', '-g', ...(globalNpm ?? [])]
278
- : [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target]
288
+ : [...invocation.prefixArgs, 'plugin', '--profile', profile, action, npmUpdateTarget]
279
289
  // 记录实际执行的命令(npm 通道显示包名,git 通道显示 HTTPS 源),
280
290
  // 失败提 Issue 时作为「已尝试的安装方式」随附
281
291
  const executed = isGlobalNpm
282
292
  ? `npm install -g ${(globalNpm ?? []).join(' ')}`
283
- : `dsh plugin --profile ${profile} ${action} ${target}`
293
+ : `dsh plugin --profile ${profile} ${action} ${npmUpdateTarget}`
284
294
  if (task && !task.attempts.includes(executed)) {
285
295
  task.attempts.push(executed)
286
296
  }
@@ -105,6 +105,18 @@ export function appendLog(profile: string, entry: LogEntry): void {
105
105
  }
106
106
  }
107
107
 
108
+ /** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
109
+ export function clearLog(profile: string): boolean {
110
+ try {
111
+ const file = logFilePath(profile)
112
+ mkdirSync(dirname(file), { recursive: true })
113
+ writeFileSync(file, '')
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
108
120
  /** 兼容旧格式:早期无 category(level 用 info/ok/fail/warn),归一化到新版。 */
109
121
  function normalize(raw: unknown): LogEntry | null {
110
122
  if (typeof raw !== 'object' || raw === null) return null