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"]);
});
});