opencode-plugin-hub 0.1.1 → 0.1.3

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
@@ -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)再打开一次。"
@@ -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;
@@ -346,12 +348,18 @@ const CSS = `
346
348
  }
347
349
 
348
350
  /* cards */
349
- .ph-list { display: flex; flex-direction: column; gap: 8px; }
351
+ .ph-list {
352
+ display: grid;
353
+ grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
354
+ gap: 10px;
355
+ align-items: stretch;
356
+ }
350
357
  .ph-card {
358
+ position: relative;
351
359
  display: flex;
352
- align-items: flex-start;
353
- gap: 12px;
354
- padding: 14px;
360
+ flex-direction: column;
361
+ gap: 9px;
362
+ padding: 12px;
355
363
  border: 1px solid var(--ph-border);
356
364
  border-radius: 12px;
357
365
  background: var(--ph-layer);
@@ -362,22 +370,59 @@ const CSS = `
362
370
  background: var(--ph-layer-2);
363
371
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
364
372
  }
373
+ /* Highlight cards whose plugin has a pending update. */
374
+ .ph-card[data-stale="true"] {
375
+ border-color: color-mix(in srgb, var(--ph-accent) 36%, transparent);
376
+ background: color-mix(in srgb, var(--ph-accent) 5%, transparent);
377
+ }
378
+ /* While an install or update runs, pulse and sweep the card. */
379
+ .ph-card[data-busy="true"] {
380
+ border-color: color-mix(in srgb, var(--ph-accent) 45%, transparent);
381
+ animation: ph-busy-pulse 1.1s ease-in-out infinite;
382
+ }
383
+ .ph-card[data-busy="true"]::after {
384
+ content: "";
385
+ position: absolute;
386
+ inset: 0;
387
+ border-radius: inherit;
388
+ background: linear-gradient(
389
+ 100deg,
390
+ transparent 32%,
391
+ color-mix(in srgb, var(--ph-accent) 12%, transparent) 50%,
392
+ transparent 68%
393
+ );
394
+ background-size: 220% 100%;
395
+ animation: ph-shimmer 1.1s linear infinite;
396
+ pointer-events: none;
397
+ }
398
+ @keyframes ph-busy-pulse {
399
+ 0%,
400
+ 100% {
401
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--ph-accent) 20%, transparent);
402
+ }
403
+ 50% {
404
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--ph-accent) 8%, transparent);
405
+ }
406
+ }
407
+ .ph-card-main { display: flex; flex: 1; flex-direction: column; gap: 8px; min-width: 0; }
408
+ .ph-card-body { display: flex; flex: 1; flex-direction: column; gap: 5px; min-width: 0; }
409
+ .ph-card-body[role="button"] { cursor: pointer; border-radius: 6px; }
410
+ .ph-card-head { display: flex; align-items: flex-start; gap: 9px; }
411
+ .ph-card-titles { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
365
412
  .ph-tile {
366
413
  flex: 0 0 auto;
367
414
  display: grid;
368
415
  place-items: center;
369
- width: 38px;
370
- height: 38px;
416
+ width: 32px;
417
+ height: 32px;
371
418
  border: 1px solid color-mix(in srgb, var(--ph-accent) 20%, transparent);
372
- border-radius: 10px;
419
+ border-radius: 9px;
373
420
  background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
374
421
  color: var(--ph-accent);
375
- font-size: 15px;
422
+ font-size: 13px;
376
423
  font-weight: 700;
377
424
  overflow: hidden;
378
425
  }
379
- .ph-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
380
- .ph-card-body[role="button"] { cursor: pointer; border-radius: 6px; }
381
426
  .ph-name-row { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
382
427
  .ph-name {
383
428
  font-size: 13.5px;
@@ -407,7 +452,16 @@ const CSS = `
407
452
  text-transform: uppercase;
408
453
  color: var(--ph-faint);
409
454
  }
410
- .ph-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; flex-wrap: wrap; }
455
+ .ph-actions {
456
+ display: flex;
457
+ align-items: center;
458
+ justify-content: flex-end;
459
+ gap: 4px;
460
+ flex-wrap: wrap;
461
+ margin-top: auto;
462
+ padding-top: 8px;
463
+ border-top: 1px solid var(--ph-border);
464
+ }
411
465
 
412
466
  /* badges and chips */
413
467
  .ph-badge {
@@ -469,6 +523,7 @@ button.ph-chip[data-active="true"] {
469
523
  border-color: color-mix(in srgb, var(--ph-accent) 32%, transparent);
470
524
  background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
471
525
  }
526
+ .ph-chip[data-disabled="true"] { opacity: 0.45; cursor: default; }
472
527
  .ph-chip-count { font-size: 9px; opacity: 0.7; }
473
528
 
474
529
  /* empty and loading */
@@ -595,10 +650,36 @@ button.ph-chip:focus-visible,
595
650
  max-width: none;
596
651
  padding: 0 0 8px;
597
652
  }
598
- .ph-root[data-embedded="true"] .ph-toolbar {
653
+ /* The host caps "x-large" at 980x600. Give the standalone hub more room and
654
+ let the dialog be resized from its bottom-right corner, within the window. */
655
+ [data-component="dialog-v2"][data-size="x-large"]:has(.ph-root[data-embedded="true"]) [data-slot="dialog-container"] {
656
+ width: min(100vw - 48px, 1180px);
657
+ height: min(100vh - 88px, 780px);
658
+ min-width: min(100vw - 48px, 720px);
659
+ min-height: min(100vh - 88px, 480px);
660
+ max-width: calc(100vw - 24px);
661
+ max-height: calc(100vh - 24px);
662
+ resize: both;
663
+ }
664
+ [data-component="dialog-v2"][data-size="x-large"]:has(.ph-detail) [data-slot="dialog-container"] {
665
+ width: min(100vw - 48px, 1180px);
666
+ height: min(100vh - 88px, 780px);
667
+ min-width: min(100vw - 48px, 720px);
668
+ min-height: min(100vh - 88px, 480px);
669
+ max-width: calc(100vw - 24px);
670
+ max-height: calc(100vh - 24px);
671
+ resize: both;
672
+ }
673
+ /* Notices and the toolbar share one sticky head, so a note raised after an
674
+ action (e.g. updating a plugin near the bottom of the list) is always on
675
+ screen instead of appearing far above the current scroll position. */
676
+ .ph-head {
599
677
  position: sticky;
600
678
  top: 0;
601
679
  z-index: 3;
680
+ display: flex;
681
+ flex-direction: column;
682
+ gap: 14px;
602
683
  padding: 6px 0;
603
684
  margin: -6px 0;
604
685
  background: color-mix(in srgb, var(--color-v2-background-bg-base, Canvas) 92%, transparent);
@@ -617,9 +698,7 @@ button.ph-chip:focus-visible,
617
698
 
618
699
  @media (max-width: 760px) {
619
700
  .ph-hero { flex-direction: column; }
620
- .ph-card { flex-wrap: wrap; }
621
- .ph-card-body { flex-basis: calc(100% - 50px); }
622
- .ph-actions { width: 100%; justify-content: flex-start; padding-left: 50px; }
701
+ .ph-list { grid-template-columns: 1fr; }
623
702
  .ph-detail-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
624
703
  }
625
704
  `
@@ -885,6 +964,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
885
964
 
886
965
  const [updates, setUpdates] = createSignal<Map<string, UpdateInfo>>(new Map())
887
966
  const [checked, setChecked] = createSignal(false)
967
+ const [staleOnly, setStaleOnly] = createSignal(false)
888
968
 
889
969
  const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<string>>(new Set())
890
970
  const [note, setNote] = createSignal<Note>()
@@ -987,7 +1067,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
987
1067
  notify(result.message, "error")
988
1068
  return
989
1069
  }
990
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1070
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
991
1071
  const latest = version || hits().find((hit) => hit.name === name)?.version || ""
992
1072
  setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
993
1073
  setHits((prev) =>
@@ -1018,7 +1098,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1018
1098
  notify(result.message, "error")
1019
1099
  return
1020
1100
  }
1021
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1101
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
1022
1102
  setCatalog((prev) =>
1023
1103
  prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
1024
1104
  )
@@ -1145,8 +1225,13 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1145
1225
  return list
1146
1226
  })
1147
1227
 
1148
- const installable = createMemo(() => visible().filter((entry) => entry.pkg).length)
1228
+ const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
1149
1229
  const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
1230
+ const visiblePlugins = createMemo(() => {
1231
+ const plugins = state()?.plugins ?? []
1232
+ if (!staleOnly()) return plugins
1233
+ return plugins.filter((entry) => updates().get(entry.spec)?.hasUpdate === true)
1234
+ })
1150
1235
  const bySpec = (spec: string) => updates().get(spec)
1151
1236
  const byPackage = createMemo(() => {
1152
1237
  const map = new Map<string, UpdateInfo>()
@@ -1156,6 +1241,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1156
1241
 
1157
1242
  const switchTab = (next: Tab) => {
1158
1243
  setTab(next)
1244
+ // Leaving the installed view resets its "updatable only" filter.
1245
+ if (next !== "installed") setStaleOnly(false)
1159
1246
  // The first look at the installed list also checks the registry, once.
1160
1247
  if (next !== "catalog" && !checked() && !isBusy("updates")) void checkUpdates(true)
1161
1248
  }
@@ -1193,6 +1280,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1193
1280
  const catalogRow = (entry: CatalogEntry) => {
1194
1281
  const info = () => byPackage().get(entry.pkg)
1195
1282
  const stale = () => info()?.hasUpdate === true
1283
+ const busy = () => isBusy(`install:${entry.pkg}`)
1196
1284
  const detail = () =>
1197
1285
  openDetail(entry.pkg, {
1198
1286
  version: entry.version,
@@ -1200,54 +1288,62 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1200
1288
  repo: entry.repo,
1201
1289
  })
1202
1290
  return (
1203
- <article class="ph-card">
1204
- <div class="ph-tile">
1205
- <ui.Icon name={typeIcon(entry.type)} size="normal" />
1206
- </div>
1207
- <div
1208
- class="ph-card-body"
1209
- role="button"
1210
- tabindex="0"
1211
- onClick={() => (entry.pkg ? detail() : void openUrl(entry.repo))}
1212
- onKeyDown={(event) => {
1213
- if (event.key === "Enter" || event.key === " ") {
1214
- event.preventDefault()
1215
- if (entry.pkg) detail()
1216
- else void openUrl(entry.repo)
1217
- }
1218
- }}
1219
- >
1220
- <div class="ph-name-row">
1221
- <span class="ph-name">{entry.name}</span>
1222
- <Show when={entry.pkg}>
1223
- <span class="ph-pkg">{entry.pkg}</span>
1224
- </Show>
1225
- <Show when={entry.installed}>
1226
- <span class="ph-badge" data-tone="success">
1227
- 已安装
1228
- </span>
1229
- </Show>
1230
- <Show when={stale()}>
1231
- <span class="ph-badge" data-tone="accent">
1232
- 可更新 → v{info()!.latest}
1233
- </span>
1234
- </Show>
1291
+ <article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
1292
+ <div class="ph-card-main">
1293
+ <div class="ph-card-head">
1294
+ <div class="ph-tile">
1295
+ <ui.Icon name={typeIcon(entry.type)} size="normal" />
1296
+ </div>
1297
+ <div class="ph-card-titles">
1298
+ <div class="ph-name-row">
1299
+ <span class="ph-name">{entry.name}</span>
1300
+ </div>
1301
+ <Show when={entry.pkg}>
1302
+ <span class="ph-pkg">{entry.pkg}</span>
1303
+ </Show>
1304
+ </div>
1305
+ <div class="ph-chips">
1306
+ <Show when={entry.installed}>
1307
+ <span class="ph-badge" data-tone="success">
1308
+ 已安装
1309
+ </span>
1310
+ </Show>
1311
+ <Show when={stale()}>
1312
+ <span class="ph-badge" data-tone="accent">
1313
+ 可更新 → v{info()!.latest}
1314
+ </span>
1315
+ </Show>
1316
+ </div>
1235
1317
  </div>
1236
- <div class="ph-desc ph-clamp">{entry.desc || entry.long || "—"}</div>
1237
- <div class="ph-meta">
1238
- <span class="ph-type">{TYPE_LABEL[entry.type] ?? entry.type}</span>
1239
- <Show when={entry.version}>
1240
- <span class="ph-dot" />
1241
- <span>v{entry.version}</span>
1242
- </Show>
1243
- <Show when={!entry.pkg}>
1244
- <span class="ph-dot" />
1245
- <span>仅 GitHub 发布</span>
1246
- </Show>
1247
- <Show when={entry.tags.length > 0}>
1248
- <span class="ph-dot" />
1249
- <span>{entry.tags.slice(0, 4).join(" / ")}</span>
1250
- </Show>
1318
+ <div
1319
+ class="ph-card-body"
1320
+ role="button"
1321
+ tabindex="0"
1322
+ onClick={() => (entry.pkg ? detail() : void openUrl(entry.repo))}
1323
+ onKeyDown={(event) => {
1324
+ if (event.key === "Enter" || event.key === " ") {
1325
+ event.preventDefault()
1326
+ if (entry.pkg) detail()
1327
+ else void openUrl(entry.repo)
1328
+ }
1329
+ }}
1330
+ >
1331
+ <div class="ph-desc ph-clamp">{entry.desc || entry.long || "—"}</div>
1332
+ <div class="ph-meta">
1333
+ <span class="ph-type">{TYPE_LABEL[entry.type] ?? entry.type}</span>
1334
+ <Show when={entry.version}>
1335
+ <span class="ph-dot" />
1336
+ <span>v{entry.version}</span>
1337
+ </Show>
1338
+ <Show when={!entry.pkg}>
1339
+ <span class="ph-dot" />
1340
+ <span>仅 GitHub 发布</span>
1341
+ </Show>
1342
+ <Show when={entry.tags.length > 0}>
1343
+ <span class="ph-dot" />
1344
+ <span>{entry.tags.slice(0, 4).join(" / ")}</span>
1345
+ </Show>
1346
+ </div>
1251
1347
  </div>
1252
1348
  </div>
1253
1349
  <div class="ph-actions">
@@ -1269,7 +1365,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1269
1365
  variant="ghost"
1270
1366
  size="small"
1271
1367
  icon="download"
1272
- busy={isBusy(`install:${entry.pkg}`)}
1368
+ busy={busy()}
1273
1369
  onClick={() => void install(entry.pkg, info()!.latest)}
1274
1370
  >
1275
1371
  更新
@@ -1280,7 +1376,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1280
1376
  variant="neutral"
1281
1377
  size="small"
1282
1378
  icon="download"
1283
- busy={isBusy(`install:${entry.pkg}`)}
1379
+ busy={busy()}
1284
1380
  onClick={() => void install(entry.pkg)}
1285
1381
  >
1286
1382
  安装
@@ -1304,71 +1400,70 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1304
1400
  npm: hit.npm,
1305
1401
  })
1306
1402
  return (
1307
- <article class="ph-card">
1308
- <div class="ph-tile">{initial(hit.name)}</div>
1309
- <div
1310
- class="ph-card-body"
1311
- role="button"
1312
- tabindex="0"
1313
- onClick={detail}
1314
- onKeyDown={(event) => {
1315
- if (event.key === "Enter" || event.key === " ") {
1316
- event.preventDefault()
1317
- detail()
1318
- }
1319
- }}
1320
- >
1321
- <div class="ph-name-row">
1322
- <span class="ph-name">{hit.name}</span>
1323
- <span class="ph-pkg">v{hit.version}</span>
1324
- <Show when={hit.installed}>
1325
- <span class="ph-badge" data-tone="success">
1326
- 已安装{hit.current ? ` v${hit.current}` : ""}
1327
- </span>
1328
- </Show>
1329
- <Show when={hit.hasUpdate}>
1330
- <span class="ph-badge" data-tone="accent">
1331
- 可更新 → v{hit.version}
1332
- </span>
1333
- </Show>
1403
+ <article class="ph-card" data-busy={isBusy(`install:${hit.name}`) ? "true" : undefined}>
1404
+ <div class="ph-card-main">
1405
+ <div class="ph-card-head">
1406
+ <div class="ph-tile">{initial(hit.name)}</div>
1407
+ <div class="ph-card-titles">
1408
+ <div class="ph-name-row">
1409
+ <span class="ph-name">{hit.name}</span>
1410
+ </div>
1411
+ <span class="ph-pkg">v{hit.version}</span>
1412
+ </div>
1413
+ <div class="ph-chips">
1414
+ <Show when={hit.installed}>
1415
+ <span class="ph-badge" data-tone="success">
1416
+ 已安装{hit.current ? ` v${hit.current}` : ""}
1417
+ </span>
1418
+ </Show>
1419
+ <Show when={hit.hasUpdate}>
1420
+ <span class="ph-badge" data-tone="accent">
1421
+ 可更新 → v{hit.version}
1422
+ </span>
1423
+ </Show>
1424
+ </div>
1334
1425
  </div>
1335
- <div class="ph-desc ph-clamp">{hit.description || "—"}</div>
1336
- <div class="ph-meta">
1337
- <span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
1338
- <ui.Icon name="download" size="small" />
1339
- {formatCount(hit.weekly)}/周
1340
- </span>
1341
- <span class="ph-dot" />
1342
- <span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
1343
- <Show when={hit.publisher}>
1344
- <span class="ph-dot" />
1345
- <span>by {hit.publisher}</span>
1346
- </Show>
1347
- <Show when={hit.license}>
1348
- <span class="ph-dot" />
1349
- <span>{hit.license}</span>
1350
- </Show>
1351
- <Show when={hit.published}>
1426
+ <div
1427
+ class="ph-card-body"
1428
+ role="button"
1429
+ tabindex="0"
1430
+ onClick={detail}
1431
+ onKeyDown={(event) => {
1432
+ if (event.key === "Enter" || event.key === " ") {
1433
+ event.preventDefault()
1434
+ detail()
1435
+ }
1436
+ }}
1437
+ >
1438
+ <div class="ph-desc ph-clamp">{hit.description || "—"}</div>
1439
+ <div class="ph-meta">
1440
+ <span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
1441
+ <ui.Icon name="download" size="small" />
1442
+ {formatCount(hit.weekly)}/周
1443
+ </span>
1352
1444
  <span class="ph-dot" />
1353
- <span>{relativeDate(hit.published)}更新</span>
1445
+ <span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
1446
+ <Show when={hit.publisher}>
1447
+ <span class="ph-dot" />
1448
+ <span>by {hit.publisher}</span>
1449
+ </Show>
1450
+ <Show when={hit.published}>
1451
+ <span class="ph-dot" />
1452
+ <span>{relativeDate(hit.published)}更新</span>
1453
+ </Show>
1454
+ </div>
1455
+ <Show when={hit.keywords.length > 0}>
1456
+ <div class="ph-chips">
1457
+ <For each={hit.keywords.slice(0, 3)}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
1458
+ </div>
1354
1459
  </Show>
1355
1460
  </div>
1356
- <Show when={hit.keywords.length > 0}>
1357
- <div class="ph-chips">
1358
- <For each={hit.keywords}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
1359
- </div>
1360
- </Show>
1361
1461
  </div>
1362
1462
  <div class="ph-actions">
1363
1463
  <Show when={hit.homepage}>
1364
- <ui.Button
1365
- variant="ghost-muted"
1366
- size="small"
1367
- icon="globe"
1368
- onClick={() => void openUrl(hit.homepage)}
1369
- >
1370
- 主页
1371
- </ui.Button>
1464
+ <ui.Tooltip value="主页">
1465
+ <ui.IconButton icon="globe" size="small" label="主页" onClick={() => void openUrl(hit.homepage)} />
1466
+ </ui.Tooltip>
1372
1467
  </Show>
1373
1468
  <Show when={hit.repo}>
1374
1469
  <ui.Tooltip value="源码仓库">
@@ -1413,35 +1508,44 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1413
1508
  const installedRow = (entry: PluginEntry) => {
1414
1509
  const info = () => bySpec(entry.spec)
1415
1510
  const stale = () => info()?.hasUpdate === true
1511
+ const busy = () => isBusy(`install:${entry.name}`)
1416
1512
  const current = () => info()?.current || entry.version
1417
1513
  const unchecked = () => (info()?.error ?? "") !== ""
1418
1514
  return (
1419
- <article class="ph-card">
1420
- <div class="ph-tile">{initial(entry.name)}</div>
1421
- <div class="ph-card-body">
1422
- <div class="ph-name-row">
1423
- <span class="ph-name">{entry.name}</span>
1424
- {versionLabel(current(), info()?.latest ?? "", stale())}
1425
- <Show when={entry.kind === "path"}>
1426
- <span class="ph-badge">本地目录</span>
1427
- </Show>
1428
- <Show when={stale()}>
1429
- <span class="ph-badge" data-tone="accent">
1430
- 可更新 → v{info()!.latest}
1431
- </span>
1432
- </Show>
1433
- <Show when={unchecked()}>
1434
- <span class="ph-badge" data-tone="warning">
1435
- 未能检查
1436
- </span>
1437
- </Show>
1515
+ <article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
1516
+ <div class="ph-card-main">
1517
+ <div class="ph-card-head">
1518
+ <div class="ph-tile">{initial(entry.name)}</div>
1519
+ <div class="ph-card-titles">
1520
+ <div class="ph-name-row">
1521
+ <span class="ph-name">{entry.name}</span>
1522
+ </div>
1523
+ <span class="ph-pkg">{entry.spec}</span>
1524
+ </div>
1525
+ <div class="ph-chips">
1526
+ <Show when={stale()}>
1527
+ <span class="ph-badge" data-tone="accent">
1528
+ 可更新 → v{info()!.latest}
1529
+ </span>
1530
+ </Show>
1531
+ <Show when={unchecked()}>
1532
+ <span class="ph-badge" data-tone="warning">
1533
+ 未能检查
1534
+ </span>
1535
+ </Show>
1536
+ <Show when={entry.kind === "path"}>
1537
+ <span class="ph-badge">本地目录</span>
1538
+ </Show>
1539
+ </div>
1438
1540
  </div>
1439
- <div class="ph-meta">
1440
- <span class="ph-pkg">{entry.spec}</span>
1441
- <Show when={stale() && entry.kind === "path"}>
1442
- <span class="ph-dot" />
1443
- <span>更新会覆盖目录内容,原文件自动备份</span>
1444
- </Show>
1541
+ <div class="ph-card-body">
1542
+ <div class="ph-meta">
1543
+ {versionLabel(current(), info()?.latest ?? "", stale())}
1544
+ <Show when={stale() && entry.kind === "path"}>
1545
+ <span class="ph-dot" />
1546
+ <span>更新会覆盖目录内容,原文件自动备份</span>
1547
+ </Show>
1548
+ </div>
1445
1549
  </div>
1446
1550
  </div>
1447
1551
  <div class="ph-actions">
@@ -1460,7 +1564,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1460
1564
  variant="ghost"
1461
1565
  size="small"
1462
1566
  icon="download"
1463
- busy={isBusy(`install:${entry.name}`)}
1567
+ busy={busy()}
1464
1568
  onClick={() => void install(info()!.name, info()!.latest)}
1465
1569
  >
1466
1570
  更新
@@ -1471,7 +1575,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1471
1575
  variant="ghost-muted"
1472
1576
  size="small"
1473
1577
  icon="trash"
1474
- busy={isBusy(`install:${entry.name}`)}
1578
+ busy={busy()}
1475
1579
  onClick={() => void uninstall(entry)}
1476
1580
  >
1477
1581
  移除
@@ -1496,7 +1600,16 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1496
1600
  <h2 class="ph-title">插件中心</h2>
1497
1601
  </Show>
1498
1602
  <p class="ph-sub">
1499
- 浏览社区精选、搜索 npm 上的 OpenCode 插件。安装前先看下载量、README 和主页,确认它到底能做什么。
1603
+ 浏览{" "}
1604
+ <button
1605
+ type="button"
1606
+ class="ph-inline-source"
1607
+ title={CATALOG_SOURCE_TITLE}
1608
+ onClick={() => void openUrl(registryUrl())}
1609
+ >
1610
+ awesome-opencode
1611
+ </button>{" "}
1612
+ 社区维护的精选目录、搜索 npm 上的 OpenCode 插件。安装前先看下载量、README 和主页,确认它到底能做什么。
1500
1613
  </p>
1501
1614
  <div class="ph-path" title={state()?.configPath}>
1502
1615
  配置:<code>{state()?.configPath ?? "读取中…"}</code> · <code>{state()?.configKey ?? "plugins"}[]</code>
@@ -1504,21 +1617,22 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1504
1617
  </div>
1505
1618
  </section>
1506
1619
 
1507
- <Show when={note()}>
1508
- <div class="ph-notice" data-tone={note()!.tone}>
1509
- <ui.Icon name={noteIcon(note()!.tone)} size="small" />
1510
- <span>{note()!.text}</span>
1511
- </div>
1512
- </Show>
1620
+ <div class="ph-head">
1621
+ <Show when={note()}>
1622
+ <div class="ph-notice" data-tone={note()!.tone}>
1623
+ <ui.Icon name={noteIcon(note()!.tone)} size="small" />
1624
+ <span>{note()!.text}</span>
1625
+ </div>
1626
+ </Show>
1513
1627
 
1514
- <Show when={state()?.error}>
1515
- <div class="ph-notice" data-tone="error">
1516
- <ui.Icon name="circle-exclamation" size="small" />
1517
- <span>{state()!.error}</span>
1518
- </div>
1519
- </Show>
1628
+ <Show when={state()?.error}>
1629
+ <div class="ph-notice" data-tone="error">
1630
+ <ui.Icon name="circle-exclamation" size="small" />
1631
+ <span>{state()!.error}</span>
1632
+ </div>
1633
+ </Show>
1520
1634
 
1521
- <div class="ph-toolbar">
1635
+ <div class="ph-toolbar">
1522
1636
  <div class="ph-tabs" role="tablist">
1523
1637
  {tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
1524
1638
  {tabButton("search", "npm 搜索", "magnifying-glass", searched() ? hits().length : undefined)}
@@ -1590,7 +1704,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1590
1704
  >
1591
1705
  检查更新
1592
1706
  </ui.Button>
1593
- </Show>
1707
+ </Show>
1708
+ </div>
1594
1709
  </div>
1595
1710
 
1596
1711
  <Switch>
@@ -1600,7 +1715,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1600
1715
  <div class="ph-filter-row">
1601
1716
  <div class="ph-grow">
1602
1717
  <ui.TextInput
1603
- label="筛选社区精选插件"
1718
+ label="筛选 awesome-opencode 精选插件"
1604
1719
  placeholder="按名称、描述或标签筛选,例如 memory / auth / token"
1605
1720
  type="search"
1606
1721
  value={filter()}
@@ -1628,17 +1743,6 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1628
1743
  </For>
1629
1744
  </div>
1630
1745
  </div>
1631
- <div class="ph-meta">
1632
- <span>{SOURCE_LABEL[meta()?.source ?? ""] ?? "加载中…"}</span>
1633
- <span class="ph-dot" />
1634
- <span>
1635
- {catalog().length} 个条目 · {installable()} 个可安装
1636
- </span>
1637
- <Show when={meta()?.fetchedAt}>
1638
- <span class="ph-dot" />
1639
- <span>更新于 {formatDate(meta()!.fetchedAt)}</span>
1640
- </Show>
1641
- </div>
1642
1746
  </div>
1643
1747
 
1644
1748
  <Show when={isBusy("catalog") && catalog().length === 0}>
@@ -1773,6 +1877,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1773
1877
  <h3 class="ph-h3">
1774
1878
  已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
1775
1879
  </h3>
1880
+ <div class="ph-chips">
1881
+ <button
1882
+ class="ph-chip"
1883
+ data-active={staleOnly()}
1884
+ data-disabled={outdated() === 0 ? "true" : undefined}
1885
+ title={outdated() === 0 ? "检查更新后再来看可更新的插件" : "只显示有新版本的插件"}
1886
+ onClick={() => {
1887
+ if (outdated() > 0) setStaleOnly(!staleOnly())
1888
+ }}
1889
+ >
1890
+ 可更新
1891
+ <span class="ph-chip-count">{outdated()}</span>
1892
+ </button>
1893
+ </div>
1894
+ <span class="ph-spacer" />
1776
1895
  <Show when={checked()}>
1777
1896
  <span class="ph-meta">
1778
1897
  检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
@@ -1785,13 +1904,27 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1785
1904
  <div class="ph-empty">
1786
1905
  <ui.Icon name="extensions" size="large" />
1787
1906
  <strong>还没有安装插件</strong>
1788
- <span>去「精选目录」看看社区推荐,或者在「npm 搜索」里找长尾插件。</span>
1907
+ <span>去「精选目录」看看 awesome-opencode 的推荐,或者在「npm 搜索」里找长尾插件。</span>
1789
1908
  </div>
1790
1909
  }
1791
1910
  >
1792
- <div class="ph-list">
1793
- <For each={state()?.plugins ?? []}>{(entry) => installedRow(entry)}</For>
1794
- </div>
1911
+ <Show
1912
+ when={visiblePlugins().length > 0}
1913
+ fallback={
1914
+ <div class="ph-empty">
1915
+ <ui.Icon name="checklist" size="large" />
1916
+ <strong>没有可更新的插件</strong>
1917
+ <span>已安装的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
1918
+ <ui.Button variant="ghost" size="small" onClick={() => setStaleOnly(false)}>
1919
+ 显示全部
1920
+ </ui.Button>
1921
+ </div>
1922
+ }
1923
+ >
1924
+ <div class="ph-list">
1925
+ <For each={visiblePlugins()}>{(entry) => installedRow(entry)}</For>
1926
+ </div>
1927
+ </Show>
1795
1928
  </Show>
1796
1929
  </section>
1797
1930
  </Match>
@@ -1799,7 +1932,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1799
1932
 
1800
1933
  <footer class="ph-footer">
1801
1934
  <div class="ph-footer-note">
1802
- 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点上方工具栏的「彻底重启」,应用会自动退出并重新打开。
1935
+ 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点工具栏的「彻底重启」,应用会自动退出并重新打开。
1803
1936
  </div>
1804
1937
  </footer>
1805
1938
  </div>
@@ -1809,11 +1942,24 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1809
1942
  /** The standalone x-large dialog opened from the titlebar pill and the ⌘⌥P shortcut. */
1810
1943
  export function PluginHubDialog(props: { ctx: Context; admin: AdminClient; onClose: () => void }) {
1811
1944
  const ui = props.ctx.ui
1945
+ const openSettings = () => {
1946
+ props.onClose()
1947
+ // Let the dialog unmount before the host swaps in the Settings view.
1948
+ setTimeout(() => props.ctx.layout.settings("hub"), 0)
1949
+ }
1812
1950
  return (
1813
1951
  <ui.Dialog
1814
1952
  title="插件中心"
1815
1953
  size="x-large"
1816
- footer={<ui.Button onClick={props.onClose}>关闭</ui.Button>}
1954
+ footer={
1955
+ <div class="ph-dialog-footer">
1956
+ <span class="ph-footer-note">拖动右下角可调整大小</span>
1957
+ <ui.Button size="small" variant="ghost" icon="settings-gear" onClick={openSettings}>
1958
+ 在设置中打开
1959
+ </ui.Button>
1960
+ <ui.Button size="small" onClick={props.onClose}>关闭</ui.Button>
1961
+ </div>
1962
+ }
1817
1963
  >
1818
1964
  <PluginHubPage ctx={props.ctx} admin={props.admin} embedded />
1819
1965
  </ui.Dialog>