opencode-plugin-hub 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +270 -2
- package/dist/gui-experimental.cjs +544 -0
- package/dist/index.js +11 -0
- package/package.json +61 -4
- package/src/contract.ts +186 -0
- package/src/gui/admin.ts +1004 -0
- package/src/gui/catalog.ts +227 -0
- package/src/gui/page.tsx +1821 -0
- package/src/gui/registry-snapshot.json +1 -0
- package/src/gui.tsx +77 -0
- package/src/index.ts +13 -0
package/src/gui/page.tsx
ADDED
|
@@ -0,0 +1,1821 @@
|
|
|
1
|
+
import { createMemo, createSignal, For, onMount, Show, Switch, Match } from "solid-js"
|
|
2
|
+
import type { Context, IconName, IpcClient } from "@opencode/plugin/gui"
|
|
3
|
+
import type { Admin } from "../contract"
|
|
4
|
+
|
|
5
|
+
type AdminClient = IpcClient<typeof Admin.spec>
|
|
6
|
+
type HubState = Awaited<ReturnType<AdminClient["snapshot"]>>
|
|
7
|
+
type PluginEntry = HubState["plugins"][number]
|
|
8
|
+
type SearchHit = Awaited<ReturnType<AdminClient["search"]>>["hits"][number]
|
|
9
|
+
type CatalogEntry = Awaited<ReturnType<AdminClient["browse"]>>["entries"][number]
|
|
10
|
+
type CatalogResult = Awaited<ReturnType<AdminClient["browse"]>>
|
|
11
|
+
type UpdateInfo = Awaited<ReturnType<AdminClient["updates"]>>["items"][number]
|
|
12
|
+
type PackageDetail = Awaited<ReturnType<AdminClient["detail"]>>
|
|
13
|
+
|
|
14
|
+
type Tab = "catalog" | "search" | "installed"
|
|
15
|
+
type Tone = "info" | "success" | "warning" | "error"
|
|
16
|
+
type Note = { text: string; tone: Tone }
|
|
17
|
+
|
|
18
|
+
const PAGE = 24
|
|
19
|
+
|
|
20
|
+
const SOURCE_LABEL: Record<string, string> = {
|
|
21
|
+
network: "社区目录 · 已更新",
|
|
22
|
+
cache: "社区目录 · 本地缓存",
|
|
23
|
+
builtin: "社区目录 · 内置快照",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
27
|
+
plugins: "插件",
|
|
28
|
+
mcp: "MCP",
|
|
29
|
+
themes: "主题",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TYPE_FILTERS = [
|
|
33
|
+
{ value: "all", label: "全部" },
|
|
34
|
+
{ value: "plugins", label: "插件" },
|
|
35
|
+
{ value: "mcp", label: "MCP" },
|
|
36
|
+
{ value: "themes", label: "主题" },
|
|
37
|
+
] as const
|
|
38
|
+
type TypeFilter = (typeof TYPE_FILTERS)[number]["value"]
|
|
39
|
+
|
|
40
|
+
const SORTS = [
|
|
41
|
+
{ value: "relevance", label: "相关度" },
|
|
42
|
+
{ value: "downloads", label: "周下载" },
|
|
43
|
+
{ value: "recent", label: "最近发布" },
|
|
44
|
+
] as const
|
|
45
|
+
type Sort = (typeof SORTS)[number]["value"]
|
|
46
|
+
|
|
47
|
+
const SUGGESTIONS = ["qoder", "notify", "memory", "auth", "usage", "theme", "mcp"]
|
|
48
|
+
|
|
49
|
+
const COMPACT = new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 })
|
|
50
|
+
|
|
51
|
+
function formatCount(value: number): string {
|
|
52
|
+
if (!Number.isFinite(value) || value <= 0) return "0"
|
|
53
|
+
return COMPACT.format(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatBytes(value: number): string {
|
|
57
|
+
if (!Number.isFinite(value) || value <= 0) return "—"
|
|
58
|
+
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`
|
|
59
|
+
if (value >= 1024) return `${Math.round(value / 1024)} KB`
|
|
60
|
+
return `${value} B`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativeDate(value: string): string {
|
|
64
|
+
if (!value) return ""
|
|
65
|
+
const time = Date.parse(value)
|
|
66
|
+
if (!Number.isFinite(time)) return value.slice(0, 10)
|
|
67
|
+
const days = Math.floor((Date.now() - time) / 86_400_000)
|
|
68
|
+
if (days <= 0) return "今天"
|
|
69
|
+
if (days === 1) return "昨天"
|
|
70
|
+
if (days < 30) return `${days} 天前`
|
|
71
|
+
if (days < 365) return `${Math.floor(days / 30)} 个月前`
|
|
72
|
+
return `${Math.floor(days / 365)} 年前`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatDate(epoch: number): string {
|
|
76
|
+
if (!epoch) return ""
|
|
77
|
+
return new Date(epoch).toLocaleString("zh-CN", {
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function initial(name: string): string {
|
|
86
|
+
const base = name.replace(/^@/, "").split("/").pop() ?? name
|
|
87
|
+
const cleaned = base.replace(/^opencode[-_]?/i, "")
|
|
88
|
+
return (cleaned || base).slice(0, 1).toUpperCase()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function typeIcon(type: string): IconName {
|
|
92
|
+
if (type === "mcp") return "mcp"
|
|
93
|
+
if (type === "themes") return "appearance"
|
|
94
|
+
return "extensions"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function repoLink(url: string, directory: string): string {
|
|
98
|
+
if (!url || !directory) return url
|
|
99
|
+
if (!/^https:\/\/github\.com\//i.test(url)) return url
|
|
100
|
+
return `${url}/tree/HEAD/${directory.replace(/^\/+/, "")}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function noteIcon(tone: Tone): IconName {
|
|
104
|
+
if (tone === "error") return "circle-exclamation"
|
|
105
|
+
if (tone === "success") return "circle-check"
|
|
106
|
+
if (tone === "warning") return "warning"
|
|
107
|
+
return "info"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A schema error almost always means the window bundle and the `serve --service` process
|
|
112
|
+
* are running different builds of this plugin. Say that in words instead of showing
|
|
113
|
+
* `SchemaError(Missing key at ...)`, which gives the user nothing to act on.
|
|
114
|
+
*/
|
|
115
|
+
function readableError(error: unknown): string {
|
|
116
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
117
|
+
if (/SchemaError|Missing key at/i.test(text)) {
|
|
118
|
+
return "插件窗口与后台服务版本不一致。点上方工具栏的「彻底重启」让两边换成同一版本。"
|
|
119
|
+
}
|
|
120
|
+
if (/unknown method|not implemented|no such method|method.*not.*found/i.test(text)) {
|
|
121
|
+
return "后台服务还是旧版本,暂时没有「彻底重启」能力。请先手动完全退出 OpenCode(并结束 serve --service)再打开一次。"
|
|
122
|
+
}
|
|
123
|
+
return text
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const NPM_LIKE = /^(@[a-z0-9-_.]+\/)?[a-z0-9-_.]+$/i
|
|
127
|
+
|
|
128
|
+
interface DetailFallback {
|
|
129
|
+
version?: string
|
|
130
|
+
description?: string
|
|
131
|
+
homepage?: string
|
|
132
|
+
repo?: string
|
|
133
|
+
npm?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const CSS = `
|
|
137
|
+
:root {
|
|
138
|
+
--ph-strong: var(--color-v2-text-text-contrast, var(--color-text-strong, currentColor));
|
|
139
|
+
--ph-muted: var(--color-v2-text-text-muted, var(--color-text-weak, color-mix(in srgb, currentColor 58%, transparent)));
|
|
140
|
+
--ph-faint: var(--color-v2-text-text-faint, color-mix(in srgb, currentColor 42%, transparent));
|
|
141
|
+
--ph-border: var(--color-v2-border-border-base, var(--color-border-weak-base, color-mix(in srgb, currentColor 12%, transparent)));
|
|
142
|
+
--ph-border-strong: var(--color-v2-border-border-strong, color-mix(in srgb, currentColor 24%, transparent));
|
|
143
|
+
--ph-layer: var(--color-v2-background-bg-layer-01, var(--color-surface-base, color-mix(in srgb, currentColor 3%, transparent)));
|
|
144
|
+
--ph-layer-2: var(--color-v2-background-bg-layer-02, var(--color-surface-raised-base, color-mix(in srgb, currentColor 6%, transparent)));
|
|
145
|
+
--ph-layer-3: var(--color-v2-background-bg-layer-03, color-mix(in srgb, currentColor 10%, transparent));
|
|
146
|
+
--ph-accent: var(--color-v2-text-text-accent, var(--color-text-interactive-base, var(--color-blue-primary, #4f8cff)));
|
|
147
|
+
--ph-success: var(--color-v2-state-fg-success, var(--color-green, #2f9e5f));
|
|
148
|
+
--ph-danger: var(--color-v2-state-fg-danger, var(--color-red, #d9534f));
|
|
149
|
+
--ph-warning: var(--color-v2-state-fg-warning, var(--color-orange, #c98a16));
|
|
150
|
+
--ph-hover: var(--color-v2-overlay-simple-overlay-hover, color-mix(in srgb, currentColor 6%, transparent));
|
|
151
|
+
}
|
|
152
|
+
.ph-root {
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-direction: column;
|
|
155
|
+
gap: 14px;
|
|
156
|
+
max-width: 920px;
|
|
157
|
+
padding: 4px 0 40px;
|
|
158
|
+
color: var(--color-v2-text-text-base, var(--color-text-base, inherit));
|
|
159
|
+
}
|
|
160
|
+
.ph-root *, .ph-detail * { box-sizing: border-box; }
|
|
161
|
+
.ph-spacer { flex: 1; }
|
|
162
|
+
|
|
163
|
+
/* hero */
|
|
164
|
+
.ph-hero {
|
|
165
|
+
display: flex;
|
|
166
|
+
gap: 22px;
|
|
167
|
+
justify-content: space-between;
|
|
168
|
+
align-items: stretch;
|
|
169
|
+
padding: 20px 22px;
|
|
170
|
+
border: 1px solid var(--ph-border);
|
|
171
|
+
border-radius: 14px;
|
|
172
|
+
background:
|
|
173
|
+
radial-gradient(120% 160% at 0% 0%, color-mix(in srgb, var(--ph-accent) 13%, transparent) 0%, transparent 52%),
|
|
174
|
+
var(--ph-layer);
|
|
175
|
+
}
|
|
176
|
+
.ph-hero-copy { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
|
|
177
|
+
.ph-kicker {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: 6px;
|
|
181
|
+
font-size: 10.5px;
|
|
182
|
+
font-weight: 700;
|
|
183
|
+
letter-spacing: 0.12em;
|
|
184
|
+
color: var(--ph-accent);
|
|
185
|
+
}
|
|
186
|
+
.ph-title {
|
|
187
|
+
margin: 0;
|
|
188
|
+
font-size: 20px;
|
|
189
|
+
font-weight: 650;
|
|
190
|
+
letter-spacing: -0.015em;
|
|
191
|
+
color: var(--ph-strong);
|
|
192
|
+
}
|
|
193
|
+
.ph-sub {
|
|
194
|
+
margin: 0;
|
|
195
|
+
font-size: 12px;
|
|
196
|
+
line-height: 1.6;
|
|
197
|
+
color: var(--ph-muted);
|
|
198
|
+
max-width: 54ch;
|
|
199
|
+
}
|
|
200
|
+
.ph-path {
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
text-overflow: ellipsis;
|
|
203
|
+
white-space: nowrap;
|
|
204
|
+
font-size: 11px;
|
|
205
|
+
}
|
|
206
|
+
.ph-path code {
|
|
207
|
+
font-size: 10.5px;
|
|
208
|
+
padding: 1px 5px;
|
|
209
|
+
border-radius: 5px;
|
|
210
|
+
background: var(--ph-layer-2);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* notice */
|
|
214
|
+
.ph-notice {
|
|
215
|
+
display: flex;
|
|
216
|
+
align-items: flex-start;
|
|
217
|
+
gap: 8px;
|
|
218
|
+
padding: 10px 12px;
|
|
219
|
+
border: 1px solid var(--ph-border);
|
|
220
|
+
border-radius: 10px;
|
|
221
|
+
background: var(--ph-layer);
|
|
222
|
+
color: var(--ph-muted);
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
line-height: 1.55;
|
|
225
|
+
}
|
|
226
|
+
.ph-notice[data-tone="error"] {
|
|
227
|
+
color: var(--ph-danger);
|
|
228
|
+
border-color: color-mix(in srgb, var(--ph-danger) 28%, transparent);
|
|
229
|
+
background: color-mix(in srgb, var(--ph-danger) 7%, transparent);
|
|
230
|
+
}
|
|
231
|
+
.ph-notice[data-tone="success"] {
|
|
232
|
+
color: var(--ph-success);
|
|
233
|
+
border-color: color-mix(in srgb, var(--ph-success) 28%, transparent);
|
|
234
|
+
background: color-mix(in srgb, var(--ph-success) 7%, transparent);
|
|
235
|
+
}
|
|
236
|
+
.ph-notice[data-tone="warning"] {
|
|
237
|
+
color: var(--ph-warning);
|
|
238
|
+
border-color: color-mix(in srgb, var(--ph-warning) 28%, transparent);
|
|
239
|
+
background: color-mix(in srgb, var(--ph-warning) 7%, transparent);
|
|
240
|
+
}
|
|
241
|
+
.ph-notice span { flex: 1; min-width: 0; }
|
|
242
|
+
|
|
243
|
+
/* toolbar and tabs */
|
|
244
|
+
.ph-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
245
|
+
.ph-tabs {
|
|
246
|
+
display: flex;
|
|
247
|
+
gap: 2px;
|
|
248
|
+
padding: 3px;
|
|
249
|
+
border: 1px solid var(--ph-border);
|
|
250
|
+
border-radius: 10px;
|
|
251
|
+
background: var(--ph-layer);
|
|
252
|
+
}
|
|
253
|
+
.ph-tab {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
gap: 6px;
|
|
257
|
+
padding: 6px 12px;
|
|
258
|
+
border: 0;
|
|
259
|
+
border-radius: 8px;
|
|
260
|
+
background: transparent;
|
|
261
|
+
color: var(--ph-muted);
|
|
262
|
+
font-family: inherit;
|
|
263
|
+
font-size: 12px;
|
|
264
|
+
font-weight: 500;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
transition: background 0.15s, color 0.15s;
|
|
267
|
+
}
|
|
268
|
+
.ph-tab:hover { color: var(--ph-strong); background: var(--ph-hover); }
|
|
269
|
+
.ph-tab[data-active="true"] {
|
|
270
|
+
color: var(--ph-strong);
|
|
271
|
+
background: var(--ph-layer-3);
|
|
272
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
273
|
+
}
|
|
274
|
+
.ph-count {
|
|
275
|
+
display: inline-flex;
|
|
276
|
+
align-items: center;
|
|
277
|
+
justify-content: center;
|
|
278
|
+
min-width: 16px;
|
|
279
|
+
padding: 1px 5px;
|
|
280
|
+
border-radius: 99px;
|
|
281
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
282
|
+
color: var(--ph-muted);
|
|
283
|
+
font-size: 9.5px;
|
|
284
|
+
font-weight: 600;
|
|
285
|
+
}
|
|
286
|
+
.ph-count-alert {
|
|
287
|
+
background: color-mix(in srgb, var(--ph-accent) 14%, transparent);
|
|
288
|
+
color: var(--ph-accent);
|
|
289
|
+
white-space: nowrap;
|
|
290
|
+
}
|
|
291
|
+
.ph-tab[data-active="true"] .ph-count {
|
|
292
|
+
background: color-mix(in srgb, var(--ph-accent) 16%, transparent);
|
|
293
|
+
color: var(--ph-accent);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* panels, filters */
|
|
297
|
+
.ph-panel { display: flex; flex-direction: column; gap: 12px; }
|
|
298
|
+
.ph-filters { display: flex; flex-direction: column; gap: 10px; }
|
|
299
|
+
.ph-filter-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
300
|
+
.ph-grow { flex: 1; min-width: 220px; }
|
|
301
|
+
.ph-searchbar { display: flex; align-items: flex-end; gap: 8px; }
|
|
302
|
+
.ph-searchbar .ph-grow { min-width: 200px; }
|
|
303
|
+
.ph-meta {
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: 8px;
|
|
307
|
+
flex-wrap: wrap;
|
|
308
|
+
font-size: 11px;
|
|
309
|
+
color: var(--ph-muted);
|
|
310
|
+
line-height: 1.5;
|
|
311
|
+
}
|
|
312
|
+
.ph-meta > span { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
|
|
313
|
+
.ph-dot { flex: 0 0 auto; width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.4; }
|
|
314
|
+
.ph-resultbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
|
315
|
+
.ph-h3 {
|
|
316
|
+
display: flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
gap: 7px;
|
|
319
|
+
margin: 0;
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
font-weight: 600;
|
|
322
|
+
color: var(--ph-muted);
|
|
323
|
+
}
|
|
324
|
+
.ph-sort {
|
|
325
|
+
display: flex;
|
|
326
|
+
gap: 2px;
|
|
327
|
+
padding: 2px;
|
|
328
|
+
border: 1px solid var(--ph-border);
|
|
329
|
+
border-radius: 8px;
|
|
330
|
+
background: var(--ph-layer);
|
|
331
|
+
}
|
|
332
|
+
.ph-sort button {
|
|
333
|
+
padding: 3px 8px;
|
|
334
|
+
border: 0;
|
|
335
|
+
border-radius: 6px;
|
|
336
|
+
background: transparent;
|
|
337
|
+
color: var(--ph-muted);
|
|
338
|
+
font-family: inherit;
|
|
339
|
+
font-size: 10.5px;
|
|
340
|
+
cursor: pointer;
|
|
341
|
+
}
|
|
342
|
+
.ph-sort button:hover { color: var(--ph-strong); background: var(--ph-hover); }
|
|
343
|
+
.ph-sort button[data-active="true"] {
|
|
344
|
+
color: var(--ph-accent);
|
|
345
|
+
background: color-mix(in srgb, var(--ph-accent) 11%, transparent);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* cards */
|
|
349
|
+
.ph-list { display: flex; flex-direction: column; gap: 8px; }
|
|
350
|
+
.ph-card {
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: flex-start;
|
|
353
|
+
gap: 12px;
|
|
354
|
+
padding: 14px;
|
|
355
|
+
border: 1px solid var(--ph-border);
|
|
356
|
+
border-radius: 12px;
|
|
357
|
+
background: var(--ph-layer);
|
|
358
|
+
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
|
359
|
+
}
|
|
360
|
+
.ph-card:hover {
|
|
361
|
+
border-color: var(--ph-border-strong);
|
|
362
|
+
background: var(--ph-layer-2);
|
|
363
|
+
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
|
|
364
|
+
}
|
|
365
|
+
.ph-tile {
|
|
366
|
+
flex: 0 0 auto;
|
|
367
|
+
display: grid;
|
|
368
|
+
place-items: center;
|
|
369
|
+
width: 38px;
|
|
370
|
+
height: 38px;
|
|
371
|
+
border: 1px solid color-mix(in srgb, var(--ph-accent) 20%, transparent);
|
|
372
|
+
border-radius: 10px;
|
|
373
|
+
background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
|
|
374
|
+
color: var(--ph-accent);
|
|
375
|
+
font-size: 15px;
|
|
376
|
+
font-weight: 700;
|
|
377
|
+
overflow: hidden;
|
|
378
|
+
}
|
|
379
|
+
.ph-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
380
|
+
.ph-card-body[role="button"] { cursor: pointer; border-radius: 6px; }
|
|
381
|
+
.ph-name-row { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
|
382
|
+
.ph-name {
|
|
383
|
+
font-size: 13.5px;
|
|
384
|
+
font-weight: 600;
|
|
385
|
+
color: var(--ph-strong);
|
|
386
|
+
overflow-wrap: anywhere;
|
|
387
|
+
}
|
|
388
|
+
.ph-pkg {
|
|
389
|
+
font-size: 10.5px;
|
|
390
|
+
color: var(--ph-faint);
|
|
391
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
392
|
+
overflow-wrap: anywhere;
|
|
393
|
+
}
|
|
394
|
+
.ph-version { font-size: 10.5px; font-weight: 400; color: var(--ph-muted); }
|
|
395
|
+
.ph-version-arrow { color: var(--ph-accent); }
|
|
396
|
+
.ph-desc { font-size: 12px; line-height: 1.55; color: var(--ph-muted); }
|
|
397
|
+
.ph-clamp {
|
|
398
|
+
display: -webkit-box;
|
|
399
|
+
-webkit-line-clamp: 2;
|
|
400
|
+
-webkit-box-orient: vertical;
|
|
401
|
+
overflow: hidden;
|
|
402
|
+
}
|
|
403
|
+
.ph-type {
|
|
404
|
+
font-size: 9.5px;
|
|
405
|
+
font-weight: 600;
|
|
406
|
+
letter-spacing: 0.05em;
|
|
407
|
+
text-transform: uppercase;
|
|
408
|
+
color: var(--ph-faint);
|
|
409
|
+
}
|
|
410
|
+
.ph-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; flex-wrap: wrap; }
|
|
411
|
+
|
|
412
|
+
/* badges and chips */
|
|
413
|
+
.ph-badge {
|
|
414
|
+
display: inline-flex;
|
|
415
|
+
align-items: center;
|
|
416
|
+
gap: 3px;
|
|
417
|
+
padding: 2px 7px;
|
|
418
|
+
border: 1px solid var(--ph-border);
|
|
419
|
+
border-radius: 99px;
|
|
420
|
+
background: var(--ph-layer-2);
|
|
421
|
+
color: var(--ph-muted);
|
|
422
|
+
font-size: 9.5px;
|
|
423
|
+
font-weight: 600;
|
|
424
|
+
line-height: 1.5;
|
|
425
|
+
white-space: nowrap;
|
|
426
|
+
}
|
|
427
|
+
.ph-badge[data-tone="accent"] {
|
|
428
|
+
color: var(--ph-accent);
|
|
429
|
+
border-color: color-mix(in srgb, var(--ph-accent) 30%, transparent);
|
|
430
|
+
background: color-mix(in srgb, var(--ph-accent) 9%, transparent);
|
|
431
|
+
}
|
|
432
|
+
.ph-badge[data-tone="success"] {
|
|
433
|
+
color: var(--ph-success);
|
|
434
|
+
border-color: color-mix(in srgb, var(--ph-success) 30%, transparent);
|
|
435
|
+
background: color-mix(in srgb, var(--ph-success) 9%, transparent);
|
|
436
|
+
}
|
|
437
|
+
.ph-badge[data-tone="warning"] {
|
|
438
|
+
color: var(--ph-warning);
|
|
439
|
+
border-color: color-mix(in srgb, var(--ph-warning) 30%, transparent);
|
|
440
|
+
background: color-mix(in srgb, var(--ph-warning) 9%, transparent);
|
|
441
|
+
}
|
|
442
|
+
.ph-badge[data-tone="danger"] {
|
|
443
|
+
color: var(--ph-danger);
|
|
444
|
+
border-color: color-mix(in srgb, var(--ph-danger) 30%, transparent);
|
|
445
|
+
background: color-mix(in srgb, var(--ph-danger) 9%, transparent);
|
|
446
|
+
}
|
|
447
|
+
.ph-chips { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
448
|
+
.ph-chip {
|
|
449
|
+
display: inline-flex;
|
|
450
|
+
align-items: center;
|
|
451
|
+
gap: 4px;
|
|
452
|
+
padding: 2px 8px;
|
|
453
|
+
border: 1px solid var(--ph-border);
|
|
454
|
+
border-radius: 99px;
|
|
455
|
+
background: var(--ph-layer-2);
|
|
456
|
+
color: var(--ph-muted);
|
|
457
|
+
font-family: inherit;
|
|
458
|
+
font-size: 10.5px;
|
|
459
|
+
line-height: 1.5;
|
|
460
|
+
white-space: nowrap;
|
|
461
|
+
max-width: 100%;
|
|
462
|
+
overflow: hidden;
|
|
463
|
+
text-overflow: ellipsis;
|
|
464
|
+
}
|
|
465
|
+
button.ph-chip { cursor: pointer; transition: color 0.15s, border-color 0.15s, background 0.15s; }
|
|
466
|
+
button.ph-chip:hover { color: var(--ph-strong); border-color: var(--ph-border-strong); }
|
|
467
|
+
button.ph-chip[data-active="true"] {
|
|
468
|
+
color: var(--ph-accent);
|
|
469
|
+
border-color: color-mix(in srgb, var(--ph-accent) 32%, transparent);
|
|
470
|
+
background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
|
|
471
|
+
}
|
|
472
|
+
.ph-chip-count { font-size: 9px; opacity: 0.7; }
|
|
473
|
+
|
|
474
|
+
/* empty and loading */
|
|
475
|
+
.ph-empty {
|
|
476
|
+
display: flex;
|
|
477
|
+
flex-direction: column;
|
|
478
|
+
align-items: center;
|
|
479
|
+
gap: 7px;
|
|
480
|
+
padding: 38px 18px;
|
|
481
|
+
border: 1px dashed var(--ph-border);
|
|
482
|
+
border-radius: 12px;
|
|
483
|
+
color: var(--ph-muted);
|
|
484
|
+
text-align: center;
|
|
485
|
+
}
|
|
486
|
+
.ph-empty strong { font-size: 13px; font-weight: 600; color: var(--ph-strong); }
|
|
487
|
+
.ph-empty span { font-size: 12px; line-height: 1.55; max-width: 48ch; }
|
|
488
|
+
.ph-empty .ph-chips { justify-content: center; margin-top: 2px; }
|
|
489
|
+
.ph-skeleton {
|
|
490
|
+
height: 70px;
|
|
491
|
+
border: 1px solid var(--ph-border);
|
|
492
|
+
border-radius: 12px;
|
|
493
|
+
background: linear-gradient(90deg, var(--ph-layer) 0%, var(--ph-layer-2) 50%, var(--ph-layer) 100%);
|
|
494
|
+
background-size: 200% 100%;
|
|
495
|
+
animation: ph-shimmer 1.4s ease-in-out infinite;
|
|
496
|
+
}
|
|
497
|
+
@keyframes ph-shimmer {
|
|
498
|
+
0% { background-position: 200% 0; }
|
|
499
|
+
100% { background-position: -200% 0; }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* detail dialog */
|
|
503
|
+
.ph-detail { display: flex; flex-direction: column; gap: 14px; }
|
|
504
|
+
.ph-detail-head { display: flex; flex-direction: column; gap: 5px; }
|
|
505
|
+
.ph-detail-title { font-size: 16px; font-weight: 650; color: var(--ph-strong); overflow-wrap: anywhere; }
|
|
506
|
+
.ph-detail-desc { font-size: 12.5px; line-height: 1.6; color: var(--ph-muted); }
|
|
507
|
+
.ph-detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
|
508
|
+
.ph-detail-stat {
|
|
509
|
+
padding: 9px 10px;
|
|
510
|
+
border: 1px solid var(--ph-border);
|
|
511
|
+
border-radius: 10px;
|
|
512
|
+
background: var(--ph-layer);
|
|
513
|
+
}
|
|
514
|
+
.ph-detail-stat b { display: block; font-size: 15px; font-weight: 650; line-height: 1.25; color: var(--ph-strong); }
|
|
515
|
+
.ph-detail-stat span { display: block; margin-top: 1px; font-size: 10px; color: var(--ph-muted); }
|
|
516
|
+
.ph-detail-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
|
517
|
+
.ph-detail-links { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
|
518
|
+
.ph-dialog-footer { display: flex; align-items: center; gap: 6px; width: 100%; flex-wrap: wrap; }
|
|
519
|
+
.ph-detail-error {
|
|
520
|
+
max-width: 34ch;
|
|
521
|
+
font-size: 10.5px;
|
|
522
|
+
line-height: 1.4;
|
|
523
|
+
color: var(--ph-danger);
|
|
524
|
+
overflow: hidden;
|
|
525
|
+
text-overflow: ellipsis;
|
|
526
|
+
white-space: nowrap;
|
|
527
|
+
}
|
|
528
|
+
.ph-detail-loading {
|
|
529
|
+
display: flex;
|
|
530
|
+
flex-direction: column;
|
|
531
|
+
align-items: center;
|
|
532
|
+
justify-content: center;
|
|
533
|
+
gap: 10px;
|
|
534
|
+
min-height: 220px;
|
|
535
|
+
font-size: 12px;
|
|
536
|
+
color: var(--ph-muted);
|
|
537
|
+
}
|
|
538
|
+
.ph-warning {
|
|
539
|
+
display: flex;
|
|
540
|
+
align-items: flex-start;
|
|
541
|
+
gap: 7px;
|
|
542
|
+
padding: 9px 11px;
|
|
543
|
+
border: 1px solid color-mix(in srgb, var(--ph-warning) 28%, transparent);
|
|
544
|
+
border-radius: 9px;
|
|
545
|
+
background: color-mix(in srgb, var(--ph-warning) 7%, transparent);
|
|
546
|
+
color: var(--ph-warning);
|
|
547
|
+
font-size: 11.5px;
|
|
548
|
+
line-height: 1.5;
|
|
549
|
+
}
|
|
550
|
+
.ph-warning[data-tone="danger"] {
|
|
551
|
+
border-color: color-mix(in srgb, var(--ph-danger) 28%, transparent);
|
|
552
|
+
background: color-mix(in srgb, var(--ph-danger) 7%, transparent);
|
|
553
|
+
color: var(--ph-danger);
|
|
554
|
+
}
|
|
555
|
+
.ph-section { display: flex; flex-direction: column; gap: 8px; }
|
|
556
|
+
.ph-section-title {
|
|
557
|
+
font-size: 10.5px;
|
|
558
|
+
font-weight: 700;
|
|
559
|
+
letter-spacing: 0.09em;
|
|
560
|
+
text-transform: uppercase;
|
|
561
|
+
color: var(--ph-faint);
|
|
562
|
+
}
|
|
563
|
+
.ph-details {
|
|
564
|
+
padding: 8px 10px;
|
|
565
|
+
border: 1px solid var(--ph-border);
|
|
566
|
+
border-radius: 10px;
|
|
567
|
+
background: var(--ph-layer);
|
|
568
|
+
}
|
|
569
|
+
.ph-details summary { cursor: pointer; font-size: 11.5px; color: var(--ph-muted); }
|
|
570
|
+
.ph-details .ph-chips { margin-top: 8px; }
|
|
571
|
+
.ph-readme { font-size: 12.5px; line-height: 1.65; }
|
|
572
|
+
.ph-readme img { max-width: 100%; height: auto; }
|
|
573
|
+
.ph-readme pre { overflow: auto; }
|
|
574
|
+
.ph-readme table { display: block; max-width: 100%; overflow: auto; }
|
|
575
|
+
.ph-readme-empty {
|
|
576
|
+
padding: 16px;
|
|
577
|
+
border: 1px dashed var(--ph-border);
|
|
578
|
+
border-radius: 10px;
|
|
579
|
+
color: var(--ph-muted);
|
|
580
|
+
font-size: 12px;
|
|
581
|
+
line-height: 1.55;
|
|
582
|
+
}
|
|
583
|
+
.ph-readme-note { font-size: 10.5px; color: var(--ph-faint); }
|
|
584
|
+
|
|
585
|
+
.ph-tab:focus-visible,
|
|
586
|
+
.ph-sort button:focus-visible,
|
|
587
|
+
button.ph-chip:focus-visible,
|
|
588
|
+
.ph-card-body[role="button"]:focus-visible {
|
|
589
|
+
outline: 2px solid color-mix(in srgb, var(--ph-accent) 55%, transparent);
|
|
590
|
+
outline-offset: 2px;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* embedded in the standalone dialog */
|
|
594
|
+
.ph-root[data-embedded="true"] {
|
|
595
|
+
max-width: none;
|
|
596
|
+
padding: 0 0 8px;
|
|
597
|
+
}
|
|
598
|
+
.ph-root[data-embedded="true"] .ph-toolbar {
|
|
599
|
+
position: sticky;
|
|
600
|
+
top: 0;
|
|
601
|
+
z-index: 3;
|
|
602
|
+
padding: 6px 0;
|
|
603
|
+
margin: -6px 0;
|
|
604
|
+
background: color-mix(in srgb, var(--color-v2-background-bg-base, Canvas) 92%, transparent);
|
|
605
|
+
backdrop-filter: blur(8px);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/* footer */
|
|
609
|
+
.ph-footer {
|
|
610
|
+
display: flex;
|
|
611
|
+
align-items: center;
|
|
612
|
+
gap: 10px;
|
|
613
|
+
padding-top: 12px;
|
|
614
|
+
border-top: 1px solid var(--ph-border);
|
|
615
|
+
}
|
|
616
|
+
.ph-footer-note { flex: 1; font-size: 11px; line-height: 1.55; color: var(--ph-muted); }
|
|
617
|
+
|
|
618
|
+
@media (max-width: 760px) {
|
|
619
|
+
.ph-hero { flex-direction: column; }
|
|
620
|
+
.ph-card { flex-wrap: wrap; }
|
|
621
|
+
.ph-card-body { flex-basis: calc(100% - 50px); }
|
|
622
|
+
.ph-actions { width: 100%; justify-content: flex-start; padding-left: 50px; }
|
|
623
|
+
.ph-detail-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
624
|
+
}
|
|
625
|
+
`
|
|
626
|
+
|
|
627
|
+
interface DetailDialogProps {
|
|
628
|
+
ctx: Context
|
|
629
|
+
admin: AdminClient
|
|
630
|
+
name: string
|
|
631
|
+
fallback?: DetailFallback
|
|
632
|
+
busy: boolean
|
|
633
|
+
onInstall: (version: string) => void
|
|
634
|
+
onClose: () => void
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function DetailDialog(props: DetailDialogProps) {
|
|
638
|
+
const ui = props.ctx.ui
|
|
639
|
+
const [data, setData] = createSignal<PackageDetail>()
|
|
640
|
+
const [failure, setFailure] = createSignal("")
|
|
641
|
+
const [loading, setLoading] = createSignal(true)
|
|
642
|
+
|
|
643
|
+
const load = async () => {
|
|
644
|
+
try {
|
|
645
|
+
const result = await props.admin.detail({ name: props.name })
|
|
646
|
+
setData(result)
|
|
647
|
+
if (result.error) setFailure(result.error)
|
|
648
|
+
} catch (error) {
|
|
649
|
+
setFailure(readableError(error))
|
|
650
|
+
} finally {
|
|
651
|
+
setLoading(false)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
onMount(() => {
|
|
656
|
+
void load()
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
const open = (url: string) => {
|
|
660
|
+
if (!url) return
|
|
661
|
+
void props.admin.openUrl({ url })
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const version = () => data()?.version || props.fallback?.version || ""
|
|
665
|
+
const description = () => data()?.description || props.fallback?.description || ""
|
|
666
|
+
const homepage = () => data()?.homepage || props.fallback?.homepage || ""
|
|
667
|
+
const repo = () => data()?.repo || props.fallback?.repo || ""
|
|
668
|
+
const npm = () => data()?.npm || props.fallback?.npm || `https://www.npmjs.com/package/${props.name}`
|
|
669
|
+
const canInstall = () => !loading() && data() !== undefined && !failure() && (data()?.version ?? "") !== ""
|
|
670
|
+
const update = () => data()?.hasUpdate === true
|
|
671
|
+
const installed = () => data()?.installed === true
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<ui.Dialog
|
|
675
|
+
title={props.name}
|
|
676
|
+
size="x-large"
|
|
677
|
+
footer={
|
|
678
|
+
<div class="ph-dialog-footer">
|
|
679
|
+
<Show when={homepage()}>
|
|
680
|
+
<ui.Button size="small" variant="ghost" icon="globe" onClick={() => open(homepage())}>
|
|
681
|
+
主页
|
|
682
|
+
</ui.Button>
|
|
683
|
+
</Show>
|
|
684
|
+
<Show when={repo()}>
|
|
685
|
+
<ui.Button
|
|
686
|
+
size="small"
|
|
687
|
+
variant="ghost"
|
|
688
|
+
icon="github"
|
|
689
|
+
onClick={() => open(repoLink(repo(), data()?.repoDir ?? ""))}
|
|
690
|
+
>
|
|
691
|
+
源码
|
|
692
|
+
</ui.Button>
|
|
693
|
+
</Show>
|
|
694
|
+
<Show when={npm()}>
|
|
695
|
+
<ui.Button size="small" variant="ghost" icon="cube" onClick={() => open(npm())}>
|
|
696
|
+
npm
|
|
697
|
+
</ui.Button>
|
|
698
|
+
</Show>
|
|
699
|
+
<span class="ph-spacer" />
|
|
700
|
+
<Show when={failure()}>
|
|
701
|
+
<span class="ph-detail-error" title={failure()}>
|
|
702
|
+
{failure()}
|
|
703
|
+
</span>
|
|
704
|
+
</Show>
|
|
705
|
+
<ui.Button size="small" onClick={props.onClose}>
|
|
706
|
+
关闭
|
|
707
|
+
</ui.Button>
|
|
708
|
+
<Show when={canInstall()}>
|
|
709
|
+
<Show
|
|
710
|
+
when={update() || !installed()}
|
|
711
|
+
fallback={
|
|
712
|
+
<ui.Button size="small" disabled>
|
|
713
|
+
已安装
|
|
714
|
+
</ui.Button>
|
|
715
|
+
}
|
|
716
|
+
>
|
|
717
|
+
<ui.Button
|
|
718
|
+
variant="contrast"
|
|
719
|
+
size="small"
|
|
720
|
+
icon="download"
|
|
721
|
+
busy={props.busy}
|
|
722
|
+
onClick={() => props.onInstall(data()!.version)}
|
|
723
|
+
>
|
|
724
|
+
{update() ? `更新到 v${version()}` : `安装 v${version()}`}
|
|
725
|
+
</ui.Button>
|
|
726
|
+
</Show>
|
|
727
|
+
</Show>
|
|
728
|
+
</div>
|
|
729
|
+
}
|
|
730
|
+
>
|
|
731
|
+
<div class="ph-detail">
|
|
732
|
+
<Show when={loading()}>
|
|
733
|
+
<div class="ph-detail-loading">
|
|
734
|
+
<ui.Loader size="large" />
|
|
735
|
+
<span>正在读取 npm 上的 README 与统计…</span>
|
|
736
|
+
</div>
|
|
737
|
+
</Show>
|
|
738
|
+
|
|
739
|
+
<Show when={!loading() && data()}>
|
|
740
|
+
<div class="ph-detail-head">
|
|
741
|
+
<div class="ph-name-row">
|
|
742
|
+
<Show when={version()}>
|
|
743
|
+
<span class="ph-badge" data-tone="accent">
|
|
744
|
+
v{version()}
|
|
745
|
+
</span>
|
|
746
|
+
</Show>
|
|
747
|
+
<Show when={installed()}>
|
|
748
|
+
<span class="ph-badge" data-tone="success">
|
|
749
|
+
已安装{data()!.current ? ` v${data()!.current}` : ""}
|
|
750
|
+
</span>
|
|
751
|
+
</Show>
|
|
752
|
+
<Show when={update()}>
|
|
753
|
+
<span class="ph-badge" data-tone="warning">
|
|
754
|
+
可更新
|
|
755
|
+
</span>
|
|
756
|
+
</Show>
|
|
757
|
+
<Show when={data()!.deprecated}>
|
|
758
|
+
<span class="ph-badge" data-tone="danger">
|
|
759
|
+
已废弃
|
|
760
|
+
</span>
|
|
761
|
+
</Show>
|
|
762
|
+
</div>
|
|
763
|
+
<div class="ph-detail-desc">{description() || "这个包没有提供描述。"}</div>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<Show when={data()!.version}>
|
|
767
|
+
<div class="ph-detail-stats">
|
|
768
|
+
<div class="ph-detail-stat">
|
|
769
|
+
<b title={`${data()!.weekly.toLocaleString()} 次`}>{formatCount(data()!.weekly)}</b>
|
|
770
|
+
<span>周下载</span>
|
|
771
|
+
</div>
|
|
772
|
+
<div class="ph-detail-stat">
|
|
773
|
+
<b title={`${data()!.monthly.toLocaleString()} 次`}>{formatCount(data()!.monthly)}</b>
|
|
774
|
+
<span>月下载</span>
|
|
775
|
+
</div>
|
|
776
|
+
<div class="ph-detail-stat">
|
|
777
|
+
<b>{data()!.versions || "—"}</b>
|
|
778
|
+
<span>已发布版本</span>
|
|
779
|
+
</div>
|
|
780
|
+
<div class="ph-detail-stat">
|
|
781
|
+
<b>{data()!.unpacked ? formatBytes(data()!.unpacked) : "—"}</b>
|
|
782
|
+
<span>{data()!.files ? `解包体积 · ${data()!.files} 文件` : "解包体积"}</span>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
</Show>
|
|
786
|
+
|
|
787
|
+
<Show when={data()!.deprecated}>
|
|
788
|
+
<div class="ph-warning">
|
|
789
|
+
<ui.Icon name="warning" size="small" />
|
|
790
|
+
<span>{data()!.deprecated}</span>
|
|
791
|
+
</div>
|
|
792
|
+
</Show>
|
|
793
|
+
|
|
794
|
+
<Show when={failure()}>
|
|
795
|
+
<div class="ph-warning" data-tone="danger">
|
|
796
|
+
<ui.Icon name="circle-exclamation" size="small" />
|
|
797
|
+
<span>{failure()}</span>
|
|
798
|
+
</div>
|
|
799
|
+
</Show>
|
|
800
|
+
|
|
801
|
+
<div class="ph-detail-meta">
|
|
802
|
+
<Show when={data()!.license}>
|
|
803
|
+
<span class="ph-chip">许可证 {data()!.license}</span>
|
|
804
|
+
</Show>
|
|
805
|
+
<Show when={data()!.published}>
|
|
806
|
+
<span class="ph-chip">发布于 {relativeDate(data()!.published)}</span>
|
|
807
|
+
</Show>
|
|
808
|
+
<Show when={data()!.created}>
|
|
809
|
+
<span class="ph-chip">首次发布 {relativeDate(data()!.created)}</span>
|
|
810
|
+
</Show>
|
|
811
|
+
<Show when={data()!.maintainers.length > 0}>
|
|
812
|
+
<span class="ph-chip">维护者 {data()!.maintainers.join(", ")}</span>
|
|
813
|
+
</Show>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
<Show when={data()!.keywords.length > 0}>
|
|
817
|
+
<div class="ph-chips">
|
|
818
|
+
<For each={data()!.keywords}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
|
|
819
|
+
</div>
|
|
820
|
+
</Show>
|
|
821
|
+
|
|
822
|
+
<Show when={data()!.bugs}>
|
|
823
|
+
<div class="ph-detail-links">
|
|
824
|
+
<ui.Button
|
|
825
|
+
size="small"
|
|
826
|
+
variant="ghost-muted"
|
|
827
|
+
icon="bubble-5"
|
|
828
|
+
onClick={() => open(data()!.bugs)}
|
|
829
|
+
>
|
|
830
|
+
问题反馈
|
|
831
|
+
</ui.Button>
|
|
832
|
+
</div>
|
|
833
|
+
</Show>
|
|
834
|
+
|
|
835
|
+
<Show when={data()!.dependencies.length > 0}>
|
|
836
|
+
<details class="ph-details">
|
|
837
|
+
<summary>依赖 {data()!.dependencies.length} 个</summary>
|
|
838
|
+
<div class="ph-chips">
|
|
839
|
+
<For each={data()!.dependencies}>{(dep) => <span class="ph-chip">{dep}</span>}</For>
|
|
840
|
+
</div>
|
|
841
|
+
</details>
|
|
842
|
+
</Show>
|
|
843
|
+
|
|
844
|
+
<section class="ph-section">
|
|
845
|
+
<div class="ph-section-title">README</div>
|
|
846
|
+
<Show
|
|
847
|
+
when={data()!.readme}
|
|
848
|
+
fallback={
|
|
849
|
+
<div class="ph-readme-empty">
|
|
850
|
+
这个包没有附带 README。可以打开 npm 页面或项目主页进一步了解。
|
|
851
|
+
</div>
|
|
852
|
+
}
|
|
853
|
+
>
|
|
854
|
+
<ui.Markdown text={data()!.readme} class="ph-readme" />
|
|
855
|
+
<Show when={data()!.readmeTruncated}>
|
|
856
|
+
<div class="ph-readme-note">README 较长,这里只显示前一部分。完整内容见 npm 页面。</div>
|
|
857
|
+
</Show>
|
|
858
|
+
</Show>
|
|
859
|
+
</section>
|
|
860
|
+
</Show>
|
|
861
|
+
|
|
862
|
+
<Show when={!loading() && !data()}>
|
|
863
|
+
<div class="ph-readme-empty">{failure() || "未能读取这个包的详情。"}</div>
|
|
864
|
+
</Show>
|
|
865
|
+
</div>
|
|
866
|
+
</ui.Dialog>
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedded?: boolean }) {
|
|
871
|
+
const ui = props.ctx.ui
|
|
872
|
+
const [state, setState] = createSignal<HubState>()
|
|
873
|
+
const [tab, setTab] = createSignal<Tab>("catalog")
|
|
874
|
+
|
|
875
|
+
const [catalog, setCatalog] = createSignal<readonly CatalogEntry[]>([])
|
|
876
|
+
const [meta, setMeta] = createSignal<CatalogResult>()
|
|
877
|
+
const [filter, setFilter] = createSignal("")
|
|
878
|
+
const [typeFilter, setTypeFilter] = createSignal<TypeFilter>("all")
|
|
879
|
+
const [limit, setLimit] = createSignal(PAGE)
|
|
880
|
+
|
|
881
|
+
const [hits, setHits] = createSignal<readonly SearchHit[]>([])
|
|
882
|
+
const [query, setQuery] = createSignal("")
|
|
883
|
+
const [searched, setSearched] = createSignal(false)
|
|
884
|
+
const [sort, setSort] = createSignal<Sort>("relevance")
|
|
885
|
+
|
|
886
|
+
const [updates, setUpdates] = createSignal<Map<string, UpdateInfo>>(new Map())
|
|
887
|
+
const [checked, setChecked] = createSignal(false)
|
|
888
|
+
|
|
889
|
+
const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<string>>(new Set())
|
|
890
|
+
const [note, setNote] = createSignal<Note>()
|
|
891
|
+
const isBusy = (key: string) => busyKeys().has(key)
|
|
892
|
+
const begin = (key: string) =>
|
|
893
|
+
setBusyKeys((prev) => {
|
|
894
|
+
const next = new Set(prev)
|
|
895
|
+
next.add(key)
|
|
896
|
+
return next
|
|
897
|
+
})
|
|
898
|
+
const end = (key: string) =>
|
|
899
|
+
setBusyKeys((prev) => {
|
|
900
|
+
const next = new Set(prev)
|
|
901
|
+
next.delete(key)
|
|
902
|
+
return next
|
|
903
|
+
})
|
|
904
|
+
const track = async <T,>(key: string, task: () => Promise<T>): Promise<T> => {
|
|
905
|
+
begin(key)
|
|
906
|
+
try {
|
|
907
|
+
return await task()
|
|
908
|
+
} finally {
|
|
909
|
+
end(key)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const notify = (text: string, tone: Tone = "info") => setNote(text ? { text, tone } : undefined)
|
|
913
|
+
|
|
914
|
+
const refresh = async () => {
|
|
915
|
+
try {
|
|
916
|
+
setState(await props.admin.snapshot())
|
|
917
|
+
} catch (error) {
|
|
918
|
+
notify(readableError(error), "error")
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Fills the update map: keyed by config spec, so directory plugins match their own row. */
|
|
923
|
+
const checkUpdates = async (silent = false) => {
|
|
924
|
+
await track("updates", async () => {
|
|
925
|
+
try {
|
|
926
|
+
const result = await props.admin.updates()
|
|
927
|
+
const map = new Map<string, UpdateInfo>()
|
|
928
|
+
for (const item of result.items) map.set(item.spec, item)
|
|
929
|
+
setUpdates(map)
|
|
930
|
+
setChecked(true)
|
|
931
|
+
if (silent) return
|
|
932
|
+
const stale = result.items.filter((item) => item.hasUpdate).length
|
|
933
|
+
const failed = result.items.filter((item) => item.error).length
|
|
934
|
+
// Never report "up to date" for packages the registry could not be asked about.
|
|
935
|
+
const message = result.error
|
|
936
|
+
? result.error
|
|
937
|
+
: failed > 0
|
|
938
|
+
? `${failed} 个插件未能检查(registry 连接失败),其余${stale > 0 ? `有 ${stale} 个可更新` : "已是最新"}`
|
|
939
|
+
: stale > 0
|
|
940
|
+
? `发现 ${stale} 个可更新的插件`
|
|
941
|
+
: "全部已是最新版本"
|
|
942
|
+
notify(
|
|
943
|
+
message,
|
|
944
|
+
result.error ? "error" : failed > 0 ? "warning" : stale > 0 ? "info" : "success",
|
|
945
|
+
)
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (!silent) notify(readableError(error), "error")
|
|
948
|
+
}
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const loadCatalog = async (force: boolean) => {
|
|
953
|
+
await track("catalog", async () => {
|
|
954
|
+
try {
|
|
955
|
+
const result = await props.admin.browse({ force })
|
|
956
|
+
setCatalog([...result.entries])
|
|
957
|
+
setMeta(result)
|
|
958
|
+
if (result.error) notify(result.error, "error")
|
|
959
|
+
// Keep the installed view in sync with what the catalog just reported.
|
|
960
|
+
await refresh()
|
|
961
|
+
} catch (error) {
|
|
962
|
+
notify(readableError(error), "error")
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const runSearch = async (text: string) => {
|
|
968
|
+
setQuery(text)
|
|
969
|
+
await track("search", async () => {
|
|
970
|
+
try {
|
|
971
|
+
const result = await props.admin.search({ query: text })
|
|
972
|
+
setHits(result.hits)
|
|
973
|
+
setSearched(true)
|
|
974
|
+
if (result.error) notify(result.error, "error")
|
|
975
|
+
} catch (error) {
|
|
976
|
+
notify(readableError(error), "error")
|
|
977
|
+
}
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const install = async (name: string, version = "") => {
|
|
982
|
+
await track(`install:${name}`, async () => {
|
|
983
|
+
try {
|
|
984
|
+
const result = await props.admin.install({ spec: name, version })
|
|
985
|
+
setState(result.state)
|
|
986
|
+
if (!result.ok) {
|
|
987
|
+
notify(result.message, "error")
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
|
|
991
|
+
const latest = version || hits().find((hit) => hit.name === name)?.version || ""
|
|
992
|
+
setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
|
|
993
|
+
setHits((prev) =>
|
|
994
|
+
prev.map((hit) =>
|
|
995
|
+
hit.name === name ? { ...hit, installed: true, current: latest, hasUpdate: false } : hit,
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
await checkUpdates(true)
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
notify(readableError(error), "error")
|
|
1001
|
+
}
|
|
1002
|
+
})
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const uninstall = async (entry: PluginEntry) => {
|
|
1006
|
+
const ok = await props.ctx.dialogs.confirm({
|
|
1007
|
+
title: `移除 ${entry.name}?`,
|
|
1008
|
+
description: "只从 opencode.json 里删掉这一项,已安装的文件不会删除。",
|
|
1009
|
+
confirm: "移除",
|
|
1010
|
+
danger: true,
|
|
1011
|
+
})
|
|
1012
|
+
if (!ok) return
|
|
1013
|
+
await track(`install:${entry.name}`, async () => {
|
|
1014
|
+
try {
|
|
1015
|
+
const result = await props.admin.remove({ spec: entry.spec, version: "" })
|
|
1016
|
+
setState(result.state)
|
|
1017
|
+
if (!result.ok) {
|
|
1018
|
+
notify(result.message, "error")
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
|
|
1022
|
+
setCatalog((prev) =>
|
|
1023
|
+
prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
|
|
1024
|
+
)
|
|
1025
|
+
setHits((prev) =>
|
|
1026
|
+
prev.map((hit) =>
|
|
1027
|
+
hit.name === entry.name ? { ...hit, installed: false, current: "", hasUpdate: false } : hit,
|
|
1028
|
+
),
|
|
1029
|
+
)
|
|
1030
|
+
setUpdates((prev) => {
|
|
1031
|
+
const next = new Map(prev)
|
|
1032
|
+
next.delete(entry.spec)
|
|
1033
|
+
return next
|
|
1034
|
+
})
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
notify(readableError(error), "error")
|
|
1037
|
+
}
|
|
1038
|
+
})
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const reload = async () => {
|
|
1042
|
+
await track("reload", async () => {
|
|
1043
|
+
try {
|
|
1044
|
+
const result = await props.admin.reload()
|
|
1045
|
+
setState(result.state)
|
|
1046
|
+
notify(result.message, result.ok ? "success" : "error")
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
notify(readableError(error), "error")
|
|
1049
|
+
}
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Quits the app, clears a stale service and opens the app again. The session is restored by OpenCode. */
|
|
1054
|
+
const restartApp = async () => {
|
|
1055
|
+
const ok = await props.ctx.dialogs.confirm({
|
|
1056
|
+
title: "彻底重启 OpenCode?",
|
|
1057
|
+
description:
|
|
1058
|
+
"应用会退出并自动重新打开,让插件代码和后台 serve --service 一起换成最新版本。当前会话通常会自动恢复。",
|
|
1059
|
+
confirm: "重启",
|
|
1060
|
+
})
|
|
1061
|
+
if (!ok) return
|
|
1062
|
+
await track("restart", async () => {
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await props.admin.restart()
|
|
1065
|
+
if (!result.ok) {
|
|
1066
|
+
notify("无法自动重启,请手动完全退出 OpenCode 再打开。", "error")
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
notify("正在重启 OpenCode…如果没有自动打开,请手动启动。", "info")
|
|
1070
|
+
} catch {
|
|
1071
|
+
notify(
|
|
1072
|
+
"自动重启调用失败,后台服务可能还是旧版本。请先手动完全退出 OpenCode(并结束 serve --service)再打开一次。",
|
|
1073
|
+
"error",
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const openUrl = async (url: string) => {
|
|
1080
|
+
if (!url) return
|
|
1081
|
+
try {
|
|
1082
|
+
const result = await props.admin.openUrl({ url })
|
|
1083
|
+
if (!result.ok) notify(`无法打开链接:${url}`, "error")
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
notify(readableError(error), "error")
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const openConfig = async () => {
|
|
1090
|
+
await track("config", async () => {
|
|
1091
|
+
try {
|
|
1092
|
+
const result = await props.admin.openConfig()
|
|
1093
|
+
if (!result.ok) notify("无法打开配置文件", "error")
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
notify(readableError(error), "error")
|
|
1096
|
+
}
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const openDetail = (name: string, fallback?: DetailFallback) => {
|
|
1101
|
+
props.ctx.dialogs.open((dialog) => (
|
|
1102
|
+
<DetailDialog
|
|
1103
|
+
ctx={props.ctx}
|
|
1104
|
+
admin={props.admin}
|
|
1105
|
+
name={name}
|
|
1106
|
+
fallback={fallback}
|
|
1107
|
+
busy={isBusy(`install:${name}`)}
|
|
1108
|
+
onInstall={(version) => {
|
|
1109
|
+
dialog.close()
|
|
1110
|
+
void install(name, version)
|
|
1111
|
+
}}
|
|
1112
|
+
onClose={() => dialog.close()}
|
|
1113
|
+
/>
|
|
1114
|
+
))
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** Local filter over the curated list: no request per keystroke. */
|
|
1118
|
+
const visible = createMemo(() => {
|
|
1119
|
+
const needle = filter().trim().toLowerCase()
|
|
1120
|
+
const type = typeFilter()
|
|
1121
|
+
return catalog().filter((entry) => {
|
|
1122
|
+
if (type !== "all" && entry.type !== type) return false
|
|
1123
|
+
if (!needle) return true
|
|
1124
|
+
return [entry.name, entry.pkg, entry.desc, entry.long, ...entry.tags]
|
|
1125
|
+
.join(" ")
|
|
1126
|
+
.toLowerCase()
|
|
1127
|
+
.includes(needle)
|
|
1128
|
+
})
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
const typeCounts = createMemo<Record<TypeFilter, number>>(() => {
|
|
1132
|
+
const all = catalog()
|
|
1133
|
+
return {
|
|
1134
|
+
all: all.length,
|
|
1135
|
+
plugins: all.filter((entry) => entry.type === "plugins").length,
|
|
1136
|
+
mcp: all.filter((entry) => entry.type === "mcp").length,
|
|
1137
|
+
themes: all.filter((entry) => entry.type === "themes").length,
|
|
1138
|
+
}
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
const sortedHits = createMemo(() => {
|
|
1142
|
+
const list = [...hits()]
|
|
1143
|
+
if (sort() === "downloads") list.sort((a, b) => b.weekly - a.weekly)
|
|
1144
|
+
else if (sort() === "recent") list.sort((a, b) => (b.published || "").localeCompare(a.published || ""))
|
|
1145
|
+
return list
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
const installable = createMemo(() => visible().filter((entry) => entry.pkg).length)
|
|
1149
|
+
const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
|
|
1150
|
+
const bySpec = (spec: string) => updates().get(spec)
|
|
1151
|
+
const byPackage = createMemo(() => {
|
|
1152
|
+
const map = new Map<string, UpdateInfo>()
|
|
1153
|
+
for (const item of updates().values()) map.set(item.name, item)
|
|
1154
|
+
return map
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
const switchTab = (next: Tab) => {
|
|
1158
|
+
setTab(next)
|
|
1159
|
+
// The first look at the installed list also checks the registry, once.
|
|
1160
|
+
if (next !== "catalog" && !checked() && !isBusy("updates")) void checkUpdates(true)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
onMount(() => {
|
|
1164
|
+
void refresh()
|
|
1165
|
+
void loadCatalog(false)
|
|
1166
|
+
void checkUpdates(true)
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
const tabButton = (id: Tab, label: string, icon: IconName, count?: number | string, alert?: number) => (
|
|
1170
|
+
<button class="ph-tab" data-active={tab() === id} onClick={() => switchTab(id)}>
|
|
1171
|
+
<ui.Icon name={icon} size="small" />
|
|
1172
|
+
{label}
|
|
1173
|
+
<Show when={count !== undefined && count !== ""}>
|
|
1174
|
+
<span class="ph-count">{count}</span>
|
|
1175
|
+
</Show>
|
|
1176
|
+
<Show when={alert !== undefined && alert > 0}>
|
|
1177
|
+
<span class="ph-count ph-count-alert" title={`${alert} 个可更新`}>
|
|
1178
|
+
{alert} 可更新
|
|
1179
|
+
</span>
|
|
1180
|
+
</Show>
|
|
1181
|
+
</button>
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
const versionLabel = (current: string, latest: string, stale: boolean) => (
|
|
1185
|
+
<span class="ph-version">
|
|
1186
|
+
{current ? `v${current}` : ""}
|
|
1187
|
+
<Show when={stale && latest}>
|
|
1188
|
+
<span class="ph-version-arrow"> → v{latest}</span>
|
|
1189
|
+
</Show>
|
|
1190
|
+
</span>
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
const catalogRow = (entry: CatalogEntry) => {
|
|
1194
|
+
const info = () => byPackage().get(entry.pkg)
|
|
1195
|
+
const stale = () => info()?.hasUpdate === true
|
|
1196
|
+
const detail = () =>
|
|
1197
|
+
openDetail(entry.pkg, {
|
|
1198
|
+
version: entry.version,
|
|
1199
|
+
description: entry.desc || entry.long,
|
|
1200
|
+
repo: entry.repo,
|
|
1201
|
+
})
|
|
1202
|
+
return (
|
|
1203
|
+
<article class="ph-card">
|
|
1204
|
+
<div class="ph-tile">
|
|
1205
|
+
<ui.Icon name={typeIcon(entry.type)} size="normal" />
|
|
1206
|
+
</div>
|
|
1207
|
+
<div
|
|
1208
|
+
class="ph-card-body"
|
|
1209
|
+
role="button"
|
|
1210
|
+
tabindex="0"
|
|
1211
|
+
onClick={() => (entry.pkg ? detail() : void openUrl(entry.repo))}
|
|
1212
|
+
onKeyDown={(event) => {
|
|
1213
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1214
|
+
event.preventDefault()
|
|
1215
|
+
if (entry.pkg) detail()
|
|
1216
|
+
else void openUrl(entry.repo)
|
|
1217
|
+
}
|
|
1218
|
+
}}
|
|
1219
|
+
>
|
|
1220
|
+
<div class="ph-name-row">
|
|
1221
|
+
<span class="ph-name">{entry.name}</span>
|
|
1222
|
+
<Show when={entry.pkg}>
|
|
1223
|
+
<span class="ph-pkg">{entry.pkg}</span>
|
|
1224
|
+
</Show>
|
|
1225
|
+
<Show when={entry.installed}>
|
|
1226
|
+
<span class="ph-badge" data-tone="success">
|
|
1227
|
+
已安装
|
|
1228
|
+
</span>
|
|
1229
|
+
</Show>
|
|
1230
|
+
<Show when={stale()}>
|
|
1231
|
+
<span class="ph-badge" data-tone="accent">
|
|
1232
|
+
可更新 → v{info()!.latest}
|
|
1233
|
+
</span>
|
|
1234
|
+
</Show>
|
|
1235
|
+
</div>
|
|
1236
|
+
<div class="ph-desc ph-clamp">{entry.desc || entry.long || "—"}</div>
|
|
1237
|
+
<div class="ph-meta">
|
|
1238
|
+
<span class="ph-type">{TYPE_LABEL[entry.type] ?? entry.type}</span>
|
|
1239
|
+
<Show when={entry.version}>
|
|
1240
|
+
<span class="ph-dot" />
|
|
1241
|
+
<span>v{entry.version}</span>
|
|
1242
|
+
</Show>
|
|
1243
|
+
<Show when={!entry.pkg}>
|
|
1244
|
+
<span class="ph-dot" />
|
|
1245
|
+
<span>仅 GitHub 发布</span>
|
|
1246
|
+
</Show>
|
|
1247
|
+
<Show when={entry.tags.length > 0}>
|
|
1248
|
+
<span class="ph-dot" />
|
|
1249
|
+
<span>{entry.tags.slice(0, 4).join(" / ")}</span>
|
|
1250
|
+
</Show>
|
|
1251
|
+
</div>
|
|
1252
|
+
</div>
|
|
1253
|
+
<div class="ph-actions">
|
|
1254
|
+
<Show when={entry.pkg}>
|
|
1255
|
+
<ui.Button variant="ghost" size="small" icon="eye" onClick={detail}>
|
|
1256
|
+
详情
|
|
1257
|
+
</ui.Button>
|
|
1258
|
+
</Show>
|
|
1259
|
+
<ui.Button
|
|
1260
|
+
variant="ghost-muted"
|
|
1261
|
+
size="small"
|
|
1262
|
+
icon="square-arrow-top-right"
|
|
1263
|
+
onClick={() => void openUrl(entry.repo)}
|
|
1264
|
+
>
|
|
1265
|
+
源码
|
|
1266
|
+
</ui.Button>
|
|
1267
|
+
<Show when={stale()}>
|
|
1268
|
+
<ui.Button
|
|
1269
|
+
variant="ghost"
|
|
1270
|
+
size="small"
|
|
1271
|
+
icon="download"
|
|
1272
|
+
busy={isBusy(`install:${entry.pkg}`)}
|
|
1273
|
+
onClick={() => void install(entry.pkg, info()!.latest)}
|
|
1274
|
+
>
|
|
1275
|
+
更新
|
|
1276
|
+
</ui.Button>
|
|
1277
|
+
</Show>
|
|
1278
|
+
<Show when={!stale() && !entry.installed && entry.pkg}>
|
|
1279
|
+
<ui.Button
|
|
1280
|
+
variant="neutral"
|
|
1281
|
+
size="small"
|
|
1282
|
+
icon="download"
|
|
1283
|
+
busy={isBusy(`install:${entry.pkg}`)}
|
|
1284
|
+
onClick={() => void install(entry.pkg)}
|
|
1285
|
+
>
|
|
1286
|
+
安装
|
|
1287
|
+
</ui.Button>
|
|
1288
|
+
</Show>
|
|
1289
|
+
<Show when={!stale() && !entry.installed && !entry.pkg}>
|
|
1290
|
+
<span class="ph-badge">需手动</span>
|
|
1291
|
+
</Show>
|
|
1292
|
+
</div>
|
|
1293
|
+
</article>
|
|
1294
|
+
)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const searchRow = (hit: SearchHit) => {
|
|
1298
|
+
const detail = () =>
|
|
1299
|
+
openDetail(hit.name, {
|
|
1300
|
+
version: hit.version,
|
|
1301
|
+
description: hit.description,
|
|
1302
|
+
homepage: hit.homepage,
|
|
1303
|
+
repo: hit.repo,
|
|
1304
|
+
npm: hit.npm,
|
|
1305
|
+
})
|
|
1306
|
+
return (
|
|
1307
|
+
<article class="ph-card">
|
|
1308
|
+
<div class="ph-tile">{initial(hit.name)}</div>
|
|
1309
|
+
<div
|
|
1310
|
+
class="ph-card-body"
|
|
1311
|
+
role="button"
|
|
1312
|
+
tabindex="0"
|
|
1313
|
+
onClick={detail}
|
|
1314
|
+
onKeyDown={(event) => {
|
|
1315
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1316
|
+
event.preventDefault()
|
|
1317
|
+
detail()
|
|
1318
|
+
}
|
|
1319
|
+
}}
|
|
1320
|
+
>
|
|
1321
|
+
<div class="ph-name-row">
|
|
1322
|
+
<span class="ph-name">{hit.name}</span>
|
|
1323
|
+
<span class="ph-pkg">v{hit.version}</span>
|
|
1324
|
+
<Show when={hit.installed}>
|
|
1325
|
+
<span class="ph-badge" data-tone="success">
|
|
1326
|
+
已安装{hit.current ? ` v${hit.current}` : ""}
|
|
1327
|
+
</span>
|
|
1328
|
+
</Show>
|
|
1329
|
+
<Show when={hit.hasUpdate}>
|
|
1330
|
+
<span class="ph-badge" data-tone="accent">
|
|
1331
|
+
可更新 → v{hit.version}
|
|
1332
|
+
</span>
|
|
1333
|
+
</Show>
|
|
1334
|
+
</div>
|
|
1335
|
+
<div class="ph-desc ph-clamp">{hit.description || "—"}</div>
|
|
1336
|
+
<div class="ph-meta">
|
|
1337
|
+
<span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
|
|
1338
|
+
<ui.Icon name="download" size="small" />
|
|
1339
|
+
{formatCount(hit.weekly)}/周
|
|
1340
|
+
</span>
|
|
1341
|
+
<span class="ph-dot" />
|
|
1342
|
+
<span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
|
|
1343
|
+
<Show when={hit.publisher}>
|
|
1344
|
+
<span class="ph-dot" />
|
|
1345
|
+
<span>by {hit.publisher}</span>
|
|
1346
|
+
</Show>
|
|
1347
|
+
<Show when={hit.license}>
|
|
1348
|
+
<span class="ph-dot" />
|
|
1349
|
+
<span>{hit.license}</span>
|
|
1350
|
+
</Show>
|
|
1351
|
+
<Show when={hit.published}>
|
|
1352
|
+
<span class="ph-dot" />
|
|
1353
|
+
<span>{relativeDate(hit.published)}更新</span>
|
|
1354
|
+
</Show>
|
|
1355
|
+
</div>
|
|
1356
|
+
<Show when={hit.keywords.length > 0}>
|
|
1357
|
+
<div class="ph-chips">
|
|
1358
|
+
<For each={hit.keywords}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
|
|
1359
|
+
</div>
|
|
1360
|
+
</Show>
|
|
1361
|
+
</div>
|
|
1362
|
+
<div class="ph-actions">
|
|
1363
|
+
<Show when={hit.homepage}>
|
|
1364
|
+
<ui.Button
|
|
1365
|
+
variant="ghost-muted"
|
|
1366
|
+
size="small"
|
|
1367
|
+
icon="globe"
|
|
1368
|
+
onClick={() => void openUrl(hit.homepage)}
|
|
1369
|
+
>
|
|
1370
|
+
主页
|
|
1371
|
+
</ui.Button>
|
|
1372
|
+
</Show>
|
|
1373
|
+
<Show when={hit.repo}>
|
|
1374
|
+
<ui.Tooltip value="源码仓库">
|
|
1375
|
+
<ui.IconButton
|
|
1376
|
+
icon="github"
|
|
1377
|
+
size="small"
|
|
1378
|
+
label="源码仓库"
|
|
1379
|
+
onClick={() => void openUrl(hit.repo)}
|
|
1380
|
+
/>
|
|
1381
|
+
</ui.Tooltip>
|
|
1382
|
+
</Show>
|
|
1383
|
+
<ui.Button variant="ghost" size="small" icon="eye" onClick={detail}>
|
|
1384
|
+
详情
|
|
1385
|
+
</ui.Button>
|
|
1386
|
+
<Show when={hit.hasUpdate}>
|
|
1387
|
+
<ui.Button
|
|
1388
|
+
variant="ghost"
|
|
1389
|
+
size="small"
|
|
1390
|
+
icon="download"
|
|
1391
|
+
busy={isBusy(`install:${hit.name}`)}
|
|
1392
|
+
onClick={() => void install(hit.name, hit.version)}
|
|
1393
|
+
>
|
|
1394
|
+
更新
|
|
1395
|
+
</ui.Button>
|
|
1396
|
+
</Show>
|
|
1397
|
+
<Show when={!hit.hasUpdate && !hit.installed}>
|
|
1398
|
+
<ui.Button
|
|
1399
|
+
variant="neutral"
|
|
1400
|
+
size="small"
|
|
1401
|
+
icon="download"
|
|
1402
|
+
busy={isBusy(`install:${hit.name}`)}
|
|
1403
|
+
onClick={() => void install(hit.name)}
|
|
1404
|
+
>
|
|
1405
|
+
安装
|
|
1406
|
+
</ui.Button>
|
|
1407
|
+
</Show>
|
|
1408
|
+
</div>
|
|
1409
|
+
</article>
|
|
1410
|
+
)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const installedRow = (entry: PluginEntry) => {
|
|
1414
|
+
const info = () => bySpec(entry.spec)
|
|
1415
|
+
const stale = () => info()?.hasUpdate === true
|
|
1416
|
+
const current = () => info()?.current || entry.version
|
|
1417
|
+
const unchecked = () => (info()?.error ?? "") !== ""
|
|
1418
|
+
return (
|
|
1419
|
+
<article class="ph-card">
|
|
1420
|
+
<div class="ph-tile">{initial(entry.name)}</div>
|
|
1421
|
+
<div class="ph-card-body">
|
|
1422
|
+
<div class="ph-name-row">
|
|
1423
|
+
<span class="ph-name">{entry.name}</span>
|
|
1424
|
+
{versionLabel(current(), info()?.latest ?? "", stale())}
|
|
1425
|
+
<Show when={entry.kind === "path"}>
|
|
1426
|
+
<span class="ph-badge">本地目录</span>
|
|
1427
|
+
</Show>
|
|
1428
|
+
<Show when={stale()}>
|
|
1429
|
+
<span class="ph-badge" data-tone="accent">
|
|
1430
|
+
可更新 → v{info()!.latest}
|
|
1431
|
+
</span>
|
|
1432
|
+
</Show>
|
|
1433
|
+
<Show when={unchecked()}>
|
|
1434
|
+
<span class="ph-badge" data-tone="warning">
|
|
1435
|
+
未能检查
|
|
1436
|
+
</span>
|
|
1437
|
+
</Show>
|
|
1438
|
+
</div>
|
|
1439
|
+
<div class="ph-meta">
|
|
1440
|
+
<span class="ph-pkg">{entry.spec}</span>
|
|
1441
|
+
<Show when={stale() && entry.kind === "path"}>
|
|
1442
|
+
<span class="ph-dot" />
|
|
1443
|
+
<span>更新会覆盖目录内容,原文件自动备份</span>
|
|
1444
|
+
</Show>
|
|
1445
|
+
</div>
|
|
1446
|
+
</div>
|
|
1447
|
+
<div class="ph-actions">
|
|
1448
|
+
<Show when={NPM_LIKE.test(entry.name)}>
|
|
1449
|
+
<ui.Button
|
|
1450
|
+
variant="ghost"
|
|
1451
|
+
size="small"
|
|
1452
|
+
icon="eye"
|
|
1453
|
+
onClick={() => openDetail(entry.name, { version: current() })}
|
|
1454
|
+
>
|
|
1455
|
+
详情
|
|
1456
|
+
</ui.Button>
|
|
1457
|
+
</Show>
|
|
1458
|
+
<Show when={stale()}>
|
|
1459
|
+
<ui.Button
|
|
1460
|
+
variant="ghost"
|
|
1461
|
+
size="small"
|
|
1462
|
+
icon="download"
|
|
1463
|
+
busy={isBusy(`install:${entry.name}`)}
|
|
1464
|
+
onClick={() => void install(info()!.name, info()!.latest)}
|
|
1465
|
+
>
|
|
1466
|
+
更新
|
|
1467
|
+
</ui.Button>
|
|
1468
|
+
</Show>
|
|
1469
|
+
<Show when={entry.removable}>
|
|
1470
|
+
<ui.Button
|
|
1471
|
+
variant="ghost-muted"
|
|
1472
|
+
size="small"
|
|
1473
|
+
icon="trash"
|
|
1474
|
+
busy={isBusy(`install:${entry.name}`)}
|
|
1475
|
+
onClick={() => void uninstall(entry)}
|
|
1476
|
+
>
|
|
1477
|
+
移除
|
|
1478
|
+
</ui.Button>
|
|
1479
|
+
</Show>
|
|
1480
|
+
</div>
|
|
1481
|
+
</article>
|
|
1482
|
+
)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return (
|
|
1486
|
+
<div class="ph-root" data-embedded={props.embedded ? "true" : undefined}>
|
|
1487
|
+
<style>{CSS}</style>
|
|
1488
|
+
|
|
1489
|
+
<section class="ph-hero">
|
|
1490
|
+
<div class="ph-hero-copy">
|
|
1491
|
+
<div class="ph-kicker">
|
|
1492
|
+
<ui.Icon name="extensions" size="small" />
|
|
1493
|
+
PLUGIN HUB
|
|
1494
|
+
</div>
|
|
1495
|
+
<Show when={!props.embedded}>
|
|
1496
|
+
<h2 class="ph-title">插件中心</h2>
|
|
1497
|
+
</Show>
|
|
1498
|
+
<p class="ph-sub">
|
|
1499
|
+
浏览社区精选、搜索 npm 上的 OpenCode 插件。安装前先看下载量、README 和主页,确认它到底能做什么。
|
|
1500
|
+
</p>
|
|
1501
|
+
<div class="ph-path" title={state()?.configPath}>
|
|
1502
|
+
配置:<code>{state()?.configPath ?? "读取中…"}</code> · <code>{state()?.configKey ?? "plugins"}[]</code>
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
</section>
|
|
1506
|
+
|
|
1507
|
+
<Show when={note()}>
|
|
1508
|
+
<div class="ph-notice" data-tone={note()!.tone}>
|
|
1509
|
+
<ui.Icon name={noteIcon(note()!.tone)} size="small" />
|
|
1510
|
+
<span>{note()!.text}</span>
|
|
1511
|
+
</div>
|
|
1512
|
+
</Show>
|
|
1513
|
+
|
|
1514
|
+
<Show when={state()?.error}>
|
|
1515
|
+
<div class="ph-notice" data-tone="error">
|
|
1516
|
+
<ui.Icon name="circle-exclamation" size="small" />
|
|
1517
|
+
<span>{state()!.error}</span>
|
|
1518
|
+
</div>
|
|
1519
|
+
</Show>
|
|
1520
|
+
|
|
1521
|
+
<div class="ph-toolbar">
|
|
1522
|
+
<div class="ph-tabs" role="tablist">
|
|
1523
|
+
{tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
|
|
1524
|
+
{tabButton("search", "npm 搜索", "magnifying-glass", searched() ? hits().length : undefined)}
|
|
1525
|
+
{tabButton("installed", "已安装", "checklist", state()?.plugins.length ?? 0, outdated())}
|
|
1526
|
+
</div>
|
|
1527
|
+
<span class="ph-spacer" />
|
|
1528
|
+
<ui.Button
|
|
1529
|
+
variant="ghost"
|
|
1530
|
+
size="small"
|
|
1531
|
+
icon="open-file"
|
|
1532
|
+
busy={isBusy("config")}
|
|
1533
|
+
onClick={() => void openConfig()}
|
|
1534
|
+
>
|
|
1535
|
+
打开配置
|
|
1536
|
+
</ui.Button>
|
|
1537
|
+
<ui.Button
|
|
1538
|
+
variant="ghost"
|
|
1539
|
+
size="small"
|
|
1540
|
+
icon="refresh"
|
|
1541
|
+
busy={isBusy("reload")}
|
|
1542
|
+
onClick={() => void reload()}
|
|
1543
|
+
>
|
|
1544
|
+
重载配置
|
|
1545
|
+
</ui.Button>
|
|
1546
|
+
<ui.Button
|
|
1547
|
+
variant="neutral"
|
|
1548
|
+
size="small"
|
|
1549
|
+
icon="outline-rotate-clockwise"
|
|
1550
|
+
busy={isBusy("restart")}
|
|
1551
|
+
onClick={() => void restartApp()}
|
|
1552
|
+
>
|
|
1553
|
+
彻底重启
|
|
1554
|
+
</ui.Button>
|
|
1555
|
+
<Show when={tab() === "catalog"}>
|
|
1556
|
+
<ui.Button
|
|
1557
|
+
variant="ghost"
|
|
1558
|
+
size="small"
|
|
1559
|
+
icon="refresh"
|
|
1560
|
+
busy={isBusy("catalog")}
|
|
1561
|
+
onClick={() => void loadCatalog(true)}
|
|
1562
|
+
>
|
|
1563
|
+
更新目录
|
|
1564
|
+
</ui.Button>
|
|
1565
|
+
</Show>
|
|
1566
|
+
<Show when={tab() === "search"}>
|
|
1567
|
+
<Show when={searched()}>
|
|
1568
|
+
<ui.Button
|
|
1569
|
+
variant="ghost"
|
|
1570
|
+
size="small"
|
|
1571
|
+
icon="close"
|
|
1572
|
+
onClick={() => {
|
|
1573
|
+
setHits([])
|
|
1574
|
+
setSearched(false)
|
|
1575
|
+
setQuery("")
|
|
1576
|
+
setNote(undefined)
|
|
1577
|
+
}}
|
|
1578
|
+
>
|
|
1579
|
+
清空
|
|
1580
|
+
</ui.Button>
|
|
1581
|
+
</Show>
|
|
1582
|
+
</Show>
|
|
1583
|
+
<Show when={tab() === "installed"}>
|
|
1584
|
+
<ui.Button
|
|
1585
|
+
variant="ghost"
|
|
1586
|
+
size="small"
|
|
1587
|
+
icon="refresh"
|
|
1588
|
+
busy={isBusy("updates")}
|
|
1589
|
+
onClick={() => void checkUpdates()}
|
|
1590
|
+
>
|
|
1591
|
+
检查更新
|
|
1592
|
+
</ui.Button>
|
|
1593
|
+
</Show>
|
|
1594
|
+
</div>
|
|
1595
|
+
|
|
1596
|
+
<Switch>
|
|
1597
|
+
<Match when={tab() === "catalog"}>
|
|
1598
|
+
<section class="ph-panel">
|
|
1599
|
+
<div class="ph-filters">
|
|
1600
|
+
<div class="ph-filter-row">
|
|
1601
|
+
<div class="ph-grow">
|
|
1602
|
+
<ui.TextInput
|
|
1603
|
+
label="筛选社区精选插件"
|
|
1604
|
+
placeholder="按名称、描述或标签筛选,例如 memory / auth / token"
|
|
1605
|
+
type="search"
|
|
1606
|
+
value={filter()}
|
|
1607
|
+
onInput={(value) => {
|
|
1608
|
+
setFilter(value)
|
|
1609
|
+
setLimit(PAGE)
|
|
1610
|
+
}}
|
|
1611
|
+
/>
|
|
1612
|
+
</div>
|
|
1613
|
+
<div class="ph-chips">
|
|
1614
|
+
<For each={TYPE_FILTERS}>
|
|
1615
|
+
{(item) => (
|
|
1616
|
+
<button
|
|
1617
|
+
class="ph-chip"
|
|
1618
|
+
data-active={typeFilter() === item.value}
|
|
1619
|
+
onClick={() => {
|
|
1620
|
+
setTypeFilter(item.value)
|
|
1621
|
+
setLimit(PAGE)
|
|
1622
|
+
}}
|
|
1623
|
+
>
|
|
1624
|
+
{item.label}
|
|
1625
|
+
<span class="ph-chip-count">{typeCounts()[item.value]}</span>
|
|
1626
|
+
</button>
|
|
1627
|
+
)}
|
|
1628
|
+
</For>
|
|
1629
|
+
</div>
|
|
1630
|
+
</div>
|
|
1631
|
+
<div class="ph-meta">
|
|
1632
|
+
<span>{SOURCE_LABEL[meta()?.source ?? ""] ?? "加载中…"}</span>
|
|
1633
|
+
<span class="ph-dot" />
|
|
1634
|
+
<span>
|
|
1635
|
+
{catalog().length} 个条目 · {installable()} 个可安装
|
|
1636
|
+
</span>
|
|
1637
|
+
<Show when={meta()?.fetchedAt}>
|
|
1638
|
+
<span class="ph-dot" />
|
|
1639
|
+
<span>更新于 {formatDate(meta()!.fetchedAt)}</span>
|
|
1640
|
+
</Show>
|
|
1641
|
+
</div>
|
|
1642
|
+
</div>
|
|
1643
|
+
|
|
1644
|
+
<Show when={isBusy("catalog") && catalog().length === 0}>
|
|
1645
|
+
<div class="ph-list">
|
|
1646
|
+
<For each={[0, 1, 2, 3]}>{() => <div class="ph-skeleton" />}</For>
|
|
1647
|
+
</div>
|
|
1648
|
+
</Show>
|
|
1649
|
+
|
|
1650
|
+
<Show when={!isBusy("catalog") || catalog().length > 0}>
|
|
1651
|
+
<Show
|
|
1652
|
+
when={visible().length > 0}
|
|
1653
|
+
fallback={
|
|
1654
|
+
<div class="ph-empty">
|
|
1655
|
+
<ui.Icon name="magnifying-glass" size="large" />
|
|
1656
|
+
<strong>没有匹配的插件</strong>
|
|
1657
|
+
<span>试试别的关键词,或者清除筛选条件。</span>
|
|
1658
|
+
<ui.Button
|
|
1659
|
+
variant="ghost"
|
|
1660
|
+
size="small"
|
|
1661
|
+
onClick={() => {
|
|
1662
|
+
setFilter("")
|
|
1663
|
+
setTypeFilter("all")
|
|
1664
|
+
}}
|
|
1665
|
+
>
|
|
1666
|
+
清除筛选
|
|
1667
|
+
</ui.Button>
|
|
1668
|
+
</div>
|
|
1669
|
+
}
|
|
1670
|
+
>
|
|
1671
|
+
<div class="ph-list">
|
|
1672
|
+
<For each={visible().slice(0, limit())}>{(entry) => catalogRow(entry)}</For>
|
|
1673
|
+
</div>
|
|
1674
|
+
<Show when={visible().length > limit()}>
|
|
1675
|
+
<ui.Button variant="ghost" size="small" onClick={() => setLimit((n) => n + PAGE)}>
|
|
1676
|
+
显示更多(还有 {visible().length - limit()} 个)
|
|
1677
|
+
</ui.Button>
|
|
1678
|
+
</Show>
|
|
1679
|
+
</Show>
|
|
1680
|
+
</Show>
|
|
1681
|
+
</section>
|
|
1682
|
+
</Match>
|
|
1683
|
+
|
|
1684
|
+
<Match when={tab() === "search"}>
|
|
1685
|
+
<section class="ph-panel">
|
|
1686
|
+
<div class="ph-searchbar">
|
|
1687
|
+
<div class="ph-grow">
|
|
1688
|
+
<ui.TextInput
|
|
1689
|
+
label="搜索 npm 上的 OpenCode 插件"
|
|
1690
|
+
placeholder="搜索插件,例如 qoder / notify / usage"
|
|
1691
|
+
type="search"
|
|
1692
|
+
value={query()}
|
|
1693
|
+
onInput={setQuery}
|
|
1694
|
+
onSubmit={(value) => void runSearch(value)}
|
|
1695
|
+
/>
|
|
1696
|
+
</div>
|
|
1697
|
+
<ui.Button
|
|
1698
|
+
variant="contrast"
|
|
1699
|
+
icon="magnifying-glass"
|
|
1700
|
+
busy={isBusy("search")}
|
|
1701
|
+
onClick={() => void runSearch(query())}
|
|
1702
|
+
>
|
|
1703
|
+
搜索
|
|
1704
|
+
</ui.Button>
|
|
1705
|
+
</div>
|
|
1706
|
+
|
|
1707
|
+
<div class="ph-meta">
|
|
1708
|
+
<span>直查 npm 上带 opencode-plugin 标签的包,覆盖精选目录之外的长尾;结果未经人工筛选。</span>
|
|
1709
|
+
</div>
|
|
1710
|
+
|
|
1711
|
+
<Show when={!searched()}>
|
|
1712
|
+
<div class="ph-empty">
|
|
1713
|
+
<ui.Icon name="magnifying-glass" size="large" />
|
|
1714
|
+
<strong>搜索 npm 上的 OpenCode 插件</strong>
|
|
1715
|
+
<span>按名称或用途搜索,例如 auth、notify、memory。点开任意结果可以看 README、下载量和主页。</span>
|
|
1716
|
+
<div class="ph-chips">
|
|
1717
|
+
<For each={SUGGESTIONS}>
|
|
1718
|
+
{(text) => (
|
|
1719
|
+
<button class="ph-chip" onClick={() => void runSearch(text)}>
|
|
1720
|
+
{text}
|
|
1721
|
+
</button>
|
|
1722
|
+
)}
|
|
1723
|
+
</For>
|
|
1724
|
+
</div>
|
|
1725
|
+
</div>
|
|
1726
|
+
</Show>
|
|
1727
|
+
|
|
1728
|
+
<Show when={searched()}>
|
|
1729
|
+
<div class="ph-resultbar">
|
|
1730
|
+
<h3 class="ph-h3">
|
|
1731
|
+
搜索结果 <span class="ph-count">{sortedHits().length}</span>
|
|
1732
|
+
</h3>
|
|
1733
|
+
<div class="ph-sort">
|
|
1734
|
+
<For each={SORTS}>
|
|
1735
|
+
{(item) => (
|
|
1736
|
+
<button data-active={sort() === item.value} onClick={() => setSort(item.value)}>
|
|
1737
|
+
{item.label}
|
|
1738
|
+
</button>
|
|
1739
|
+
)}
|
|
1740
|
+
</For>
|
|
1741
|
+
</div>
|
|
1742
|
+
</div>
|
|
1743
|
+
|
|
1744
|
+
<Show when={isBusy("search") && hits().length === 0}>
|
|
1745
|
+
<div class="ph-list">
|
|
1746
|
+
<For each={[0, 1, 2, 3]}>{() => <div class="ph-skeleton" />}</For>
|
|
1747
|
+
</div>
|
|
1748
|
+
</Show>
|
|
1749
|
+
|
|
1750
|
+
<Show when={!isBusy("search") || hits().length > 0}>
|
|
1751
|
+
<Show
|
|
1752
|
+
when={sortedHits().length > 0}
|
|
1753
|
+
fallback={
|
|
1754
|
+
<div class="ph-empty">
|
|
1755
|
+
<ui.Icon name="magnifying-glass" size="large" />
|
|
1756
|
+
<strong>没有找到匹配的插件</strong>
|
|
1757
|
+
<span>换个关键词试试,或者清空搜索查看建议。</span>
|
|
1758
|
+
</div>
|
|
1759
|
+
}
|
|
1760
|
+
>
|
|
1761
|
+
<div class="ph-list">
|
|
1762
|
+
<For each={sortedHits()}>{(hit) => searchRow(hit)}</For>
|
|
1763
|
+
</div>
|
|
1764
|
+
</Show>
|
|
1765
|
+
</Show>
|
|
1766
|
+
</Show>
|
|
1767
|
+
</section>
|
|
1768
|
+
</Match>
|
|
1769
|
+
|
|
1770
|
+
<Match when={tab() === "installed"}>
|
|
1771
|
+
<section class="ph-panel">
|
|
1772
|
+
<div class="ph-resultbar">
|
|
1773
|
+
<h3 class="ph-h3">
|
|
1774
|
+
已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
|
|
1775
|
+
</h3>
|
|
1776
|
+
<Show when={checked()}>
|
|
1777
|
+
<span class="ph-meta">
|
|
1778
|
+
检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
|
|
1779
|
+
</span>
|
|
1780
|
+
</Show>
|
|
1781
|
+
</div>
|
|
1782
|
+
<Show
|
|
1783
|
+
when={(state()?.plugins.length ?? 0) > 0}
|
|
1784
|
+
fallback={
|
|
1785
|
+
<div class="ph-empty">
|
|
1786
|
+
<ui.Icon name="extensions" size="large" />
|
|
1787
|
+
<strong>还没有安装插件</strong>
|
|
1788
|
+
<span>去「精选目录」看看社区推荐,或者在「npm 搜索」里找长尾插件。</span>
|
|
1789
|
+
</div>
|
|
1790
|
+
}
|
|
1791
|
+
>
|
|
1792
|
+
<div class="ph-list">
|
|
1793
|
+
<For each={state()?.plugins ?? []}>{(entry) => installedRow(entry)}</For>
|
|
1794
|
+
</div>
|
|
1795
|
+
</Show>
|
|
1796
|
+
</section>
|
|
1797
|
+
</Match>
|
|
1798
|
+
</Switch>
|
|
1799
|
+
|
|
1800
|
+
<footer class="ph-footer">
|
|
1801
|
+
<div class="ph-footer-note">
|
|
1802
|
+
安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点上方工具栏的「彻底重启」,应用会自动退出并重新打开。
|
|
1803
|
+
</div>
|
|
1804
|
+
</footer>
|
|
1805
|
+
</div>
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/** The standalone x-large dialog opened from the titlebar pill and the ⌘⌥P shortcut. */
|
|
1810
|
+
export function PluginHubDialog(props: { ctx: Context; admin: AdminClient; onClose: () => void }) {
|
|
1811
|
+
const ui = props.ctx.ui
|
|
1812
|
+
return (
|
|
1813
|
+
<ui.Dialog
|
|
1814
|
+
title="插件中心"
|
|
1815
|
+
size="x-large"
|
|
1816
|
+
footer={<ui.Button onClick={props.onClose}>关闭</ui.Button>}
|
|
1817
|
+
>
|
|
1818
|
+
<PluginHubPage ctx={props.ctx} admin={props.admin} embedded />
|
|
1819
|
+
</ui.Dialog>
|
|
1820
|
+
)
|
|
1821
|
+
}
|