feat: add plugin install ranking (#2633)

This commit is contained in:
Jesse Merhi
2026-06-15 09:28:26 -07:00
committed by GitHub
parent e5f3ba272b
commit 078425f074
23 changed files with 376 additions and 76 deletions
+24
View File
@@ -232,6 +232,30 @@ describe("download metric helpers", () => {
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:two");
});
it("prunes stale package install metric dedupe rows after download rows", async () => {
vi.setSystemTime(30 * 86_400_000);
const { db, delete_, indexCalls } = makeDb(
{},
{
packageInstallMetricDedupes: [
{ _id: "packageInstallMetricDedupes:one" },
{ _id: "packageInstallMetricDedupes:two" },
],
},
);
const result = await pruneDownloadMetricDedupesHandler({ db }, {});
expect(result).toEqual({ deleted: 2, hasMore: false });
expect(indexCalls.map((call) => call.table)).toEqual([
"downloadMetricDedupes",
"packageInstallMetricDedupes",
]);
expect(indexCalls[1]?.indexName).toBe("by_day");
expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:one");
expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:two");
});
it("reschedules stale dedupe pruning when one bounded batch fills", async () => {
vi.setSystemTime(30 * 86_400_000);
const rows = Array.from({ length: 200 }, (_, index) => ({
+16 -4
View File
@@ -105,16 +105,28 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({
args: {},
handler: async (ctx) => {
const cutoffDayStart = getDayStart(Date.now() - DEDUPE_RETENTION_MS);
const stale = await ctx.db
const staleDownloads = await ctx.db
.query("downloadMetricDedupes")
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
.take(PRUNE_BATCH_SIZE);
const remainingBatchSize = PRUNE_BATCH_SIZE - staleDownloads.length;
const staleInstalls =
remainingBatchSize > 0
? await ctx.db
.query("packageInstallMetricDedupes")
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
.take(remainingBatchSize)
: [];
for (const entry of stale) {
for (const entry of staleDownloads) {
await ctx.db.delete(entry._id);
}
for (const entry of staleInstalls) {
await ctx.db.delete(entry._id);
}
const hasMore = stale.length === PRUNE_BATCH_SIZE;
const deleted = staleDownloads.length + staleInstalls.length;
const hasMore = deleted === PRUNE_BATCH_SIZE;
if (hasMore) {
await ctx.scheduler.runAfter(
0,
@@ -123,7 +135,7 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({
);
}
return { deleted: stale.length, hasMore };
return { deleted, hasMore };
},
});
+10 -3
View File
@@ -10743,9 +10743,16 @@ describe("httpApiV1 handlers", () => {
);
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageInstallInternal, {
packageId: "packages:demo-plugin",
});
expect(runMutation).toHaveBeenCalledWith(
internal.packages.recordPackageInstallInternal,
expect.objectContaining({
packageId: "packages:demo-plugin",
identityKind: "ip",
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
dayStart: expect.any(Number),
occurredAt: expect.any(Number),
}),
);
expect(runMutation).toHaveBeenCalledWith(
internal.downloadMetrics.recordDownloadMetricInternal,
expect.objectContaining({
+24 -8
View File
@@ -259,7 +259,7 @@ function normalizeCapabilityTagSegment(value: string) {
const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const;
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended"] as const;
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended", "installs"] as const;
const MAX_PLUGIN_EXPORT_FILE_COUNT = 10_000;
const MAX_PLUGIN_EXPORT_PAGE_LIMIT = 250;
const DEFAULT_PLUGIN_EXPORT_PAGE_LIMIT = 250;
@@ -707,22 +707,34 @@ async function streamClawPackRelease(
const blob = await ctx.storage.get(release.clawpackStorageId);
if (!blob) return text("ClawPack artifact not found", 404, rateHeaders);
try {
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
const now = Date.now();
const metricArgs = identity
? await buildDownloadMetricArgs({
target: { kind: "package", id: pkg._id },
identity,
now,
})
: null;
if (statKind === "install") {
await runMutationRef(ctx, internalRefs.packages.recordPackageInstallInternal, {
packageId: pkg._id,
...(metricArgs
? {
identityKind: metricArgs.identityKind,
identityHash: metricArgs.identityHash,
dayStart: metricArgs.dayStart,
occurredAt: metricArgs.occurredAt,
}
: {}),
});
}
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
if (identity) {
if (metricArgs) {
await runMutationRef(
ctx,
internalRefs.downloadMetrics.recordDownloadMetricInternal,
await buildDownloadMetricArgs({
target: { kind: "package", id: pkg._id },
identity,
now: Date.now(),
}),
metricArgs,
);
}
} catch {
@@ -1013,6 +1025,10 @@ function compareCatalogItemsForSort(
const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0);
if (downloads !== 0) return downloads;
}
if (sort === "installs") {
const installs = (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0);
if (installs !== 0) return installs;
}
return compareCatalogItems(a, b);
}
+55 -2
View File
@@ -137,7 +137,7 @@ const listPublicPageHandler = (
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads" | "recommended";
sort?: "updated" | "downloads" | "recommended" | "installs";
paginationOpts: { cursor: string | null; numItems: number };
},
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
@@ -152,7 +152,7 @@ const listPageForViewerInternalHandler = (
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads" | "recommended";
sort?: "updated" | "downloads" | "recommended" | "installs";
viewerUserId?: string;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -1129,6 +1129,8 @@ function makeDigestCtx(options: {
if (
indexName === "by_active_downloads" ||
indexName === "by_active_family_downloads" ||
indexName === "by_active_installs" ||
indexName === "by_active_family_installs" ||
indexName === "by_active_recommended_rank" ||
indexName === "by_active_family_recommended_rank" ||
indexName === "by_active_recommended_score" ||
@@ -2305,6 +2307,57 @@ describe("packages public queries", () => {
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 });
});
it("uses a family-scoped installs index for install-sorted family pages", async () => {
const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({
packagePages: [
{
page: [
makePackageDoc({
_id: "packages:code-plugin-a",
name: "code-plugin-a",
normalizedName: "code-plugin-a",
displayName: "Code Plugin A",
family: "code-plugin",
stats: { downloads: 100, installs: 200, stars: 0, versions: 1 },
}),
makePackageDoc({
_id: "packages:code-plugin-b",
name: "code-plugin-b",
normalizedName: "code-plugin-b",
displayName: "Code Plugin B",
family: "code-plugin",
stats: { downloads: 500, installs: 100, stars: 0, versions: 1 },
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "installs",
paginationOpts: { cursor: null, numItems: 1 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-a"]);
expect(result.isDone).toBe(false);
expect(result.continueCursor.startsWith("pkgpage:")).toBe(true);
expect(indexNames).toEqual(["by_active_family_installs"]);
expect(indexFilters).toEqual([
{
indexName: "by_active_family_installs",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
],
},
]);
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 });
});
it("uses a family-scoped weighted recommended score index after backfill", async () => {
const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({
packagePages: [
+77 -1
View File
@@ -20,7 +20,16 @@ const recordDownloadHandler = (
)._handler;
const recordInstallHandler = (
recordPackageInstallInternal as unknown as WrappedHandler<{ packageId: string }, void>
recordPackageInstallInternal as unknown as WrappedHandler<
{
packageId: string;
identityKind?: "user" | "ip";
identityHash?: string;
dayStart?: number;
occurredAt?: number;
},
void
>
)._handler;
const processStatsHandler = (
@@ -99,6 +108,73 @@ describe("package stat events", () => {
);
});
it("dedupes identity-backed installs before appending stat events", async () => {
const insert = vi.fn();
const unique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
_id: "packageInstallMetricDedupes:existing",
});
const queryBuilder = {
eq: vi.fn(() => queryBuilder),
};
const withIndex = vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
buildQuery(queryBuilder);
return { unique };
});
const ctx = {
db: {
query: vi.fn(() => ({ withIndex })),
get: vi.fn(),
normalizeId: vi.fn(),
insert,
patch: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
system: {
get: vi.fn(),
query: vi.fn(),
},
},
};
const args = {
packageId: "packages:one",
identityKind: "ip" as const,
identityHash: "hash-ip",
dayStart: 86_400_000,
occurredAt: 86_500_000,
};
await recordInstallHandler(ctx, args);
await recordInstallHandler(ctx, args);
expect(withIndex).toHaveBeenCalledWith("by_target_metric_identity_day", expect.any(Function));
expect(queryBuilder.eq).toHaveBeenCalledWith("targetKind", "package");
expect(queryBuilder.eq).toHaveBeenCalledWith("targetId", "packages:one");
expect(queryBuilder.eq).toHaveBeenCalledWith("metricKind", "install");
expect(queryBuilder.eq).toHaveBeenCalledWith("identityKind", "ip");
expect(queryBuilder.eq).toHaveBeenCalledWith("identityHash", "hash-ip");
expect(queryBuilder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
expect(insert).toHaveBeenCalledWith(
"packageInstallMetricDedupes",
expect.objectContaining({
targetKind: "package",
targetId: "packages:one",
metricKind: "install",
identityKind: "ip",
identityHash: "hash-ip",
dayStart: 86_400_000,
}),
);
expect(insert).toHaveBeenCalledWith(
"packageStatEvents",
expect.objectContaining({
packageId: "packages:one",
kind: "install",
occurredAt: 86_500_000,
}),
);
expect(insert).toHaveBeenCalledTimes(2);
});
it("aggregates queued downloads and installs before patching package stats", async () => {
const events = [
{ _id: "packageStatEvents:1", packageId: "packages:one", kind: "download" },
+67 -18
View File
@@ -2684,7 +2684,12 @@ export const listPublicPage = query({
capabilityTag: v.optional(v.string()),
category: v.optional(v.string()),
sort: v.optional(
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
v.union(
v.literal("updated"),
v.literal("downloads"),
v.literal("recommended"),
v.literal("installs"),
),
),
paginationOpts: paginationOptsValidator,
},
@@ -3154,7 +3159,12 @@ export const listPageForViewerInternal = internalQuery({
capabilityTag: v.optional(v.string()),
category: v.optional(v.string()),
sort: v.optional(
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
v.union(
v.literal("updated"),
v.literal("downloads"),
v.literal("recommended"),
v.literal("installs"),
),
),
viewerUserId: v.optional(v.id("users")),
paginationOpts: paginationOptsValidator,
@@ -3206,7 +3216,7 @@ async function listPackagePageImpl(
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads" | "recommended";
sort?: "updated" | "downloads" | "recommended" | "installs";
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -3263,23 +3273,27 @@ async function listPackagePageImpl(
: await getPackageRecommendedIndexName(ctx, family)
: null;
if (args.sort === "downloads" || recommendedIndexName) {
if (args.sort === "downloads" || args.sort === "installs" || recommendedIndexName) {
let cursor = pageCursor;
let pageOffset = offset;
let pageSize: number | null = decodedCursor.pageSize ?? null;
let done = decodedCursor.done;
const buildSortedQuery = () =>
family
? ctx.db
.query("packages")
.withIndex(recommendedIndexName ?? "by_active_family_downloads", (q) =>
q.eq("softDeletedAt", undefined).eq("family", family),
)
: ctx.db
.query("packages")
.withIndex(recommendedIndexName ?? "by_active_downloads", (q) =>
q.eq("softDeletedAt", undefined),
);
const buildSortedQuery = () => {
if (family) {
const indexName =
args.sort === "installs"
? "by_active_family_installs"
: (recommendedIndexName ?? "by_active_family_downloads");
return ctx.db
.query("packages")
.withIndex(indexName, (q) => q.eq("softDeletedAt", undefined).eq("family", family));
}
const indexName =
args.sort === "installs"
? "by_active_installs"
: (recommendedIndexName ?? "by_active_downloads");
return ctx.db.query("packages").withIndex(indexName, (q) => q.eq("softDeletedAt", undefined));
};
while ((pageOffset > 0 || !done) && collected.length < targetCount) {
const scanPageSize = Math.min(
@@ -3593,12 +3607,47 @@ export const recordPackageDownloadInternal = internalMutation({
});
export const recordPackageInstallInternal = internalMutation({
args: { packageId: v.id("packages") },
args: {
packageId: v.id("packages"),
identityKind: v.optional(v.union(v.literal("user"), v.literal("ip"))),
identityHash: v.optional(v.string()),
dayStart: v.optional(v.number()),
occurredAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const identityKind = args.identityKind;
const identityHash = args.identityHash;
const dayStart = args.dayStart;
if (identityKind && identityHash && typeof dayStart === "number") {
const existing = await ctx.db
.query("packageInstallMetricDedupes")
.withIndex("by_target_metric_identity_day", (q) =>
q
.eq("targetKind", "package")
.eq("targetId", args.packageId)
.eq("metricKind", "install")
.eq("identityKind", identityKind)
.eq("identityHash", identityHash)
.eq("dayStart", dayStart),
)
.unique();
if (existing) return;
await ctx.db.insert("packageInstallMetricDedupes", {
targetKind: "package",
targetId: args.packageId,
metricKind: "install",
identityKind,
identityHash,
dayStart,
createdAt: Date.now(),
});
}
await ctx.db.insert("packageStatEvents", {
packageId: args.packageId,
kind: "install",
occurredAt: Date.now(),
occurredAt: args.occurredAt ?? Date.now(),
processedAt: undefined,
});
},
+22
View File
@@ -1168,6 +1168,8 @@ const packages = defineTable({
.index("by_active_updated", ["softDeletedAt", "updatedAt"])
.index("by_active_downloads", ["softDeletedAt", "stats.downloads", "updatedAt"])
.index("by_active_family_downloads", ["softDeletedAt", "family", "stats.downloads", "updatedAt"])
.index("by_active_installs", ["softDeletedAt", "stats.installs", "updatedAt"])
.index("by_active_family_installs", ["softDeletedAt", "family", "stats.installs", "updatedAt"])
.index("by_active_recommended_rank", [
"softDeletedAt",
"stats.stars",
@@ -2377,6 +2379,25 @@ const downloadMetricDedupes = defineTable({
])
.index("by_day", ["dayStart"]);
const packageInstallMetricDedupes = defineTable({
targetKind: v.literal("package"),
targetId: v.id("packages"),
metricKind: v.literal("install"),
identityKind: downloadMetricIdentityKind,
identityHash: v.string(),
dayStart: v.number(),
createdAt: v.number(),
})
.index("by_target_metric_identity_day", [
"targetKind",
"targetId",
"metricKind",
"identityKind",
"identityHash",
"dayStart",
])
.index("by_day", ["dayStart"]);
const reservedSlugs = defineTable({
slug: v.string(),
originalOwnerUserId: v.id("users"),
@@ -2560,6 +2581,7 @@ export default defineSchema({
rateLimitShards,
downloadDedupes,
downloadMetricDedupes,
packageInstallMetricDedupes,
reservedSlugs,
reservedHandles,
githubBackupSyncState,
+3
View File
@@ -98,6 +98,9 @@ Public read:
- `GET /api/v1/skills/{slug}/file?path=&version=&tag=`
- `GET /api/v1/resolve?slug=&hash=`
- `GET /api/v1/download?slug=&version=&tag=`
- `GET /api/v1/plugins?limit=&cursor=&sort=`
- `sort`: `recommended` (default), `installs`, `updated`, `downloads`
- `GET /api/v1/plugins/search?q=...`
- `GET /api/v1/packages/{name}/versions/{version}/artifact`
- `GET /api/v1/packages/{name}/versions/{version}/security`
- `GET /api/v1/packages/{name}/versions/{version}/artifact/download`
+1 -1
View File
@@ -54,7 +54,7 @@ pnpm add -g clawhub
| Bundle plugins | Packaged plugin bundles for OpenClaw distribution | `clawhub package publish <source>` |
ClawHub tracks semver versions, tags such as `latest`, changelogs, files,
downloads, stars, and security scan summaries. Public pages show current registry
installs, stars, and security scan summaries. Public pages show current registry
state so users can inspect a skill or plugin before installing it.
## Native OpenClaw flows
+1
View File
@@ -616,6 +616,7 @@ Query params:
- `cursor` (optional): pagination cursor
- `isOfficial` (optional): `true` or `false`
- `executesCode` (optional): `true` or `false`
- `sort` (optional): `recommended` (default), `installs`, `updated`, `downloads`
- `capabilityTag` (optional): capability filter for plugin packages
- `category` (optional): plugin category filter. Current values:
`channels`, `mcp-tooling`, `data`, `security`, `observability`,
+5 -5
View File
@@ -656,7 +656,7 @@ describe("plugin detail route", () => {
expect(screen.queryByText("Verified")).toBeNull();
});
it("renders plugin download counts in the metadata sidebar", async () => {
it("renders plugin install counts in the metadata sidebar", async () => {
loaderDataMock = {
...loaderDataMock,
detail: {
@@ -673,12 +673,12 @@ describe("plugin detail route", () => {
render(<Component />);
const downloadsLabel = screen.getByText("Downloads");
const installsLabel = screen.getByText("Installs");
const currentVersionLabel = screen.getByText("Current version");
expect(downloadsLabel.compareDocumentPosition(currentVersionLabel)).toBe(
expect(installsLabel.compareDocumentPosition(currentVersionLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(screen.getByText("1.2k")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
});
it("shows plugin settings when the viewer can manage the plugin", async () => {
@@ -867,7 +867,7 @@ describe("plugin detail route", () => {
label?.startsWith("Security audit"),
);
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Downloads"));
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Installs"));
expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull();
});
+25 -5
View File
@@ -361,7 +361,7 @@ describe("plugins route", () => {
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("sort");
});
it("forwards downloads sort for plugin browse", async () => {
it("forwards explicit plugin browse sorts", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const { loadPluginsPageData } = await import("../routes/plugins/index");
@@ -375,6 +375,17 @@ describe("plugins route", () => {
limit: 25,
}),
);
await loadPluginsPageData({
sort: "installs",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
sort: "installs",
limit: 25,
}),
);
});
it("forwards category through catalog loading without changing the query", async () => {
@@ -433,7 +444,7 @@ describe("plugins route", () => {
});
});
it("renders plugin download counts in browse results", async () => {
it("renders plugin install counts in browse results", async () => {
loaderDataMock = {
items: [
{
@@ -445,7 +456,7 @@ describe("plugins route", () => {
executesCode: true,
createdAt: 1,
updatedAt: 1,
stats: { downloads: 1_234, installs: 0, stars: 0, versions: 1 },
stats: { downloads: 1_234, installs: 9, stars: 0, versions: 1 },
},
],
nextCursor: null,
@@ -457,7 +468,7 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByText("1.2k")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
});
it("renders the browse shell immediately while catalog data loads", async () => {
@@ -854,6 +865,9 @@ describe("plugins route", () => {
expect(validateSearch({ sort: "recommended" })).toEqual(
expect.objectContaining({ sort: "recommended" }),
);
expect(validateSearch({ sort: "installs" })).toEqual(
expect.objectContaining({ sort: "installs" }),
);
expect(validateSearch({ sort: "relevance" })).toEqual(
expect.objectContaining({ sort: "relevance" }),
);
@@ -1030,6 +1044,7 @@ describe("plugins route", () => {
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.getByRole("radio", { name: "Most installed" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Recently updated" })).toBeTruthy();
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
});
@@ -1120,7 +1135,12 @@ describe("plugins route", () => {
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions).toEqual(["Recommended", "Most downloaded", "Recently updated"]);
expect(sortOptions).toEqual([
"Recommended",
"Most installed",
"Most downloaded",
"Recently updated",
]);
expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Name" })).toBeNull();
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { ArrowDownToLine } from "lucide-react";
import { PackageCheck } from "lucide-react";
import { formatCompactStat } from "../lib/numberFormat";
import type { PackageListItem } from "../lib/packageApi";
import { MarketplaceIcon } from "./MarketplaceIcon";
@@ -11,7 +11,7 @@ type PluginListItemProps = {
};
export function PluginListItem({ item, variant = "list" }: PluginListItemProps) {
const downloads = formatCompactStat(item.stats?.downloads ?? 0);
const installs = formatCompactStat(item.stats?.installs ?? 0);
if (variant === "card") {
return (
@@ -40,7 +40,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
<ArrowDownToLine size={14} aria-hidden="true" /> {downloads}
<PackageCheck size={14} aria-hidden="true" /> {installs}
</span>
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
@@ -79,7 +79,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
<ArrowDownToLine size={14} aria-hidden="true" /> {downloads}
<PackageCheck size={14} aria-hidden="true" /> {installs}
</span>
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
+3 -3
View File
@@ -121,8 +121,8 @@ describe("SkillHeader", () => {
expect(onToggleStar).not.toHaveBeenCalled();
expect(onOpenReport).not.toHaveBeenCalled();
expect(screen.getByText("Owner")).toBeTruthy();
expect(screen.getByText("Downloads")).toBeTruthy();
expect(screen.getByText("2")).toBeTruthy();
expect(screen.getByText("Installs")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(container.querySelector('a[href="/user/local"]')).toBeTruthy();
expect(
container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/user/local"]'),
@@ -174,7 +174,7 @@ describe("SkillHeader", () => {
it("hides archive-only metadata for source-backed skills", () => {
renderHeader({ showArchiveMetadata: false });
expect(screen.getByText("Downloads")).toBeTruthy();
expect(screen.getByText("Installs")).toBeTruthy();
expect(screen.getByText("Owner")).toBeTruthy();
expect(screen.getByText("Last updated")).toBeTruthy();
expect(screen.queryByText("Current version")).toBeNull();
+1 -1
View File
@@ -476,7 +476,7 @@ function SkillSidebarStats({
ariaLabel="Skill metadata"
density="compact"
blocks={[
{ label: "Downloads", value: formattedStats.downloads, large: true },
{ label: "Installs", value: formattedStats.installsAllTime, large: true },
{ label: "Repository", value: githubRepositoryLink },
{
label: "Owner",
+3 -3
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { ArrowDownToLine, Star } from "lucide-react";
import { PackageCheck, Star } from "lucide-react";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
@@ -49,8 +49,8 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps)
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<ArrowDownToLine size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.downloads)}
<PackageCheck size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.installsAllTime ?? 0)}
</span>
</div>
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
import { ArrowDownToLine, Star } from "lucide-react";
import { PackageCheck, Star } from "lucide-react";
import { formatSkillStatsTriplet, type SkillStatsTriplet } from "../lib/numberFormat";
export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) {
@@ -11,8 +11,8 @@ export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) {
</span>
<span className="skill-stats-dot">·</span>
<span className="skill-stats-item">
<ArrowDownToLine size={14} aria-hidden="true" />
{formatted.downloads}
<PackageCheck size={14} aria-hidden="true" />
{formatted.installsAllTime}
</span>
</span>
);
+13 -6
View File
@@ -559,13 +559,11 @@ describe("fetchPluginCatalog", () => {
expect(url.searchParams.get("isOfficial")).toBe("true");
});
it("forwards downloads sort to the dedicated plugins browse endpoint", async () => {
it("forwards explicit sort values to the dedicated plugins browse endpoint", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }),
);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
return new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 });
});
await fetchPluginCatalog({
sort: "downloads",
@@ -575,6 +573,15 @@ describe("fetchPluginCatalog", () => {
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
expect(url.pathname).toBe("/api/v1/plugins");
expect(url.searchParams.get("sort")).toBe("downloads");
await fetchPluginCatalog({
sort: "installs",
limit: 20,
});
const installsUrl = new URL(fetchMock.mock.calls[1]?.[0] as string);
expect(installsUrl.pathname).toBe("/api/v1/plugins");
expect(installsUrl.searchParams.get("sort")).toBe("installs");
});
it("uses the dedicated plugins search endpoint for search mode", async () => {
+1 -1
View File
@@ -169,7 +169,7 @@ export type PackageVersionDetail = {
};
type PluginFamily = "code-plugin" | "bundle-plugin";
type PackageCatalogSort = "updated" | "downloads" | "recommended";
type PackageCatalogSort = "updated" | "downloads" | "recommended" | "installs";
type PluginCatalogResult = {
items: PackageListItem[];
+4 -4
View File
@@ -560,7 +560,7 @@ function SkillsHome() {
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
</span>
</div>
<span className="home-v2-c-install">
@@ -596,7 +596,7 @@ function SkillsHome() {
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
</span>
</div>
<span className="home-v2-c-install">
@@ -677,7 +677,7 @@ function SkillsHome() {
<span className="home-v2-proof-sep" />
<div className="home-v2-proof-item">
<span className="home-v2-proof-num">12M</span>
<span className="home-v2-proof-label">downloads</span>
<span className="home-v2-proof-label">installs</span>
</div>
<span className="home-v2-proof-sep" />
<div className="home-v2-proof-item">
@@ -727,7 +727,7 @@ function SkillsHome() {
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
</span>
</div>
<span className="home-v2-trend-install">
+2 -2
View File
@@ -729,8 +729,8 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
density="compact"
blocks={[
{
label: "Downloads",
value: formatCompactStat(pkg.stats?.downloads ?? 0),
label: "Installs",
value: formatCompactStat(pkg.stats?.installs ?? 0),
large: true,
},
{ label: "Repository", value: sourceRepoLink },
+12 -2
View File
@@ -16,7 +16,7 @@ import {
type PackageListItem,
} from "../../lib/packageApi";
type VisiblePluginSort = "recommended" | "updated" | "downloads";
type VisiblePluginSort = "recommended" | "updated" | "downloads" | "installs";
type PluginSort = VisiblePluginSort | "relevance";
type LegacyPluginSort = PluginSort | "newest" | "name";
@@ -39,6 +39,7 @@ type LegacyPluginView = PluginView | "cards";
const PLUGIN_SORT_OPTIONS = [
{ value: "recommended", label: "Recommended" },
{ value: "installs", label: "Most installed" },
{ value: "downloads", label: "Most downloaded" },
{ value: "updated", label: "Recently updated" },
];
@@ -97,6 +98,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined {
value === "relevance" ||
value === "updated" ||
value === "downloads" ||
value === "installs" ||
value === "newest" ||
value === "name"
) {
@@ -118,6 +120,9 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) {
if (sort === "downloads") {
return (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0) || tieBreak();
}
if (sort === "installs") {
return (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0) || tieBreak();
}
return tieBreak();
});
@@ -145,7 +150,11 @@ export async function loadPluginsPageData(
featured: args.featured,
isOfficial: args.official,
executesCode: args.executesCode,
...(!args.q && (args.sort === "downloads" || !args.sort || args.sort === "recommended")
...(!args.q &&
(args.sort === "downloads" ||
args.sort === "installs" ||
!args.sort ||
args.sort === "recommended")
? { sort: args.sort ?? "recommended" }
: {}),
limit: PLUGINS_PAGE_SIZE,
@@ -223,6 +232,7 @@ export const Route = createFileRoute("/plugins/")({
search.sort !== "recommended" &&
search.sort !== "updated" &&
search.sort !== "downloads" &&
search.sort !== "installs" &&
!(hasQuery && search.sort === "relevance");
const staleFeatured = Boolean(search.featured);
const invalidCategory = Boolean(search.category && !isPluginCategorySlug(search.category));