feat(plugins): add marketplace sort modes with release dates
This commit is contained in:
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">
|
||||
|
||||
+70
-1
@@ -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 },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -194,6 +194,11 @@ export default withEnglishFallback({
|
||||
installed: "インストール済み",
|
||||
settings: "設定",
|
||||
searchMarketplace: "プラグイン、発行者、タグを検索",
|
||||
sortBy: "並べ替え",
|
||||
sortByName: "名前 (A–Z)",
|
||||
sortByRecentlyUpdated: "最近の更新",
|
||||
sortByRecentlyListed: "最近の公開",
|
||||
sortByUpdatesFirst: "更新ありを優先",
|
||||
allRepositories: "すべてのリポジトリ",
|
||||
marketplaceGuideTitle: "プラグインはコミュニティ開発者がメンテナンスしています",
|
||||
marketplaceGuideDescription: "プラグインの問題や機能要望は、プラグインカードのソースリポジトリリンクから該当リポジトリに Issue を投稿してください。DBX メインリポジトリはアプリ本体の問題のみを受け付けています。",
|
||||
|
||||
@@ -193,6 +193,11 @@ export default withEnglishFallback({
|
||||
installed: "설치됨",
|
||||
settings: "설정",
|
||||
searchMarketplace: "플러그인, 게시자 또는 태그 검색",
|
||||
sortBy: "정렬 기준",
|
||||
sortByName: "이름 (A–Z)",
|
||||
sortByRecentlyUpdated: "최근 업데이트",
|
||||
sortByRecentlyListed: "최근 등록",
|
||||
sortByUpdatesFirst: "업데이트 우선",
|
||||
allRepositories: "모든 저장소",
|
||||
marketplaceGuideTitle: "플러그인은 커뮤니티 개발자가 유지 관리합니다",
|
||||
marketplaceGuideDescription: "플러그인 문제나 기능 제안은 플러그인 카드의 소스 저장소 링크를 통해 해당 저장소에 Issue를 등록해 주세요. DBX 메인 저장소는 앱 자체의 문제만 처리합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -141,6 +141,11 @@ export default withEnglishFallback({
|
||||
installed: "Установленные",
|
||||
settings: "Настройки",
|
||||
searchMarketplace: "Поиск плагинов, издателей или тегов",
|
||||
sortBy: "Сортировка",
|
||||
sortByName: "По имени (A–Z)",
|
||||
sortByRecentlyUpdated: "Недавно обновлённые",
|
||||
sortByRecentlyListed: "Недавно опубликованные",
|
||||
sortByUpdatesFirst: "Сначала с обновлениями",
|
||||
allRepositories: "Все репозитории",
|
||||
marketplaceGuideTitle: "Плагины поддерживаются сообществом разработчиков",
|
||||
marketplaceGuideDescription: "По вопросам о плагинах или для запроса функций создайте issue в собственном репозитории плагина (ссылка на исходный репозиторий есть на карточке плагина); основной репозиторий DBX принимает только вопросы о самом приложении.",
|
||||
|
||||
@@ -116,6 +116,11 @@ export default withEnglishFallback({
|
||||
installed: "已安装",
|
||||
settings: "设置",
|
||||
searchMarketplace: "搜索插件、发布者或标签",
|
||||
sortBy: "排序方式",
|
||||
sortByName: "名称 (A–Z)",
|
||||
sortByRecentlyUpdated: "最近更新",
|
||||
sortByRecentlyListed: "最近上架",
|
||||
sortByUpdatesFirst: "有更新优先",
|
||||
allRepositories: "全部仓库",
|
||||
marketplaceGuideTitle: "插件由社区开发者维护",
|
||||
marketplaceGuideDescription: "插件问题或功能建议请通过插件卡片上的源码仓库入口,前往对应插件仓库提交 Issue;DBX 主仓库仅受理应用本身的问题。",
|
||||
|
||||
@@ -194,6 +194,11 @@ export default withEnglishFallback({
|
||||
installed: "已安裝",
|
||||
settings: "設定",
|
||||
searchMarketplace: "搜尋外掛、發佈者或標籤",
|
||||
sortBy: "排序方式",
|
||||
sortByName: "名稱 (A–Z)",
|
||||
sortByRecentlyUpdated: "最近更新",
|
||||
sortByRecentlyListed: "最近上架",
|
||||
sortByUpdatesFirst: "有更新優先",
|
||||
allRepositories: "全部倉庫",
|
||||
marketplaceGuideTitle: "外掛由社群開發者維護",
|
||||
marketplaceGuideDescription: "外掛問題或功能建議請透過外掛卡片上的原始碼倉庫入口,前往對應外掛倉庫提交 Issue;DBX 主倉庫僅受理應用程式本身的問題。",
|
||||
|
||||
+174
-2
@@ -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 || {});
|
||||
|
||||
Reference in new issue
Block a user