opencode-plugin-hub 0.1.0 → 0.1.2

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.1.0",
3
+ "version": "0.1.2",
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
@@ -131,6 +131,8 @@ export const CatalogResult = Schema.Struct({
131
131
  entries: Schema.Array(CatalogEntry),
132
132
  /** `network`, `cache` or `builtin`. */
133
133
  source: Schema.String,
134
+ /** The registry URL the entries came from. Optional for older cached results. */
135
+ registry: Schema.optional(Schema.String),
134
136
  fetchedAt: Schema.Number,
135
137
  error: Schema.String,
136
138
  })
@@ -27,6 +27,8 @@ export interface CatalogResultValue {
27
27
  entries: CatalogEntryValue[]
28
28
  /** Where the list came from: `network`, `cache` or `builtin`. */
29
29
  source: string
30
+ /** The registry URL the entries came from. */
31
+ registry: string
30
32
  /** Epoch milliseconds of the data, 0 for the built-in snapshot. */
31
33
  fetchedAt: number
32
34
  error: string
@@ -129,7 +131,7 @@ function toEntries(raw: RawEntry[], installed: Set<string>): CatalogEntryValue[]
129
131
  return out
130
132
  }
131
133
 
132
- async function fetchRemote(): Promise<RawEntry[]> {
134
+ async function fetchRemote(): Promise<{ entries: RawEntry[]; url: string }> {
133
135
  let lastError = ""
134
136
  for (const url of SOURCES) {
135
137
  try {
@@ -143,7 +145,7 @@ async function fetchRemote(): Promise<RawEntry[]> {
143
145
  lastError = "registry 格式不是数组"
144
146
  continue
145
147
  }
146
- return body as RawEntry[]
148
+ return { entries: body as RawEntry[], url }
147
149
  } catch (error) {
148
150
  lastError = describe(error)
149
151
  }
@@ -153,6 +155,8 @@ async function fetchRemote(): Promise<RawEntry[]> {
153
155
 
154
156
  interface CacheFile {
155
157
  fetchedAt: number
158
+ /** The source URL that produced this cache. Older caches do not have it. */
159
+ url?: string
156
160
  entries: RawEntry[]
157
161
  }
158
162
 
@@ -167,11 +171,11 @@ async function readCache(): Promise<CacheFile | undefined> {
167
171
  }
168
172
  }
169
173
 
170
- async function writeCache(entries: RawEntry[]): Promise<number> {
174
+ async function writeCache(entries: RawEntry[], url: string): Promise<number> {
171
175
  const fetchedAt = Date.now()
172
176
  try {
173
177
  await mkdir(cacheDir(), { recursive: true })
174
- await writeFile(cacheFile(), JSON.stringify({ fetchedAt, entries }), "utf8")
178
+ await writeFile(cacheFile(), JSON.stringify({ fetchedAt, url, entries }), "utf8")
175
179
  } catch {
176
180
  // A failed cache write still leaves the in-memory result usable.
177
181
  }
@@ -186,17 +190,30 @@ export async function browse(installed: Set<string>, force: boolean): Promise<Ca
186
190
  const cache = await readCache()
187
191
  const fresh = cache !== undefined && Date.now() - cache.fetchedAt < TTL
188
192
  if (cache && fresh && !force) {
189
- return { entries: toEntries(cache.entries, installed), source: "cache", fetchedAt: cache.fetchedAt, error: "" }
193
+ return {
194
+ entries: toEntries(cache.entries, installed),
195
+ source: "cache",
196
+ registry: cache.url ?? SOURCES[0]!,
197
+ fetchedAt: cache.fetchedAt,
198
+ error: "",
199
+ }
190
200
  }
191
201
  try {
192
202
  const remote = await fetchRemote()
193
- const fetchedAt = await writeCache(remote)
194
- return { entries: toEntries(remote, installed), source: "network", fetchedAt, error: "" }
203
+ const fetchedAt = await writeCache(remote.entries, remote.url)
204
+ return {
205
+ entries: toEntries(remote.entries, installed),
206
+ source: "network",
207
+ registry: remote.url,
208
+ fetchedAt,
209
+ error: "",
210
+ }
195
211
  } catch (error) {
196
212
  if (cache) {
197
213
  return {
198
214
  entries: toEntries(cache.entries, installed),
199
215
  source: "cache",
216
+ registry: cache.url ?? SOURCES[0]!,
200
217
  fetchedAt: cache.fetchedAt,
201
218
  error: describe(error),
202
219
  }
@@ -204,6 +221,7 @@ export async function browse(installed: Set<string>, force: boolean): Promise<Ca
204
221
  return {
205
222
  entries: toEntries(SNAPSHOT.map(toRaw), installed),
206
223
  source: "builtin",
224
+ registry: SOURCES[0]!,
207
225
  fetchedAt: 0,
208
226
  error: describe(error),
209
227
  }
package/src/gui/page.tsx CHANGED
@@ -17,11 +17,8 @@ type Note = { text: string; tone: Tone }
17
17
 
18
18
  const PAGE = 24
19
19
 
20
- const SOURCE_LABEL: Record<string, string> = {
21
- network: "社区目录 · 已更新",
22
- cache: "社区目录 · 本地缓存",
23
- builtin: "社区目录 · 内置快照",
24
- }
20
+ const CATALOG_SOURCE_FALLBACK = "https://github.com/awesome-opencode/awesome-opencode"
21
+ const CATALOG_SOURCE_TITLE = "精选目录来自 awesome-opencode 社区维护的 registry.json,不是 OpenCode 官方推荐"
25
22
 
26
23
  const TYPE_LABEL: Record<string, string> = {
27
24
  plugins: "插件",
@@ -72,16 +69,6 @@ function relativeDate(value: string): string {
72
69
  return `${Math.floor(days / 365)} 年前`
73
70
  }
74
71
 
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
72
  function initial(name: string): string {
86
73
  const base = name.replace(/^@/, "").split("/").pop() ?? name
87
74
  const cleaned = base.replace(/^opencode[-_]?/i, "")
@@ -115,7 +102,7 @@ function noteIcon(tone: Tone): IconName {
115
102
  function readableError(error: unknown): string {
116
103
  const text = error instanceof Error ? error.message : String(error)
117
104
  if (/SchemaError|Missing key at/i.test(text)) {
118
- return "插件窗口与后台服务版本不一致。点顶部的「彻底重启」让两边换成同一版本。"
105
+ return "插件窗口与后台服务版本不一致。点上方工具栏的「彻底重启」让两边换成同一版本。"
119
106
  }
120
107
  if (/unknown method|not implemented|no such method|method.*not.*found/i.test(text)) {
121
108
  return "后台服务还是旧版本,暂时没有「彻底重启」能力。请先手动完全退出 OpenCode(并结束 serve --service)再打开一次。"
@@ -173,7 +160,7 @@ const CSS = `
173
160
  radial-gradient(120% 160% at 0% 0%, color-mix(in srgb, var(--ph-accent) 13%, transparent) 0%, transparent 52%),
174
161
  var(--ph-layer);
175
162
  }
176
- .ph-hero-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
163
+ .ph-hero-copy { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
177
164
  .ph-kicker {
178
165
  display: flex;
179
166
  align-items: center;
@@ -195,7 +182,22 @@ const CSS = `
195
182
  font-size: 12px;
196
183
  line-height: 1.6;
197
184
  color: var(--ph-muted);
198
- max-width: 54ch;
185
+ max-width: 72ch;
186
+ }
187
+ .ph-inline-source {
188
+ padding: 0;
189
+ border: 0;
190
+ background: none;
191
+ color: var(--ph-accent);
192
+ font: inherit;
193
+ line-height: inherit;
194
+ cursor: pointer;
195
+ }
196
+ .ph-inline-source:hover { text-decoration: underline; }
197
+ .ph-inline-source:focus-visible {
198
+ outline: 2px solid color-mix(in srgb, var(--ph-accent) 55%, transparent);
199
+ outline-offset: 2px;
200
+ border-radius: 2px;
199
201
  }
200
202
  .ph-path {
201
203
  overflow: hidden;
@@ -209,23 +211,6 @@ const CSS = `
209
211
  border-radius: 5px;
210
212
  background: var(--ph-layer-2);
211
213
  }
212
- .ph-hero-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
213
- .ph-stats {
214
- display: grid;
215
- grid-template-columns: repeat(2, minmax(92px, 1fr));
216
- gap: 8px;
217
- align-content: center;
218
- }
219
- .ph-stat {
220
- min-width: 92px;
221
- padding: 10px 12px;
222
- border: 1px solid var(--ph-border);
223
- border-radius: 10px;
224
- background: var(--ph-layer-2);
225
- }
226
- .ph-stat b { display: block; font-size: 18px; font-weight: 650; line-height: 1.2; color: var(--ph-strong); }
227
- .ph-stat span { font-size: 10.5px; color: var(--ph-muted); }
228
- .ph-stat[data-tone="accent"] b { color: var(--ph-accent); }
229
214
 
230
215
  /* notice */
231
216
  .ph-notice {
@@ -300,6 +285,11 @@ const CSS = `
300
285
  font-size: 9.5px;
301
286
  font-weight: 600;
302
287
  }
288
+ .ph-count-alert {
289
+ background: color-mix(in srgb, var(--ph-accent) 14%, transparent);
290
+ color: var(--ph-accent);
291
+ white-space: nowrap;
292
+ }
303
293
  .ph-tab[data-active="true"] .ph-count {
304
294
  background: color-mix(in srgb, var(--ph-accent) 16%, transparent);
305
295
  color: var(--ph-accent);
@@ -607,6 +597,26 @@ button.ph-chip:focus-visible,
607
597
  max-width: none;
608
598
  padding: 0 0 8px;
609
599
  }
600
+ /* The host caps "x-large" at 980x600. Give the standalone hub more room and
601
+ let the dialog be resized from its bottom-right corner, within the window. */
602
+ [data-component="dialog-v2"][data-size="x-large"]:has(.ph-root[data-embedded="true"]) [data-slot="dialog-container"] {
603
+ width: min(100vw - 48px, 1180px);
604
+ height: min(100vh - 88px, 780px);
605
+ min-width: min(100vw - 48px, 720px);
606
+ min-height: min(100vh - 88px, 480px);
607
+ max-width: calc(100vw - 24px);
608
+ max-height: calc(100vh - 24px);
609
+ resize: both;
610
+ }
611
+ [data-component="dialog-v2"][data-size="x-large"]:has(.ph-detail) [data-slot="dialog-container"] {
612
+ width: min(100vw - 48px, 1180px);
613
+ height: min(100vh - 88px, 780px);
614
+ min-width: min(100vw - 48px, 720px);
615
+ min-height: min(100vh - 88px, 480px);
616
+ max-width: calc(100vw - 24px);
617
+ max-height: calc(100vh - 24px);
618
+ resize: both;
619
+ }
610
620
  .ph-root[data-embedded="true"] .ph-toolbar {
611
621
  position: sticky;
612
622
  top: 0;
@@ -629,7 +639,6 @@ button.ph-chip:focus-visible,
629
639
 
630
640
  @media (max-width: 760px) {
631
641
  .ph-hero { flex-direction: column; }
632
- .ph-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
633
642
  .ph-card { flex-wrap: wrap; }
634
643
  .ph-card-body { flex-basis: calc(100% - 50px); }
635
644
  .ph-actions { width: 100%; justify-content: flex-start; padding-left: 50px; }
@@ -1000,7 +1009,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1000
1009
  notify(result.message, "error")
1001
1010
  return
1002
1011
  }
1003
- notify(`${result.message}。点顶部的「彻底重启」后生效`, "success")
1012
+ notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1004
1013
  const latest = version || hits().find((hit) => hit.name === name)?.version || ""
1005
1014
  setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
1006
1015
  setHits((prev) =>
@@ -1031,7 +1040,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1031
1040
  notify(result.message, "error")
1032
1041
  return
1033
1042
  }
1034
- notify(`${result.message}。点顶部的「彻底重启」后生效`, "success")
1043
+ notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1035
1044
  setCatalog((prev) =>
1036
1045
  prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
1037
1046
  )
@@ -1158,7 +1167,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1158
1167
  return list
1159
1168
  })
1160
1169
 
1161
- const installable = createMemo(() => visible().filter((entry) => entry.pkg).length)
1170
+ const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
1162
1171
  const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
1163
1172
  const bySpec = (spec: string) => updates().get(spec)
1164
1173
  const byPackage = createMemo(() => {
@@ -1179,13 +1188,18 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1179
1188
  void checkUpdates(true)
1180
1189
  })
1181
1190
 
1182
- const tabButton = (id: Tab, label: string, icon: IconName, count?: number | string) => (
1191
+ const tabButton = (id: Tab, label: string, icon: IconName, count?: number | string, alert?: number) => (
1183
1192
  <button class="ph-tab" data-active={tab() === id} onClick={() => switchTab(id)}>
1184
1193
  <ui.Icon name={icon} size="small" />
1185
1194
  {label}
1186
1195
  <Show when={count !== undefined && count !== ""}>
1187
1196
  <span class="ph-count">{count}</span>
1188
1197
  </Show>
1198
+ <Show when={alert !== undefined && alert > 0}>
1199
+ <span class="ph-count ph-count-alert" title={`${alert} 个可更新`}>
1200
+ {alert} 可更新
1201
+ </span>
1202
+ </Show>
1189
1203
  </button>
1190
1204
  )
1191
1205
 
@@ -1504,58 +1518,20 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1504
1518
  <h2 class="ph-title">插件中心</h2>
1505
1519
  </Show>
1506
1520
  <p class="ph-sub">
1507
- 浏览社区精选、搜索 npm 上的 OpenCode 插件。安装前先看下载量、README 和主页,确认它到底能做什么。
1521
+ 浏览{" "}
1522
+ <button
1523
+ type="button"
1524
+ class="ph-inline-source"
1525
+ title={CATALOG_SOURCE_TITLE}
1526
+ onClick={() => void openUrl(registryUrl())}
1527
+ >
1528
+ awesome-opencode
1529
+ </button>{" "}
1530
+ 社区维护的精选目录、搜索 npm 上的 OpenCode 插件。安装前先看下载量、README 和主页,确认它到底能做什么。
1508
1531
  </p>
1509
1532
  <div class="ph-path" title={state()?.configPath}>
1510
1533
  配置:<code>{state()?.configPath ?? "读取中…"}</code> · <code>{state()?.configKey ?? "plugins"}[]</code>
1511
1534
  </div>
1512
- <div class="ph-hero-actions">
1513
- <ui.Button
1514
- variant="neutral"
1515
- size="small"
1516
- icon="open-file"
1517
- busy={isBusy("config")}
1518
- onClick={() => void openConfig()}
1519
- >
1520
- 打开配置
1521
- </ui.Button>
1522
- <ui.Button
1523
- variant="ghost"
1524
- size="small"
1525
- icon="refresh"
1526
- busy={isBusy("reload")}
1527
- onClick={() => void reload()}
1528
- >
1529
- 重载配置
1530
- </ui.Button>
1531
- <ui.Button
1532
- variant="ghost"
1533
- size="small"
1534
- icon="outline-rotate-clockwise"
1535
- busy={isBusy("restart")}
1536
- onClick={() => void restartApp()}
1537
- >
1538
- 彻底重启
1539
- </ui.Button>
1540
- </div>
1541
- </div>
1542
- <div class="ph-stats">
1543
- <div class="ph-stat">
1544
- <b>{state()?.plugins.length ?? "—"}</b>
1545
- <span>已安装</span>
1546
- </div>
1547
- <div class="ph-stat" data-tone={outdated() > 0 ? "accent" : ""}>
1548
- <b>{checked() ? outdated() : "—"}</b>
1549
- <span>可更新</span>
1550
- </div>
1551
- <div class="ph-stat">
1552
- <b>{meta() ? catalog().length : "—"}</b>
1553
- <span>精选条目</span>
1554
- </div>
1555
- <div class="ph-stat">
1556
- <b>{meta() ? catalog().filter((entry) => entry.pkg).length : "—"}</b>
1557
- <span>可直接安装</span>
1558
- </div>
1559
1535
  </div>
1560
1536
  </section>
1561
1537
 
@@ -1577,20 +1553,36 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1577
1553
  <div class="ph-tabs" role="tablist">
1578
1554
  {tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
1579
1555
  {tabButton("search", "npm 搜索", "magnifying-glass", searched() ? hits().length : undefined)}
1580
- {tabButton("installed", "已安装", "checklist", state()?.plugins.length ?? 0)}
1556
+ {tabButton("installed", "已安装", "checklist", state()?.plugins.length ?? 0, outdated())}
1581
1557
  </div>
1582
1558
  <span class="ph-spacer" />
1583
- <Show when={props.embedded}>
1584
- <ui.Button
1585
- variant="ghost"
1586
- size="small"
1587
- icon="open-file"
1588
- busy={isBusy("config")}
1589
- onClick={() => void openConfig()}
1590
- >
1591
- 配置
1592
- </ui.Button>
1593
- </Show>
1559
+ <ui.Button
1560
+ variant="ghost"
1561
+ size="small"
1562
+ icon="open-file"
1563
+ busy={isBusy("config")}
1564
+ onClick={() => void openConfig()}
1565
+ >
1566
+ 打开配置
1567
+ </ui.Button>
1568
+ <ui.Button
1569
+ variant="ghost"
1570
+ size="small"
1571
+ icon="refresh"
1572
+ busy={isBusy("reload")}
1573
+ onClick={() => void reload()}
1574
+ >
1575
+ 重载配置
1576
+ </ui.Button>
1577
+ <ui.Button
1578
+ variant="neutral"
1579
+ size="small"
1580
+ icon="outline-rotate-clockwise"
1581
+ busy={isBusy("restart")}
1582
+ onClick={() => void restartApp()}
1583
+ >
1584
+ 彻底重启
1585
+ </ui.Button>
1594
1586
  <Show when={tab() === "catalog"}>
1595
1587
  <ui.Button
1596
1588
  variant="ghost"
@@ -1639,7 +1631,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1639
1631
  <div class="ph-filter-row">
1640
1632
  <div class="ph-grow">
1641
1633
  <ui.TextInput
1642
- label="筛选社区精选插件"
1634
+ label="筛选 awesome-opencode 精选插件"
1643
1635
  placeholder="按名称、描述或标签筛选,例如 memory / auth / token"
1644
1636
  type="search"
1645
1637
  value={filter()}
@@ -1667,17 +1659,6 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1667
1659
  </For>
1668
1660
  </div>
1669
1661
  </div>
1670
- <div class="ph-meta">
1671
- <span>{SOURCE_LABEL[meta()?.source ?? ""] ?? "加载中…"}</span>
1672
- <span class="ph-dot" />
1673
- <span>
1674
- {catalog().length} 个条目 · {installable()} 个可安装
1675
- </span>
1676
- <Show when={meta()?.fetchedAt}>
1677
- <span class="ph-dot" />
1678
- <span>更新于 {formatDate(meta()!.fetchedAt)}</span>
1679
- </Show>
1680
- </div>
1681
1662
  </div>
1682
1663
 
1683
1664
  <Show when={isBusy("catalog") && catalog().length === 0}>
@@ -1824,7 +1805,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1824
1805
  <div class="ph-empty">
1825
1806
  <ui.Icon name="extensions" size="large" />
1826
1807
  <strong>还没有安装插件</strong>
1827
- <span>去「精选目录」看看社区推荐,或者在「npm 搜索」里找长尾插件。</span>
1808
+ <span>去「精选目录」看看 awesome-opencode 的推荐,或者在「npm 搜索」里找长尾插件。</span>
1828
1809
  </div>
1829
1810
  }
1830
1811
  >
@@ -1838,7 +1819,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1838
1819
 
1839
1820
  <footer class="ph-footer">
1840
1821
  <div class="ph-footer-note">
1841
- 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点顶部的「彻底重启」,应用会自动退出并重新打开。
1822
+ 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点上方工具栏的「彻底重启」,应用会自动退出并重新打开。
1842
1823
  </div>
1843
1824
  </footer>
1844
1825
  </div>
@@ -1848,11 +1829,24 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1848
1829
  /** The standalone x-large dialog opened from the titlebar pill and the ⌘⌥P shortcut. */
1849
1830
  export function PluginHubDialog(props: { ctx: Context; admin: AdminClient; onClose: () => void }) {
1850
1831
  const ui = props.ctx.ui
1832
+ const openSettings = () => {
1833
+ props.onClose()
1834
+ // Let the dialog unmount before the host swaps in the Settings view.
1835
+ setTimeout(() => props.ctx.layout.settings("hub"), 0)
1836
+ }
1851
1837
  return (
1852
1838
  <ui.Dialog
1853
1839
  title="插件中心"
1854
1840
  size="x-large"
1855
- footer={<ui.Button onClick={props.onClose}>关闭</ui.Button>}
1841
+ footer={
1842
+ <div class="ph-dialog-footer">
1843
+ <span class="ph-footer-note">拖动右下角可调整大小</span>
1844
+ <ui.Button size="small" variant="ghost" icon="settings-gear" onClick={openSettings}>
1845
+ 在设置中打开
1846
+ </ui.Button>
1847
+ <ui.Button size="small" onClick={props.onClose}>关闭</ui.Button>
1848
+ </div>
1849
+ }
1856
1850
  >
1857
1851
  <PluginHubPage ctx={props.ctx} admin={props.admin} embedded />
1858
1852
  </ui.Dialog>