opencode-plugin-hub 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-hub",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Plugin manager for OpenCode Desktop: browse community plugins, search npm, inspect README and downloads, install and update without editing JSON.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/contract.ts CHANGED
@@ -65,7 +65,9 @@ export const PackageDetail = Schema.Struct({
65
65
  license: Schema.String,
66
66
  keywords: Schema.Array(Schema.String),
67
67
  maintainers: Schema.Array(Schema.String),
68
+ /** Weekly downloads; `-1` when the download API could not be asked. */
68
69
  weekly: Schema.Number,
70
+ /** Monthly downloads; `-1` when the download API could not be asked. */
69
71
  monthly: Schema.Number,
70
72
  /** Publish date of the latest version, ISO 8601. */
71
73
  published: Schema.String,
@@ -156,6 +158,25 @@ export const Mutation = Schema.Struct({
156
158
  state: HubState,
157
159
  })
158
160
 
161
+ /** One configured plugin's runtime truth as the running server reports it. */
162
+ export const RuntimeInfo = Schema.Struct({
163
+ spec: Schema.String,
164
+ name: Schema.String,
165
+ /** "loaded" (server runs it), "failed" (server reports an error) or "unknown" (no runtime answer). */
166
+ status: Schema.Union([Schema.Literal("loaded"), Schema.Literal("failed"), Schema.Literal("unknown")]),
167
+ /** Version the running server loaded, empty when unknown. */
168
+ runtimeVersion: Schema.String,
169
+ /** The server's failure message for a plugin that failed to load. */
170
+ error: Schema.String,
171
+ /** True when the server itself could not be asked; per-plugin status stays "unknown". */
172
+ available: Schema.Boolean,
173
+ })
174
+
175
+ export const RuntimeResult = Schema.Struct({
176
+ items: Schema.Array(RuntimeInfo),
177
+ error: Schema.String,
178
+ })
179
+
159
180
  export const Target = Schema.Struct({
160
181
  spec: Schema.String,
161
182
  version: Schema.String,
@@ -184,5 +205,6 @@ export const Admin = Ipc.define({
184
205
  remove: { input: Target, output: Mutation },
185
206
  reload: { output: Mutation },
186
207
  restart: { output: Opened },
208
+ runtime: { output: RuntimeResult },
187
209
  },
188
210
  })
package/src/gui/admin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process"
2
2
  import { homedir, tmpdir } from "node:os"
3
3
  import { basename, join } from "node:path"
4
- import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
4
+ import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
5
5
  import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
6
6
 
7
7
  export interface InstalledEntryValue {
@@ -58,7 +58,9 @@ export interface PackageDetailValue {
58
58
  license: string
59
59
  keywords: string[]
60
60
  maintainers: string[]
61
+ /** Weekly downloads; `-1` when the download API could not be asked (unknown, not zero). */
61
62
  weekly: number
63
+ /** Monthly downloads; `-1` when the download API could not be asked (unknown, not zero). */
62
64
  monthly: number
63
65
  published: string
64
66
  created: string
@@ -379,13 +381,40 @@ export async function updates(): Promise<UpdatesResultValue> {
379
381
  /**
380
382
  * Edits one value in place, so the user's comments, ordering and indentation survive on every other line.
381
383
  * `path` is a jsonc path such as `["plugins", 2]`.
384
+ *
385
+ * All config mutations funnel through this function while holding a module-level
386
+ * queue: two overlapping edits (two cards, or the settings page and the dialog)
387
+ * each re-read the file inside their turn, so neither clobbers the other.
388
+ * Writes land via tmp-file + rename so a crash cannot leave a half-written config.
382
389
  */
390
+ let configQueue: Promise<unknown> = Promise.resolve()
391
+ function enqueue<T>(task: () => Promise<T>): Promise<T> {
392
+ const next = configQueue.then(task, task)
393
+ configQueue = next.catch(() => undefined)
394
+ return next
395
+ }
396
+
383
397
  async function applyEdit(file: ConfigFile, path: (string | number)[], value: unknown): Promise<HubStateValue> {
384
- const text = file.text.trim().length > 0 ? file.text : "{}\n"
385
- const options = { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" } }
386
- const edits = modify(text, path, value, options)
387
- await writeFile(file.path, applyEdits(text, edits), "utf8")
388
- return snapshot()
398
+ return enqueue(async () => {
399
+ // Re-read inside the queue: the file may have changed since the caller read it.
400
+ const fresh = await readConfig()
401
+ const text = fresh.text.trim().length > 0 ? fresh.text : "{}\n"
402
+ const options = { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" } }
403
+ const edits = modify(text, path, value, options)
404
+ await atomicWrite(fresh.path, applyEdits(text, edits))
405
+ return snapshot()
406
+ })
407
+ }
408
+
409
+ /** Write via a temp file in the same directory, then rename over the target (atomic on the same volume). */
410
+ async function atomicWrite(path: string, text: string): Promise<void> {
411
+ const tmp = `${path}.plugin-hub-${process.pid}-${Date.now()}.tmp`
412
+ try {
413
+ await writeFile(tmp, text, "utf8")
414
+ await rename(tmp, path)
415
+ } finally {
416
+ await rm(tmp, { force: true }).catch(() => undefined)
417
+ }
389
418
  }
390
419
 
391
420
  /** Index of the entry that owns `name`, matching either the raw name or a directory's package.json name. */
@@ -530,7 +559,9 @@ export async function install(spec: string, version: string): Promise<MutationVa
530
559
  // A directory plugin cannot be updated by editing the config: its files have to be replaced.
531
560
  if (index >= 0 && splitSpec(file.plugins[index]!).kind === "path") {
532
561
  const dir = expandPath(file.plugins[index]!)
533
- const result = await refreshDirectory(dir, name, version.trim())
562
+ // The directory refresh rewrites files, not the config — still run it
563
+ // through the queue so it cannot interleave with a config edit.
564
+ const result = await enqueue(() => refreshDirectory(dir, name, version.trim()))
534
565
  return { ok: result.ok, message: result.message, state: await snapshot() }
535
566
  }
536
567
 
@@ -676,17 +707,18 @@ function trimReadme(raw: string): { text: string; truncated: boolean } {
676
707
  return { text: `${text}\n\n…`, truncated: true }
677
708
  }
678
709
 
710
+ /** `-1` means the download API could not be asked: the UI must show 暂不可用, not 0. */
679
711
  async function loadDownloads(name: string, period: "last-week" | "last-month"): Promise<number> {
680
712
  try {
681
713
  const response = await fetch(`${NPM_API}/downloads/point/${period}/${encodeURIComponent(name)}`, {
682
714
  signal: AbortSignal.timeout(8_000),
683
715
  headers: { accept: "application/json" },
684
716
  })
685
- if (!response.ok) return 0
717
+ if (!response.ok) return -1
686
718
  const body = (await response.json()) as { downloads?: number }
687
719
  return Number(body.downloads ?? 0) || 0
688
720
  } catch {
689
- return 0
721
+ return -1
690
722
  }
691
723
  }
692
724
 
@@ -998,6 +1030,102 @@ export async function reload(argv: readonly string[]): Promise<MutationValue> {
998
1030
  }
999
1031
  }
1000
1032
 
1033
+ /** One configured plugin's runtime truth, as the running server reports it. */
1034
+ export interface RuntimeInfoValue {
1035
+ spec: string
1036
+ name: string
1037
+ status: "loaded" | "failed" | "unknown"
1038
+ runtimeVersion: string
1039
+ error: string
1040
+ available: boolean
1041
+ }
1042
+
1043
+ export interface RuntimeResultValue {
1044
+ items: RuntimeInfoValue[]
1045
+ error: string
1046
+ }
1047
+
1048
+ interface PluginListInfo {
1049
+ source?:
1050
+ | { type: "builtin" }
1051
+ | { type: "package"; target?: string; version?: string; outdated?: true; updating?: true }
1052
+ | { type: "local"; path?: string }
1053
+ | { type: "sdk" }
1054
+ state?: { status: "active" } | { status: "failed"; error?: string }
1055
+ }
1056
+
1057
+ /**
1058
+ * Cross-references the config with the server's running plugin list, so the UI
1059
+ * can tell 已配置待生效 from 已加载. Best effort: when the server cannot be
1060
+ * asked, every item stays "unknown" instead of pretending to know.
1061
+ */
1062
+ export async function runtime(endpoint: { url: string; headers: Readonly<Record<string, string>> } | undefined): Promise<RuntimeResultValue> {
1063
+ const state = await snapshot()
1064
+ if (!endpoint) return { items: state.plugins.map(toUnknown), error: "无法连接后台服务,运行状态未知" }
1065
+ try {
1066
+ // The sidecar serves its web UI at `/`, so `/plugin` is an HTML page. The
1067
+ // JSON API lives under `/api`; asking the wrong path yields 200 HTML and a
1068
+ // confusing parse error.
1069
+ const response = await fetch(`${endpoint.url.replace(/\/+$/, "")}/api/plugin`, {
1070
+ signal: AbortSignal.timeout(8_000),
1071
+ headers: { accept: "application/json", ...endpoint.headers },
1072
+ })
1073
+ if (!response.ok) {
1074
+ return { items: state.plugins.map(toUnknown), error: `后台服务返回 ${response.status},运行状态未知` }
1075
+ }
1076
+ // Do not assume JSON: a misrouted request can return an HTML page with 200.
1077
+ const text = await response.text()
1078
+ let body: { data?: PluginListInfo[] } | PluginListInfo[]
1079
+ try {
1080
+ body = JSON.parse(text) as { data?: PluginListInfo[] } | PluginListInfo[]
1081
+ } catch {
1082
+ return { items: state.plugins.map(toUnknown), error: "后台返回的不是 JSON,运行状态未知" }
1083
+ }
1084
+ const list = Array.isArray(body) ? body : body.data ?? []
1085
+ const items = state.plugins.map((entry) => {
1086
+ // The server reports the source target; the config holds the spec (name, name@range or a path).
1087
+ const name = splitSpec(entry.spec).name
1088
+ const loaded = list.find((info) => {
1089
+ const source = info.source
1090
+ if (!source) return false
1091
+ if (source.type === "package" && typeof source.target === "string") {
1092
+ return splitSpec(source.target).name === name
1093
+ }
1094
+ if (source.type === "local" && entry.kind === "path") {
1095
+ return source.path === expandPath(entry.spec)
1096
+ }
1097
+ return false
1098
+ })
1099
+ // Only the fields in RuntimeInfo cross the IPC boundary; config details stay on this side.
1100
+ const base = { spec: entry.spec, name: entry.name }
1101
+ if (!loaded) return { ...base, status: "unknown" as const, runtimeVersion: "", error: "", available: true }
1102
+ if (loaded.state?.status === "failed") {
1103
+ return {
1104
+ ...base,
1105
+ status: "failed" as const,
1106
+ runtimeVersion: "",
1107
+ error: loaded.state.error ?? "加载失败",
1108
+ available: true,
1109
+ }
1110
+ }
1111
+ return {
1112
+ ...base,
1113
+ status: "loaded" as const,
1114
+ runtimeVersion: loaded.source?.type === "package" ? (loaded.source.version ?? "") : entry.version,
1115
+ error: "",
1116
+ available: true,
1117
+ }
1118
+ })
1119
+ return { items, error: "" }
1120
+ } catch (error) {
1121
+ return { items: state.plugins.map(toUnknown), error: `无法读取运行状态:${describe(error)}` }
1122
+ }
1123
+ }
1124
+
1125
+ function toUnknown(entry: InstalledEntryValue): RuntimeInfoValue {
1126
+ return { spec: entry.spec, name: entry.name, status: "unknown", runtimeVersion: "", error: "", available: false }
1127
+ }
1128
+
1001
1129
  function describe(error: unknown): string {
1002
1130
  if (error instanceof Error) return error.message
1003
1131
  return String(error)
package/src/gui/page.tsx CHANGED
@@ -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`
@@ -513,6 +522,18 @@ const CSS = `
513
522
  border-color: color-mix(in srgb, var(--ph-danger) 30%, transparent);
514
523
  background: color-mix(in srgb, var(--ph-danger) 9%, transparent);
515
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
+ }
516
537
  .ph-chips { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
517
538
  .ph-chip {
518
539
  display: inline-flex;
@@ -860,11 +881,11 @@ function DetailDialog(props: DetailDialogProps) {
860
881
  <Show when={data()!.version}>
861
882
  <div class="ph-detail-stats">
862
883
  <div class="ph-detail-stat">
863
- <b title={`${data()!.weekly.toLocaleString()} 次`}>{formatCount(data()!.weekly)}</b>
884
+ <b title={countTitle(data()!.weekly, "周下载")}>{formatCount(data()!.weekly)}</b>
864
885
  <span>周下载</span>
865
886
  </div>
866
887
  <div class="ph-detail-stat">
867
- <b title={`${data()!.monthly.toLocaleString()} 次`}>{formatCount(data()!.monthly)}</b>
888
+ <b title={countTitle(data()!.monthly, "月下载")}>{formatCount(data()!.monthly)}</b>
868
889
  <span>月下载</span>
869
890
  </div>
870
891
  <div class="ph-detail-stat">
@@ -982,6 +1003,26 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
982
1003
  const [updates, setUpdates] = createSignal<Map<string, UpdateInfo>>(new Map())
983
1004
  const [checked, setChecked] = createSignal(false)
984
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()
985
1026
 
986
1027
  const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<string>>(new Set())
987
1028
  const [note, setNote] = createSignal<Note>()
@@ -1016,6 +1057,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1016
1057
  }
1017
1058
  }
1018
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
+
1019
1075
  /** Fills the update map: keyed by config spec, so directory plugins match their own row. */
1020
1076
  const checkUpdates = async (silent = false) => {
1021
1077
  await track("updates", async () => {
@@ -1024,7 +1080,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1024
1080
  const map = new Map<string, UpdateInfo>()
1025
1081
  for (const item of result.items) map.set(item.spec, item)
1026
1082
  setUpdates(map)
1027
- setChecked(true)
1083
+ // 整体失败时 items 为空:这不是“检查完成、全部最新”,不能标记为已检查。
1084
+ setChecked(result.error === "" && result.items.length > 0)
1028
1085
  if (silent) return
1029
1086
  const stale = result.items.filter((item) => item.hasUpdate).length
1030
1087
  const failed = result.items.filter((item) => item.error).length
@@ -1032,10 +1089,10 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1032
1089
  const message = result.error
1033
1090
  ? result.error
1034
1091
  : failed > 0
1035
- ? `${failed} 个插件未能检查(registry 连接失败),其余${stale > 0 ? `有 ${stale} 个可更新` : "已是最新"}`
1092
+ ? `${failed} 个插件未能检查(registry 连接失败)${stale > 0 ? `,其余 ${stale} 个可更新` : ""}`
1036
1093
  : stale > 0
1037
1094
  ? `发现 ${stale} 个可更新的插件`
1038
- : "全部已是最新版本"
1095
+ : "已检查的插件均为最新版本"
1039
1096
  notify(
1040
1097
  message,
1041
1098
  result.error ? "error" : failed > 0 ? "warning" : stale > 0 ? "info" : "success",
@@ -1065,13 +1122,17 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1065
1122
 
1066
1123
  const runSearch = async (text: string) => {
1067
1124
  setQuery(text)
1125
+ // 只接受最后一次请求的结果:较早的关键词晚返回时不得覆盖新结果。
1126
+ const seq = ++searchSeq
1068
1127
  await track("search", async () => {
1069
1128
  try {
1070
1129
  const result = await props.admin.search({ query: text })
1130
+ if (seq !== searchSeq) return
1071
1131
  setHits(result.hits)
1072
1132
  setSearched(true)
1073
1133
  if (result.error) notify(result.error, "error")
1074
1134
  } catch (error) {
1135
+ if (seq !== searchSeq) return
1075
1136
  notify(readableError(error), "error")
1076
1137
  }
1077
1138
  })
@@ -1080,11 +1141,16 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1080
1141
  /** 输入防抖 450ms 自动搜索;同一个词不重复请求。 */
1081
1142
  let searchTimer: number | undefined
1082
1143
  let lastSearch = ""
1144
+ let searchSeq = 0
1083
1145
  const scheduleSearch = (text: string) => {
1084
1146
  setQuery(text)
1085
1147
  if (searchTimer) window.clearTimeout(searchTimer)
1086
1148
  const trimmed = text.trim()
1087
- if (!trimmed) return
1149
+ if (!trimmed) {
1150
+ // 清空即失效:排队中的旧结果不允许再回填。
1151
+ searchSeq += 1
1152
+ return
1153
+ }
1088
1154
  searchTimer = window.setTimeout(() => {
1089
1155
  if (trimmed === lastSearch) return
1090
1156
  lastSearch = trimmed
@@ -1101,7 +1167,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1101
1167
  notify(result.message, "error")
1102
1168
  return
1103
1169
  }
1104
- notify(`${result.message}。点右上角「更多操作 → 彻底重启 OpenCode」后生效`, "success")
1170
+ notify(`${result.message}。配置需重启后生效:点「彻底重启 OpenCode」完成加载`, "success")
1105
1171
  const latest = version || hits().find((hit) => hit.name === name)?.version || ""
1106
1172
  setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
1107
1173
  setHits((prev) =>
@@ -1109,7 +1175,10 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1109
1175
  hit.name === name ? { ...hit, installed: true, current: latest, hasUpdate: false } : hit,
1110
1176
  ),
1111
1177
  )
1178
+ // 配置变了:之前的运行事实已过期,重新读取;新 spec 在服务端列表里
1179
+ // 出现之前,卡片会如实显示“待重启生效”。
1112
1180
  await checkUpdates(true)
1181
+ void loadRuntime()
1113
1182
  } catch (error) {
1114
1183
  notify(readableError(error), "error")
1115
1184
  }
@@ -1132,7 +1201,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1132
1201
  notify(result.message, "error")
1133
1202
  return
1134
1203
  }
1135
- notify(`${result.message}。点右上角「更多操作 → 彻底重启 OpenCode」后生效`, "success")
1204
+ notify(`${result.message}。配置需重启后生效:点「彻底重启 OpenCode」完成移除`, "success")
1136
1205
  setCatalog((prev) =>
1137
1206
  prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
1138
1207
  )
@@ -1146,6 +1215,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1146
1215
  next.delete(entry.spec)
1147
1216
  return next
1148
1217
  })
1218
+ void loadRuntime()
1149
1219
  } catch (error) {
1150
1220
  notify(readableError(error), "error")
1151
1221
  }
@@ -1254,7 +1324,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1254
1324
 
1255
1325
  const sortedHits = createMemo(() => {
1256
1326
  const list = [...hits()]
1257
- 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
1258
1328
  else if (sort() === "recent") list.sort((a, b) => (b.published || "").localeCompare(a.published || ""))
1259
1329
  return list
1260
1330
  })
@@ -1267,6 +1337,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1267
1337
 
1268
1338
  const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
1269
1339
  const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
1340
+ // 未能检查的条目数:失败不能被总结成“全部已是最新”。
1341
+ const uncheckedCount = createMemo(() => [...updates().values()].filter((item) => item.error).length)
1270
1342
  const visiblePlugins = createMemo(() => {
1271
1343
  const plugins = state()?.plugins ?? []
1272
1344
  if (!staleOnly()) return plugins
@@ -1292,10 +1364,28 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1292
1364
  void refresh()
1293
1365
  void loadCatalog(false)
1294
1366
  void checkUpdates(true)
1367
+ void loadRuntime()
1295
1368
  })
1296
1369
 
1297
1370
  const tabButton = (id: Tab, label: string, icon: IconName, count?: number | string, alert?: number) => (
1298
- <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
+ >
1299
1389
  <ui.Icon name={icon} size="small" />
1300
1390
  {label}
1301
1391
  <Show when={count !== undefined && count !== ""}>
@@ -1478,12 +1568,12 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1478
1568
  >
1479
1569
  <div class="ph-desc ph-clamp">{hit.description || "—"}</div>
1480
1570
  <div class="ph-meta">
1481
- <span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
1571
+ <span title={countTitle(hit.weekly, "周下载")}>
1482
1572
  <ui.Icon name="download" size="small" />
1483
1573
  {formatCount(hit.weekly)}/周
1484
1574
  </span>
1485
1575
  <span class="ph-dot" />
1486
- <span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
1576
+ <span title={countTitle(hit.monthly, "月下载")}>{formatCount(hit.monthly)}/月</span>
1487
1577
  <Show when={hit.publisher}>
1488
1578
  <span class="ph-dot" />
1489
1579
  <span>by {hit.publisher}</span>
@@ -1552,6 +1642,26 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1552
1642
  const busy = () => isBusy(`install:${entry.name}`)
1553
1643
  const current = () => info()?.current || entry.version
1554
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
+ : "运行状态未验证"
1555
1665
  return (
1556
1666
  <article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
1557
1667
  <div class="ph-card-main">
@@ -1564,6 +1674,9 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1564
1674
  <span class="ph-pkg">{entry.spec}</span>
1565
1675
  </div>
1566
1676
  <div class="ph-chips">
1677
+ <span class="ph-badge" data-runtime={runtimeStatus()} title={runtimeLabel()}>
1678
+ {runtimeLabel()}
1679
+ </span>
1567
1680
  <Show when={stale()}>
1568
1681
  <span class="ph-badge" data-tone="accent">
1569
1682
  可更新 → v{info()!.latest}
@@ -1676,6 +1789,26 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1676
1789
  </div>
1677
1790
  </Show>
1678
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
+ <ui.Button size="small" variant="ghost" icon="outline-rotate-clockwise" onClick={() => void loadRuntime()}>
1798
+ 重试
1799
+ </ui.Button>
1800
+ </div>
1801
+ </Show>
1802
+ <Show when={hasPendingChanges()}>
1803
+ <div class="ph-notice" data-tone="info">
1804
+ <ui.Icon name="outline-rotate-clockwise" size="small" />
1805
+ <span>有 {pendingChangesCount()} 项配置变更待重启生效。插件在应用启动时加载,重启后才能运行新配置。</span>
1806
+ <ui.Button size="small" variant="ghost" icon="outline-rotate-clockwise" onClick={() => void restartApp()}>
1807
+ 彻底重启 OpenCode
1808
+ </ui.Button>
1809
+ </div>
1810
+ </Show>
1811
+
1679
1812
  <div class="ph-toolbar">
1680
1813
  <div class="ph-tabs" role="tablist">
1681
1814
  {tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
@@ -1705,6 +1838,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1705
1838
  setSearched(false)
1706
1839
  setQuery("")
1707
1840
  setNote(undefined)
1841
+ searchSeq += 1 // 丢弃仍在路上的搜索结果
1708
1842
  }}
1709
1843
  >
1710
1844
  清空
@@ -1749,7 +1883,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1749
1883
 
1750
1884
  <Switch>
1751
1885
  <Match when={tab() === "catalog"}>
1752
- <section class="ph-panel">
1886
+ <section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
1753
1887
  <div class="ph-filters">
1754
1888
  <div class="ph-filter-row">
1755
1889
  <div class="ph-grow">
@@ -1825,7 +1959,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1825
1959
  </Match>
1826
1960
 
1827
1961
  <Match when={tab() === "search"}>
1828
- <section class="ph-panel">
1962
+ <section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
1829
1963
  <div class="ph-searchbar">
1830
1964
  <div class="ph-grow">
1831
1965
  <ui.TextInput
@@ -1924,7 +2058,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1924
2058
  </Match>
1925
2059
 
1926
2060
  <Match when={tab() === "installed"}>
1927
- <section class="ph-panel">
2061
+ <section class="ph-panel" role="tabpanel" id="ph-tabpanel" aria-labelledby={`ph-tab-${tab()}`}>
1928
2062
  <div class="ph-resultbar">
1929
2063
  <h3 class="ph-h3">
1930
2064
  已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
@@ -1946,7 +2080,11 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1946
2080
  <span class="ph-spacer" />
1947
2081
  <Show when={checked()}>
1948
2082
  <span class="ph-meta">
1949
- 检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
2083
+ {uncheckedCount() > 0
2084
+ ? `已检查 ${Math.max(0, (state()?.plugins.length ?? 0) - uncheckedCount())} 个 · ${uncheckedCount()} 个未能检查${outdated() > 0 ? ` · ${outdated()} 个可更新` : ""}`
2085
+ : outdated() > 0
2086
+ ? `检查过 npm registry · ${outdated()} 个可更新`
2087
+ : "检查过 npm registry · 已检查的插件均为最新"}
1950
2088
  </span>
1951
2089
  </Show>
1952
2090
  </div>
@@ -1965,8 +2103,18 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1965
2103
  fallback={
1966
2104
  <div class="ph-empty">
1967
2105
  <ui.Icon name="checklist" size="large" />
1968
- <strong>没有可更新的插件</strong>
1969
- <span>已安装的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
2106
+ <Show
2107
+ when={uncheckedCount() > 0}
2108
+ fallback={
2109
+ <>
2110
+ <strong>没有可更新的插件</strong>
2111
+ <span>已检查的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
2112
+ </>
2113
+ }
2114
+ >
2115
+ <strong>有 {uncheckedCount()} 个插件未能检查</strong>
2116
+ <span>部分插件没能连上 npm registry,不能当作“已是最新”。点工具栏的「检查更新」重试。</span>
2117
+ </Show>
1970
2118
  <ui.Button variant="ghost" size="small" onClick={() => setStaleOnly(false)}>
1971
2119
  显示全部
1972
2120
  </ui.Button>
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
  })