opencode-plugin-hub 0.1.4 → 0.3.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.md +3 -1
- package/dist/gui-experimental.cjs +44 -17
- package/package.json +2 -2
- package/src/contract.ts +22 -0
- package/src/gui/admin.ts +127 -9
- package/src/gui/page.tsx +245 -50
- package/src/gui.tsx +2 -0
package/src/gui/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createMemo, createSignal, For, onMount, Show, Switch, Match } from "solid-js"
|
|
1
|
+
import { createEffect, createMemo, createSignal, For, onMount, Show, Switch, Match } from "solid-js"
|
|
2
2
|
import type { Context, IconName, IpcClient } from "@opencode/plugin/gui"
|
|
3
3
|
import type { Admin } from "../contract"
|
|
4
4
|
|
|
@@ -10,6 +10,7 @@ type CatalogEntry = Awaited<ReturnType<AdminClient["browse"]>>["entries"][number
|
|
|
10
10
|
type CatalogResult = Awaited<ReturnType<AdminClient["browse"]>>
|
|
11
11
|
type UpdateInfo = Awaited<ReturnType<AdminClient["updates"]>>["items"][number]
|
|
12
12
|
type PackageDetail = Awaited<ReturnType<AdminClient["detail"]>>
|
|
13
|
+
type RuntimeInfo = Awaited<ReturnType<AdminClient["runtime"]>>["items"][number]
|
|
13
14
|
|
|
14
15
|
type Tab = "catalog" | "search" | "installed"
|
|
15
16
|
type Tone = "info" | "success" | "warning" | "error"
|
|
@@ -46,10 +47,18 @@ const SUGGESTIONS = ["qoder", "notify", "memory", "auth", "usage", "theme", "mcp
|
|
|
46
47
|
const COMPACT = new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 })
|
|
47
48
|
|
|
48
49
|
function formatCount(value: number): string {
|
|
50
|
+
// -1 marks "the download API could not be asked": unknown, never rendered as 0.
|
|
51
|
+
if (value < 0) return "—"
|
|
49
52
|
if (!Number.isFinite(value) || value <= 0) return "0"
|
|
50
53
|
return COMPACT.format(value)
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/** The count is genuinely unknown (API failed), so the tooltip must not claim a number. */
|
|
57
|
+
function countTitle(value: number, kind: string): string | undefined {
|
|
58
|
+
if (value < 0) return `${kind}暂不可用`
|
|
59
|
+
return `${kind} ${value.toLocaleString()} 次`
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
function formatBytes(value: number): string {
|
|
54
63
|
if (!Number.isFinite(value) || value <= 0) return "—"
|
|
55
64
|
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`
|
|
@@ -241,6 +250,21 @@ const CSS = `
|
|
|
241
250
|
background: color-mix(in srgb, var(--ph-warning) 7%, transparent);
|
|
242
251
|
}
|
|
243
252
|
.ph-notice span { flex: 1; min-width: 0; }
|
|
253
|
+
.ph-notice-close {
|
|
254
|
+
flex: 0 0 auto;
|
|
255
|
+
display: grid;
|
|
256
|
+
place-items: center;
|
|
257
|
+
width: 22px;
|
|
258
|
+
height: 22px;
|
|
259
|
+
padding: 0;
|
|
260
|
+
border: 0;
|
|
261
|
+
border-radius: 6px;
|
|
262
|
+
background: transparent;
|
|
263
|
+
color: inherit;
|
|
264
|
+
opacity: 0.55;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
}
|
|
267
|
+
.ph-notice-close:hover { opacity: 1; background: color-mix(in srgb, currentColor 10%, transparent); }
|
|
244
268
|
|
|
245
269
|
/* toolbar and tabs */
|
|
246
270
|
.ph-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
@@ -498,6 +522,18 @@ const CSS = `
|
|
|
498
522
|
border-color: color-mix(in srgb, var(--ph-danger) 30%, transparent);
|
|
499
523
|
background: color-mix(in srgb, var(--ph-danger) 9%, transparent);
|
|
500
524
|
}
|
|
525
|
+
/* 运行状态徽章:加载成功用成功色,失败用危险色,待重启/未知保持中性——
|
|
526
|
+
待生效不是错误,不能一进列表就满屏红色。 */
|
|
527
|
+
.ph-badge[data-runtime="loaded"] {
|
|
528
|
+
color: var(--ph-success);
|
|
529
|
+
border-color: color-mix(in srgb, var(--ph-success) 30%, transparent);
|
|
530
|
+
background: color-mix(in srgb, var(--ph-success) 9%, transparent);
|
|
531
|
+
}
|
|
532
|
+
.ph-badge[data-runtime="failed"] {
|
|
533
|
+
color: var(--ph-danger);
|
|
534
|
+
border-color: color-mix(in srgb, var(--ph-danger) 30%, transparent);
|
|
535
|
+
background: color-mix(in srgb, var(--ph-danger) 9%, transparent);
|
|
536
|
+
}
|
|
501
537
|
.ph-chips { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
502
538
|
.ph-chip {
|
|
503
539
|
display: inline-flex;
|
|
@@ -845,11 +881,11 @@ function DetailDialog(props: DetailDialogProps) {
|
|
|
845
881
|
<Show when={data()!.version}>
|
|
846
882
|
<div class="ph-detail-stats">
|
|
847
883
|
<div class="ph-detail-stat">
|
|
848
|
-
<b title={
|
|
884
|
+
<b title={countTitle(data()!.weekly, "周下载")}>{formatCount(data()!.weekly)}</b>
|
|
849
885
|
<span>周下载</span>
|
|
850
886
|
</div>
|
|
851
887
|
<div class="ph-detail-stat">
|
|
852
|
-
<b title={
|
|
888
|
+
<b title={countTitle(data()!.monthly, "月下载")}>{formatCount(data()!.monthly)}</b>
|
|
853
889
|
<span>月下载</span>
|
|
854
890
|
</div>
|
|
855
891
|
<div class="ph-detail-stat">
|
|
@@ -961,10 +997,32 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
961
997
|
const [query, setQuery] = createSignal("")
|
|
962
998
|
const [searched, setSearched] = createSignal(false)
|
|
963
999
|
const [sort, setSort] = createSignal<Sort>("relevance")
|
|
1000
|
+
/** 搜索结果分批渲染:50 张卡一次挂载会拖慢首屏。 */
|
|
1001
|
+
const [hitLimit, setHitLimit] = createSignal(12)
|
|
964
1002
|
|
|
965
1003
|
const [updates, setUpdates] = createSignal<Map<string, UpdateInfo>>(new Map())
|
|
966
1004
|
const [checked, setChecked] = createSignal(false)
|
|
967
1005
|
const [staleOnly, setStaleOnly] = createSignal(false)
|
|
1006
|
+
// 运行事实(服务端已加载/失败/未知),与“已在配置中”分开表达。
|
|
1007
|
+
const [runtime, setRuntime] = createSignal<Map<string, RuntimeInfo>>(new Map())
|
|
1008
|
+
const [runtimeError, setRuntimeError] = createSignal("")
|
|
1009
|
+
/** 配置与上一次已知运行状态不一致的条目数:安装/移除后等待重启生效的数量。 */
|
|
1010
|
+
const pendingCount = createMemo(() => {
|
|
1011
|
+
const map = runtime()
|
|
1012
|
+
if (map.size === 0) return 0 // 从未拿到过运行事实:不虚构“待生效”数量
|
|
1013
|
+
return (state()?.plugins ?? []).filter((entry) => {
|
|
1014
|
+
const info = map.get(entry.spec)
|
|
1015
|
+
// unknown:服务端已应答但没加载它;info 缺失:刚改过配置,尚未重新读取。
|
|
1016
|
+
// failed 是“已加载但报错”,不算等待重启。
|
|
1017
|
+
return !info || info.status === "unknown"
|
|
1018
|
+
}).length
|
|
1019
|
+
})
|
|
1020
|
+
/**
|
|
1021
|
+
* 待生效状态栏只在拿到过运行事实、且确有配置项与运行事实不一致时出现。
|
|
1022
|
+
* 服务端读不到(旧版本后台)时显示未知,不显示虚构的“全部已生效”。
|
|
1023
|
+
*/
|
|
1024
|
+
const hasPendingChanges = () => runtime().size > 0 && pendingCount() > 0
|
|
1025
|
+
const pendingChangesCount = () => pendingCount()
|
|
968
1026
|
|
|
969
1027
|
const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<string>>(new Set())
|
|
970
1028
|
const [note, setNote] = createSignal<Note>()
|
|
@@ -999,6 +1057,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
999
1057
|
}
|
|
1000
1058
|
}
|
|
1001
1059
|
|
|
1060
|
+
/** 读取服务端运行事实;读不到时保留 unknown,不假装全部已加载。 */
|
|
1061
|
+
const loadRuntime = async () => {
|
|
1062
|
+
try {
|
|
1063
|
+
const result = await props.admin.runtime()
|
|
1064
|
+
const map = new Map<string, RuntimeInfo>()
|
|
1065
|
+
for (const item of result.items) map.set(item.spec, item)
|
|
1066
|
+
setRuntime(map)
|
|
1067
|
+
setRuntimeError(result.error)
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
// Older running builds have no runtime method: keep the honest unknown state.
|
|
1070
|
+
setRuntime(new Map())
|
|
1071
|
+
setRuntimeError((state()) ? readableError(error) : "")
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1002
1075
|
/** Fills the update map: keyed by config spec, so directory plugins match their own row. */
|
|
1003
1076
|
const checkUpdates = async (silent = false) => {
|
|
1004
1077
|
await track("updates", async () => {
|
|
@@ -1007,7 +1080,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1007
1080
|
const map = new Map<string, UpdateInfo>()
|
|
1008
1081
|
for (const item of result.items) map.set(item.spec, item)
|
|
1009
1082
|
setUpdates(map)
|
|
1010
|
-
|
|
1083
|
+
// 整体失败时 items 为空:这不是“检查完成、全部最新”,不能标记为已检查。
|
|
1084
|
+
setChecked(result.error === "" && result.items.length > 0)
|
|
1011
1085
|
if (silent) return
|
|
1012
1086
|
const stale = result.items.filter((item) => item.hasUpdate).length
|
|
1013
1087
|
const failed = result.items.filter((item) => item.error).length
|
|
@@ -1015,10 +1089,10 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1015
1089
|
const message = result.error
|
|
1016
1090
|
? result.error
|
|
1017
1091
|
: failed > 0
|
|
1018
|
-
? `${failed} 个插件未能检查(registry
|
|
1092
|
+
? `${failed} 个插件未能检查(registry 连接失败)${stale > 0 ? `,其余 ${stale} 个可更新` : ""}`
|
|
1019
1093
|
: stale > 0
|
|
1020
1094
|
? `发现 ${stale} 个可更新的插件`
|
|
1021
|
-
: "
|
|
1095
|
+
: "已检查的插件均为最新版本"
|
|
1022
1096
|
notify(
|
|
1023
1097
|
message,
|
|
1024
1098
|
result.error ? "error" : failed > 0 ? "warning" : stale > 0 ? "info" : "success",
|
|
@@ -1048,18 +1122,42 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1048
1122
|
|
|
1049
1123
|
const runSearch = async (text: string) => {
|
|
1050
1124
|
setQuery(text)
|
|
1125
|
+
// 只接受最后一次请求的结果:较早的关键词晚返回时不得覆盖新结果。
|
|
1126
|
+
const seq = ++searchSeq
|
|
1051
1127
|
await track("search", async () => {
|
|
1052
1128
|
try {
|
|
1053
1129
|
const result = await props.admin.search({ query: text })
|
|
1130
|
+
if (seq !== searchSeq) return
|
|
1054
1131
|
setHits(result.hits)
|
|
1055
1132
|
setSearched(true)
|
|
1056
1133
|
if (result.error) notify(result.error, "error")
|
|
1057
1134
|
} catch (error) {
|
|
1135
|
+
if (seq !== searchSeq) return
|
|
1058
1136
|
notify(readableError(error), "error")
|
|
1059
1137
|
}
|
|
1060
1138
|
})
|
|
1061
1139
|
}
|
|
1062
1140
|
|
|
1141
|
+
/** 输入防抖 450ms 自动搜索;同一个词不重复请求。 */
|
|
1142
|
+
let searchTimer: number | undefined
|
|
1143
|
+
let lastSearch = ""
|
|
1144
|
+
let searchSeq = 0
|
|
1145
|
+
const scheduleSearch = (text: string) => {
|
|
1146
|
+
setQuery(text)
|
|
1147
|
+
if (searchTimer) window.clearTimeout(searchTimer)
|
|
1148
|
+
const trimmed = text.trim()
|
|
1149
|
+
if (!trimmed) {
|
|
1150
|
+
// 清空即失效:排队中的旧结果不允许再回填。
|
|
1151
|
+
searchSeq += 1
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
searchTimer = window.setTimeout(() => {
|
|
1155
|
+
if (trimmed === lastSearch) return
|
|
1156
|
+
lastSearch = trimmed
|
|
1157
|
+
void runSearch(trimmed)
|
|
1158
|
+
}, 450)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1063
1161
|
const install = async (name: string, version = "") => {
|
|
1064
1162
|
await track(`install:${name}`, async () => {
|
|
1065
1163
|
try {
|
|
@@ -1069,7 +1167,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1069
1167
|
notify(result.message, "error")
|
|
1070
1168
|
return
|
|
1071
1169
|
}
|
|
1072
|
-
notify(`${result.message}
|
|
1170
|
+
notify(`${result.message}。配置需重启后生效:点「彻底重启 OpenCode」完成加载`, "success")
|
|
1073
1171
|
const latest = version || hits().find((hit) => hit.name === name)?.version || ""
|
|
1074
1172
|
setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
|
|
1075
1173
|
setHits((prev) =>
|
|
@@ -1077,7 +1175,10 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1077
1175
|
hit.name === name ? { ...hit, installed: true, current: latest, hasUpdate: false } : hit,
|
|
1078
1176
|
),
|
|
1079
1177
|
)
|
|
1178
|
+
// 配置变了:之前的运行事实已过期,重新读取;新 spec 在服务端列表里
|
|
1179
|
+
// 出现之前,卡片会如实显示“待重启生效”。
|
|
1080
1180
|
await checkUpdates(true)
|
|
1181
|
+
void loadRuntime()
|
|
1081
1182
|
} catch (error) {
|
|
1082
1183
|
notify(readableError(error), "error")
|
|
1083
1184
|
}
|
|
@@ -1100,7 +1201,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1100
1201
|
notify(result.message, "error")
|
|
1101
1202
|
return
|
|
1102
1203
|
}
|
|
1103
|
-
notify(`${result.message}
|
|
1204
|
+
notify(`${result.message}。配置需重启后生效:点「彻底重启 OpenCode」完成移除`, "success")
|
|
1104
1205
|
setCatalog((prev) =>
|
|
1105
1206
|
prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
|
|
1106
1207
|
)
|
|
@@ -1114,6 +1215,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1114
1215
|
next.delete(entry.spec)
|
|
1115
1216
|
return next
|
|
1116
1217
|
})
|
|
1218
|
+
void loadRuntime()
|
|
1117
1219
|
} catch (error) {
|
|
1118
1220
|
notify(readableError(error), "error")
|
|
1119
1221
|
}
|
|
@@ -1222,13 +1324,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1222
1324
|
|
|
1223
1325
|
const sortedHits = createMemo(() => {
|
|
1224
1326
|
const list = [...hits()]
|
|
1225
|
-
if (sort() === "downloads") list.sort((a, b) => b.weekly - a.weekly)
|
|
1327
|
+
if (sort() === "downloads") list.sort((a, b) => b.weekly - a.weekly) // -1 (unknown) sinks to the end
|
|
1226
1328
|
else if (sort() === "recent") list.sort((a, b) => (b.published || "").localeCompare(a.published || ""))
|
|
1227
1329
|
return list
|
|
1228
1330
|
})
|
|
1331
|
+
const visibleHits = createMemo(() => sortedHits().slice(0, hitLimit()))
|
|
1332
|
+
// 结果集合变化(新搜索、清空)后回到首批;副作用放在 createEffect 而不是 memo 里。
|
|
1333
|
+
createEffect(() => {
|
|
1334
|
+
void hits()
|
|
1335
|
+
setHitLimit(12)
|
|
1336
|
+
})
|
|
1229
1337
|
|
|
1230
1338
|
const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
|
|
1231
1339
|
const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
|
|
1340
|
+
// 未能检查的条目数:失败不能被总结成“全部已是最新”。
|
|
1341
|
+
const uncheckedCount = createMemo(() => [...updates().values()].filter((item) => item.error).length)
|
|
1232
1342
|
const visiblePlugins = createMemo(() => {
|
|
1233
1343
|
const plugins = state()?.plugins ?? []
|
|
1234
1344
|
if (!staleOnly()) return plugins
|
|
@@ -1250,13 +1360,32 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1250
1360
|
}
|
|
1251
1361
|
|
|
1252
1362
|
onMount(() => {
|
|
1363
|
+
// refresh 先行保证「已安装」不依赖目录拉取的成败;loadCatalog 成功后还会再同步一次。
|
|
1253
1364
|
void refresh()
|
|
1254
1365
|
void loadCatalog(false)
|
|
1255
1366
|
void checkUpdates(true)
|
|
1367
|
+
void loadRuntime()
|
|
1256
1368
|
})
|
|
1257
1369
|
|
|
1258
1370
|
const tabButton = (id: Tab, label: string, icon: IconName, count?: number | string, alert?: number) => (
|
|
1259
|
-
<button
|
|
1371
|
+
<button
|
|
1372
|
+
class="ph-tab"
|
|
1373
|
+
role="tab"
|
|
1374
|
+
id={`ph-tab-${id}`}
|
|
1375
|
+
aria-selected={tab() === id}
|
|
1376
|
+
aria-controls="ph-tabpanel"
|
|
1377
|
+
data-active={tab() === id}
|
|
1378
|
+
onClick={() => switchTab(id)}
|
|
1379
|
+
onKeyDown={(event) => {
|
|
1380
|
+
// 左右方向键在页签间移动,符合 tablist 的键盘约定。
|
|
1381
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
|
|
1382
|
+
event.preventDefault()
|
|
1383
|
+
const order: Tab[] = ["catalog", "search", "installed"]
|
|
1384
|
+
const index = order.indexOf(tab())
|
|
1385
|
+
const next = event.key === "ArrowRight" ? (index + 1) % order.length : (index + order.length - 1) % order.length
|
|
1386
|
+
switchTab(order[next]!)
|
|
1387
|
+
}}
|
|
1388
|
+
>
|
|
1260
1389
|
<ui.Icon name={icon} size="small" />
|
|
1261
1390
|
{label}
|
|
1262
1391
|
<Show when={count !== undefined && count !== ""}>
|
|
@@ -1439,12 +1568,12 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1439
1568
|
>
|
|
1440
1569
|
<div class="ph-desc ph-clamp">{hit.description || "—"}</div>
|
|
1441
1570
|
<div class="ph-meta">
|
|
1442
|
-
<span title={
|
|
1571
|
+
<span title={countTitle(hit.weekly, "周下载")}>
|
|
1443
1572
|
<ui.Icon name="download" size="small" />
|
|
1444
1573
|
{formatCount(hit.weekly)}/周
|
|
1445
1574
|
</span>
|
|
1446
1575
|
<span class="ph-dot" />
|
|
1447
|
-
<span title={
|
|
1576
|
+
<span title={countTitle(hit.monthly, "月下载")}>{formatCount(hit.monthly)}/月</span>
|
|
1448
1577
|
<Show when={hit.publisher}>
|
|
1449
1578
|
<span class="ph-dot" />
|
|
1450
1579
|
<span>by {hit.publisher}</span>
|
|
@@ -1513,6 +1642,26 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1513
1642
|
const busy = () => isBusy(`install:${entry.name}`)
|
|
1514
1643
|
const current = () => info()?.current || entry.version
|
|
1515
1644
|
const unchecked = () => (info()?.error ?? "") !== ""
|
|
1645
|
+
// 运行事实:服务端已加载 / 加载失败 / 未知(没拿到运行信息或刚改过配置)。
|
|
1646
|
+
const runtimeInfo = () => runtime().get(entry.spec)
|
|
1647
|
+
const runtimeStatus = () => {
|
|
1648
|
+
const info = runtimeInfo()
|
|
1649
|
+
if (!info) return runtimeError() ? "unknown" : "pending"
|
|
1650
|
+
if (info.status === "failed") return "failed"
|
|
1651
|
+
if (info.status === "loaded") return "loaded"
|
|
1652
|
+
// 服务端已应答但列表里没有:配置已写入、尚未被加载;应答不可用才是真正的“未验证”。
|
|
1653
|
+
return info.available ? "pending" : "unknown"
|
|
1654
|
+
}
|
|
1655
|
+
const runtimeLabel = () =>
|
|
1656
|
+
runtimeStatus() === "loaded"
|
|
1657
|
+
? runtimeInfo()!.runtimeVersion
|
|
1658
|
+
? `运行中 v${runtimeInfo()!.runtimeVersion}`
|
|
1659
|
+
: "运行中"
|
|
1660
|
+
: runtimeStatus() === "failed"
|
|
1661
|
+
? "加载失败"
|
|
1662
|
+
: runtimeStatus() === "pending"
|
|
1663
|
+
? "待重启生效"
|
|
1664
|
+
: "运行状态未验证"
|
|
1516
1665
|
return (
|
|
1517
1666
|
<article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
|
|
1518
1667
|
<div class="ph-card-main">
|
|
@@ -1525,6 +1674,9 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1525
1674
|
<span class="ph-pkg">{entry.spec}</span>
|
|
1526
1675
|
</div>
|
|
1527
1676
|
<div class="ph-chips">
|
|
1677
|
+
<span class="ph-badge" data-runtime={runtimeStatus()} title={runtimeLabel()}>
|
|
1678
|
+
{runtimeLabel()}
|
|
1679
|
+
</span>
|
|
1528
1680
|
<Show when={stale()}>
|
|
1529
1681
|
<span class="ph-badge" data-tone="accent">
|
|
1530
1682
|
可更新 → v{info()!.latest}
|
|
@@ -1624,6 +1776,9 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1624
1776
|
<div class="ph-notice" data-tone={note()!.tone}>
|
|
1625
1777
|
<ui.Icon name={noteIcon(note()!.tone)} size="small" />
|
|
1626
1778
|
<span>{note()!.text}</span>
|
|
1779
|
+
<button type="button" class="ph-notice-close" aria-label="关闭提示" onClick={() => setNote(undefined)}>
|
|
1780
|
+
<ui.Icon name="close" size="small" />
|
|
1781
|
+
</button>
|
|
1627
1782
|
</div>
|
|
1628
1783
|
</Show>
|
|
1629
1784
|
|
|
@@ -1634,6 +1789,23 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1634
1789
|
</div>
|
|
1635
1790
|
</Show>
|
|
1636
1791
|
|
|
1792
|
+
{/* 待生效变更常驻在工具栏上方,直到重启完成;不能只在安装后提示一次。 */}
|
|
1793
|
+
<Show when={runtimeError()}>
|
|
1794
|
+
<div class="ph-notice" data-tone="warning">
|
|
1795
|
+
<ui.Icon name="circle-exclamation" size="small" />
|
|
1796
|
+
<span>{runtimeError()}。重启后重新打开插件中心会再读取。</span>
|
|
1797
|
+
</div>
|
|
1798
|
+
</Show>
|
|
1799
|
+
<Show when={hasPendingChanges()}>
|
|
1800
|
+
<div class="ph-notice" data-tone="info">
|
|
1801
|
+
<ui.Icon name="outline-rotate-clockwise" size="small" />
|
|
1802
|
+
<span>有 {pendingChangesCount()} 项配置变更待重启生效。插件在应用启动时加载,重启后才能运行新配置。</span>
|
|
1803
|
+
<ui.Button size="small" variant="ghost" icon="outline-rotate-clockwise" onClick={() => void restartApp()}>
|
|
1804
|
+
彻底重启 OpenCode
|
|
1805
|
+
</ui.Button>
|
|
1806
|
+
</div>
|
|
1807
|
+
</Show>
|
|
1808
|
+
|
|
1637
1809
|
<div class="ph-toolbar">
|
|
1638
1810
|
<div class="ph-tabs" role="tablist">
|
|
1639
1811
|
{tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
|
|
@@ -1641,33 +1813,6 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1641
1813
|
{tabButton("installed", "已安装", "checklist", state()?.plugins.length ?? 0, outdated())}
|
|
1642
1814
|
</div>
|
|
1643
1815
|
<span class="ph-spacer" />
|
|
1644
|
-
<ui.Button
|
|
1645
|
-
variant="ghost"
|
|
1646
|
-
size="small"
|
|
1647
|
-
icon="open-file"
|
|
1648
|
-
busy={isBusy("config")}
|
|
1649
|
-
onClick={() => void openConfig()}
|
|
1650
|
-
>
|
|
1651
|
-
打开配置
|
|
1652
|
-
</ui.Button>
|
|
1653
|
-
<ui.Button
|
|
1654
|
-
variant="ghost"
|
|
1655
|
-
size="small"
|
|
1656
|
-
icon="refresh"
|
|
1657
|
-
busy={isBusy("reload")}
|
|
1658
|
-
onClick={() => void reload()}
|
|
1659
|
-
>
|
|
1660
|
-
重载配置
|
|
1661
|
-
</ui.Button>
|
|
1662
|
-
<ui.Button
|
|
1663
|
-
variant="neutral"
|
|
1664
|
-
size="small"
|
|
1665
|
-
icon="outline-rotate-clockwise"
|
|
1666
|
-
busy={isBusy("restart")}
|
|
1667
|
-
onClick={() => void restartApp()}
|
|
1668
|
-
>
|
|
1669
|
-
彻底重启
|
|
1670
|
-
</ui.Button>
|
|
1671
1816
|
<Show when={tab() === "catalog"}>
|
|
1672
1817
|
<ui.Button
|
|
1673
1818
|
variant="ghost"
|
|
@@ -1690,6 +1835,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1690
1835
|
setSearched(false)
|
|
1691
1836
|
setQuery("")
|
|
1692
1837
|
setNote(undefined)
|
|
1838
|
+
searchSeq += 1 // 丢弃仍在路上的搜索结果
|
|
1693
1839
|
}}
|
|
1694
1840
|
>
|
|
1695
1841
|
清空
|
|
@@ -1707,12 +1853,34 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1707
1853
|
检查更新
|
|
1708
1854
|
</ui.Button>
|
|
1709
1855
|
</Show>
|
|
1856
|
+
{/* 低频管理操作收进溢出菜单,工具栏只留当前任务需要的动作 */}
|
|
1857
|
+
<ui.Menu
|
|
1858
|
+
label="更多操作"
|
|
1859
|
+
icon="outline-dots"
|
|
1860
|
+
placement="bottom-end"
|
|
1861
|
+
items={[
|
|
1862
|
+
{
|
|
1863
|
+
type: "group",
|
|
1864
|
+
label: "配置",
|
|
1865
|
+
items: [
|
|
1866
|
+
{ label: "打开配置文件", icon: "open-file", onSelect: () => void openConfig() },
|
|
1867
|
+
{ label: "重载配置", icon: "refresh", onSelect: () => void reload() },
|
|
1868
|
+
],
|
|
1869
|
+
},
|
|
1870
|
+
{ type: "separator" },
|
|
1871
|
+
{
|
|
1872
|
+
label: "彻底重启 OpenCode…",
|
|
1873
|
+
icon: "outline-rotate-clockwise",
|
|
1874
|
+
onSelect: () => void restartApp(),
|
|
1875
|
+
},
|
|
1876
|
+
]}
|
|
1877
|
+
/>
|
|
1710
1878
|
</div>
|
|
1711
1879
|
</div>
|
|
1712
1880
|
|
|
1713
1881
|
<Switch>
|
|
1714
1882
|
<Match when={tab() === "catalog"}>
|
|
1715
|
-
<section class="ph-panel">
|
|
1883
|
+
<section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
|
|
1716
1884
|
<div class="ph-filters">
|
|
1717
1885
|
<div class="ph-filter-row">
|
|
1718
1886
|
<div class="ph-grow">
|
|
@@ -1788,16 +1956,20 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1788
1956
|
</Match>
|
|
1789
1957
|
|
|
1790
1958
|
<Match when={tab() === "search"}>
|
|
1791
|
-
<section class="ph-panel">
|
|
1959
|
+
<section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
|
|
1792
1960
|
<div class="ph-searchbar">
|
|
1793
1961
|
<div class="ph-grow">
|
|
1794
1962
|
<ui.TextInput
|
|
1795
1963
|
label="搜索 npm 上的 OpenCode 插件"
|
|
1796
|
-
placeholder="搜索插件,例如 qoder / notify / usage"
|
|
1964
|
+
placeholder="搜索插件,例如 qoder / notify / usage(输入后自动搜索)"
|
|
1797
1965
|
type="search"
|
|
1798
1966
|
value={query()}
|
|
1799
|
-
onInput={
|
|
1800
|
-
onSubmit={(value) =>
|
|
1967
|
+
onInput={scheduleSearch}
|
|
1968
|
+
onSubmit={(value) => {
|
|
1969
|
+
if (searchTimer) window.clearTimeout(searchTimer)
|
|
1970
|
+
lastSearch = value.trim()
|
|
1971
|
+
void runSearch(value)
|
|
1972
|
+
}}
|
|
1801
1973
|
/>
|
|
1802
1974
|
</div>
|
|
1803
1975
|
<ui.Button
|
|
@@ -1865,8 +2037,17 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1865
2037
|
}
|
|
1866
2038
|
>
|
|
1867
2039
|
<div class="ph-list">
|
|
1868
|
-
<For each={
|
|
2040
|
+
<For each={visibleHits()}>{(hit) => searchRow(hit)}</For>
|
|
1869
2041
|
</div>
|
|
2042
|
+
<Show when={sortedHits().length > visibleHits().length}>
|
|
2043
|
+
<ui.Button
|
|
2044
|
+
variant="ghost"
|
|
2045
|
+
size="small"
|
|
2046
|
+
onClick={() => setHitLimit((n) => n + 12)}
|
|
2047
|
+
>
|
|
2048
|
+
显示更多(还有 {sortedHits().length - visibleHits().length} 个)
|
|
2049
|
+
</ui.Button>
|
|
2050
|
+
</Show>
|
|
1870
2051
|
</Show>
|
|
1871
2052
|
</Show>
|
|
1872
2053
|
</Show>
|
|
@@ -1874,7 +2055,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1874
2055
|
</Match>
|
|
1875
2056
|
|
|
1876
2057
|
<Match when={tab() === "installed"}>
|
|
1877
|
-
<section class="ph-panel">
|
|
2058
|
+
<section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
|
|
1878
2059
|
<div class="ph-resultbar">
|
|
1879
2060
|
<h3 class="ph-h3">
|
|
1880
2061
|
已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
|
|
@@ -1896,7 +2077,11 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1896
2077
|
<span class="ph-spacer" />
|
|
1897
2078
|
<Show when={checked()}>
|
|
1898
2079
|
<span class="ph-meta">
|
|
1899
|
-
|
|
2080
|
+
{uncheckedCount() > 0
|
|
2081
|
+
? `已检查 ${Math.max(0, (state()?.plugins.length ?? 0) - uncheckedCount())} 个 · ${uncheckedCount()} 个未能检查${outdated() > 0 ? ` · ${outdated()} 个可更新` : ""}`
|
|
2082
|
+
: outdated() > 0
|
|
2083
|
+
? `检查过 npm registry · ${outdated()} 个可更新`
|
|
2084
|
+
: "检查过 npm registry · 已检查的插件均为最新"}
|
|
1900
2085
|
</span>
|
|
1901
2086
|
</Show>
|
|
1902
2087
|
</div>
|
|
@@ -1915,8 +2100,18 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1915
2100
|
fallback={
|
|
1916
2101
|
<div class="ph-empty">
|
|
1917
2102
|
<ui.Icon name="checklist" size="large" />
|
|
1918
|
-
<
|
|
1919
|
-
|
|
2103
|
+
<Show
|
|
2104
|
+
when={uncheckedCount() > 0}
|
|
2105
|
+
fallback={
|
|
2106
|
+
<>
|
|
2107
|
+
<strong>没有可更新的插件</strong>
|
|
2108
|
+
<span>已检查的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
|
|
2109
|
+
</>
|
|
2110
|
+
}
|
|
2111
|
+
>
|
|
2112
|
+
<strong>有 {uncheckedCount()} 个插件未能检查</strong>
|
|
2113
|
+
<span>部分插件没能连上 npm registry,不能当作“已是最新”。点工具栏的「检查更新」重试。</span>
|
|
2114
|
+
</Show>
|
|
1920
2115
|
<ui.Button variant="ghost" size="small" onClick={() => setStaleOnly(false)}>
|
|
1921
2116
|
显示全部
|
|
1922
2117
|
</ui.Button>
|
|
@@ -1934,7 +2129,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
|
|
|
1934
2129
|
|
|
1935
2130
|
<footer class="ph-footer">
|
|
1936
2131
|
<div class="ph-footer-note">
|
|
1937
|
-
|
|
2132
|
+
安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点右上角「更多操作 → 彻底重启 OpenCode」,应用会自动退出并重新打开。
|
|
1938
2133
|
</div>
|
|
1939
2134
|
</footer>
|
|
1940
2135
|
</div>
|
package/src/gui.tsx
CHANGED
|
@@ -72,6 +72,8 @@ export default Plugin.define({
|
|
|
72
72
|
remove: (input) => admin.remove(input.spec),
|
|
73
73
|
reload: () => admin.reload(ctx.cli.command),
|
|
74
74
|
restart: () => ({ ok: admin.restart() }),
|
|
75
|
+
// 运行事实来自应用自己的 sidecar;读不到时页面如实显示“未知”。
|
|
76
|
+
runtime: () => admin.runtime(ctx.serverEndpoints.get("sidecar")),
|
|
75
77
|
})
|
|
76
78
|
},
|
|
77
79
|
})
|