feat(plugins): add marketplace sort modes with release dates

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-24 19:16:02 +08:00
1 parent 4c1eaa6b16
commit 4b9a1c4d5c
14 files changed
+373 -6

No files matched your search

@@ -26,11 +26,14 @@ import {
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
filterMarketplacePluginListings,
formatMarketplaceReleasedDate,
listingRepositoryCanVerify,
marketplaceHomepageUrl,
pluginSourceChange,
sortMarketplacePluginListings,
type InstalledPluginUpdateEntry,
type MarketplacePluginListing,
type MarketplacePluginSortMode,
type PluginSourceChange,
} from "@/lib/plugins/pluginMarketplace";
import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch";
@@ -70,6 +73,7 @@ const GithubIcon = defineComponent({
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
const MARKETPLACE_VIEW_MODE_STORAGE_KEY = "dbx-plugin-marketplace-view-mode";
const MARKETPLACE_SORT_MODE_STORAGE_KEY = "dbx-plugin-marketplace-sort-mode";
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
@@ -113,6 +117,15 @@ const repositoryCatalogUrl = ref("");
const marketplaceQuery = ref("");
const marketplaceRepositoryId = ref("all");
const marketplaceViewMode = ref<"grid" | "list">(safeLocalStorageGet(MARKETPLACE_VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "grid");
const MARKETPLACE_SORT_MODES: MarketplacePluginSortMode[] = ["name", "recently-updated", "recently-listed", "updates-first"];
// "name" stays the default: the batch specs select listings positionally, so a persisted
// value must be re-validated against the known modes before it may change the order.
const marketplaceSortMode = ref<MarketplacePluginSortMode>(
((): MarketplacePluginSortMode => {
const stored = safeLocalStorageGet(MARKETPLACE_SORT_MODE_STORAGE_KEY) as MarketplacePluginSortMode;
return MARKETPLACE_SORT_MODES.includes(stored) ? stored : "name";
})(),
);
const webFileInput = ref<HTMLInputElement | null>(null);
const panelRootRef = ref<HTMLElement | null>(null);
const draggingPackage = ref(false);
@@ -148,6 +161,8 @@ const providerConnections = computed(() => {
const selectedConnection = computed(() => providerConnections.value.find((connection) => connection.id === selectedConnectionId.value));
const marketplaceListings = computed(() => buildMarketplacePluginListings(catalogResults.value, installedPlugins.value, appLocale.value));
const filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value));
// Render order only: batch selection and update execution keep the builder's name order.
const sortedMarketplaceListings = computed(() => sortMarketplacePluginListings(filteredMarketplaceListings.value, marketplaceSortMode.value));
const batchUpdatableListings = computed(() => filteredMarketplaceListings.value.filter((listing) => listing.status === "update"));
const batchSelectedListings = computed(() => filteredMarketplaceListings.value.filter((listing) => isBatchSelectableListing(listing.status) && selectedListingKeys.value.has(listing.key)));
const batchSelectedInstalled = computed(() => definitions.value.filter((definition) => selectedInstalledIds.value.has(definition.plugin.manifest.id)));
@@ -898,6 +913,7 @@ onMounted(() => {
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
});
watch(marketplaceViewMode, (mode) => safeLocalStorageSet(MARKETPLACE_VIEW_MODE_STORAGE_KEY, mode));
watch(marketplaceSortMode, (mode) => safeLocalStorageSet(MARKETPLACE_SORT_MODE_STORAGE_KEY, mode));
onBeforeUnmount(() => {
window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated);
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
@@ -934,6 +950,15 @@ onBeforeUnmount(() => {
<Input data-plugin-marketplace-search v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
</div>
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
<Select v-model="marketplaceSortMode">
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-40 sm:flex-none" :aria-label="t('pluginPlatform.sortBy')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="name">{{ t("pluginPlatform.sortByName") }}</SelectItem>
<SelectItem value="recently-updated">{{ t("pluginPlatform.sortByRecentlyUpdated") }}</SelectItem>
<SelectItem value="recently-listed">{{ t("pluginPlatform.sortByRecentlyListed") }}</SelectItem>
<SelectItem value="updates-first">{{ t("pluginPlatform.sortByUpdatesFirst") }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="marketplaceRepositoryId">
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-52 sm:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
<SelectContent>
@@ -985,13 +1010,13 @@ onBeforeUnmount(() => {
</div>
<div v-if="marketplaceLoading" class="flex min-h-[440px] flex-1 items-center justify-center gap-2 rounded-lg border border-dashed p-12 text-xs text-muted-foreground"><Loader2 class="size-4 animate-spin" />{{ t("pluginPlatform.loadingMarketplace") }}</div>
<div v-else-if="!filteredMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<div v-else-if="!sortedMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<Store class="size-7 text-muted-foreground" />
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noMarketplacePlugins") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
</div>
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
<div class="flex items-start gap-3">
<button
v-if="batchMode && isBatchSelectableListing(listing.status)"
@@ -1036,6 +1061,7 @@ onBeforeUnmount(() => {
>
<Globe class="size-3.5" />
</button>
<span v-if="listing.latestVersionReleasedAt" class="shrink-0 text-[10px] text-muted-foreground">{{ formatMarketplaceReleasedDate(listing.latestVersionReleasedAt, appLocale) }}</span>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
</div>
</div>
@@ -1081,7 +1107,7 @@ onBeforeUnmount(() => {
</article>
</div>
<div v-else class="flex w-full flex-col gap-2">
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
<button
v-if="batchMode && isBatchSelectableListing(listing.status)"
type="button"
@@ -1118,6 +1144,7 @@ onBeforeUnmount(() => {
>
<Globe class="size-3.5" />
</button>
<span v-if="listing.latestVersionReleasedAt" class="shrink-0 text-[10px] text-muted-foreground">{{ formatMarketplaceReleasedDate(listing.latestVersionReleasedAt, appLocale) }}</span>
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
@@ -3,7 +3,7 @@
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { formatMarketplaceReleasedDate, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { COMPONENT_PLUGINS_UPDATED_EVENT, COMPONENT_UPDATES_CHANGED_EVENT } from "@/lib/updates/componentUpdateEvents";
const mocks = vi.hoisted(() => ({
@@ -66,6 +66,7 @@ type PanelState = {
batchRunning: boolean;
batchMode: boolean;
marketplaceViewMode: "grid" | "list";
marketplaceSortMode: string;
marketplaceRepositoryId: string;
marketplaceListings: MarketplacePluginListing[];
catalogResults: PluginRepositoryCatalogResult[];
@@ -785,3 +786,71 @@ describe("PluginContributionsPanel completed batch outcomes", () => {
expect(state.batchRunning).toBe(false);
});
});
describe("PluginContributionsPanel marketplace sort", () => {
const OLDEST = "2025-01-01T00:00:00Z";
const NEWEST = "2026-06-01T00:00:00Z";
// Name order (a.older, b.newer) is deliberately the opposite of date order, so the
// rendered order tells the two sort modes apart.
function datedCatalog(): PluginRepositoryCatalogResult {
const result = catalog("first", ["a.older", "b.newer"]);
const [older, newer] = result.catalog!.plugins;
older.versions[0].releasedAt = OLDEST;
newer.versions[0].releasedAt = NEWEST;
return result;
}
function renderedOrder(): string[] {
return [...host.querySelectorAll("article")].map((article) => (article.textContent?.includes("a.older") ? "a.older" : "b.newer"));
}
// The persisted sort mode is read during setup, so restoring it can only be observed
// by mounting again with localStorage already seeded.
async function remount(): Promise<void> {
app.unmount();
host.remove();
host = document.createElement("div");
document.body.append(host);
app = createApp(PluginContributionsPanel, { onPluginRuntimeReplaced: mocks.refreshPluginWorkbenches });
state = (app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } }).$.setupState;
await flushUi();
state.batchMode = true;
state.selectedPluginId = "a";
await nextTick();
}
it("restores the persisted mode on remount, shows the release date in both views, and persists changes", async () => {
mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([datedCatalog()]);
localStorage.setItem("dbx-plugin-marketplace-sort-mode", "recently-updated");
await remount();
expect(state.marketplaceSortMode).toBe("recently-updated");
expect(renderedOrder()).toEqual(["b.newer", "a.older"]);
const newestText = formatMarketplaceReleasedDate(NEWEST, "en");
const oldestText = formatMarketplaceReleasedDate(OLDEST, "en");
expect(newestText).not.toBe("");
expect(host.querySelectorAll("article")[0].textContent).toContain(newestText);
expect(host.querySelectorAll("article")[1].textContent).toContain(oldestText);
state.marketplaceViewMode = "list";
await nextTick();
expect(renderedOrder()).toEqual(["b.newer", "a.older"]);
expect(host.querySelectorAll("article")[0].textContent).toContain(newestText);
expect(host.querySelectorAll("article")[1].textContent).toContain(oldestText);
state.marketplaceSortMode = "name";
await flushUi();
expect(renderedOrder()).toEqual(["a.older", "b.newer"]);
expect(localStorage.getItem("dbx-plugin-marketplace-sort-mode")).toBe("name");
});
it("falls back to name order when the stored sort mode is unknown", async () => {
mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([datedCatalog()]);
localStorage.setItem("dbx-plugin-marketplace-sort-mode", "not-a-mode");
await remount();
expect(state.marketplaceSortMode).toBe("name");
expect(renderedOrder()).toEqual(["a.older", "b.newer"]);
});
});
@@ -63,6 +63,7 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
{ feature: "Dameng object compilation", keys: ["contextMenu.compileObjectFailedTitle", "contextMenu.compileObjectFailedMessage"], locales: EXCEPT_AZ_TR, translated: true },
{ feature: "user administration Host change", keys: ["userAdmin.changeHost", "userAdmin.newHost"], translated: true },
{ feature: "plugin batch actions", keys: ["pluginPlatform.batchDuplicateSources", "pluginPlatform.batchRefreshFailed"], locales: EXCEPT_AZ_TR },
{ feature: "plugin marketplace sorting (#10078)", keys: under("pluginPlatform", ["sortBy", "sortByName", "sortByRecentlyUpdated", "sortByRecentlyListed", "sortByUpdatesFirst"]), locales: EXCEPT_AZ_TR, translated: true },
{ feature: "shared refresh action (#8768)", keys: ["common.refresh"] },
{ feature: "transfer bulk select", keys: under("transfer", ["bulkSelectObjects", "bulkSelectTitle", "bulkSelectHint", "bulkSelectPlaceholder", "bulkSelectConfirm", "bulkSelectMatched", "bulkSelectUnmatched", "noMatchingObjects"]), translated: true },
{ feature: "table info pin", keys: ["grid.pinTableInfo", "grid.unpinTableInfo"], translated: true },
+5
View File
@@ -191,6 +191,11 @@ export default {
installed: "Installed",
settings: "Settings",
searchMarketplace: "Search plugins, publishers, or tags",
sortBy: "Sort by",
sortByName: "Name (A–Z)",
sortByRecentlyUpdated: "Recently updated",
sortByRecentlyListed: "Recently published",
sortByUpdatesFirst: "Updates first",
allRepositories: "All repositories",
marketplaceGuideTitle: "Plugins are maintained by community developers",
marketplaceGuideDescription: "For plugin issues or feature requests, please open an issue in the plugin's own repository (use the source repository link on the plugin card); the main DBX repository only handles issues with the app itself.",
+5
View File
@@ -194,6 +194,11 @@ export default withEnglishFallback({
installed: "Instalados",
settings: "Ajustes",
searchMarketplace: "Buscar plugins, editores o etiquetas",
sortBy: "Ordenar por",
sortByName: "Nombre (A–Z)",
sortByRecentlyUpdated: "Actualizados recientemente",
sortByRecentlyListed: "Publicados recientemente",
sortByUpdatesFirst: "Actualizaciones primero",
allRepositories: "Todos los repositorios",
marketplaceGuideTitle: "Los plugins los mantienen desarrolladores de la comunidad",
marketplaceGuideDescription: "Para problemas o sugerencias sobre un plugin, abre una issue en el repositorio del propio plugin (usa el enlace del repositorio de origen en la tarjeta); el repositorio principal de DBX solo atiende problemas de la propia aplicación.",
+5
View File
@@ -193,6 +193,11 @@ export default withEnglishFallback({
installed: "Installati",
settings: "Impostazioni",
searchMarketplace: "Cerca plugin, autori o tag",
sortBy: "Ordina per",
sortByName: "Nome (A–Z)",
sortByRecentlyUpdated: "Aggiornati di recente",
sortByRecentlyListed: "Pubblicati di recente",
sortByUpdatesFirst: "Prima gli aggiornamenti",
allRepositories: "Tutti i repository",
marketplaceGuideTitle: "I plugin sono mantenuti dagli sviluppatori della community",
marketplaceGuideDescription: "Per problemi o suggerimenti su un plugin, apri una issue nel repository del plugin stesso (usa il link del repository sorgente sulla scheda); il repository principale di DBX gestisce solo i problemi dell'applicazione.",
+5
View File
@@ -194,6 +194,11 @@ export default withEnglishFallback({
installed: "インストール済み",
settings: "設定",
searchMarketplace: "プラグイン、発行者、タグを検索",
sortBy: "並べ替え",
sortByName: "名前 (A–Z)",
sortByRecentlyUpdated: "最近の更新",
sortByRecentlyListed: "最近の公開",
sortByUpdatesFirst: "更新ありを優先",
allRepositories: "すべてのリポジトリ",
marketplaceGuideTitle: "プラグインはコミュニティ開発者がメンテナンスしています",
marketplaceGuideDescription: "プラグインの問題や機能要望は、プラグインカードのソースリポジトリリンクから該当リポジトリに Issue を投稿してください。DBX メインリポジトリはアプリ本体の問題のみを受け付けています。",
+5
View File
@@ -193,6 +193,11 @@ export default withEnglishFallback({
installed: "설치됨",
settings: "설정",
searchMarketplace: "플러그인, 게시자 또는 태그 검색",
sortBy: "정렬 기준",
sortByName: "이름 (A–Z)",
sortByRecentlyUpdated: "최근 업데이트",
sortByRecentlyListed: "최근 등록",
sortByUpdatesFirst: "업데이트 우선",
allRepositories: "모든 저장소",
marketplaceGuideTitle: "플러그인은 커뮤니티 개발자가 유지 관리합니다",
marketplaceGuideDescription: "플러그인 문제나 기능 제안은 플러그인 카드의 소스 저장소 링크를 통해 해당 저장소에 Issue를 등록해 주세요. DBX 메인 저장소는 앱 자체의 문제만 처리합니다.",
+5
View File
@@ -194,6 +194,11 @@ export default withEnglishFallback({
installed: "Instalados",
settings: "Configurações",
searchMarketplace: "Buscar plugins, publicadores ou tags",
sortBy: "Ordenar por",
sortByName: "Nome (A–Z)",
sortByRecentlyUpdated: "Atualizados recentemente",
sortByRecentlyListed: "Publicados recentemente",
sortByUpdatesFirst: "Atualizações primeiro",
allRepositories: "Todos os repositórios",
marketplaceGuideTitle: "Os plugins são mantidos por desenvolvedores da comunidade",
marketplaceGuideDescription: "Para problemas ou sugestões sobre um plugin, abra uma issue no repositório do próprio plugin (use o link do repositório de origem no cartão); o repositório principal do DBX trata apenas de problemas do próprio aplicativo.",
+5
View File
@@ -141,6 +141,11 @@ export default withEnglishFallback({
installed: "Установленные",
settings: "Настройки",
searchMarketplace: "Поиск плагинов, издателей или тегов",
sortBy: "Сортировка",
sortByName: "По имени (A–Z)",
sortByRecentlyUpdated: "Недавно обновлённые",
sortByRecentlyListed: "Недавно опубликованные",
sortByUpdatesFirst: "Сначала с обновлениями",
allRepositories: "Все репозитории",
marketplaceGuideTitle: "Плагины поддерживаются сообществом разработчиков",
marketplaceGuideDescription: "По вопросам о плагинах или для запроса функций создайте issue в собственном репозитории плагина (ссылка на исходный репозиторий есть на карточке плагина); основной репозиторий DBX принимает только вопросы о самом приложении.",
+5
View File
@@ -116,6 +116,11 @@ export default withEnglishFallback({
installed: "已安装",
settings: "设置",
searchMarketplace: "搜索插件、发布者或标签",
sortBy: "排序方式",
sortByName: "名称 (A–Z)",
sortByRecentlyUpdated: "最近更新",
sortByRecentlyListed: "最近上架",
sortByUpdatesFirst: "有更新优先",
allRepositories: "全部仓库",
marketplaceGuideTitle: "插件由社区开发者维护",
marketplaceGuideDescription: "插件问题或功能建议请通过插件卡片上的源码仓库入口,前往对应插件仓库提交 Issue;DBX 主仓库仅受理应用本身的问题。",
+5
View File
@@ -194,6 +194,11 @@ export default withEnglishFallback({
installed: "已安裝",
settings: "設定",
searchMarketplace: "搜尋外掛、發佈者或標籤",
sortBy: "排序方式",
sortByName: "名稱 (A–Z)",
sortByRecentlyUpdated: "最近更新",
sortByRecentlyListed: "最近上架",
sortByUpdatesFirst: "有更新優先",
allRepositories: "全部倉庫",
marketplaceGuideTitle: "外掛由社群開發者維護",
marketplaceGuideDescription: "外掛問題或功能建議請透過外掛卡片上的原始碼倉庫入口,前往對應外掛倉庫提交 Issue;DBX 主倉庫僅受理應用程式本身的問題。",
+174 -2
View File
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import { buildInstalledUpdateIndex, buildMarketplacePluginListings, compareVersions, filterMarketplacePluginListings, marketplaceHomepageUrl, pluginSourceChange, selectMarketplaceArtifact } from "./pluginMarketplace";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
import {
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
compareVersions,
filterMarketplacePluginListings,
formatMarketplaceReleasedDate,
marketplaceHomepageUrl,
pluginSourceChange,
selectMarketplaceArtifact,
sortMarketplacePluginListings,
type MarketplacePluginListing,
} from "./pluginMarketplace";
import type { InstalledPlugin, PluginMarketplacePlugin, PluginRepositoryCatalogResult } from "@/types/database";
const result: PluginRepositoryCatalogResult = {
repository: { id: "dbx-official", name: "DBX Marketplace", kind: "official", enabled: true, managed: true },
@@ -50,6 +61,12 @@ function installed(version: string): InstalledPlugin {
};
}
function installedFor(pluginId: string, version: string): InstalledPlugin {
const plugin = installed(version);
plugin.manifest.id = pluginId;
return plugin;
}
function repositoryResult(repositoryId: string, repositoryName: string, latestVersion: string, verified = true): PluginRepositoryCatalogResult {
const copy = structuredClone(result);
copy.repository = { id: repositoryId, name: repositoryName, kind: verified ? "official" : "custom", enabled: true, managed: !verified ? false : true };
@@ -171,3 +188,158 @@ describe("compareVersions", () => {
expect(compareVersions("1.1.0", "0.9")).toBeGreaterThan(0);
});
});
describe("sortMarketplacePluginListings", () => {
type DatedPluginSpec = { id: string; name: string; latestVersion: string; versions: Array<{ version: string; releasedAt?: string }> };
function datedPlugin(spec: DatedPluginSpec): PluginMarketplacePlugin {
return {
id: spec.id,
name: spec.name,
description: "",
publisher: "DBX",
verified: true,
tags: [],
permissions: [],
latestVersion: spec.latestVersion,
versions: spec.versions.map((version) => ({
version: version.version,
...(version.releasedAt ? { releasedAt: version.releasedAt } : {}),
artifacts: [{ target: "darwin-arm64", url: `https://plugins.example.com/${spec.id}.dbxp`, sha256: "a".repeat(64), signingKeyId: "dbx.release" }],
})),
};
}
function datedCatalog(plugins: DatedPluginSpec[]): PluginRepositoryCatalogResult {
return {
repository: { id: "dbx-official", name: "DBX Marketplace", kind: "official", enabled: true, managed: true },
target: "darwin-arm64",
catalog: { catalogVersion: 1, repository: { id: "dbx-official", name: "DBX Marketplace" }, plugins: plugins.map(datedPlugin) },
};
}
const ids = (listings: MarketplacePluginListing[]) => listings.map((listing) => listing.plugin.id);
// Exactly what the card renders for a listing: the latestVersion entry's date, undated → the
// sort's -Infinity bucket. Used to pin sort/display coherence without restating the production
// comparator.
const displayedTime = (listing: MarketplacePluginListing) => {
const time = Date.parse(listing.latestVersionReleasedAt || "");
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
};
const displayedTimes = (listings: MarketplacePluginListing[]) => listings.map(displayedTime);
it("surfaces the latest version's release date for display and blanks unparsable input", () => {
const listings = buildMarketplacePluginListings(
[
datedCatalog([
{
id: "a.hello",
name: "Alpha",
latestVersion: "2.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2026-01-01T00:00:00Z" },
{ version: "2.0.0", releasedAt: "2026-03-01T00:00:00Z" },
],
},
]),
],
[],
"en",
);
expect(listings[0].latestVersionReleasedAt).toBe("2026-03-01T00:00:00Z");
expect(formatMarketplaceReleasedDate(listings[0].latestVersionReleasedAt, "en")).not.toBe("");
expect(formatMarketplaceReleasedDate(undefined, "en")).toBe("");
expect(formatMarketplaceReleasedDate("not-a-date", "en")).toBe("");
});
it("sorts recently-updated by the displayed latest-version date and sinks undated listings", () => {
// Beta's OLDER 0.9.0 entry carries the newest date in the catalog, but its card displays the
// latestVersion (1.0.0) date. The sort key must be that same displayed value, so Beta ties with
// Alpha instead of ranking above it while showing an older badge.
const contradictory = datedCatalog([
{ id: "a.old", name: "Alpha", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
{
id: "b.new",
name: "Beta",
latestVersion: "1.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" },
{ version: "0.9.0", releasedAt: "2026-05-01T00:00:00Z" },
],
},
{ id: "c.none", name: "Gamma", latestVersion: "1.0.0", versions: [{ version: "1.0.0" }] },
]);
const tied = sortMarketplacePluginListings(buildMarketplacePluginListings([contradictory], [], "en"), "recently-updated");
// Equal displayed dates → name order; the undated listing sinks to the tail.
expect(ids(tied)).toEqual(["a.old", "b.new", "c.none"]);
// The coherence invariant itself: the ranking is exactly the descending order of the field the
// card renders, with undated listings last. Nothing here looks at Beta's newer 0.9.0 date.
const dated = datedCatalog([
{ id: "a.new", name: "Alpha", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2026-06-01T00:00:00Z" }] },
{ id: "b.old", name: "Beta", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
{ id: "c.none", name: "Gamma", latestVersion: "1.0.0", versions: [{ version: "1.0.0" }] },
]);
const listings = buildMarketplacePluginListings([dated], [], "en");
const sorted = sortMarketplacePluginListings(listings, "recently-updated");
expect(ids(sorted)).toEqual(["a.new", "b.old", "c.none"]);
expect(sorted.map((listing) => listing.latestVersionReleasedAt)).toEqual(["2026-06-01T00:00:00Z", "2025-01-01T00:00:00Z", undefined]);
// The coherence invariant, stated fixture-independently: walking the sorted list never shows a
// badge date newer than the entry above it, and the undated listing holds the tail.
const displayed = displayedTimes(sorted);
expect(displayed.every((time, index) => index === 0 || displayed[index - 1] >= time)).toBe(true);
expect(sorted[sorted.length - 1].latestVersionReleasedAt).toBeUndefined();
});
it("sorts recently-listed by the earliest version date, including a non-latest version", () => {
// Unlike "recently updated", this mode intentionally scans every version: no card text claims
// to show a first-listed date, so min(releasedAt) cannot contradict anything on screen.
const catalog = datedCatalog([
{
id: "a.veteran",
name: "Alpha",
latestVersion: "3.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2024-01-01T00:00:00Z" },
{ version: "3.0.0", releasedAt: "2026-01-01T00:00:00Z" },
],
},
{ id: "b.newcomer", name: "Beta", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-06-01T00:00:00Z" }] },
// Newest latest-version date of the three, oldest first-listed date is still the veteran's.
{
id: "c.young",
name: "Gamma",
latestVersion: "1.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2026-08-01T00:00:00Z" },
{ version: "0.1.0", releasedAt: "2024-06-01T00:00:00Z" },
],
},
]);
const listings = buildMarketplacePluginListings([catalog], [], "en");
expect(ids(sortMarketplacePluginListings(listings, "recently-listed"))).toEqual(["b.newcomer", "c.young", "a.veteran"]);
});
it("puts update-bearing listings first, then the newest-updated order; name mode keeps A–Z", () => {
// Both plugins have exactly one version, so the latestVersion entry and the single version share
// a date: updates-first's tie-break uses the same displayed value as "recently-updated".
const catalog = datedCatalog([
{ id: "a.alpha", name: "Alpha", latestVersion: "2.0.0", versions: [{ version: "2.0.0", releasedAt: "2026-06-01T00:00:00Z" }] },
{ id: "b.zulu", name: "Zulu", latestVersion: "2.0.0", versions: [{ version: "2.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
]);
const listings = buildMarketplacePluginListings([catalog], [installedFor("b.zulu", "1.0.0")], "en");
// The builder output itself is the long-standing default: localized name order.
expect(ids(listings)).toEqual(["a.alpha", "b.zulu"]);
const updatesFirst = sortMarketplacePluginListings(listings, "updates-first");
expect(ids(updatesFirst)).toEqual(["b.zulu", "a.alpha"]);
// The update-bearing listing leads even though its displayed date is the older one.
expect(displayedTimes(updatesFirst)).toEqual([Date.parse("2025-01-01T00:00:00Z"), Date.parse("2026-06-01T00:00:00Z")]);
expect(ids(sortMarketplacePluginListings(listings, "name"))).toEqual(["a.alpha", "b.zulu"]);
});
});
@@ -3,6 +3,8 @@ import { uuid } from "@/lib/common/utils";
export type MarketplacePluginStatus = "install" | "installed" | "update" | "unsupported";
export type MarketplacePluginSortMode = "name" | "recently-updated" | "recently-listed" | "updates-first";
export const UNIVERSAL_PLUGIN_TARGET = "universal";
export interface MarketplacePluginListing {
@@ -16,6 +18,9 @@ export interface MarketplacePluginListing {
installed?: InstalledPlugin;
verified: boolean;
status: MarketplacePluginStatus;
// releasedAt of the catalog's latestVersion entry, when provided; shown on the card
// next to the version badge so time-based sort modes have a visible key.
latestVersionReleasedAt?: string;
}
/**
@@ -51,6 +56,7 @@ export function buildMarketplacePluginListings(results: readonly PluginRepositor
installed,
verified: plugin.verified && listingRepositoryCanVerify(result.repository),
status,
latestVersionReleasedAt: latestVersion?.releasedAt || undefined,
};
}),
)
@@ -163,6 +169,53 @@ export function filterMarketplacePluginListings(listings: readonly MarketplacePl
});
}
// Missing or unparsable releasedAt sorts below every dated listing (-Infinity tail).
function releaseTime(releasedAt: string | undefined): number {
const time = Date.parse(releasedAt || "");
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
}
// "Recently updated" must use the SAME value the card renders (latestVersionReleasedAt, the
// catalog's latestVersion entry): the ranking can then never contradict the date on the badge.
// Third-party catalogs are not required to keep latestVersion at the newest date, so a catalog
// that dates an older version later than its current one sorts by the date it advertises for its
// current version — the honest reading of "recently updated". The official store satisfies the
// latestVersion-is-newest invariant 30/30 (verified live 2026-09-24), so real data is unaffected.
function latestVersionReleaseTime(listing: MarketplacePluginListing): number {
return releaseTime(listing.latestVersionReleasedAt);
}
// "Recently listed" scans EVERY version: nothing on the card claims to display a first-listed
// date, so min(releasedAt) across the catalog is the only signal available (min ≈ first listed,
// the docs-site heuristic) and cannot contradict the badge.
function firstReleaseTime(listing: MarketplacePluginListing): number {
const times = listing.plugin.versions.map((version) => Date.parse(version.releasedAt || "")).filter((time) => !Number.isNaN(time));
return times.length ? Math.min(...times) : Number.NEGATIVE_INFINITY;
}
// Copies before sorting: the builder output is also consumed elsewhere (batch selection,
// the installed-tab update index) and must keep its name order there.
export function sortMarketplacePluginListings(listings: readonly MarketplacePluginListing[], mode: MarketplacePluginSortMode): MarketplacePluginListing[] {
const byName = (left: MarketplacePluginListing, right: MarketplacePluginListing) => left.name.localeCompare(right.name);
if (mode === "name") return [...listings].sort(byName);
const releaseTimeFor = mode === "recently-listed" ? firstReleaseTime : latestVersionReleaseTime;
if (mode === "updates-first") {
return [...listings].sort((left, right) => {
const updatable = (listing: MarketplacePluginListing) => (listing.status === "update" ? 0 : 1);
if (updatable(left) !== updatable(right)) return updatable(left) - updatable(right);
return releaseTimeFor(right) - releaseTimeFor(left) || byName(left, right);
});
}
return [...listings].sort((left, right) => releaseTimeFor(right) - releaseTimeFor(left) || byName(left, right));
}
export function formatMarketplaceReleasedDate(releasedAt: string | undefined, locale: string): string {
if (!releasedAt) return "";
const date = new Date(releasedAt);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(locale.replace("_", "-"), { dateStyle: "medium" }).format(date);
}
function marketplacePluginLocalization(plugin: PluginMarketplacePlugin, locale: string): { name: string; description: string } {
const normalizedLocale = locale.replace("_", "-").toLowerCase();
const entries = Object.entries(plugin.localizations || {});