opencode-plugin-hub 0.1.2 → 0.1.4

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
@@ -102,7 +102,7 @@ function noteIcon(tone: Tone): IconName {
102
102
  function readableError(error: unknown): string {
103
103
  const text = error instanceof Error ? error.message : String(error)
104
104
  if (/SchemaError|Missing key at/i.test(text)) {
105
- return "插件窗口与后台服务版本不一致。点上方工具栏的「彻底重启」让两边换成同一版本。"
105
+ return "插件窗口与后台服务版本不一致。点工具栏的「彻底重启」让两边换成同一版本。"
106
106
  }
107
107
  if (/unknown method|not implemented|no such method|method.*not.*found/i.test(text)) {
108
108
  return "后台服务还是旧版本,暂时没有「彻底重启」能力。请先手动完全退出 OpenCode(并结束 serve --service)再打开一次。"
@@ -348,12 +348,18 @@ const CSS = `
348
348
  }
349
349
 
350
350
  /* cards */
351
- .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
+ }
352
357
  .ph-card {
358
+ position: relative;
353
359
  display: flex;
354
- align-items: flex-start;
355
- gap: 12px;
356
- padding: 14px;
360
+ flex-direction: column;
361
+ gap: 9px;
362
+ padding: 12px;
357
363
  border: 1px solid var(--ph-border);
358
364
  border-radius: 12px;
359
365
  background: var(--ph-layer);
@@ -364,22 +370,59 @@ const CSS = `
364
370
  background: var(--ph-layer-2);
365
371
  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.05);
366
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; }
367
412
  .ph-tile {
368
413
  flex: 0 0 auto;
369
414
  display: grid;
370
415
  place-items: center;
371
- width: 38px;
372
- height: 38px;
416
+ width: 32px;
417
+ height: 32px;
373
418
  border: 1px solid color-mix(in srgb, var(--ph-accent) 20%, transparent);
374
- border-radius: 10px;
419
+ border-radius: 9px;
375
420
  background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
376
421
  color: var(--ph-accent);
377
- font-size: 15px;
422
+ font-size: 13px;
378
423
  font-weight: 700;
379
424
  overflow: hidden;
380
425
  }
381
- .ph-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
382
- .ph-card-body[role="button"] { cursor: pointer; border-radius: 6px; }
383
426
  .ph-name-row { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
384
427
  .ph-name {
385
428
  font-size: 13.5px;
@@ -409,7 +452,16 @@ const CSS = `
409
452
  text-transform: uppercase;
410
453
  color: var(--ph-faint);
411
454
  }
412
- .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
+ }
413
465
 
414
466
  /* badges and chips */
415
467
  .ph-badge {
@@ -471,6 +523,7 @@ button.ph-chip[data-active="true"] {
471
523
  border-color: color-mix(in srgb, var(--ph-accent) 32%, transparent);
472
524
  background: color-mix(in srgb, var(--ph-accent) 10%, transparent);
473
525
  }
526
+ .ph-chip[data-disabled="true"] { opacity: 0.45; cursor: default; }
474
527
  .ph-chip-count { font-size: 9px; opacity: 0.7; }
475
528
 
476
529
  /* empty and loading */
@@ -617,10 +670,16 @@ button.ph-chip:focus-visible,
617
670
  max-height: calc(100vh - 24px);
618
671
  resize: both;
619
672
  }
620
- .ph-root[data-embedded="true"] .ph-toolbar {
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 {
621
677
  position: sticky;
622
678
  top: 0;
623
679
  z-index: 3;
680
+ display: flex;
681
+ flex-direction: column;
682
+ gap: 14px;
624
683
  padding: 6px 0;
625
684
  margin: -6px 0;
626
685
  background: color-mix(in srgb, var(--color-v2-background-bg-base, Canvas) 92%, transparent);
@@ -639,9 +698,7 @@ button.ph-chip:focus-visible,
639
698
 
640
699
  @media (max-width: 760px) {
641
700
  .ph-hero { flex-direction: column; }
642
- .ph-card { flex-wrap: wrap; }
643
- .ph-card-body { flex-basis: calc(100% - 50px); }
644
- .ph-actions { width: 100%; justify-content: flex-start; padding-left: 50px; }
701
+ .ph-list { grid-template-columns: 1fr; }
645
702
  .ph-detail-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
646
703
  }
647
704
  `
@@ -907,6 +964,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
907
964
 
908
965
  const [updates, setUpdates] = createSignal<Map<string, UpdateInfo>>(new Map())
909
966
  const [checked, setChecked] = createSignal(false)
967
+ const [staleOnly, setStaleOnly] = createSignal(false)
910
968
 
911
969
  const [busyKeys, setBusyKeys] = createSignal<ReadonlySet<string>>(new Set())
912
970
  const [note, setNote] = createSignal<Note>()
@@ -977,7 +1035,9 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
977
1035
  const result = await props.admin.browse({ force })
978
1036
  setCatalog([...result.entries])
979
1037
  setMeta(result)
980
- if (result.error) notify(result.error, "error")
1038
+ // A fallback source still shows the full list, so a network miss is a downgrade to note,
1039
+ // not an error to alarm about. Only the stale built-in snapshot is worth a warning.
1040
+ if (result.error) notify(result.error, result.source === "builtin" ? "warning" : "info")
981
1041
  // Keep the installed view in sync with what the catalog just reported.
982
1042
  await refresh()
983
1043
  } catch (error) {
@@ -1009,7 +1069,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1009
1069
  notify(result.message, "error")
1010
1070
  return
1011
1071
  }
1012
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1072
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
1013
1073
  const latest = version || hits().find((hit) => hit.name === name)?.version || ""
1014
1074
  setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
1015
1075
  setHits((prev) =>
@@ -1040,7 +1100,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1040
1100
  notify(result.message, "error")
1041
1101
  return
1042
1102
  }
1043
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1103
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
1044
1104
  setCatalog((prev) =>
1045
1105
  prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
1046
1106
  )
@@ -1169,6 +1229,11 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1169
1229
 
1170
1230
  const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
1171
1231
  const outdated = createMemo(() => [...updates().values()].filter((item) => item.hasUpdate).length)
1232
+ const visiblePlugins = createMemo(() => {
1233
+ const plugins = state()?.plugins ?? []
1234
+ if (!staleOnly()) return plugins
1235
+ return plugins.filter((entry) => updates().get(entry.spec)?.hasUpdate === true)
1236
+ })
1172
1237
  const bySpec = (spec: string) => updates().get(spec)
1173
1238
  const byPackage = createMemo(() => {
1174
1239
  const map = new Map<string, UpdateInfo>()
@@ -1178,6 +1243,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1178
1243
 
1179
1244
  const switchTab = (next: Tab) => {
1180
1245
  setTab(next)
1246
+ // Leaving the installed view resets its "updatable only" filter.
1247
+ if (next !== "installed") setStaleOnly(false)
1181
1248
  // The first look at the installed list also checks the registry, once.
1182
1249
  if (next !== "catalog" && !checked() && !isBusy("updates")) void checkUpdates(true)
1183
1250
  }
@@ -1215,6 +1282,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1215
1282
  const catalogRow = (entry: CatalogEntry) => {
1216
1283
  const info = () => byPackage().get(entry.pkg)
1217
1284
  const stale = () => info()?.hasUpdate === true
1285
+ const busy = () => isBusy(`install:${entry.pkg}`)
1218
1286
  const detail = () =>
1219
1287
  openDetail(entry.pkg, {
1220
1288
  version: entry.version,
@@ -1222,54 +1290,62 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1222
1290
  repo: entry.repo,
1223
1291
  })
1224
1292
  return (
1225
- <article class="ph-card">
1226
- <div class="ph-tile">
1227
- <ui.Icon name={typeIcon(entry.type)} size="normal" />
1228
- </div>
1229
- <div
1230
- class="ph-card-body"
1231
- role="button"
1232
- tabindex="0"
1233
- onClick={() => (entry.pkg ? detail() : void openUrl(entry.repo))}
1234
- onKeyDown={(event) => {
1235
- if (event.key === "Enter" || event.key === " ") {
1236
- event.preventDefault()
1237
- if (entry.pkg) detail()
1238
- else void openUrl(entry.repo)
1239
- }
1240
- }}
1241
- >
1242
- <div class="ph-name-row">
1243
- <span class="ph-name">{entry.name}</span>
1244
- <Show when={entry.pkg}>
1245
- <span class="ph-pkg">{entry.pkg}</span>
1246
- </Show>
1247
- <Show when={entry.installed}>
1248
- <span class="ph-badge" data-tone="success">
1249
- 已安装
1250
- </span>
1251
- </Show>
1252
- <Show when={stale()}>
1253
- <span class="ph-badge" data-tone="accent">
1254
- 可更新 → v{info()!.latest}
1255
- </span>
1256
- </Show>
1293
+ <article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
1294
+ <div class="ph-card-main">
1295
+ <div class="ph-card-head">
1296
+ <div class="ph-tile">
1297
+ <ui.Icon name={typeIcon(entry.type)} size="normal" />
1298
+ </div>
1299
+ <div class="ph-card-titles">
1300
+ <div class="ph-name-row">
1301
+ <span class="ph-name">{entry.name}</span>
1302
+ </div>
1303
+ <Show when={entry.pkg}>
1304
+ <span class="ph-pkg">{entry.pkg}</span>
1305
+ </Show>
1306
+ </div>
1307
+ <div class="ph-chips">
1308
+ <Show when={entry.installed}>
1309
+ <span class="ph-badge" data-tone="success">
1310
+ 已安装
1311
+ </span>
1312
+ </Show>
1313
+ <Show when={stale()}>
1314
+ <span class="ph-badge" data-tone="accent">
1315
+ 可更新 → v{info()!.latest}
1316
+ </span>
1317
+ </Show>
1318
+ </div>
1257
1319
  </div>
1258
- <div class="ph-desc ph-clamp">{entry.desc || entry.long || "—"}</div>
1259
- <div class="ph-meta">
1260
- <span class="ph-type">{TYPE_LABEL[entry.type] ?? entry.type}</span>
1261
- <Show when={entry.version}>
1262
- <span class="ph-dot" />
1263
- <span>v{entry.version}</span>
1264
- </Show>
1265
- <Show when={!entry.pkg}>
1266
- <span class="ph-dot" />
1267
- <span>仅 GitHub 发布</span>
1268
- </Show>
1269
- <Show when={entry.tags.length > 0}>
1270
- <span class="ph-dot" />
1271
- <span>{entry.tags.slice(0, 4).join(" / ")}</span>
1272
- </Show>
1320
+ <div
1321
+ class="ph-card-body"
1322
+ role="button"
1323
+ tabindex="0"
1324
+ onClick={() => (entry.pkg ? detail() : void openUrl(entry.repo))}
1325
+ onKeyDown={(event) => {
1326
+ if (event.key === "Enter" || event.key === " ") {
1327
+ event.preventDefault()
1328
+ if (entry.pkg) detail()
1329
+ else void openUrl(entry.repo)
1330
+ }
1331
+ }}
1332
+ >
1333
+ <div class="ph-desc ph-clamp">{entry.desc || entry.long || "—"}</div>
1334
+ <div class="ph-meta">
1335
+ <span class="ph-type">{TYPE_LABEL[entry.type] ?? entry.type}</span>
1336
+ <Show when={entry.version}>
1337
+ <span class="ph-dot" />
1338
+ <span>v{entry.version}</span>
1339
+ </Show>
1340
+ <Show when={!entry.pkg}>
1341
+ <span class="ph-dot" />
1342
+ <span>仅 GitHub 发布</span>
1343
+ </Show>
1344
+ <Show when={entry.tags.length > 0}>
1345
+ <span class="ph-dot" />
1346
+ <span>{entry.tags.slice(0, 4).join(" / ")}</span>
1347
+ </Show>
1348
+ </div>
1273
1349
  </div>
1274
1350
  </div>
1275
1351
  <div class="ph-actions">
@@ -1291,7 +1367,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1291
1367
  variant="ghost"
1292
1368
  size="small"
1293
1369
  icon="download"
1294
- busy={isBusy(`install:${entry.pkg}`)}
1370
+ busy={busy()}
1295
1371
  onClick={() => void install(entry.pkg, info()!.latest)}
1296
1372
  >
1297
1373
  更新
@@ -1302,7 +1378,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1302
1378
  variant="neutral"
1303
1379
  size="small"
1304
1380
  icon="download"
1305
- busy={isBusy(`install:${entry.pkg}`)}
1381
+ busy={busy()}
1306
1382
  onClick={() => void install(entry.pkg)}
1307
1383
  >
1308
1384
  安装
@@ -1326,71 +1402,70 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1326
1402
  npm: hit.npm,
1327
1403
  })
1328
1404
  return (
1329
- <article class="ph-card">
1330
- <div class="ph-tile">{initial(hit.name)}</div>
1331
- <div
1332
- class="ph-card-body"
1333
- role="button"
1334
- tabindex="0"
1335
- onClick={detail}
1336
- onKeyDown={(event) => {
1337
- if (event.key === "Enter" || event.key === " ") {
1338
- event.preventDefault()
1339
- detail()
1340
- }
1341
- }}
1342
- >
1343
- <div class="ph-name-row">
1344
- <span class="ph-name">{hit.name}</span>
1345
- <span class="ph-pkg">v{hit.version}</span>
1346
- <Show when={hit.installed}>
1347
- <span class="ph-badge" data-tone="success">
1348
- 已安装{hit.current ? ` v${hit.current}` : ""}
1349
- </span>
1350
- </Show>
1351
- <Show when={hit.hasUpdate}>
1352
- <span class="ph-badge" data-tone="accent">
1353
- 可更新 → v{hit.version}
1354
- </span>
1355
- </Show>
1405
+ <article class="ph-card" data-busy={isBusy(`install:${hit.name}`) ? "true" : undefined}>
1406
+ <div class="ph-card-main">
1407
+ <div class="ph-card-head">
1408
+ <div class="ph-tile">{initial(hit.name)}</div>
1409
+ <div class="ph-card-titles">
1410
+ <div class="ph-name-row">
1411
+ <span class="ph-name">{hit.name}</span>
1412
+ </div>
1413
+ <span class="ph-pkg">v{hit.version}</span>
1414
+ </div>
1415
+ <div class="ph-chips">
1416
+ <Show when={hit.installed}>
1417
+ <span class="ph-badge" data-tone="success">
1418
+ 已安装{hit.current ? ` v${hit.current}` : ""}
1419
+ </span>
1420
+ </Show>
1421
+ <Show when={hit.hasUpdate}>
1422
+ <span class="ph-badge" data-tone="accent">
1423
+ 可更新 → v{hit.version}
1424
+ </span>
1425
+ </Show>
1426
+ </div>
1356
1427
  </div>
1357
- <div class="ph-desc ph-clamp">{hit.description || "—"}</div>
1358
- <div class="ph-meta">
1359
- <span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
1360
- <ui.Icon name="download" size="small" />
1361
- {formatCount(hit.weekly)}/周
1362
- </span>
1363
- <span class="ph-dot" />
1364
- <span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
1365
- <Show when={hit.publisher}>
1366
- <span class="ph-dot" />
1367
- <span>by {hit.publisher}</span>
1368
- </Show>
1369
- <Show when={hit.license}>
1370
- <span class="ph-dot" />
1371
- <span>{hit.license}</span>
1372
- </Show>
1373
- <Show when={hit.published}>
1428
+ <div
1429
+ class="ph-card-body"
1430
+ role="button"
1431
+ tabindex="0"
1432
+ onClick={detail}
1433
+ onKeyDown={(event) => {
1434
+ if (event.key === "Enter" || event.key === " ") {
1435
+ event.preventDefault()
1436
+ detail()
1437
+ }
1438
+ }}
1439
+ >
1440
+ <div class="ph-desc ph-clamp">{hit.description || "—"}</div>
1441
+ <div class="ph-meta">
1442
+ <span title={`周下载 ${hit.weekly.toLocaleString()} 次`}>
1443
+ <ui.Icon name="download" size="small" />
1444
+ {formatCount(hit.weekly)}/周
1445
+ </span>
1374
1446
  <span class="ph-dot" />
1375
- <span>{relativeDate(hit.published)}更新</span>
1447
+ <span title={`月下载 ${hit.monthly.toLocaleString()} 次`}>{formatCount(hit.monthly)}/月</span>
1448
+ <Show when={hit.publisher}>
1449
+ <span class="ph-dot" />
1450
+ <span>by {hit.publisher}</span>
1451
+ </Show>
1452
+ <Show when={hit.published}>
1453
+ <span class="ph-dot" />
1454
+ <span>{relativeDate(hit.published)}更新</span>
1455
+ </Show>
1456
+ </div>
1457
+ <Show when={hit.keywords.length > 0}>
1458
+ <div class="ph-chips">
1459
+ <For each={hit.keywords.slice(0, 3)}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
1460
+ </div>
1376
1461
  </Show>
1377
1462
  </div>
1378
- <Show when={hit.keywords.length > 0}>
1379
- <div class="ph-chips">
1380
- <For each={hit.keywords}>{(keyword) => <span class="ph-chip">{keyword}</span>}</For>
1381
- </div>
1382
- </Show>
1383
1463
  </div>
1384
1464
  <div class="ph-actions">
1385
1465
  <Show when={hit.homepage}>
1386
- <ui.Button
1387
- variant="ghost-muted"
1388
- size="small"
1389
- icon="globe"
1390
- onClick={() => void openUrl(hit.homepage)}
1391
- >
1392
- 主页
1393
- </ui.Button>
1466
+ <ui.Tooltip value="主页">
1467
+ <ui.IconButton icon="globe" size="small" label="主页" onClick={() => void openUrl(hit.homepage)} />
1468
+ </ui.Tooltip>
1394
1469
  </Show>
1395
1470
  <Show when={hit.repo}>
1396
1471
  <ui.Tooltip value="源码仓库">
@@ -1435,35 +1510,44 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1435
1510
  const installedRow = (entry: PluginEntry) => {
1436
1511
  const info = () => bySpec(entry.spec)
1437
1512
  const stale = () => info()?.hasUpdate === true
1513
+ const busy = () => isBusy(`install:${entry.name}`)
1438
1514
  const current = () => info()?.current || entry.version
1439
1515
  const unchecked = () => (info()?.error ?? "") !== ""
1440
1516
  return (
1441
- <article class="ph-card">
1442
- <div class="ph-tile">{initial(entry.name)}</div>
1443
- <div class="ph-card-body">
1444
- <div class="ph-name-row">
1445
- <span class="ph-name">{entry.name}</span>
1446
- {versionLabel(current(), info()?.latest ?? "", stale())}
1447
- <Show when={entry.kind === "path"}>
1448
- <span class="ph-badge">本地目录</span>
1449
- </Show>
1450
- <Show when={stale()}>
1451
- <span class="ph-badge" data-tone="accent">
1452
- 可更新 → v{info()!.latest}
1453
- </span>
1454
- </Show>
1455
- <Show when={unchecked()}>
1456
- <span class="ph-badge" data-tone="warning">
1457
- 未能检查
1458
- </span>
1459
- </Show>
1517
+ <article class="ph-card" data-stale={stale() ? "true" : undefined} data-busy={busy() ? "true" : undefined}>
1518
+ <div class="ph-card-main">
1519
+ <div class="ph-card-head">
1520
+ <div class="ph-tile">{initial(entry.name)}</div>
1521
+ <div class="ph-card-titles">
1522
+ <div class="ph-name-row">
1523
+ <span class="ph-name">{entry.name}</span>
1524
+ </div>
1525
+ <span class="ph-pkg">{entry.spec}</span>
1526
+ </div>
1527
+ <div class="ph-chips">
1528
+ <Show when={stale()}>
1529
+ <span class="ph-badge" data-tone="accent">
1530
+ 可更新 → v{info()!.latest}
1531
+ </span>
1532
+ </Show>
1533
+ <Show when={unchecked()}>
1534
+ <span class="ph-badge" data-tone="warning">
1535
+ 未能检查
1536
+ </span>
1537
+ </Show>
1538
+ <Show when={entry.kind === "path"}>
1539
+ <span class="ph-badge">本地目录</span>
1540
+ </Show>
1541
+ </div>
1460
1542
  </div>
1461
- <div class="ph-meta">
1462
- <span class="ph-pkg">{entry.spec}</span>
1463
- <Show when={stale() && entry.kind === "path"}>
1464
- <span class="ph-dot" />
1465
- <span>更新会覆盖目录内容,原文件自动备份</span>
1466
- </Show>
1543
+ <div class="ph-card-body">
1544
+ <div class="ph-meta">
1545
+ {versionLabel(current(), info()?.latest ?? "", stale())}
1546
+ <Show when={stale() && entry.kind === "path"}>
1547
+ <span class="ph-dot" />
1548
+ <span>更新会覆盖目录内容,原文件自动备份</span>
1549
+ </Show>
1550
+ </div>
1467
1551
  </div>
1468
1552
  </div>
1469
1553
  <div class="ph-actions">
@@ -1482,7 +1566,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1482
1566
  variant="ghost"
1483
1567
  size="small"
1484
1568
  icon="download"
1485
- busy={isBusy(`install:${entry.name}`)}
1569
+ busy={busy()}
1486
1570
  onClick={() => void install(info()!.name, info()!.latest)}
1487
1571
  >
1488
1572
  更新
@@ -1493,7 +1577,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1493
1577
  variant="ghost-muted"
1494
1578
  size="small"
1495
1579
  icon="trash"
1496
- busy={isBusy(`install:${entry.name}`)}
1580
+ busy={busy()}
1497
1581
  onClick={() => void uninstall(entry)}
1498
1582
  >
1499
1583
  移除
@@ -1535,21 +1619,22 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1535
1619
  </div>
1536
1620
  </section>
1537
1621
 
1538
- <Show when={note()}>
1539
- <div class="ph-notice" data-tone={note()!.tone}>
1540
- <ui.Icon name={noteIcon(note()!.tone)} size="small" />
1541
- <span>{note()!.text}</span>
1542
- </div>
1543
- </Show>
1622
+ <div class="ph-head">
1623
+ <Show when={note()}>
1624
+ <div class="ph-notice" data-tone={note()!.tone}>
1625
+ <ui.Icon name={noteIcon(note()!.tone)} size="small" />
1626
+ <span>{note()!.text}</span>
1627
+ </div>
1628
+ </Show>
1544
1629
 
1545
- <Show when={state()?.error}>
1546
- <div class="ph-notice" data-tone="error">
1547
- <ui.Icon name="circle-exclamation" size="small" />
1548
- <span>{state()!.error}</span>
1549
- </div>
1550
- </Show>
1630
+ <Show when={state()?.error}>
1631
+ <div class="ph-notice" data-tone="error">
1632
+ <ui.Icon name="circle-exclamation" size="small" />
1633
+ <span>{state()!.error}</span>
1634
+ </div>
1635
+ </Show>
1551
1636
 
1552
- <div class="ph-toolbar">
1637
+ <div class="ph-toolbar">
1553
1638
  <div class="ph-tabs" role="tablist">
1554
1639
  {tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
1555
1640
  {tabButton("search", "npm 搜索", "magnifying-glass", searched() ? hits().length : undefined)}
@@ -1621,7 +1706,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1621
1706
  >
1622
1707
  检查更新
1623
1708
  </ui.Button>
1624
- </Show>
1709
+ </Show>
1710
+ </div>
1625
1711
  </div>
1626
1712
 
1627
1713
  <Switch>
@@ -1793,6 +1879,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1793
1879
  <h3 class="ph-h3">
1794
1880
  已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
1795
1881
  </h3>
1882
+ <div class="ph-chips">
1883
+ <button
1884
+ class="ph-chip"
1885
+ data-active={staleOnly()}
1886
+ data-disabled={outdated() === 0 ? "true" : undefined}
1887
+ title={outdated() === 0 ? "检查更新后再来看可更新的插件" : "只显示有新版本的插件"}
1888
+ onClick={() => {
1889
+ if (outdated() > 0) setStaleOnly(!staleOnly())
1890
+ }}
1891
+ >
1892
+ 可更新
1893
+ <span class="ph-chip-count">{outdated()}</span>
1894
+ </button>
1895
+ </div>
1896
+ <span class="ph-spacer" />
1796
1897
  <Show when={checked()}>
1797
1898
  <span class="ph-meta">
1798
1899
  检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
@@ -1809,9 +1910,23 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1809
1910
  </div>
1810
1911
  }
1811
1912
  >
1812
- <div class="ph-list">
1813
- <For each={state()?.plugins ?? []}>{(entry) => installedRow(entry)}</For>
1814
- </div>
1913
+ <Show
1914
+ when={visiblePlugins().length > 0}
1915
+ fallback={
1916
+ <div class="ph-empty">
1917
+ <ui.Icon name="checklist" size="large" />
1918
+ <strong>没有可更新的插件</strong>
1919
+ <span>已安装的插件都已是最新版本。点工具栏的「检查更新」可以再查一次。</span>
1920
+ <ui.Button variant="ghost" size="small" onClick={() => setStaleOnly(false)}>
1921
+ 显示全部
1922
+ </ui.Button>
1923
+ </div>
1924
+ }
1925
+ >
1926
+ <div class="ph-list">
1927
+ <For each={visiblePlugins()}>{(entry) => installedRow(entry)}</For>
1928
+ </div>
1929
+ </Show>
1815
1930
  </Show>
1816
1931
  </section>
1817
1932
  </Match>
@@ -1819,7 +1934,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1819
1934
 
1820
1935
  <footer class="ph-footer">
1821
1936
  <div class="ph-footer-note">
1822
- 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点上方工具栏的「彻底重启」,应用会自动退出并重新打开。
1937
+ 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点工具栏的「彻底重启」,应用会自动退出并重新打开。
1823
1938
  </div>
1824
1939
  </footer>
1825
1940
  </div>