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.
- package/README.en.md +56 -28
- package/README.md +56 -28
- package/client/client.js +2282 -230
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +432 -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 +66 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/npm-resolve.js +67 -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 +457 -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 +69 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +24 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/npm-resolve.d.ts +6 -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 +82 -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 +243 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +342 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +368 -0
- package/src/client/hooks/useCatalog.ts +202 -0
- package/src/client/hooks/useTaskQueue.ts +502 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +240 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +165 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +188 -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 +1006 -0
- package/src/client/styles/tokens.module.css +91 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +432 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +69 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +84 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/npm-resolve.ts +67 -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 +492 -0
- package/src/client/Section.module.css +0 -589
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background install/remove task queue for the Plugin Hub.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server-side FIFO active queue by polling /active, settles
|
|
5
|
+
* vanished tasks against /status, and exposes the queue actions (install,
|
|
6
|
+
* uninstall, cancel) plus the modal task lookups used by the dialogs.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
|
+
import type { HubPlugin, Translate } from '../types.ts'
|
|
10
|
+
import { installCommandOf, installTargetOf } from '../lib/catalog.ts'
|
|
11
|
+
|
|
12
|
+
/** 客户端任务队列项:镜像服务端 active 任务(running 在前、pending 在后)。 */
|
|
13
|
+
export interface QueueTask {
|
|
14
|
+
id: number
|
|
15
|
+
kind: 'install' | 'uninstall'
|
|
16
|
+
/** 展示目标:owner/repo(安装)或 npm 包名(卸载) */
|
|
17
|
+
target: string
|
|
18
|
+
/** 插件中文简介(入队时从插件数据带过来,轮询合并时保留):展示在仓库名下方,让人知道排队的到底是什么 */
|
|
19
|
+
desc?: string
|
|
20
|
+
/** 所属插件仓库(owner/repo):失败时错误弹窗据此提供「去仓库反馈」入口;卸载为 null */
|
|
21
|
+
repo: string | null
|
|
22
|
+
/** 安装时的目录信号快照(版本 + 仓库更新时间):任务成功后在本地记录,供「有更新」比对 */
|
|
23
|
+
version?: string
|
|
24
|
+
updatedAt?: string
|
|
25
|
+
/** pending 排队中 / running 执行中 / cancelling 取消中(客户端乐观过渡态) */
|
|
26
|
+
status: 'pending' | 'running' | 'cancelling'
|
|
27
|
+
progress: number
|
|
28
|
+
lines: string[]
|
|
29
|
+
/** 乐观入队条目:点击安装后立即显示「正在安装中」,等 POST 响应回来再换成真实任务 id。
|
|
30
|
+
* 服务端还没登记(preflight 中),轮询合并/消失检测都必须跳过,避免被当作已结束任务收尾。 */
|
|
31
|
+
optimistic?: boolean
|
|
32
|
+
/** 实际执行的安装命令(npm/git 通道随插件数据决定):失败时 issue 预填如实展示 */
|
|
33
|
+
command?: string
|
|
34
|
+
/** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败时 issue 预填一并贴给作者 */
|
|
35
|
+
attempts?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
|
|
39
|
+
export interface PendingRestart {
|
|
40
|
+
/** owner/repo(展示用,已去 github: 前缀) */
|
|
41
|
+
target: string
|
|
42
|
+
/** 待重启语义:install 装完等挂载 / uninstall 卸完等清理 */
|
|
43
|
+
kind: 'install' | 'uninstall'
|
|
44
|
+
at: number
|
|
45
|
+
desc?: string
|
|
46
|
+
version?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TaskQueueOptions {
|
|
50
|
+
t: Translate
|
|
51
|
+
refreshInstalled: () => void
|
|
52
|
+
/** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录。 */
|
|
53
|
+
onInstallDone: (viaModal: boolean, repo: string | null) => void
|
|
54
|
+
onUninstallDone: (viaModal: boolean, repo: string | null) => void
|
|
55
|
+
/** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)。 */
|
|
56
|
+
onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[]) => void
|
|
57
|
+
/** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
|
|
58
|
+
installPlugin: HubPlugin | null
|
|
59
|
+
uninstallPlugin: HubPlugin | null
|
|
60
|
+
installedName: (p: HubPlugin) => string | null
|
|
61
|
+
/** 待重启项展示信息补齐:按 owner/repo 从插件目录解析简介/版本;找不到返回 null。 */
|
|
62
|
+
resolvePending: (repo: string) => { desc?: string; version?: string } | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useTaskQueue(opts: TaskQueueOptions) {
|
|
66
|
+
const {
|
|
67
|
+
t, refreshInstalled, onInstallDone, onUninstallDone, onError,
|
|
68
|
+
installPlugin, uninstallPlugin, installedName, resolvePending,
|
|
69
|
+
} = opts
|
|
70
|
+
const [queue, setQueue] = useState<QueueTask[]>([])
|
|
71
|
+
const queueRef = useRef<QueueTask[]>([])
|
|
72
|
+
/** 待重启列表(服务端内存态 + 本地展示信息补齐);与 queue 一样以 ref 为唯一事实来源。 */
|
|
73
|
+
const [pendingRestarts, setPendingRestarts] = useState<PendingRestart[]>([])
|
|
74
|
+
const pendingRef = useRef<PendingRestart[]>([])
|
|
75
|
+
/** 本次会话内安装成功插件的展示信息(目标 → 简介/版本):轮询合并待重启列表时补齐,刷新后由 resolvePending 兜底。 */
|
|
76
|
+
const pendingInfoRef = useRef<Map<string, { desc?: string; version?: string }>>(new Map())
|
|
77
|
+
/** 保持最新的 resolvePending:轮询闭包捕获的是调用那一刻的目录状态,目录晚加载时也能补齐。 */
|
|
78
|
+
const resolvePendingRef = useRef(resolvePending)
|
|
79
|
+
resolvePendingRef.current = resolvePending
|
|
80
|
+
/** 当前打开弹窗所对应的任务 id:该任务完成时弹窗切换为结果视图 */
|
|
81
|
+
const modalTaskRef = useRef<number | null>(null)
|
|
82
|
+
/** 已开始收尾但尚未结束的任务 id(服务端 /active 已消失 → 查 /status 中):并发轮询时跳过,避免重复收尾 */
|
|
83
|
+
const settlingRef = useRef<Set<number>>(new Set())
|
|
84
|
+
/** 轮询定时器句柄(队列清空/组件卸载时清理) */
|
|
85
|
+
const pollRef = useRef<number | null>(null)
|
|
86
|
+
/** 请求在途的目标集合(同步防重):双击安装/卸载时第二击直接忽略。
|
|
87
|
+
* 本地队列任务要等 fetch 返回后才入队,仅靠 queueRef 检查拦不住请求窗口内的重复点击。 */
|
|
88
|
+
const submittingRef = useRef<Set<string>>(new Set())
|
|
89
|
+
/** 乐观入队临时 id 计数器:递减产生唯一负数,与服务端自增正数 id 永不冲突。 */
|
|
90
|
+
const tempIdRef = useRef(0)
|
|
91
|
+
/** 请求在途标记:弹窗据此禁用确认按钮,避免等待响应期间被再次点击。 */
|
|
92
|
+
const [submitting, setSubmitting] = useState(false)
|
|
93
|
+
|
|
94
|
+
/** 停止后台任务轮询(任务结束或组件卸载时清理)。 */
|
|
95
|
+
const stopPoll = () => {
|
|
96
|
+
if (pollRef.current !== null) {
|
|
97
|
+
window.clearInterval(pollRef.current)
|
|
98
|
+
pollRef.current = null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 队列与待重启都清空才停轮询:待重启期间持续轮询,宿主一重启(内存列表清空)立刻感知并消失。 */
|
|
103
|
+
const maybeStopPoll = () => {
|
|
104
|
+
if (queueRef.current.length === 0 && pendingRef.current.length === 0) stopPoll()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 组件卸载时兜底清理轮询定时器
|
|
108
|
+
useEffect(() => stopPoll, [])
|
|
109
|
+
|
|
110
|
+
/** 更新队列(同步镜像到 ref:queueRef 是唯一事实来源,state 只负责触发渲染,
|
|
111
|
+
* 避免依赖 React updater 的异步调度时序导致 gone/stopPoll/防重复入队读到旧值)。 */
|
|
112
|
+
const applyQueue = (updater: (prev: QueueTask[]) => QueueTask[]) => {
|
|
113
|
+
const next = updater(queueRef.current)
|
|
114
|
+
queueRef.current = next
|
|
115
|
+
setQueue(next)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 解析服务端待重启列表(`github:` 前缀已去掉,与安装任务的 target 对齐)。 */
|
|
119
|
+
const parsePendingRestarts = (value: unknown): PendingRestart[] => {
|
|
120
|
+
if (!Array.isArray(value)) return []
|
|
121
|
+
const out: PendingRestart[] = []
|
|
122
|
+
for (const x of value) {
|
|
123
|
+
if (typeof x !== 'object' || x === null) continue
|
|
124
|
+
const target = (x as { target?: unknown }).target
|
|
125
|
+
if (typeof target !== 'string' || target === '') continue
|
|
126
|
+
const at = (x as { at?: unknown }).at
|
|
127
|
+
out.push({
|
|
128
|
+
target: target.replace(/^github:/, ''),
|
|
129
|
+
kind: (x as { kind?: unknown }).kind === 'uninstall' ? 'uninstall' : 'install',
|
|
130
|
+
at: typeof at === 'number' ? at : 0,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 合并服务端待重启列表:保留已有展示信息,缺失时用会话内记录 / 目录解析补齐。 */
|
|
137
|
+
const applyPending = (raw: PendingRestart[]) => {
|
|
138
|
+
const prev = pendingRef.current
|
|
139
|
+
const next = raw.map((p) => {
|
|
140
|
+
const old = prev.find((q) => q.target === p.target)
|
|
141
|
+
if (old?.desc !== undefined) return { ...p, desc: old.desc, version: old.version }
|
|
142
|
+
const info = pendingInfoRef.current.get(p.target) ?? resolvePendingRef.current?.(p.target) ?? null
|
|
143
|
+
return info ? { ...p, desc: info.desc, version: info.version } : p
|
|
144
|
+
})
|
|
145
|
+
pendingRef.current = next
|
|
146
|
+
setPendingRestarts(next)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 向宿主记录某插件的安装时目录信号(version + updatedAt);version 为空时清除记录(卸载)。 */
|
|
150
|
+
const syncInstalledVersion = async (repo: string | null, version: string | undefined, updatedAt: string | undefined) => {
|
|
151
|
+
if (!repo) return
|
|
152
|
+
try {
|
|
153
|
+
await fetch('/dsh-plugin-hub/installed-version', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ repo, version: version ?? null, updatedAt }),
|
|
157
|
+
})
|
|
158
|
+
} catch {
|
|
159
|
+
// 宿主未挂本地路由:静默忽略,仅本机展示受影响
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 后台任务结束(active 中消失后查终态确认):完成 → 刷新安装表 + 结果视图/toast;失败 → 完整错误弹窗 */
|
|
164
|
+
const finishQueueTask = (ok: boolean, q: QueueTask, lines: string[]) => {
|
|
165
|
+
applyQueue((prev) => prev.filter((x) => x.id !== q.id))
|
|
166
|
+
if (ok) {
|
|
167
|
+
refreshInstalled()
|
|
168
|
+
if (q.kind === 'uninstall') {
|
|
169
|
+
// 卸载成功:清掉本地版本记录
|
|
170
|
+
void syncInstalledVersion(q.repo, undefined, undefined)
|
|
171
|
+
onUninstallDone(modalTaskRef.current === q.id, q.repo)
|
|
172
|
+
} else {
|
|
173
|
+
// 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
|
|
174
|
+
// 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
|
|
175
|
+
void syncInstalledVersion(q.repo, q.version, q.updatedAt)
|
|
176
|
+
pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
|
|
177
|
+
onInstallDone(modalTaskRef.current === q.id, q.repo)
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
|
|
181
|
+
const detail = lines.length > 0
|
|
182
|
+
? [...lines].reverse().join('\n')
|
|
183
|
+
: q.kind === 'uninstall' ? t('uninstallFail') : t('installFail')
|
|
184
|
+
onError(detail, q.repo, q.kind, q.command, q.attempts)
|
|
185
|
+
}
|
|
186
|
+
maybeStopPoll()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 卸载成功收尾前的进度过渡:任务已在服务端结束,但保留本地展示,
|
|
190
|
+
* 让进度条在 ~2.4s 内缓缓跑到 100% 再切结果/Toast,给用户一个交互过程,
|
|
191
|
+
* 而不是命令一执行进度条就一闪消失。 */
|
|
192
|
+
const settleDone = (q: QueueTask, lines: string[]) => {
|
|
193
|
+
// 收尾动画期间停止轮询:任务已从服务端 /active 消失,无需再查;
|
|
194
|
+
// 停掉可避免 pollQueue 的 gone 检测把它当「再消失一次」而重复收尾
|
|
195
|
+
stopPoll()
|
|
196
|
+
const from = Math.min(q.progress, 90) // 起点最多 90,给进度条留出跑满的缓冲空间
|
|
197
|
+
const steps = 20
|
|
198
|
+
const stepMs = 120 // 20 步 × 120ms = 2.4s
|
|
199
|
+
let step = 0
|
|
200
|
+
// 动画期间固定显示「卸载中」:任务实已结束,但进度在跑,与「排队中」文案更协调
|
|
201
|
+
applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, status: 'running' as const } : x)))
|
|
202
|
+
const timer = window.setInterval(() => {
|
|
203
|
+
step += 1
|
|
204
|
+
const progress = Math.round(from + (100 - from) * (step / steps))
|
|
205
|
+
applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, progress } : x)))
|
|
206
|
+
if (step >= steps) {
|
|
207
|
+
window.clearInterval(timer)
|
|
208
|
+
finishQueueTask(true, q, lines)
|
|
209
|
+
// 队列若还有其它已在排队/执行的任务,恢复轮询继续盯;已清空则自然停住
|
|
210
|
+
if (queueRef.current.length > 0) pollQueue()
|
|
211
|
+
}
|
|
212
|
+
}, stepMs)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 任务在 /active 中消失 = 已结束:查 /status 拿终态并收尾(cancelled 静默移除)。 */
|
|
216
|
+
const settleTask = async (q: QueueTask) => {
|
|
217
|
+
let status = 'failed'
|
|
218
|
+
let lines: string[] = []
|
|
219
|
+
let attempts: string[] | undefined
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[] } }
|
|
224
|
+
status = data.task?.status ?? 'failed'
|
|
225
|
+
lines = data.task?.lines ?? []
|
|
226
|
+
attempts = Array.isArray(data.task?.attempts) ? data.task.attempts : undefined
|
|
227
|
+
}
|
|
228
|
+
} catch { /* 服务端不可达:按失败处理 */ }
|
|
229
|
+
if (status === 'done') {
|
|
230
|
+
// 卸载成功走进度过渡动画;安装成功直接收尾
|
|
231
|
+
if (q.kind === 'uninstall') settleDone(q, lines)
|
|
232
|
+
else finishQueueTask(true, q, lines)
|
|
233
|
+
}
|
|
234
|
+
else if (status === 'failed') {
|
|
235
|
+
// 服务端终态带出的尝试记录覆盖本地:以实际执行为准
|
|
236
|
+
finishQueueTask(false, { ...q, attempts }, lines)
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
|
|
240
|
+
maybeStopPoll()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 轮询整个队列:合并服务端 /active(running 在前、pending 在后),
|
|
245
|
+
* 消失的任务查终态收尾;队列清空即停止轮询。 */
|
|
246
|
+
const pollQueue = () => {
|
|
247
|
+
stopPoll()
|
|
248
|
+
pollRef.current = window.setInterval(async () => {
|
|
249
|
+
try {
|
|
250
|
+
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
251
|
+
if (!res.ok) throw new Error(`active ${res.status}`)
|
|
252
|
+
const data = await res.json() as {
|
|
253
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
|
|
254
|
+
pendingRestarts?: unknown
|
|
255
|
+
}
|
|
256
|
+
const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
|
|
257
|
+
const byId = new Map(active.map((a) => [a.id as number, a]))
|
|
258
|
+
// 先捕获旧队列:gone 是「旧队列里服务端已消失的任务」的差集,必须在 applyQueue 更新前取
|
|
259
|
+
const prevQueue = queueRef.current
|
|
260
|
+
// 待重启列表同步(服务端内存态:宿主重启后自然变空,这里随之清空 UI)
|
|
261
|
+
applyPending(parsePendingRestarts(data.pendingRestarts))
|
|
262
|
+
// 合并:running 在前、pending 在后(与服务端一致),新出现任务补入队列
|
|
263
|
+
applyQueue((prev) => {
|
|
264
|
+
const next: QueueTask[] = []
|
|
265
|
+
for (const a of active) {
|
|
266
|
+
const id = a.id as number
|
|
267
|
+
const prevTask = prev.find((q) => q.id === id)
|
|
268
|
+
const isRemove = a.action === 'remove'
|
|
269
|
+
next.push({
|
|
270
|
+
id,
|
|
271
|
+
kind: isRemove ? 'uninstall' : 'install',
|
|
272
|
+
target: typeof a.target === 'string' ? a.target.replace(/^github:/, '') : (prevTask?.target ?? ''),
|
|
273
|
+
desc: prevTask?.desc,
|
|
274
|
+
repo: prevTask?.repo ?? (isRemove ? null : (typeof a.target === 'string' ? a.target.replace(/^github:/, '') : null)),
|
|
275
|
+
// 安装时快照的目录信号:轮询合并时保留,任务成功后记录到本地
|
|
276
|
+
version: prevTask?.version,
|
|
277
|
+
updatedAt: prevTask?.updatedAt,
|
|
278
|
+
status: a.status === 'running' ? 'running' : 'pending',
|
|
279
|
+
progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
|
|
280
|
+
lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
|
|
281
|
+
attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
// 保留乐观条目:服务端尚未登记(preflight 中),无真实 id,不能因 active 缺它就被合并吞掉
|
|
285
|
+
const keepOpt = prev.filter((q) => q.optimistic && !next.some((x) => x.id === q.id))
|
|
286
|
+
if (keepOpt.length > 0) next.push(...keepOpt)
|
|
287
|
+
// 保留「已从服务端消失、但尚未收尾」的任务:收尾(settleTask)负责移除。
|
|
288
|
+
// 若在这里提前摘除,卸载/安装完成的那一瞬队列会先变空,弹窗闪回「确认」态
|
|
289
|
+
// (busy=false 且未 done),表现为「卸载中 → 卸载 → 卸载完成」的中间回退。
|
|
290
|
+
const keepGone = prev.filter((q) =>
|
|
291
|
+
!byId.has(q.id) && !next.some((x) => x.id === q.id) && q.status !== 'cancelling' && !q.optimistic)
|
|
292
|
+
if (keepGone.length > 0) next.push(...keepGone)
|
|
293
|
+
return next
|
|
294
|
+
})
|
|
295
|
+
// 本地有、服务端已消失 → 已结束:逐个查终态收尾
|
|
296
|
+
// (cancelling 是客户端乐观过渡态,交给 cancelTask 的定时移除,不在轮询里提前收敛;
|
|
297
|
+
// optimistic 是点击后尚未登记的占位,服务端 /status 查不到,跳过免误判成失败;
|
|
298
|
+
// settling 是上一轮已开始收尾的任务,并发轮询直接跳过,避免重复收尾)
|
|
299
|
+
const gone = prevQueue.filter((q) =>
|
|
300
|
+
!byId.has(q.id) && q.status !== 'cancelling' && !q.optimistic && !settlingRef.current.has(q.id))
|
|
301
|
+
for (const q of gone) {
|
|
302
|
+
// 防御:同 id 任务可能已被上一轮收尾移除(极端时序下轮询合并出的重复条目),跳过避免重复收尾
|
|
303
|
+
if (!queueRef.current.some((x) => x.id === q.id)) continue
|
|
304
|
+
settlingRef.current.add(q.id)
|
|
305
|
+
try { await settleTask(q) } finally { settlingRef.current.delete(q.id) }
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// 服务端暂不可达:静默等待下一轮
|
|
309
|
+
}
|
|
310
|
+
}, 600)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 刷新后恢复任务队列与待重启列表:任务注册表在服务端内存里,只要宿主进程没重启就还在;
|
|
314
|
+
// 重新拉取 /active(数组)继续轮询,界面上显示「进行中」状态条,可展开查看实时输出。
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
let cancelled = false
|
|
317
|
+
const restore = async () => {
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
320
|
+
if (!res.ok) return
|
|
321
|
+
const data = await res.json() as {
|
|
322
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
|
|
323
|
+
pendingRestarts?: unknown
|
|
324
|
+
}
|
|
325
|
+
if (cancelled) return
|
|
326
|
+
// 待重启:装完没重启的持久提醒,刷新页面也要恢复
|
|
327
|
+
applyPending(parsePendingRestarts(data.pendingRestarts))
|
|
328
|
+
const items: QueueTask[] = (data.tasks ?? [])
|
|
329
|
+
.filter((a) => typeof a.id === 'number')
|
|
330
|
+
.map((a) => {
|
|
331
|
+
const isRemove = a.action === 'remove'
|
|
332
|
+
// 服务端任务的 target:github:repo(git 安装)/ npm 包名(npm 安装/卸载)。
|
|
333
|
+
// 恢复展示统一用 displayTarget(owner/repo,服务端固定传):npm 安装时 target 是包名,
|
|
334
|
+
// 直接展示会让用户看到「安装方式」的内部细节,违背无感知原则;卸载保持包名(匹配已装表)。
|
|
335
|
+
const display = typeof (a as { displayTarget?: unknown }).displayTarget === 'string' ? (a as { displayTarget: string }).displayTarget.replace(/^github:/, '') : ''
|
|
336
|
+
const fallback = typeof a.target === 'string' ? a.target.replace(/^github:/, '') : ''
|
|
337
|
+
return {
|
|
338
|
+
id: a.id as number,
|
|
339
|
+
kind: (isRemove ? 'uninstall' : 'install') as 'install' | 'uninstall',
|
|
340
|
+
target: isRemove ? fallback : (display || fallback),
|
|
341
|
+
repo: isRemove ? null : (display || fallback),
|
|
342
|
+
status: a.status === 'running' ? 'running' : 'pending',
|
|
343
|
+
progress: typeof a.progress === 'number' ? a.progress : 0,
|
|
344
|
+
lines: Array.isArray(a.lines) ? (a.lines as string[]) : [],
|
|
345
|
+
attempts: Array.isArray((a as { attempts?: unknown }).attempts) ? ((a as { attempts: string[] }).attempts) : [],
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
if (items.length > 0 || pendingRef.current.length > 0) {
|
|
349
|
+
applyQueue((prev) => [...prev, ...items.filter((n) => !prev.some((p) => p.id === n.id))])
|
|
350
|
+
// 刷新后恢复的任务同样保持折叠:摘要条提示进行中,点开才看明细/日志
|
|
351
|
+
pollQueue()
|
|
352
|
+
}
|
|
353
|
+
} catch { /* host without the plugin's server routes */ }
|
|
354
|
+
}
|
|
355
|
+
void restore()
|
|
356
|
+
return () => { cancelled = true }
|
|
357
|
+
}, [])
|
|
358
|
+
|
|
359
|
+
/** 弹窗动作:直接安装。请求宿主本地路由,任务进入服务端队列(FIFO),弹窗内实时显示进度。 */
|
|
360
|
+
const installNow = async (p: HubPlugin, opts?: { update?: boolean }) => {
|
|
361
|
+
const repo = p.source?.repo ?? ''
|
|
362
|
+
if (!repo) return
|
|
363
|
+
// 安装通道决策(用户无感知):有 npm 包名 → 走 npm;否则 git 直装。
|
|
364
|
+
// 队列条目一律用仓库名展示/防重,只有发给后端的实际安装目标随通道变化。
|
|
365
|
+
const { target } = installTargetOf(p)
|
|
366
|
+
if (!target) return
|
|
367
|
+
// 防重复入队:同一目标已在排队/执行中则忽略
|
|
368
|
+
if (queueRef.current.some((q) => q.kind === 'install' && q.target === repo)) return
|
|
369
|
+
// 请求在途防重:任务要等响应回来才进本地队列,这之前再次点击(双击)直接忽略
|
|
370
|
+
if (submittingRef.current.has(repo)) return
|
|
371
|
+
submittingRef.current.add(repo)
|
|
372
|
+
// 乐观入队:点击安装立即显示「正在安装中」,不等 POST 响应。
|
|
373
|
+
// 服务端 /install 会先跑预检(preflight,可能耗时数秒)再入队,若等响应才入队列,
|
|
374
|
+
// 用户点完安装立刻去通知中心会看不到任务 —— 只有刷新后 restore 才拉回来。
|
|
375
|
+
// 用负临时 id 占位,响应回来换成真实 task id;失败/出错则移除该占位。
|
|
376
|
+
tempIdRef.current -= 1
|
|
377
|
+
const tempId = tempIdRef.current
|
|
378
|
+
modalTaskRef.current = tempId
|
|
379
|
+
applyQueue((prev) => [...prev, {
|
|
380
|
+
id: tempId, kind: 'install', target: repo, repo, desc: p.description,
|
|
381
|
+
version: p.version, updatedAt: p.dates?.repoUpdatedAt,
|
|
382
|
+
status: 'running', progress: 0, lines: [], optimistic: true,
|
|
383
|
+
command: installCommandOf(p, true),
|
|
384
|
+
// 服务端登记后 /active 会带回真实尝试记录(npm 反查 + 执行命令),先占位空列表
|
|
385
|
+
attempts: [],
|
|
386
|
+
}])
|
|
387
|
+
setSubmitting(true)
|
|
388
|
+
try {
|
|
389
|
+
const res = await fetch('/dsh-plugin-hub/install', {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { 'content-type': 'application/json' },
|
|
392
|
+
body: JSON.stringify({ repo: target, display: repo, mode: opts?.update ? 'update' : undefined }),
|
|
393
|
+
})
|
|
394
|
+
const data = await res.json() as { ok?: boolean; task?: number; error?: string; attempts?: string[] }
|
|
395
|
+
if (!data.ok || typeof data.task !== 'number') {
|
|
396
|
+
// 请求层失败(重复安装 409 / 参数错误等):移除乐观条目 + 完整错误弹窗
|
|
397
|
+
applyQueue((prev) => prev.filter((x) => x.id !== tempId))
|
|
398
|
+
// 同步 400(如预检拦截)服务端会附上已尝试的安装方式,issue 预填一并展示
|
|
399
|
+
onError(data.error ?? `HTTP ${res.status}`, repo, 'install', installCommandOf(p, true), data.attempts)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
const taskId = data.task
|
|
403
|
+
modalTaskRef.current = taskId
|
|
404
|
+
// 用真实任务 id 替换乐观占位,解除 optimistic 标记(此后归正常收尾/轮询处理)
|
|
405
|
+
applyQueue((prev) => prev.map((x) => (x.id === tempId
|
|
406
|
+
? { ...x, id: taskId, optimistic: undefined }
|
|
407
|
+
: x)))
|
|
408
|
+
// 提前缓存展示信息:安装成功后服务端登记待重启,轮询合并时不用等目录解析
|
|
409
|
+
pendingInfoRef.current.set(repo, { desc: p.description, version: p.version })
|
|
410
|
+
// 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
|
|
411
|
+
pollQueue()
|
|
412
|
+
} catch {
|
|
413
|
+
// 网络异常:移除乐观条目 + 兜底错误弹窗
|
|
414
|
+
applyQueue((prev) => prev.filter((x) => x.id !== tempId))
|
|
415
|
+
onError(t('installFail'), repo, 'install', installCommandOf(p, true))
|
|
416
|
+
} finally {
|
|
417
|
+
submittingRef.current.delete(repo)
|
|
418
|
+
setSubmitting(false)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 弹窗动作:直接卸载。与安装同一队列机制,弹窗内实时显示进度。 */
|
|
423
|
+
const uninstallNow = async (p: HubPlugin) => {
|
|
424
|
+
const name = installedName(p)
|
|
425
|
+
if (!name) return
|
|
426
|
+
const repo = p.source?.repo ?? null
|
|
427
|
+
if (queueRef.current.some((q) => q.kind === 'uninstall' && q.target === name)) return
|
|
428
|
+
// 请求在途防重:与安装一致,响应回来前再次点击直接忽略
|
|
429
|
+
if (submittingRef.current.has(name)) return
|
|
430
|
+
submittingRef.current.add(name)
|
|
431
|
+
setSubmitting(true)
|
|
432
|
+
try {
|
|
433
|
+
const res = await fetch('/dsh-plugin-hub/uninstall', {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers: { 'content-type': 'application/json' },
|
|
436
|
+
body: JSON.stringify({ name, repo: repo ?? undefined }),
|
|
437
|
+
})
|
|
438
|
+
const data = await res.json() as { ok?: boolean; task?: number; error?: string }
|
|
439
|
+
if (!data.ok || typeof data.task !== 'number') {
|
|
440
|
+
onError(data.error ?? `HTTP ${res.status}`, repo, 'uninstall')
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
const taskId = data.task
|
|
444
|
+
modalTaskRef.current = taskId
|
|
445
|
+
applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [] }])
|
|
446
|
+
// 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
|
|
447
|
+
pollQueue()
|
|
448
|
+
} catch {
|
|
449
|
+
onError(t('uninstallFail'), repo, 'uninstall')
|
|
450
|
+
} finally {
|
|
451
|
+
submittingRef.current.delete(name)
|
|
452
|
+
setSubmitting(false)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 取消任务:排队中立即出队,执行中终止子进程;先标记「正在取消」短暂过渡后再移除。 */
|
|
457
|
+
const cancelTask = async (id: number) => {
|
|
458
|
+
if (modalTaskRef.current === id) modalTaskRef.current = null
|
|
459
|
+
// 乐观标记「正在取消」,给用户一个可见反馈,而不是瞬间消失
|
|
460
|
+
applyQueue((prev) => prev.map((q) => (q.id === id ? { ...q, status: 'cancelling' as const } : q)))
|
|
461
|
+
try {
|
|
462
|
+
await fetch('/dsh-plugin-hub/cancel', {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'content-type': 'application/json' },
|
|
465
|
+
body: JSON.stringify({ id }),
|
|
466
|
+
})
|
|
467
|
+
} catch { /* 服务端不可达:仍按取消处理,稍后移除 */ }
|
|
468
|
+
// 短暂展示「正在取消」后移除;队列清空则停止轮询
|
|
469
|
+
window.setTimeout(() => {
|
|
470
|
+
applyQueue((prev) => prev.filter((x) => x.id !== id))
|
|
471
|
+
maybeStopPoll()
|
|
472
|
+
}, 700)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 打开新弹窗前清空任务匹配:避免旧任务 id 误匹配到新弹窗。 */
|
|
476
|
+
const clearModalTask = () => { modalTaskRef.current = null }
|
|
477
|
+
|
|
478
|
+
// 弹窗对应任务:优先当前弹窗发起任务,其次按目标匹配(重新打开弹窗 / 目标已在队列时也能展示实时进度)
|
|
479
|
+
// 排除 cancelling:取消中的任务不再关联到弹窗,弹窗回到「确认」态可重新操作
|
|
480
|
+
const installModalTask = installPlugin
|
|
481
|
+
? (queue.find((q) => q.id === modalTaskRef.current && q.status !== 'cancelling')
|
|
482
|
+
?? queue.find((q) => q.kind === 'install' && q.target === (installPlugin.source?.repo ?? '') && q.status !== 'cancelling')
|
|
483
|
+
?? null)
|
|
484
|
+
: null
|
|
485
|
+
const uninstallModalTask = uninstallPlugin
|
|
486
|
+
? (queue.find((q) => q.id === modalTaskRef.current && q.status !== 'cancelling')
|
|
487
|
+
?? queue.find((q) => q.kind === 'uninstall' && q.target === (installedName(uninstallPlugin) ?? '') && q.status !== 'cancelling')
|
|
488
|
+
?? null)
|
|
489
|
+
: null
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
queue,
|
|
493
|
+
pendingRestarts,
|
|
494
|
+
installModalTask,
|
|
495
|
+
uninstallModalTask,
|
|
496
|
+
submitting,
|
|
497
|
+
installNow,
|
|
498
|
+
uninstallNow,
|
|
499
|
+
cancelTask,
|
|
500
|
+
clearModalTask,
|
|
501
|
+
}
|
|
502
|
+
}
|