@stolyarovmn/dsh-ui-registry-aggregator 0.4.14 → 0.4.15

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/README.md CHANGED
@@ -29,14 +29,20 @@ Search across connected sources, filter results, inspect evidence, and install a
29
29
  - **Fallback command** — installable results also expose their `dsh plugin add …` command.
30
30
  - **Responsive popularity metadata** — missing GitHub star counts are hydrated after Browse renders, so GitHub metadata cannot block the initial search result.
31
31
 
32
- ## What's new in 0.4.14
32
+ ## What's new in 0.4.15
33
33
 
34
- 0.4.14 is the first release published from the renamed `Stolyarovmn/dsh-ui-registry-aggregator` repository through npm Trusted Publishing. It keeps the fast Browse/search path from 0.4.13 unchanged.
34
+ 0.4.15 focuses on reliability and native DSH integration while preserving the existing Browse evidence path.
35
35
 
36
- - published from the canonical `dsh-ui-registry-aggregator` repository;
37
- - npm Trusted Publisher is wired to `.github/workflows/publish.yml`;
38
- - README screenshots and repository metadata point to the renamed repository;
39
- - search, source counts, bundle verification, and asynchronous GitHub star hydration remain unchanged from 0.4.13.
36
+ - lightweight source health checks are separated from verified DSH bundle counts;
37
+ - npm and GitHub counts load independently with native DSH progress indicators;
38
+ - GitHub requests retry transient failures, while health no longer consumes Search API quota;
39
+ - installed bundles are detected through the native Plugin Manager and duplicate installs are disabled;
40
+ - newer discovered versions are shown as advisory `Update available` notices;
41
+ - active installs can be cancelled through the native Plugin Manager request id;
42
+ - npm results can be enriched with exact GitHub provenance without broad GitHub pagination;
43
+ - search relevance is presentation-only, so GitHub stars, downloads, evidence, pagination, and explicit sorting keep their existing behavior.
44
+
45
+ Tested with DSH `0.1.7-rc.2`.
40
46
 
41
47
  ## Architecture
42
48
 
@@ -167,7 +173,7 @@ The order in which criteria are enabled defines priority.
167
173
  ## Compatibility
168
174
 
169
175
  - DSH: `>=0.1.7-rc.1 <0.2.0`
170
- - Tested with DSH `0.1.7-rc.1`
176
+ - Tested with DSH `0.1.7-rc.2`
171
177
 
172
178
  Exact package compatibility is read from package metadata when a publisher declares it. Installation is validated again by the native DSH Plugin Manager.
173
179
 
package/lib/client.js CHANGED
@@ -12,7 +12,7 @@ window.__ModuleLoader__.load({
12
12
  IconCopyOutlineRegular, IconShareOutlineRegular, IconTrashOutlineRegular,
13
13
  IconDatabaseOutlineRegular, IconCordisPluginOutlineRegular, IconLinkOutlineRegular,
14
14
  IconWarningOutlineRegular, IconSearchOutlineRegular, IconClockOutlineRegular,
15
- IconDownloadOutlineRegular, IconCheckOutlineRegular, IconChevronUpOutlineRegular,
15
+ IconDownloadOutlineRegular, IconCheckOutlineRegular, IconCloseOutlineRegular, IconChevronUpOutlineRegular,
16
16
  IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronsUpDownOutlineRegular,
17
17
  IconFlatListOutlineRegular, IconShieldOutlineRegular, IconChecklistOutlineRegular, IconCodeOutlineRegular,
18
18
  } = require("@deepseek-ai/dsh-client-ui-primitives");
@@ -332,12 +332,16 @@ window.__ModuleLoader__.load({
332
332
 
333
333
  .pm-card{padding:11px 12px}
334
334
  .pm-card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
335
+ .pm-card-install-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}
335
336
  .pm-card-install{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;flex:0 0 30px;width:30px;height:30px;padding:0;border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);cursor:pointer}
336
337
  .pm-card-install:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-brand-primary)}
337
338
  .pm-card-install[data-state=installed]{color:var(--dsw-alias-state-success-primary)}
338
339
  .pm-card-install[data-state=failed]{color:var(--dsw-alias-state-error-primary)}
339
340
  .pm-card-install:disabled{opacity:.55;cursor:default}
340
341
  .pm-card-install svg{display:block;width:16px;height:16px}
342
+ .pm-card-cancel{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;flex:0 0 30px;width:30px;height:30px;padding:0;border:.5px solid color-mix(in srgb,var(--pm-danger) 45%,var(--dsw-alias-border-l3));border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--pm-danger);cursor:pointer}
343
+ .pm-card-cancel:hover{background:color-mix(in srgb,var(--pm-danger) 9%,var(--dsw-alias-bg-layer-1));border-color:var(--pm-danger)}
344
+ .pm-card-cancel svg{display:block;width:14px;height:14px}
341
345
 
342
346
  .pm-card-description{position:relative;margin-top:7px;min-width:0}
343
347
  .pm-desc{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px}
@@ -458,16 +462,16 @@ window.__ModuleLoader__.load({
458
462
  const en = {
459
463
  nav: "Registry Aggregator", kicker: "Federated plugin registry", title: "Registry Aggregator", lead: "Aggregate connected plugin registries and catalogs, search them as one index, and install through the native DSH Plugin Manager.",
460
464
  "tab.sources": "Sources", "tab.browse": "Browse", "sources.title": "Connected sources", "sources.add": "Add source", "sources.addTitle": "Add a new source", "sources.addLead": "Connect a package registry to discover and install plugins.", "sources.manage": "Manage your connected registries and discover available plugins.", "sources.empty": "No plugin sources connected", "sources.emptyHint": "Add a catalog, npm registry, or GitHub source to begin.",
461
- "field.name": "Name", "field.type": "Type", "field.url": "Catalog / API URL", "field.urlHint": "Optional for npm and GitHub", "source.remove": "Remove", "source.share": "Share", "source.shared": "Copied", "source.enabled": "Enabled", "source.disabled": "Disabled", "source.checking": "Checking", "source.online": "Status: online", "source.packages": "{count} packages", "source.repositories": "{count} repositories", "source.ok": "Healthy", "source.error": "Unavailable", "source.duplicate": "A source with this id already exists.", "source.required": "Name is required.", "source.urlRequired": "This source type requires a catalog URL.",
465
+ "field.name": "Name", "field.type": "Type", "field.url": "Catalog / API URL", "field.urlHint": "Optional for npm and GitHub", "source.remove": "Remove", "source.share": "Share", "source.shared": "Copied", "source.enabled": "Enabled", "source.disabled": "Disabled", "source.checking": "Checking", "source.online": "Status: online", "source.packages": "{count} packages", "source.repositories": "{count} repositories", "source.countingPackages": "Counting packages…", "source.countingRepositories": "Counting repositories…", "source.ok": "Healthy", "source.error": "Unavailable", "source.duplicate": "A source with this id already exists.", "source.required": "Name is required.", "source.urlRequired": "This source type requires a catalog URL.",
462
466
  "activation.title": "Registry Aggregator ready", "activation.body": "npm and GitHub sources are connected by default. Open Registry Aggregator to search them together or add more sources.", "activation.open": "Open Registry Aggregator", "activation.later": "Later",
463
- "browse.search": "Search plugins, packages, repositories…", "browse.refresh": "Refresh", "browse.pageSize": "Per page", "browse.page": "Page {page} of {pages}", "browse.prev": "Previous", "browse.next": "Next", "browse.freshness": "Freshness", "browse.freshness.any": "Any age", "browse.freshness.30": "≤ 30 days", "browse.freshness.90": "≤ 90 days", "browse.freshness.365": "≤ 1 year", "browse.category": "Category", "browse.category.any": "All categories", "browse.dshMetadata": "DSH metadata", "browse.dshMetadata.any": "Any DSH metadata", "browse.dshMetadata.declared": "DSH declared", "browse.dshMetadata.unknown": "DSH unknown", "install": "Install", "install.confirm": "Install?", "installing": "Installing…", "install.checking": "Checking package…", "install.starting": "Starting install…", "install.running": "Installing package…", "install.runningAttempt": "Installing {index}/{total}…", "install.applying": "Applying plugin…", "installed": "Installed", "install.failed": "Install failed", "install.retry": "Retry install", "install.notBundle": "Not a DSH bundle", "install.approvalRequired": "Build-script approval required in the native Plugin Manager", "install.cancel": "Cancel", "description.more": "…", "description.expand": "Expand description", "description.less": "Less", "browse.empty": "No plugins found", "browse.sort": "Sort", "browse.sort.relevance": "Relevance", "browse.sort.stars": "Stars", "browse.sort.downloads": "Downloads", "browse.sort.freshness": "Freshest release", "browse.sort.starsDownloads": "Stars + downloads", "browse.sort.downloadsFreshness": "Downloads + freshness", "browse.sort.name": "Name", "browse.sort.priority": "Sort priority {priority}", "browse.sort.off": "Not active", "browse.sort.asc": "Ascending", "browse.sort.desc": "Descending", "browse.stableOnly": "Stable only", "browse.emptyHint": "No plugins match the current search or filters.", "browse.noSources": "No enabled registry sources. Open Sources and enable or add one.", "browse.sourcesUnavailable": "All enabled registry sources are unavailable.", "browse.loading": "Loading catalogs…", "browse.updating": "Updating results…", "browse.error": "Catalog request failed", "browse.results": "{count} results", "compat.check": "Check DSH compatibility", "compat.none": "DSH not declared", "compat.loading": "DSH …", "compat.apiPeers": "DSH API peers", "released": "released {age}", "repoUpdated": "repo {age}", "deprecated": "deprecated", "copy": "Copy command", "copied": "Copied", "copy.failed": "Could not copy", "install.warning": "Review the package and source before running this third-party install command.", "readonly": "Settings are read-only.", "unavailable": "Plugin source settings are unavailable.",
467
+ "browse.search": "Search plugins, packages, repositories…", "browse.refresh": "Refresh", "browse.pageSize": "Per page", "browse.page": "Page {page} of {pages}", "browse.prev": "Previous", "browse.next": "Next", "browse.freshness": "Freshness", "browse.freshness.any": "Any age", "browse.freshness.30": "≤ 30 days", "browse.freshness.90": "≤ 90 days", "browse.freshness.365": "≤ 1 year", "browse.category": "Category", "browse.category.any": "All categories", "browse.dshMetadata": "DSH metadata", "browse.dshMetadata.any": "Any DSH metadata", "browse.dshMetadata.declared": "DSH declared", "browse.dshMetadata.unknown": "DSH unknown", "install": "Install", "install.confirm": "Install?", "installing": "Installing…", "install.checking": "Checking package…", "install.starting": "Starting install…", "install.running": "Installing package…", "install.runningAttempt": "Installing {index}/{total}…", "install.applying": "Applying plugin…", "install.cancelActive": "Cancel install", "install.cancelling": "Cancelling…", "install.cancelFailed": "Cancel failed", "installed": "Installed", "installed.version": "Installed {version}", "update.available": "Update available {installed} → {available}", "install.failed": "Install failed", "install.retry": "Retry install", "install.notBundle": "Not a DSH bundle", "install.approvalRequired": "Build-script approval required in the native Plugin Manager", "install.cancel": "Cancel", "description.more": "…", "description.expand": "Expand description", "description.less": "Less", "browse.empty": "No plugins found", "browse.sort": "Sort", "browse.sort.relevance": "Relevance", "browse.sort.stars": "Stars", "browse.sort.downloads": "Downloads", "browse.sort.freshness": "Freshest release", "browse.sort.starsDownloads": "Stars + downloads", "browse.sort.downloadsFreshness": "Downloads + freshness", "browse.sort.name": "Name", "browse.sort.priority": "Sort priority {priority}", "browse.sort.off": "Not active", "browse.sort.asc": "Ascending", "browse.sort.desc": "Descending", "browse.stableOnly": "Stable only", "browse.emptyHint": "No plugins match the current search or filters.", "browse.noSources": "No enabled registry sources. Open Sources and enable or add one.", "browse.sourcesUnavailable": "All enabled registry sources are unavailable.", "browse.loading": "Loading catalogs…", "browse.updating": "Updating results…", "browse.error": "Catalog request failed", "browse.results": "{count} results", "compat.check": "Check DSH compatibility", "compat.none": "DSH not declared", "compat.loading": "DSH …", "compat.apiPeers": "DSH API peers", "released": "released {age}", "repoUpdated": "repo {age}", "deprecated": "deprecated", "copy": "Copy command", "copied": "Copied", "copy.failed": "Could not copy", "install.warning": "Review the package and source before running this third-party install command.", "readonly": "Settings are read-only.", "unavailable": "Plugin source settings are unavailable.",
464
468
  };
465
469
  const zh = {
466
470
  nav: "注册源聚合器", kicker: "联合插件注册表", title: "注册源聚合器", lead: "聚合已连接的插件注册表和目录,统一搜索,并通过原生 DSH Plugin Manager 安装。",
467
471
  "tab.sources": "来源", "tab.browse": "浏览", "sources.title": "已连接来源", "sources.add": "添加来源", "sources.addTitle": "添加新来源", "sources.addLead": "连接软件包注册表以发现和安装插件。", "sources.manage": "管理已连接的注册表并发现可用插件。", "sources.empty": "尚未连接插件源", "sources.emptyHint": "添加目录、npm 注册表或 GitHub 来源以开始。",
468
- "field.name": "名称", "field.type": "类型", "field.url": "目录 / API URL", "field.urlHint": "npm 和 GitHub 可选", "source.remove": "移除", "source.share": "分享", "source.shared": "已复制", "source.enabled": "已启用", "source.disabled": "已禁用", "source.checking": "检查中", "source.online": "状态:在线", "source.packages": "{count} 个软件包", "source.repositories": "{count} 个仓库", "source.ok": "正常", "source.error": "不可用", "source.duplicate": "具有此 ID 的来源已存在。", "source.required": "名称为必填项。", "source.urlRequired": "此来源类型需要目录 URL。",
472
+ "field.name": "名称", "field.type": "类型", "field.url": "目录 / API URL", "field.urlHint": "npm 和 GitHub 可选", "source.remove": "移除", "source.share": "分享", "source.shared": "已复制", "source.enabled": "已启用", "source.disabled": "已禁用", "source.checking": "检查中", "source.online": "状态:在线", "source.packages": "{count} 个软件包", "source.repositories": "{count} 个仓库", "source.countingPackages": "正在统计软件包…", "source.countingRepositories": "正在统计仓库…", "source.ok": "正常", "source.error": "不可用", "source.duplicate": "具有此 ID 的来源已存在。", "source.required": "名称为必填项。", "source.urlRequired": "此来源类型需要目录 URL。",
469
473
  "activation.title": "注册源聚合器已就绪", "activation.body": "npm 和 GitHub 来源默认已连接。打开注册源聚合器即可统一搜索或添加更多来源。", "activation.open": "打开注册源聚合器", "activation.later": "稍后",
470
- "browse.search": "搜索插件、包、仓库…", "browse.refresh": "刷新", "browse.pageSize": "每页", "browse.page": "第 {page} / {pages} 页", "browse.prev": "上一页", "browse.next": "下一页", "browse.freshness": "新鲜度", "browse.freshness.any": "不限时间", "browse.freshness.30": "≤ 30 天", "browse.freshness.90": "≤ 90 天", "browse.freshness.365": "≤ 1 年", "browse.category": "类别", "browse.category.any": "全部类别", "browse.dshMetadata": "DSH 元数据", "browse.dshMetadata.any": "任意 DSH 元数据", "browse.dshMetadata.declared": "已声明 DSH", "browse.dshMetadata.unknown": "DSH 未知", "install": "安装", "install.confirm": "确认安装?", "installing": "安装中…", "installed": "已安装", "install.failed": "安装失败", "install.retry": "重试安装", "install.cancel": "取消", "description.more": "…", "description.expand": "展开描述", "description.less": "收起", "browse.empty": "未找到插件", "browse.sort": "排序", "browse.sort.relevance": "相关性", "browse.sort.stars": "星标", "browse.sort.downloads": "下载量", "browse.sort.freshness": "最新发布", "browse.sort.starsDownloads": "星标 + 下载量", "browse.sort.downloadsFreshness": "下载量 + 新鲜度", "browse.sort.name": "名称", "browse.sort.priority": "排序优先级 {priority}", "browse.sort.off": "未启用", "browse.sort.asc": "升序", "browse.sort.desc": "降序", "browse.stableOnly": "仅稳定版", "browse.emptyHint": "当前搜索或筛选条件没有匹配的插件。", "browse.noSources": "没有启用的注册源。请打开“来源”并启用或添加一个来源。", "browse.sourcesUnavailable": "所有已启用的注册源当前都不可用。", "browse.loading": "正在加载目录…", "browse.updating": "正在更新结果…", "browse.error": "目录请求失败", "browse.results": "{count} 个结果", "compat.check": "检查 DSH 兼容性", "compat.none": "未声明 DSH", "compat.loading": "DSH …", "compat.apiPeers": "DSH API 依赖", "released": "发布 {age}", "repoUpdated": "仓库 {age}", "deprecated": "已弃用", "copy": "复制命令", "copied": "已复制", "copy.failed": "复制失败", "install.warning": "运行此第三方安装命令前,请检查包和来源。", "readonly": "设置为只读。", "unavailable": "插件源设置不可用。",
474
+ "browse.search": "搜索插件、包、仓库…", "browse.refresh": "刷新", "browse.pageSize": "每页", "browse.page": "第 {page} / {pages} 页", "browse.prev": "上一页", "browse.next": "下一页", "browse.freshness": "新鲜度", "browse.freshness.any": "不限时间", "browse.freshness.30": "≤ 30 天", "browse.freshness.90": "≤ 90 天", "browse.freshness.365": "≤ 1 年", "browse.category": "类别", "browse.category.any": "全部类别", "browse.dshMetadata": "DSH 元数据", "browse.dshMetadata.any": "任意 DSH 元数据", "browse.dshMetadata.declared": "已声明 DSH", "browse.dshMetadata.unknown": "DSH 未知", "install": "安装", "install.confirm": "确认安装?", "installing": "安装中…", "install.cancelActive": "取消安装", "install.cancelling": "正在取消…", "install.cancelFailed": "取消失败", "installed": "已安装", "installed.version": "已安装 {version}", "update.available": "可更新 {installed} → {available}", "install.failed": "安装失败", "install.retry": "重试安装", "install.cancel": "取消", "description.more": "…", "description.expand": "展开描述", "description.less": "收起", "browse.empty": "未找到插件", "browse.sort": "排序", "browse.sort.relevance": "相关性", "browse.sort.stars": "星标", "browse.sort.downloads": "下载量", "browse.sort.freshness": "最新发布", "browse.sort.starsDownloads": "星标 + 下载量", "browse.sort.downloadsFreshness": "下载量 + 新鲜度", "browse.sort.name": "名称", "browse.sort.priority": "排序优先级 {priority}", "browse.sort.off": "未启用", "browse.sort.asc": "升序", "browse.sort.desc": "降序", "browse.stableOnly": "仅稳定版", "browse.emptyHint": "当前搜索或筛选条件没有匹配的插件。", "browse.noSources": "没有启用的注册源。请打开“来源”并启用或添加一个来源。", "browse.sourcesUnavailable": "所有已启用的注册源当前都不可用。", "browse.loading": "正在加载目录…", "browse.updating": "正在更新结果…", "browse.error": "目录请求失败", "browse.results": "{count} 个结果", "compat.check": "检查 DSH 兼容性", "compat.none": "未声明 DSH", "compat.loading": "DSH …", "compat.apiPeers": "DSH API 依赖", "released": "发布 {age}", "repoUpdated": "仓库 {age}", "deprecated": "已弃用", "copy": "复制命令", "copied": "已复制", "copy.failed": "复制失败", "install.warning": "运行此第三方安装命令前,请检查包和来源。", "readonly": "设置为只读。", "unavailable": "插件源设置不可用。",
471
475
  };
472
476
 
473
477
  function slug(value) {
@@ -595,12 +599,15 @@ window.__ModuleLoader__.load({
595
599
  return h("div", { className: "pm-source-logo pm-source-logo-generic", "aria-hidden": true, children: h(PackageIcon, {}) });
596
600
  }
597
601
 
598
- function SourceRow({ source, health, t, busy, readOnly, onToggle, onRemove }) {
602
+ function SourceRow({ source, health, sourceCount, t, busy, readOnly, onToggle, onRemove }) {
599
603
  const [open, setOpen] = react.useState(true);
600
604
  const [copied, setCopied] = react.useState(false);
601
605
  const status = source.enabled === false ? "off" : health?.ok === true ? "ok" : health?.ok === false ? "bad" : "wait";
602
606
  const endpoint = sourceEndpoint(source);
603
- const count = Number.isFinite(health?.count) ? health.count : 0;
607
+ const count = Number.isFinite(sourceCount?.count) ? sourceCount.count : Number.isFinite(health?.count) ? health.count : undefined;
608
+ const countLoading = status === "ok" && sourceCount?.loading === true && count === undefined;
609
+ const countLabel = count === undefined ? undefined : source.type === "github" ? t("source.repositories", { count }) : t("source.packages", { count });
610
+ const countingLabel = source.type === "github" ? t("source.countingRepositories") : t("source.countingPackages");
604
611
  const statusText = status === "off" ? t("source.disabled") : status === "ok" ? t("source.online") : status === "bad" ? t("source.error") : t("source.checking");
605
612
  const copyEndpoint = async () => {
606
613
  try { await globalThis.navigator?.clipboard?.writeText?.(endpoint); setCopied(true); setTimeout(() => setCopied(false), 1400); } catch {}
@@ -613,8 +620,9 @@ window.__ModuleLoader__.load({
613
620
  h("div", { className: "pm-source-line", children: h("span", { className: "pm-source-name", children: source.name }) }),
614
621
  hs("div", { className: "pm-source-evidence", children: [
615
622
  hs("span", { className: "pm-source-evidence-item", children: [h("span", { className: `pm-status pm-status-${status}`, "aria-hidden": true }), h("span", { children: statusText })] }),
616
- status === "ok" ? h("span", { className: "pm-evidence-separator", "aria-hidden": true }) : null,
617
- status === "ok" ? hs("span", { className: "pm-source-evidence-item", children: [h(PackageIcon, {}), h("span", { children: t(source.type === "github" ? "source.repositories" : "source.packages", { count }) })] }) : null,
623
+ status === "ok" && (count !== undefined || countLoading) ? h("span", { className: "pm-evidence-separator", "aria-hidden": true }) : null,
624
+ countLoading ? hs("span", { className: "pm-source-evidence-item", title: countingLabel, role: "status", "aria-label": countingLabel, children: [h(StateDot, { state: "ongoing", size: 14 }), h("span", { className: "pm-sr-only", children: countingLabel })] }) : null,
625
+ status === "ok" && count !== undefined ? hs("span", { className: "pm-source-evidence-item", children: [h(PackageIcon, {}), h("span", { children: countLabel })] }) : null,
618
626
  ] }),
619
627
  ] }),
620
628
  ] }),
@@ -632,12 +640,12 @@ window.__ModuleLoader__.load({
632
640
  h("input", { className: "pm-source-url", value: endpoint, readOnly: true, title: endpoint }),
633
641
  h("button", { type: "button", className: "pm-copy-url", onClick: copyEndpoint, title: copied ? t("copied") : t("copy"), "aria-label": copied ? t("copied") : t("copy"), children: copied ? h(IconCopyOutlineRegular, { size: 16 }) : h(IconCopyOutlineRegular, { size: 16 }) }),
634
642
  ] }),
635
- health?.error ? h("div", { className: "pm-source-error", title: health.error, children: health.error }) : null,
643
+ health?.error ? h("div", { className: "pm-source-error", title: health.error, children: health.error }) : sourceCount?.error ? h("div", { className: "pm-source-error", title: sourceCount.error, children: sourceCount.error }) : null,
636
644
  ] }) : null,
637
645
  ] });
638
646
  }
639
647
 
640
- function SourcesView({ t, sources, writable, saveSources, busy, healthState, refreshHealth }) {
648
+ function SourcesView({ t, sources, writable, saveSources, busy, healthState, countMap, countsLoading, refreshHealth }) {
641
649
  const [mutationError, setMutationError] = react.useState("");
642
650
  const mutate = async (next) => {
643
651
  setMutationError("");
@@ -652,12 +660,12 @@ window.__ModuleLoader__.load({
652
660
  hs("div", { className: "pm-section-head-main", children: [
653
661
  h("div", { children: [h("div", { className: "pm-section-title", children: t("sources.title") }), h("div", { className: "pm-section-lead", children: t("sources.manage") })] }),
654
662
  ] }),
655
- h("button", { type: "button", className: "pm-refresh-icon", onClick: refreshHealth, disabled: healthState.loading, title: t("browse.refresh"), "aria-label": t("browse.refresh"), children: h(RefreshIcon, {}) }),
663
+ h("button", { type: "button", className: "pm-refresh-icon", onClick: refreshHealth, disabled: healthState.loading, title: t("browse.refresh"), "aria-label": t("browse.refresh"), children: h(RefreshIcon, { className: healthState.loading ? "pm-spin" : undefined }) }),
656
664
  ] }),
657
665
  !writable ? h("p", { className: "pm-notice pm-connected-notice", children: t("readonly") }) : null,
658
666
  mutationError ? h("p", { className: "pm-notice pm-notice-error pm-connected-notice", role: "alert", children: mutationError }) : null,
659
667
  healthState.error ? h("p", { className: "pm-notice pm-notice-error pm-connected-notice", role: "alert", children: healthState.error }) : null,
660
- sources.length === 0 ? h(Empty, { title: t("sources.empty"), hint: t("sources.emptyHint") }) : h("div", { className: "pm-source-list", children: sources.map((source) => h(SourceRow, { source, health: healthMap[source.id], t, busy, readOnly: !writable, onToggle: (id) => { void mutate(sources.map((row) => row.id === id ? { ...row, enabled: row.enabled === false } : row)); }, onRemove: (id) => { void mutate(sources.filter((row) => row.id !== id)); } }, source.id)) }),
668
+ sources.length === 0 ? h(Empty, { title: t("sources.empty"), hint: t("sources.emptyHint") }) : h("div", { className: "pm-source-list", children: sources.map((source) => h(SourceRow, { source, health: healthMap[source.id], sourceCount: countMap[source.id], t, busy, readOnly: !writable, onToggle: (id) => { void mutate(sources.map((row) => row.id === id ? { ...row, enabled: row.enabled === false } : row)); }, onRemove: (id) => { void mutate(sources.filter((row) => row.id !== id)); } }, source.id)) }),
661
669
  ] }),
662
670
  ] });
663
671
  }
@@ -671,6 +679,99 @@ window.__ModuleLoader__.load({
671
679
  return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value);
672
680
  }
673
681
 
682
+ function parseSemver(value) {
683
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(String(value ?? "").trim());
684
+ if (!match) return undefined;
685
+ return {
686
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
687
+ prerelease: match[4] ? match[4].split(".") : [],
688
+ };
689
+ }
690
+
691
+ function compareSemver(left, right) {
692
+ const a = parseSemver(left);
693
+ const b = parseSemver(right);
694
+ if (!a || !b) return 0;
695
+ for (let index = 0; index < 3; index++) {
696
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1;
697
+ }
698
+ if (!a.prerelease.length && !b.prerelease.length) return 0;
699
+ if (!a.prerelease.length) return 1;
700
+ if (!b.prerelease.length) return -1;
701
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
702
+ for (let index = 0; index < length; index++) {
703
+ const leftPart = a.prerelease[index];
704
+ const rightPart = b.prerelease[index];
705
+ if (leftPart === undefined) return -1;
706
+ if (rightPart === undefined) return 1;
707
+ if (leftPart === rightPart) continue;
708
+ const leftNumeric = /^\d+$/u.test(leftPart);
709
+ const rightNumeric = /^\d+$/u.test(rightPart);
710
+ if (leftNumeric && rightNumeric) return Number(leftPart) > Number(rightPart) ? 1 : -1;
711
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
712
+ return leftPart > rightPart ? 1 : -1;
713
+ }
714
+ return 0;
715
+ }
716
+
717
+ function localSearchVariants(value) {
718
+ const needle = String(value ?? "").trim().toLowerCase();
719
+ if (!needle) return [];
720
+ const variants = [needle];
721
+ const unscoped = needle.startsWith("@") && needle.includes("/") ? needle.slice(needle.indexOf("/") + 1) : needle;
722
+ if (unscoped !== needle) variants.push(unscoped);
723
+ let shortened = unscoped;
724
+ for (const prefix of ["dsh-client-ui-", "dsh-ui-", "dsh-plugin-"]) {
725
+ if (shortened.startsWith(prefix) && shortened.length > prefix.length) {
726
+ shortened = shortened.slice(prefix.length);
727
+ variants.push(shortened);
728
+ break;
729
+ }
730
+ }
731
+ if (shortened.startsWith("dsh-") && shortened.length > 4) variants.push(shortened.slice(4));
732
+ const parts = shortened.split("-").filter(Boolean);
733
+ if (parts.length >= 2) variants.push(parts.slice(-2).join("-"));
734
+ return [...new Set(variants.filter((item) => item.length >= 3))].slice(0, 3);
735
+ }
736
+
737
+ function localRelevanceScore(plugin, query) {
738
+ const variants = localSearchVariants(query);
739
+ if (!variants.length) return 0;
740
+ const name = String(plugin.name ?? "").toLowerCase();
741
+ const packageName = String(plugin.identity?.package ?? "").toLowerCase();
742
+ const repository = String(plugin.identity?.repository ?? "").toLowerCase();
743
+ const description = String(plugin.description ?? "").toLowerCase();
744
+ const tags = (plugin.tags ?? []).map((tag) => String(tag).toLowerCase());
745
+ let best = 0;
746
+ for (let index = 0; index < variants.length; index += 1) {
747
+ const needle = variants[index];
748
+ const penalty = index * 25;
749
+ const score = Math.max(
750
+ packageName === needle ? 1000 : 0,
751
+ name === needle ? 950 : 0,
752
+ packageName.startsWith(needle) ? 850 : 0,
753
+ name.startsWith(needle) ? 800 : 0,
754
+ packageName.includes(needle) ? 720 : 0,
755
+ name.includes(needle) ? 680 : 0,
756
+ tags.includes(needle) ? 600 : 0,
757
+ tags.some((tag) => tag.includes(needle)) ? 500 : 0,
758
+ repository.includes(needle) ? 300 : 0,
759
+ description.includes(needle) ? 200 : 0,
760
+ ) - penalty;
761
+ best = Math.max(best, score);
762
+ }
763
+ return Math.max(0, best);
764
+ }
765
+
766
+ function rankVisiblePlugins(plugins, query, sorts) {
767
+ if (!Array.isArray(plugins) || plugins.length < 2 || (Array.isArray(sorts) && sorts.length) || !String(query ?? "").trim()) return plugins;
768
+ return [...plugins].sort((left, right) => {
769
+ const relevance = localRelevanceScore(right, query) - localRelevanceScore(left, query);
770
+ if (relevance !== 0) return relevance;
771
+ return String(left.name ?? "").localeCompare(String(right.name ?? ""));
772
+ });
773
+ }
774
+
674
775
  function repositoryEvidenceKey(value) {
675
776
  if (typeof value !== "string" || !value.trim()) return "";
676
777
  let raw = value.trim().replace(/^git\+/, "").replace(/^git@github\.com:/i, "https://github.com/");
@@ -815,7 +916,7 @@ window.__ModuleLoader__.load({
815
916
  ] });
816
917
  }
817
918
 
818
- function PluginCard({ plugin, t }) {
919
+ function PluginCard({ plugin, t, bundles }) {
819
920
  const [copyState, setCopyState] = react.useState("");
820
921
  const [installState, setInstallState] = react.useState("idle");
821
922
  const [installError, setInstallError] = react.useState("");
@@ -823,6 +924,16 @@ window.__ModuleLoader__.load({
823
924
  const [detailsState, setDetailsState] = react.useState({ status: "idle" });
824
925
  const command = installCommand(plugin);
825
926
  const spec = plugin.install?.spec || "";
927
+ const packageName = plugin.identity?.package;
928
+ const installedBundle = packageName ? (bundles ?? []).find((bundle) => bundle?.name === packageName) : undefined;
929
+ const installedVersion = typeof installedBundle?.version === "string" ? installedBundle.version : undefined;
930
+ const updateAvailable = Boolean(installedVersion && plugin.version && compareSemver(plugin.version, installedVersion) === 1);
931
+ const effectiveInstallState = installedBundle ? "installed" : installState;
932
+ const installedLabel = updateAvailable
933
+ ? t("update.available", { installed: installedVersion, available: plugin.version })
934
+ : installedVersion ? t("installed.version", { version: installedVersion })
935
+ : installState === "installed" && plugin.version ? t("installed.version", { version: plugin.version })
936
+ : t("installed");
826
937
  const declaredCompatibility = plugin.evidence?.dshCompatibility;
827
938
  const loadDetails = async () => {
828
939
  if (detailsState.status === "loading" || detailsState.status === "ready") return;
@@ -868,7 +979,7 @@ window.__ModuleLoader__.load({
868
979
  };
869
980
 
870
981
  const install = async () => {
871
- if (!spec || !remote?.pluginManager || installState === "installing" || installState === "installed" || installState === "invalid") return;
982
+ if (!spec || !remote?.pluginManager || installedBundle || installState === "installing" || installState === "installed" || installState === "invalid") return;
872
983
  setInstallState("installing");
873
984
  setInstallError("");
874
985
  setInstallProgress({ phase: "checking", activity: 0 });
@@ -917,7 +1028,13 @@ window.__ModuleLoader__.load({
917
1028
  };
918
1029
  const result = await remote.pluginManager.installBundle(spec, options);
919
1030
  if (!result?.ok) throw new Error(result?.error?.message || t("install.failed"));
920
- if (result.value?.application === "failed" || result.value?.application === "cancelled") {
1031
+ if (result.value?.application === "cancelled") {
1032
+ setInstallProgress({ phase: "idle", activity: 0 });
1033
+ setInstallState("idle");
1034
+ setInstallError("");
1035
+ return;
1036
+ }
1037
+ if (result.value?.application === "failed") {
921
1038
  const pending = result.value?.pendingBuilds;
922
1039
  if (Array.isArray(pending) && pending.length) throw new Error(`${t("install.approvalRequired")}: ${pending.join(", ")}`);
923
1040
  throw new Error(result.value?.error?.diagnostic || result.value?.packageResult?.output || t("install.failed"));
@@ -934,8 +1051,25 @@ window.__ModuleLoader__.load({
934
1051
  }
935
1052
  };
936
1053
 
1054
+ const cancelInstall = async () => {
1055
+ const requestId = installProgress.requestId;
1056
+ if (!requestId || installState !== "installing" || installProgress.phase === "applying" || installProgress.phase === "cancelling"
1057
+ || typeof remote?.pluginManager?.cancelInstall !== "function") return;
1058
+ setInstallError("");
1059
+ setInstallProgress((current) => ({ ...current, phase: "cancelling" }));
1060
+ try {
1061
+ const result = await remote.pluginManager.cancelInstall(requestId);
1062
+ if (!result?.ok) throw new Error(result?.error?.message || t("install.cancelFailed"));
1063
+ if (result.value?.status === "too-late") {
1064
+ setInstallProgress((current) => ({ ...current, phase: "applying" }));
1065
+ }
1066
+ } catch (error) {
1067
+ setInstallProgress((current) => ({ ...current, phase: "installing" }));
1068
+ setInstallError(`${t("install.cancelFailed")}: ${String(error?.message ?? error)}`);
1069
+ }
1070
+ };
1071
+
937
1072
  const sources = plugin.sources ?? [];
938
- const primarySource = sources[0];
939
1073
  const requiresVerification = sources.some((source) => source.type === "npm" || source.type === "github");
940
1074
  const installability = detailsState.data?.installability ?? plugin.evidence?.installability ?? (requiresVerification ? "unknown" : "bundle");
941
1075
  const installAllowed = Boolean(spec) && (!requiresVerification || installability === "bundle");
@@ -947,15 +1081,20 @@ window.__ModuleLoader__.load({
947
1081
  ? installProgress.attempt
948
1082
  ? t("install.runningAttempt", { index: installProgress.attempt.index, total: installProgress.attempt.total })
949
1083
  : t("install.running")
950
- : installProgress.phase === "applying" ? t("install.applying")
951
- : t("installing");
952
- const installTitle = installState === "installing" ? progressTitle
953
- : installState === "installed" ? t("installed")
954
- : installState === "invalid" ? t("install.notBundle")
955
- : installState === "failed" ? t("install.retry") : t("install");
956
- const installIcon = installState === "installing" ? h(StateDot, { state: "ongoing", size: 16 })
957
- : installState === "installed" ? h(IconCheckOutlineRegular, { size: 16 })
958
- : installState === "failed" || installState === "invalid" ? h(IconWarningOutlineRegular, { size: 16 })
1084
+ : installProgress.phase === "cancelling" ? t("install.cancelling")
1085
+ : installProgress.phase === "applying" ? t("install.applying")
1086
+ : t("installing");
1087
+ const canCancelInstall = effectiveInstallState === "installing"
1088
+ && Boolean(installProgress.requestId)
1089
+ && !["applying", "cancelling"].includes(installProgress.phase)
1090
+ && typeof remote?.pluginManager?.cancelInstall === "function";
1091
+ const installTitle = effectiveInstallState === "installing" ? progressTitle
1092
+ : effectiveInstallState === "installed" ? installedLabel
1093
+ : effectiveInstallState === "invalid" ? t("install.notBundle")
1094
+ : effectiveInstallState === "failed" ? t("install.retry") : t("install");
1095
+ const installIcon = effectiveInstallState === "installing" ? h(StateDot, { state: "ongoing", size: 16 })
1096
+ : effectiveInstallState === "installed" ? h(IconCheckOutlineRegular, { size: 16 })
1097
+ : effectiveInstallState === "failed" || effectiveInstallState === "invalid" ? h(IconWarningOutlineRegular, { size: 16 })
959
1098
  : h(IconDownloadOutlineRegular, { size: 16 });
960
1099
 
961
1100
  return hs("article", { className: "pm-card", children: [
@@ -977,11 +1116,15 @@ window.__ModuleLoader__.load({
977
1116
  plugin.evidence?.rating !== undefined ? hs("span", { className: "pm-signal", title: plugin.evidence.ratingCount !== undefined ? `${plugin.evidence.ratingCount} ratings` : "Source rating", children: [h("span", { className: "pm-star-icon", children: "★" }), h("span", { children: `${plugin.evidence.rating.toFixed(1)}${plugin.evidence.ratingCount !== undefined ? ` (${compactNumber(plugin.evidence.ratingCount)})` : ""}` })] }) : null,
978
1117
  ] }),
979
1118
  ] }),
980
- installAllowed ? h("button", { type: "button", className: "pm-card-install", "data-state": installState, disabled: installState === "installing" || installState === "installed" || installState === "invalid", onClick: install, title: installTitle, "aria-label": installTitle, children: installIcon }) : null,
1119
+ installAllowed ? hs("div", { className: "pm-card-install-actions", children: [
1120
+ h("button", { type: "button", className: "pm-card-install", "data-state": effectiveInstallState, disabled: effectiveInstallState === "installing" || effectiveInstallState === "installed" || effectiveInstallState === "invalid", onClick: install, title: installTitle, "aria-label": installTitle, children: installIcon }),
1121
+ canCancelInstall ? h("button", { type: "button", className: "pm-card-cancel", onClick: () => { void cancelInstall(); }, title: t("install.cancelActive"), "aria-label": t("install.cancelActive"), children: h(IconCloseOutlineRegular, { size: 14 }) }) : null,
1122
+ ] }) : null,
981
1123
  ] }),
982
1124
  plugin.description ? h(ClampedDescription, { text: plugin.description, t, onExpand: loadDetails }) : null,
983
1125
  hs("div", { className: "pm-card-meta", children: [
984
- primarySource ? h("span", { className: "pm-badge", children: primarySource.name }, primarySource.id) : null,
1126
+ effectiveInstallState === "installed" ? h("span", { className: `pm-bundle-state ${updateAvailable ? "pm-bundle-state-unknown" : "pm-bundle-state-bundle"}`, title: installedLabel, children: installedLabel }) : null,
1127
+ ...sources.map((source) => h("span", { className: "pm-badge", children: source.name }, source.id)),
985
1128
  ...(plugin.tags ?? []).map((tag) => h("span", { className: "pm-tag", children: tag }, tag)),
986
1129
  requiresVerification ? h("span", { className: `pm-bundle-state pm-bundle-state-${installability}`, title: bundleLabel, children: bundleLabel }) : null,
987
1130
  h("button", { type: "button", className: "pm-compat pm-compat-button", title: resolvedCompatibility ? `DSH ${resolvedCompatibility}` : peerTitle || t("compat.check"), onClick: () => { void loadDetails(); }, children: compatibilityLabel }),
@@ -994,6 +1137,7 @@ window.__ModuleLoader__.load({
994
1137
  h("button", { type: "button", className: "pm-icon-btn", onClick: copy, title: copyState === "ok" ? t("copied") : t("copy"), "aria-label": copyState === "ok" ? t("copied") : t("copy"), children: h(IconCopyOutlineRegular, { size: 16 }) }),
995
1138
  ] }) : null,
996
1139
  installState === "failed" ? h("div", { className: "pm-install-status", title: installError, children: `${t("install.failed")}: ${installError}` }) : null,
1140
+ installState === "installing" && installError ? h("div", { className: "pm-install-status", title: installError, children: installError }) : null,
997
1141
  installState === "invalid" ? h("div", { className: "pm-install-status", title: installError, children: `${t("install.notBundle")}: ${installError}` }) : null,
998
1142
  h("span", { role: "status", "aria-live": "polite", className: "pm-sr-only", children: copyState === "ok" ? t("copied") : copyState === "error" ? t("copy.failed") : installTitle }),
999
1143
  ] });
@@ -1031,17 +1175,27 @@ window.__ModuleLoader__.load({
1031
1175
  });
1032
1176
  }
1033
1177
 
1034
- function BrowseView({ t, state, query, setQuery, refresh, sorts, setSorts, stableOnly, setStableOnly, freshnessDays, setFreshnessDays, tag, setTag, dshMetadata, setDshMetadata, page, setPage, pageSize, setPageSize }) {
1178
+ function BrowseView({ t, state, query, setQuery, refresh, sorts, setSorts, stableOnly, setStableOnly, freshnessDays, setFreshnessDays, tag, setTag, dshMetadata, setDshMetadata, page, setPage, pageSize, setPageSize, bundles }) {
1035
1179
  const plugins = state.data?.plugins ?? [];
1036
1180
  const total = state.data?.total ?? 0;
1037
1181
  const pageCount = state.data?.pageCount ?? 1;
1038
1182
  const currentPage = state.data?.page ?? page;
1183
+ const sourceRows = Array.isArray(state.data?.sources) ? state.data.sources : [];
1184
+ const githubSourceRow = sourceRows.find((row) => (row?.type ?? row?.source?.type) === "github");
1185
+ const githubSourceValue = githubSourceRow?.source ?? githubSourceRow;
1186
+ const githubSource = githubSourceValue ? {
1187
+ id: githubSourceValue.id,
1188
+ name: githubSourceValue.name ?? "GitHub",
1189
+ type: "github",
1190
+ ...(githubSourceValue.url ? { url: githubSourceValue.url } : {}),
1191
+ } : undefined;
1039
1192
  const [starEvidence, setStarEvidence] = react.useState({});
1040
- const starSignature = plugins.map((plugin) => `${repositoryEvidenceKey(plugin.identity?.repository)}:${plugin.evidence?.stars ?? ""}`).join("|");
1193
+ const visiblePlugins = rankVisiblePlugins(plugins, query, sorts);
1194
+ const starSignature = plugins.map((plugin) => `${repositoryEvidenceKey(plugin.identity?.repository)}:${plugin.evidence?.stars ?? ""}:${(plugin.sources ?? []).some((source) => source.type === "github") ? "github" : ""}`).join("|");
1041
1195
  react.useEffect(() => {
1042
1196
  if (state.loading || !plugins.length || !connection) return undefined;
1043
1197
  const repositories = [...new Set(plugins
1044
- .filter((plugin) => plugin.evidence?.stars === undefined)
1198
+ .filter((plugin) => plugin.evidence?.stars === undefined || !(plugin.sources ?? []).some((source) => source.type === "github"))
1045
1199
  .map((plugin) => plugin.identity?.repository)
1046
1200
  .filter((repository) => repositoryEvidenceKey(repository).startsWith("https://github.com/"))
1047
1201
  )].slice(0, 20);
@@ -1053,8 +1207,12 @@ window.__ModuleLoader__.load({
1053
1207
  const next = { ...current };
1054
1208
  for (const row of rows) {
1055
1209
  const key = repositoryEvidenceKey(row?.repository);
1056
- if (!key || typeof row?.stars !== "number") continue;
1057
- next[key] = { stars: row.stars, repositoryUpdatedAt: row.repositoryUpdatedAt };
1210
+ if (!key) continue;
1211
+ next[key] = {
1212
+ ...(typeof row?.stars === "number" ? { stars: row.stars } : {}),
1213
+ ...(row?.repositoryUpdatedAt ? { repositoryUpdatedAt: row.repositoryUpdatedAt } : {}),
1214
+ discoveryEligible: row?.discoveryEligible === true,
1215
+ };
1058
1216
  }
1059
1217
  return next;
1060
1218
  });
@@ -1062,11 +1220,17 @@ window.__ModuleLoader__.load({
1062
1220
  return () => controller.abort();
1063
1221
  }, [starSignature]);
1064
1222
  const pluginWithAsyncStars = (plugin) => {
1065
- if (plugin.evidence?.stars !== undefined) return plugin;
1066
- const evidence = starEvidence[repositoryEvidenceKey(plugin.identity?.repository)];
1067
- return evidence ? { ...plugin, evidence: { ...(plugin.evidence ?? {}), ...evidence } } : plugin;
1223
+ const asyncEvidence = starEvidence[repositoryEvidenceKey(plugin.identity?.repository)];
1224
+ if (!asyncEvidence) return plugin;
1225
+ const evidence = {
1226
+ ...(plugin.evidence ?? {}),
1227
+ ...(typeof asyncEvidence.stars === "number" ? { stars: asyncEvidence.stars } : {}),
1228
+ ...(asyncEvidence.repositoryUpdatedAt ? { repositoryUpdatedAt: asyncEvidence.repositoryUpdatedAt } : {}),
1229
+ };
1230
+ const sources = [...(plugin.sources ?? [])];
1231
+ if (asyncEvidence.discoveryEligible && githubSource && !sources.some((source) => source.type === "github")) sources.push(githubSource);
1232
+ return { ...plugin, evidence, sources };
1068
1233
  };
1069
- const sourceRows = Array.isArray(state.data?.sources) ? state.data.sources : [];
1070
1234
  const failedSources = sourceRows.filter((row) => row?.health?.ok === false);
1071
1235
  const hasEnabledSources = sourceRows.length > 0;
1072
1236
  const allSourcesFailed = hasEnabledSources && failedSources.length === sourceRows.length;
@@ -1137,7 +1301,7 @@ window.__ModuleLoader__.load({
1137
1301
  children: `${row.name ?? row.id ?? row.source?.name ?? row.source?.id ?? "source"}: ${row.health?.error ?? t("source.error")}`,
1138
1302
  }, row.source?.id ?? row.source?.name)) : null,
1139
1303
  !state.loading && !state.error && plugins.length === 0 ? h(Empty, { title: t("browse.empty"), hint: emptyHint }) : null,
1140
- plugins.length ? h("div", { className: "pm-results", "data-updating": state.loading, children: plugins.map((plugin) => h(PluginCard, { plugin: pluginWithAsyncStars(plugin), t }, plugin.identity?.package || plugin.identity?.repository || plugin.identity?.fallback)) }) : null,
1304
+ plugins.length ? h("div", { className: "pm-results", "data-updating": state.loading, children: visiblePlugins.map((plugin) => h(PluginCard, { plugin: pluginWithAsyncStars(plugin), t, bundles }, plugin.identity?.package || plugin.identity?.repository || plugin.identity?.fallback)) }) : null,
1141
1305
  plugins.length ? h("div", { className: "pm-browse-bottomline", children: bottomPager }) : null,
1142
1306
  ] }) : null,
1143
1307
  ] });
@@ -1150,7 +1314,10 @@ window.__ModuleLoader__.load({
1150
1314
  const [query, setQuery] = react.useState("");
1151
1315
  const [busy, setBusy] = react.useState(false);
1152
1316
  const [healthState, setHealthState] = react.useState({ loading: false });
1317
+ const [countMap, setCountMap] = react.useState({});
1318
+ const [countsLoading, setCountsLoading] = react.useState(false);
1153
1319
  const [browseState, setBrowseState] = react.useState({ loading: false });
1320
+ const [bundleState, setBundleState] = react.useState({ loading: false, data: [] });
1154
1321
  const [browseRevision, setBrowseRevision] = react.useState(0);
1155
1322
  const [sorts, setSorts] = react.useState([]);
1156
1323
  const [stableOnly, setStableOnly] = react.useState(false);
@@ -1194,6 +1361,56 @@ window.__ModuleLoader__.load({
1194
1361
  return () => controller.abort();
1195
1362
  }, [tab, signature, healthRevision, connectionRevision]);
1196
1363
 
1364
+ react.useEffect(() => {
1365
+ if (tab !== "browse" || typeof remote?.pluginManager?.listBundles !== "function") return undefined;
1366
+ let disposed = false;
1367
+ setBundleState((current) => ({ ...current, loading: true, error: undefined }));
1368
+ Promise.resolve(remote.pluginManager.listBundles()).then(
1369
+ (result) => {
1370
+ if (disposed) return;
1371
+ if (!result?.ok) {
1372
+ setBundleState({ loading: false, data: [], error: String(result?.error?.message ?? t("browse.error")) });
1373
+ return;
1374
+ }
1375
+ setBundleState({ loading: false, data: Array.isArray(result.value) ? result.value : [] });
1376
+ },
1377
+ (error) => { if (!disposed) setBundleState({ loading: false, data: [], error: String(error?.message ?? error) }); },
1378
+ );
1379
+ return () => { disposed = true; };
1380
+ }, [tab, connectionRevision]);
1381
+
1382
+ react.useEffect(() => {
1383
+ if (tab !== "sources" || !connection || healthState.loading || !healthState.data) return undefined;
1384
+ const healthRows = healthState.data.sources ?? [];
1385
+ const healthyIds = new Set(healthRows.filter((row) => row?.health?.ok === true).map((row) => row.source?.id).filter(Boolean));
1386
+ const targets = sources.filter((source) => source.enabled !== false && healthyIds.has(source.id));
1387
+ if (!targets.length) { setCountsLoading(false); return undefined; }
1388
+ const controller = new AbortController();
1389
+ let active = true;
1390
+ setCountsLoading(true);
1391
+ setCountMap((current) => {
1392
+ const next = { ...current };
1393
+ for (const source of targets) next[source.id] = { ...(next[source.id] ?? {}), loading: true, error: undefined };
1394
+ return next;
1395
+ });
1396
+ let pending = targets.length;
1397
+ for (const source of targets) {
1398
+ rpc("counts", { sourceId: source.id }, controller.signal).then((data) => {
1399
+ if (!active) return;
1400
+ const row = data?.sources?.[0];
1401
+ setCountMap((current) => ({ ...current, [source.id]: row ? { ...row, loading: false } : { source, loading: false, error: "source count unavailable" } }));
1402
+ }, (error) => {
1403
+ if (!active || error?.name === "AbortError") return;
1404
+ setCountMap((current) => ({ ...current, [source.id]: { source, loading: false, error: String(error?.message ?? error) } }));
1405
+ }).finally(() => {
1406
+ if (!active) return;
1407
+ pending -= 1;
1408
+ if (pending === 0) setCountsLoading(false);
1409
+ });
1410
+ }
1411
+ return () => { active = false; controller.abort(); setCountsLoading(false); };
1412
+ }, [tab, signature, healthRevision, connectionRevision, healthState.loading, healthState.data]);
1413
+
1197
1414
  react.useEffect(() => {
1198
1415
  if (tab !== "browse" || !connection) return undefined;
1199
1416
  const controller = new AbortController();
@@ -1213,7 +1430,7 @@ window.__ModuleLoader__.load({
1213
1430
  return h("div", { className: "pm-root", children: hs("section", { className: "pm-shell", "aria-labelledby": "pm-title", children: [
1214
1431
  h("p", { className: "pm-kicker", children: t("kicker") }), h("h2", { id: "pm-title", className: "pm-title", children: t("title") }), h("p", { className: "pm-lead", children: t("lead") }),
1215
1432
  hs("div", { className: "pm-tabs", role: "tablist", "aria-label": t("title"), children: [h("button", { id: "pm-tab-sources", type: "button", className: "pm-tab", role: "tab", tabIndex: tab === "sources" ? 0 : -1, "aria-controls": "pm-panel-sources", "aria-selected": tab === "sources", "data-active": tab === "sources", onKeyDown: tabKey, onClick: () => selectTab("sources"), children: hs("span", { className: "pm-tab-content", children: [h(DatabaseIcon, {}), h("span", { children: t("tab.sources") })] }) }), h("button", { id: "pm-tab-browse", type: "button", className: "pm-tab", role: "tab", tabIndex: tab === "browse" ? 0 : -1, "aria-controls": "pm-panel-browse", "aria-selected": tab === "browse", "data-active": tab === "browse", onKeyDown: tabKey, onClick: () => selectTab("browse"), children: hs("span", { className: "pm-tab-content", children: [h(GridIcon, {}), h("span", { children: t("tab.browse") })] }) })] }),
1216
- h("div", { id: `pm-panel-${tab}`, role: "tabpanel", "aria-labelledby": `pm-tab-${tab}`, children: tab === "sources" ? h(SourcesView, { t, sources, writable, saveSources, busy, healthState, refreshHealth: () => setHealthRevision((value) => value + 1) }) : h(BrowseView, { t, state: browseState, query, setQuery, refresh: () => setBrowseRevision((value) => value + 1), sorts, setSorts, stableOnly, setStableOnly, freshnessDays, setFreshnessDays, tag, setTag, dshMetadata, setDshMetadata, page, setPage, pageSize, setPageSize }) }),
1433
+ h("div", { id: `pm-panel-${tab}`, role: "tabpanel", "aria-labelledby": `pm-tab-${tab}`, children: tab === "sources" ? h(SourcesView, { t, sources, writable, saveSources, busy, healthState, countMap, countsLoading, refreshHealth: () => setHealthRevision((value) => value + 1) }) : h(BrowseView, { t, state: browseState, query, setQuery, refresh: () => setBrowseRevision((value) => value + 1), sorts, setSorts, stableOnly, setStableOnly, freshnessDays, setFreshnessDays, tag, setTag, dshMetadata, setDshMetadata, page, setPage, pageSize, setPageSize, bundles: bundleState.data ?? [] }) }),
1217
1434
  ] }) });
1218
1435
  }
1219
1436
 
package/lib/core.js CHANGED
@@ -7,6 +7,10 @@ const DEFAULT_TIMEOUT_MS = 10_000;
7
7
  const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
8
8
  const DEFAULT_MAX_PLUGINS = 2_000;
9
9
  const DEFAULT_MAX_REDIRECTS = 3;
10
+ const GITHUB_MAX_ATTEMPTS = 3;
11
+ const GITHUB_RETRY_BASE_DELAY_MS = 250;
12
+ const GITHUB_RETRY_TIMEOUT_MS = 5_000;
13
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
10
14
  const EVIDENCE_CACHE_TTL_MS = 10 * 60 * 1000;
11
15
  const METADATA_CACHE_TTL_MS = 30 * 60 * 1000;
12
16
  const npmDownloadCache = new Map();
@@ -62,6 +66,23 @@ export const GITHUB_DISCOVERY_QUERIES = Object.freeze([
62
66
  "topic:deepseek-harness topic:dsh-plugins",
63
67
  ]);
64
68
 
69
+ function githubRepositoryMatchesDiscovery(repository) {
70
+ const topics = new Set((Array.isArray(repository?.topics) ? repository.topics : [])
71
+ .map((topic) => String(topic).trim().toLowerCase()).filter(Boolean));
72
+ return topics.has("deepseek-harness-plugin")
73
+ || (topics.has("deepseek-harness") && (topics.has("dsh-plugin") || topics.has("dsh-plugins")));
74
+ }
75
+
76
+ function githubHealthUrl(source) {
77
+ const url = new URL(source?.url ?? "https://api.github.com/");
78
+ url.search = "";
79
+ url.hash = "";
80
+ let path = url.pathname.replace(/\/+$/u, "");
81
+ path = path.replace(/\/search\/repositories$/u, "");
82
+ url.pathname = `${path || ""}/rate_limit`;
83
+ return url;
84
+ }
85
+
65
86
  function text(value) {
66
87
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
67
88
  }
@@ -474,11 +495,52 @@ async function readResponseBytes(response, maxBytes) {
474
495
  return joined;
475
496
  }
476
497
 
477
- export async function fetchJson(urlInput, source, options = {}) {
498
+ function retryableSourceError(message, details = {}) {
499
+ const error = new Error(message);
500
+ error.retryable = true;
501
+ if (details.status !== undefined) error.status = details.status;
502
+ if (details.retryAfterMs !== undefined) error.retryAfterMs = details.retryAfterMs;
503
+ return error;
504
+ }
505
+
506
+ function githubRateLimitRetryMs(response) {
507
+ if (response.status !== 403) return undefined;
508
+ const retryAfterHeader = response.headers.get("retry-after");
509
+ if (retryAfterHeader !== null) {
510
+ const retryAfter = Number(retryAfterHeader);
511
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) return Math.min(retryAfter * 1000, GITHUB_RETRY_TIMEOUT_MS);
512
+ }
513
+ if (response.headers.get("x-ratelimit-remaining") !== "0") return undefined;
514
+ const resetHeader = response.headers.get("x-ratelimit-reset");
515
+ if (resetHeader === null) return undefined;
516
+ const resetSeconds = Number(resetHeader);
517
+ if (!Number.isFinite(resetSeconds)) return undefined;
518
+ const delayMs = Math.max(0, resetSeconds * 1000 - Date.now());
519
+ return delayMs <= GITHUB_RETRY_TIMEOUT_MS ? delayMs : undefined;
520
+ }
521
+
522
+ function waitForRetry(delayMs, signal) {
523
+ if (delayMs <= 0) return Promise.resolve();
524
+ if (signal?.aborted) return Promise.reject(new Error("source request aborted"));
525
+ return new Promise((resolve, reject) => {
526
+ const timer = setTimeout(done, delayMs);
527
+ function done() {
528
+ signal?.removeEventListener("abort", aborted);
529
+ resolve();
530
+ }
531
+ function aborted() {
532
+ clearTimeout(timer);
533
+ signal?.removeEventListener("abort", aborted);
534
+ reject(new Error("source request aborted"));
535
+ }
536
+ signal?.addEventListener("abort", aborted, { once: true });
537
+ });
538
+ }
539
+
540
+ async function fetchJsonAttempt(urlInput, source, options, timeoutMs) {
478
541
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
479
542
  if (typeof fetchImpl !== "function") throw new Error("Host fetch is unavailable");
480
543
  const resolveHost = options.resolveHost ?? defaultResolveHost;
481
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
482
544
  const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
483
545
  const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
484
546
  const authToken = text(options.authToken);
@@ -499,9 +561,9 @@ export async function fetchJson(urlInput, source, options = {}) {
499
561
  try {
500
562
  response = await fetchImpl(url, { method: "GET", headers, redirect: "manual", signal: controller.signal, dispatcher });
501
563
  } catch (error) {
502
- if (timedOut) throw new Error("source request timed out");
564
+ if (timedOut) throw retryableSourceError("source request timed out");
503
565
  if (options.signal?.aborted) throw new Error("source request aborted");
504
- throw new Error(`source request failed: ${String(error?.message ?? error)}`);
566
+ throw retryableSourceError(`source request failed: ${String(error?.message ?? error)}`);
505
567
  }
506
568
  if ([301, 302, 303, 307, 308].includes(response.status)) {
507
569
  const location = response.headers.get("location");
@@ -514,14 +576,18 @@ export async function fetchJson(urlInput, source, options = {}) {
514
576
  continue;
515
577
  }
516
578
  if (!response.ok) {
579
+ const rateLimitRetryMs = source?.type === "github" ? githubRateLimitRetryMs(response) : undefined;
517
580
  await response.body?.cancel?.();
518
- throw new Error(`source returned HTTP ${response.status}`);
581
+ const message = `source returned HTTP ${response.status}`;
582
+ if (RETRYABLE_HTTP_STATUSES.has(response.status)) throw retryableSourceError(message, { status: response.status });
583
+ if (rateLimitRetryMs !== undefined) throw retryableSourceError(message, { status: response.status, retryAfterMs: rateLimitRetryMs });
584
+ throw new Error(message);
519
585
  }
520
586
  let bytes;
521
587
  try {
522
588
  bytes = await readResponseBytes(response, maxBytes);
523
589
  } catch (error) {
524
- if (timedOut) throw new Error("source request timed out");
590
+ if (timedOut) throw retryableSourceError("source request timed out");
525
591
  if (options.signal?.aborted) throw new Error("source request aborted");
526
592
  throw error;
527
593
  }
@@ -536,7 +602,7 @@ export async function fetchJson(urlInput, source, options = {}) {
536
602
  }
537
603
  throw new Error("source redirected too many times");
538
604
  } catch (error) {
539
- if (timedOut) throw new Error("source request timed out");
605
+ if (timedOut) throw retryableSourceError("source request timed out");
540
606
  if (options.signal?.aborted) throw new Error("source request aborted");
541
607
  throw error;
542
608
  } finally {
@@ -545,6 +611,29 @@ export async function fetchJson(urlInput, source, options = {}) {
545
611
  }
546
612
  }
547
613
 
614
+ export async function fetchJson(urlInput, source, options = {}) {
615
+ const configuredAttempts = Number.isInteger(options.githubRetryAttempts) ? options.githubRetryAttempts : GITHUB_MAX_ATTEMPTS;
616
+ const maxAttempts = source?.type === "github" ? Math.min(Math.max(1, configuredAttempts), GITHUB_MAX_ATTEMPTS) : 1;
617
+ const firstTimeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
618
+ const retryTimeoutMs = Math.min(firstTimeoutMs, options.githubRetryTimeoutMs ?? GITHUB_RETRY_TIMEOUT_MS);
619
+ const baseDelayMs = Number.isFinite(options.githubRetryDelayMs) ? Math.max(0, options.githubRetryDelayMs) : GITHUB_RETRY_BASE_DELAY_MS;
620
+ let lastError;
621
+
622
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
623
+ if (options.signal?.aborted) throw new Error("source request aborted");
624
+ try {
625
+ const timeoutMs = attempt === 0 ? firstTimeoutMs : retryTimeoutMs;
626
+ return await fetchJsonAttempt(urlInput, source, options, timeoutMs);
627
+ } catch (error) {
628
+ lastError = error;
629
+ if (options.signal?.aborted || error?.retryable !== true || attempt + 1 >= maxAttempts) throw error;
630
+ const retryDelayMs = Math.max(baseDelayMs * (2 ** attempt), Number(error?.retryAfterMs) || 0);
631
+ await waitForRetry(Math.min(retryDelayMs, GITHUB_RETRY_TIMEOUT_MS), options.signal);
632
+ }
633
+ }
634
+ throw lastError;
635
+ }
636
+
548
637
  function catalogRows(manifest) {
549
638
  if (Array.isArray(manifest)) return manifest;
550
639
  if (manifest && typeof manifest === "object") {
@@ -742,11 +831,22 @@ export function createAdapter(sourceInput, options = {}) {
742
831
  async health() {
743
832
  const started = Date.now();
744
833
  try {
745
- let plugins = await list();
746
- if ((source.type === "npm" || source.type === "github") && shouldVerifyRegistryBundles(options)) {
747
- plugins = await enrichInstallability(plugins, options);
748
- plugins = plugins.filter((plugin) => plugin.evidence?.installability === "bundle");
834
+ if (source.type === "npm") {
835
+ const base = source.url && /\/-\/v1\/search\/?$/u.test(new URL(source.url).pathname)
836
+ ? source.url
837
+ : new URL("-/v1/search", npmRegistryBase(source.url)).toString();
838
+ const url = new URL(base);
839
+ url.searchParams.set("text", `keywords:${NPM_DISCOVERY_KEYWORDS[0]}`);
840
+ url.searchParams.set("size", "1");
841
+ npmRows(await request(url), { requireDiscoveryKeyword: true });
842
+ return { ok: true, latencyMs: Date.now() - started };
843
+ }
844
+ if (source.type === "github") {
845
+ const value = await request(githubHealthUrl(source));
846
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("GitHub health response is malformed");
847
+ return { ok: true, latencyMs: Date.now() - started };
749
848
  }
849
+ const plugins = await list();
750
850
  return { ok: true, latencyMs: Date.now() - started, count: plugins.length };
751
851
  } catch (error) {
752
852
  return { ok: false, latencyMs: Date.now() - started, error: String(error?.message ?? error) };
@@ -789,6 +889,24 @@ export async function healthSources(sourceInputs, options = {}) {
789
889
  }));
790
890
  }
791
891
 
892
+ export async function countSources(sourceInputs, options = {}) {
893
+ const sources = validatedSources(sourceInputs, options);
894
+ return mapConcurrent(sources, options.concurrency ?? 4, async (source) => {
895
+ const attribution = sourceAttribution(source);
896
+ if (!source.enabled) return { source: attribution, disabled: true };
897
+ try {
898
+ let plugins = await createAdapter(source, options).list();
899
+ if ((source.type === "npm" || source.type === "github") && shouldVerifyRegistryBundles(options)) {
900
+ plugins = await enrichInstallability(plugins, options);
901
+ plugins = plugins.filter((plugin) => plugin.evidence?.installability === "bundle");
902
+ }
903
+ return { source: attribution, count: plugins.length };
904
+ } catch (error) {
905
+ return { source: attribution, error: String(error?.message ?? error) };
906
+ }
907
+ });
908
+ }
909
+
792
910
  async function enrichNpmDownloads(plugins, options = {}) {
793
911
  const packages = [...new Set(plugins.map((plugin) => plugin.identity?.package).filter(Boolean))].slice(0, options.maxEvidencePackages ?? 100);
794
912
  if (!packages.length || options.enrichDownloads === false) return plugins;
@@ -911,6 +1029,7 @@ export async function githubRepositoryStats(repositories, options = {}) {
911
1029
  const stats = {
912
1030
  ...(finiteNumber(value?.stargazers_count) !== undefined ? { stars: finiteNumber(value.stargazers_count) } : {}),
913
1031
  ...(isoDate(value?.pushed_at ?? value?.updated_at) ? { repositoryUpdatedAt: isoDate(value.pushed_at ?? value.updated_at) } : {}),
1032
+ discoveryEligible: githubRepositoryMatchesDiscovery(value),
914
1033
  };
915
1034
  cacheValue(githubStatsCache, key, stats);
916
1035
  return { repository, ...stats };
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { browseSources, FILTER_TAGS, githubPluginDetails, githubRepositoryStats, healthSources, npmPluginDetails, SOURCE_TYPES } from "./core.js";
2
+ import { browseSources, countSources, FILTER_TAGS, githubPluginDetails, githubRepositoryStats, healthSources, npmPluginDetails, SOURCE_TYPES } from "./core.js";
3
3
 
4
4
  export const NAMESPACE = "registry-aggregator";
5
5
  export const name = "registry-aggregator";
@@ -169,6 +169,14 @@ export function apply(ctx, config = {}) {
169
169
  const value = { sources: await healthSources(sources, requestOptions), generatedAt: new Date().toISOString() };
170
170
  return { ok: true, value: limitRpcValue(value, maxRpcBytes) };
171
171
  }
172
+ if (endpoint === "counts") {
173
+ const sourceId = payload?.sourceId;
174
+ if (sourceId !== undefined && (typeof sourceId !== "string" || !sourceId.trim() || sourceId.length > 64)) throw new TypeError("sourceId must be a non-empty source id");
175
+ const selectedSources = sourceId === undefined ? sources : sources.filter((source) => source?.id === sourceId.trim());
176
+ if (sourceId !== undefined && selectedSources.length === 0) throw new RangeError("unknown sourceId");
177
+ const value = { sources: await countSources(selectedSources, requestOptions), generatedAt: new Date().toISOString() };
178
+ return { ok: true, value: limitRpcValue(value, maxRpcBytes) };
179
+ }
172
180
  if (endpoint === "details") {
173
181
  const request = detailsRequestFrom(payload);
174
182
  const value = request.kind === "npm"
@@ -207,6 +215,7 @@ export function apply(ctx, config = {}) {
207
215
  }
208
216
  rpcCtx.connection.fetch.register(route("health"));
209
217
  rpcCtx.connection.fetch.register(route("browse"));
218
+ rpcCtx.connection.fetch.register(route("counts"));
210
219
  rpcCtx.connection.fetch.register(route("details"));
211
220
  rpcCtx.connection.fetch.register(route("stars"));
212
221
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stolyarovmn/dsh-ui-registry-aggregator",
3
3
  "description": "Federated plugin registry aggregator for DeepSeek Harness: combine trusted sources, search one index, and install through the native Plugin Manager.",
4
- "version": "0.4.14",
4
+ "version": "0.4.15",
5
5
  "author": {
6
6
  "name": "Stolyarovmn",
7
7
  "url": "https://github.com/Stolyarovmn"