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/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={`${data()!.weekly.toLocaleString()} 次`}>{formatCount(data()!.weekly)}</b>
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={`${data()!.monthly.toLocaleString()} 次`}>{formatCount(data()!.monthly)}</b>
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
- setChecked(true)
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 连接失败),其余${stale > 0 ? `有 ${stale} 个可更新` : "已是最新"}`
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}。点工具栏的「彻底重启」后生效`, "success")
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}。点工具栏的「彻底重启」后生效`, "success")
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 class="ph-tab" data-active={tab() === id} onClick={() => switchTab(id)}>
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={`周下载 ${hit.weekly.toLocaleString()} 次`}>
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={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
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={setQuery}
1800
- onSubmit={(value) => void runSearch(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={sortedHits()}>{(hit) => searchRow(hit)}</For>
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
- 检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
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
- <strong>没有可更新的插件</strong>
1919
- <span>已安装的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
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
  })