opencode-plugin-hub 0.1.2 → 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
@@ -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>()
@@ -1009,7 +1067,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1009
1067
  notify(result.message, "error")
1010
1068
  return
1011
1069
  }
1012
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1070
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
1013
1071
  const latest = version || hits().find((hit) => hit.name === name)?.version || ""
1014
1072
  setCatalog((prev) => prev.map((entry) => (entry.pkg === name ? { ...entry, installed: true } : entry)))
1015
1073
  setHits((prev) =>
@@ -1040,7 +1098,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1040
1098
  notify(result.message, "error")
1041
1099
  return
1042
1100
  }
1043
- notify(`${result.message}。点上方工具栏的「彻底重启」后生效`, "success")
1101
+ notify(`${result.message}。点工具栏的「彻底重启」后生效`, "success")
1044
1102
  setCatalog((prev) =>
1045
1103
  prev.map((item) => (item.pkg === entry.name ? { ...item, installed: false } : item)),
1046
1104
  )
@@ -1169,6 +1227,11 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1169
1227
 
1170
1228
  const registryUrl = () => meta()?.registry || CATALOG_SOURCE_FALLBACK
1171
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
+ })
1172
1235
  const bySpec = (spec: string) => updates().get(spec)
1173
1236
  const byPackage = createMemo(() => {
1174
1237
  const map = new Map<string, UpdateInfo>()
@@ -1178,6 +1241,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1178
1241
 
1179
1242
  const switchTab = (next: Tab) => {
1180
1243
  setTab(next)
1244
+ // Leaving the installed view resets its "updatable only" filter.
1245
+ if (next !== "installed") setStaleOnly(false)
1181
1246
  // The first look at the installed list also checks the registry, once.
1182
1247
  if (next !== "catalog" && !checked() && !isBusy("updates")) void checkUpdates(true)
1183
1248
  }
@@ -1215,6 +1280,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1215
1280
  const catalogRow = (entry: CatalogEntry) => {
1216
1281
  const info = () => byPackage().get(entry.pkg)
1217
1282
  const stale = () => info()?.hasUpdate === true
1283
+ const busy = () => isBusy(`install:${entry.pkg}`)
1218
1284
  const detail = () =>
1219
1285
  openDetail(entry.pkg, {
1220
1286
  version: entry.version,
@@ -1222,54 +1288,62 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1222
1288
  repo: entry.repo,
1223
1289
  })
1224
1290
  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>
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>
1257
1317
  </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>
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>
1273
1347
  </div>
1274
1348
  </div>
1275
1349
  <div class="ph-actions">
@@ -1291,7 +1365,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1291
1365
  variant="ghost"
1292
1366
  size="small"
1293
1367
  icon="download"
1294
- busy={isBusy(`install:${entry.pkg}`)}
1368
+ busy={busy()}
1295
1369
  onClick={() => void install(entry.pkg, info()!.latest)}
1296
1370
  >
1297
1371
  更新
@@ -1302,7 +1376,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1302
1376
  variant="neutral"
1303
1377
  size="small"
1304
1378
  icon="download"
1305
- busy={isBusy(`install:${entry.pkg}`)}
1379
+ busy={busy()}
1306
1380
  onClick={() => void install(entry.pkg)}
1307
1381
  >
1308
1382
  安装
@@ -1326,71 +1400,70 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1326
1400
  npm: hit.npm,
1327
1401
  })
1328
1402
  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>
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>
1356
1425
  </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}>
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>
1374
1444
  <span class="ph-dot" />
1375
- <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>
1376
1459
  </Show>
1377
1460
  </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
1461
  </div>
1384
1462
  <div class="ph-actions">
1385
1463
  <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>
1464
+ <ui.Tooltip value="主页">
1465
+ <ui.IconButton icon="globe" size="small" label="主页" onClick={() => void openUrl(hit.homepage)} />
1466
+ </ui.Tooltip>
1394
1467
  </Show>
1395
1468
  <Show when={hit.repo}>
1396
1469
  <ui.Tooltip value="源码仓库">
@@ -1435,35 +1508,44 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1435
1508
  const installedRow = (entry: PluginEntry) => {
1436
1509
  const info = () => bySpec(entry.spec)
1437
1510
  const stale = () => info()?.hasUpdate === true
1511
+ const busy = () => isBusy(`install:${entry.name}`)
1438
1512
  const current = () => info()?.current || entry.version
1439
1513
  const unchecked = () => (info()?.error ?? "") !== ""
1440
1514
  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>
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>
1460
1540
  </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>
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>
1467
1549
  </div>
1468
1550
  </div>
1469
1551
  <div class="ph-actions">
@@ -1482,7 +1564,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1482
1564
  variant="ghost"
1483
1565
  size="small"
1484
1566
  icon="download"
1485
- busy={isBusy(`install:${entry.name}`)}
1567
+ busy={busy()}
1486
1568
  onClick={() => void install(info()!.name, info()!.latest)}
1487
1569
  >
1488
1570
  更新
@@ -1493,7 +1575,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1493
1575
  variant="ghost-muted"
1494
1576
  size="small"
1495
1577
  icon="trash"
1496
- busy={isBusy(`install:${entry.name}`)}
1578
+ busy={busy()}
1497
1579
  onClick={() => void uninstall(entry)}
1498
1580
  >
1499
1581
  移除
@@ -1535,21 +1617,22 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1535
1617
  </div>
1536
1618
  </section>
1537
1619
 
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>
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>
1544
1627
 
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>
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>
1551
1634
 
1552
- <div class="ph-toolbar">
1635
+ <div class="ph-toolbar">
1553
1636
  <div class="ph-tabs" role="tablist">
1554
1637
  {tabButton("catalog", "精选目录", "extensions", meta() ? catalog().length : undefined)}
1555
1638
  {tabButton("search", "npm 搜索", "magnifying-glass", searched() ? hits().length : undefined)}
@@ -1621,7 +1704,8 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1621
1704
  >
1622
1705
  检查更新
1623
1706
  </ui.Button>
1624
- </Show>
1707
+ </Show>
1708
+ </div>
1625
1709
  </div>
1626
1710
 
1627
1711
  <Switch>
@@ -1793,6 +1877,21 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1793
1877
  <h3 class="ph-h3">
1794
1878
  已安装 <span class="ph-count">{state()?.plugins.length ?? 0}</span>
1795
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" />
1796
1895
  <Show when={checked()}>
1797
1896
  <span class="ph-meta">
1798
1897
  检查过 npm registry{outdated() > 0 ? ` · ${outdated()} 个可更新` : " · 全部已是最新"}
@@ -1809,9 +1908,23 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1809
1908
  </div>
1810
1909
  }
1811
1910
  >
1812
- <div class="ph-list">
1813
- <For each={state()?.plugins ?? []}>{(entry) => installedRow(entry)}</For>
1814
- </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>
1815
1928
  </Show>
1816
1929
  </section>
1817
1930
  </Match>
@@ -1819,7 +1932,7 @@ export function PluginHubPage(props: { ctx: Context; admin: AdminClient; embedde
1819
1932
 
1820
1933
  <footer class="ph-footer">
1821
1934
  <div class="ph-footer-note">
1822
- 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点上方工具栏的「彻底重启」,应用会自动退出并重新打开。
1935
+ 安装和移除只改配置,不会立即生效:插件与模型目录都是启动时快照的。改完点工具栏的「彻底重启」,应用会自动退出并重新打开。
1823
1936
  </div>
1824
1937
  </footer>
1825
1938
  </div>