From 84019b4271b0993cc86eba3ffa3ff31a469d3bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=88=E7=A7=8B?= <49514152+yiqiui@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:27:53 +0800 Subject: [PATCH] feat(plugins): add batch install update and uninstall actions --- .../plugins/PluginContributionsPanel.vue | 458 +++++++++++------ .../PluginContributionsPanelBatch.spec.ts | 460 ++++++++++++++++++ .../__tests__/pluginBatchMessages.spec.ts | 24 + apps/desktop/src/i18n/locales/en.ts | 11 + apps/desktop/src/i18n/locales/es.ts | 11 + apps/desktop/src/i18n/locales/it.ts | 11 + apps/desktop/src/i18n/locales/ja.ts | 11 + apps/desktop/src/i18n/locales/ko.ts | 11 + apps/desktop/src/i18n/locales/pt-BR.ts | 11 + apps/desktop/src/i18n/locales/zh-CN.ts | 11 + apps/desktop/src/i18n/locales/zh-TW.ts | 11 + .../lib/plugins/__tests__/pluginBatch.spec.ts | 50 ++ apps/desktop/src/lib/plugins/pluginBatch.ts | 46 ++ 13 files changed, 988 insertions(+), 138 deletions(-) create mode 100644 apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts create mode 100644 apps/desktop/src/i18n/__tests__/pluginBatchMessages.spec.ts create mode 100644 apps/desktop/src/lib/plugins/__tests__/pluginBatch.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginBatch.ts diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index cc594c149..cf1afd29e 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments"; import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin"; import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, marketplaceHomepageUrl, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace"; +import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch"; import { formatBytes } from "@/lib/database/serverMetrics"; import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -97,6 +98,13 @@ const panelRootRef = ref(null); const draggingPackage = ref(false); let webDragDepth = 0; +// Batch operations (install / update on the marketplace tab, uninstall on the installed tab). +const batchMode = ref(false); +const selectedListingKeys = ref>(new Set()); +const selectedInstalledIds = ref>(new Set()); +const batchRunning = ref(false); +const mutationRunning = computed(() => batchRunning.value || !!marketplaceInstallingKey.value || installing.value || urlInstalling.value || operating.value); + const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value)); const definitions = computed(() => registry.value.listPlugins()); const connectionProviders = computed(() => registry.value.listConnectionProviders()); @@ -112,6 +120,9 @@ 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)); +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))); const catalogErrors = computed(() => catalogResults.value.filter((result) => result.error)); const customRepositories = computed(() => repositories.value.filter((repository) => !repository.managed)); const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0); @@ -174,17 +185,22 @@ function applyFocusTarget(focus: PluginCenterFocus) { selectProvider(focus.pluginId, provider.contribution.id); } +async function installListing(listing: MarketplacePluginListing): Promise { + const result = await api.installMarketplacePlugin({ + repositoryId: listing.repository.id, + pluginId: listing.plugin.id, + version: listing.plugin.latestVersion, + }); + beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion); + return result; +} + async function installMarketplaceListing(listing: MarketplacePluginListing) { - if (!listing.artifact || listing.status === "installed") return; + if (mutationRunning.value || !listing.artifact || !isBatchSelectableListing(listing.status)) return; marketplaceInstallingKey.value = listing.key; try { - const result = await api.installMarketplacePlugin({ - repositoryId: listing.repository.id, - pluginId: listing.plugin.id, - version: listing.plugin.latestVersion, - }); + const result = await installListing(listing); toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version })); - beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion); installedPlugins.value = await api.listPlugins(); selectPlugin(result.plugin.manifest.id); } catch (cause) { @@ -194,7 +210,119 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) { } } +function batchSummaryKey(outcome: { succeeded: unknown[]; failed: { name: string }[] }): string { + return outcome.failed.length ? "pluginPlatform.batchSummaryWithFailures" : "pluginPlatform.batchSummary"; +} + +function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: string }[] }) { + const failedNames = outcome.failed.map((failure) => failure.name).join("、"); + toast(t(batchSummaryKey(outcome), { success: outcome.succeeded.length, failed: outcome.failed.length, names: failedNames }), outcome.failed.length ? 8000 : 4000); +} + +async function refreshAfterBatch() { + try { + installedPlugins.value = await api.listPlugins(); + } catch (cause) { + error.value = t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) }); + } +} + +function toggleBatchMode() { + if (batchRunning.value) return; + batchMode.value = !batchMode.value; + if (!batchMode.value) clearBatchSelection(); +} + +function clearBatchSelection() { + selectedListingKeys.value = new Set(); + selectedInstalledIds.value = new Set(); +} + +function isListingSelected(listing: MarketplacePluginListing): boolean { + return selectedListingKeys.value.has(listing.key); +} + +function toggleListingSelection(listing: MarketplacePluginListing) { + if (batchRunning.value || !isBatchSelectableListing(listing.status)) return; + const next = new Set(selectedListingKeys.value); + if (next.has(listing.key)) next.delete(listing.key); + else next.add(listing.key); + selectedListingKeys.value = next; +} + +function selectAllUpdatable() { + if (batchRunning.value) return; + const next = new Set(selectedListingKeys.value); + for (const listing of batchUpdatableListings.value) next.add(listing.key); + selectedListingKeys.value = next; +} + +function isInstalledSelected(pluginId: string): boolean { + return selectedInstalledIds.value.has(pluginId); +} + +function toggleInstalledSelection(pluginId: string) { + if (batchRunning.value) return; + const next = new Set(selectedInstalledIds.value); + if (next.has(pluginId)) next.delete(pluginId); + else next.add(pluginId); + selectedInstalledIds.value = next; +} + +async function runBatchInstallUpdate() { + const targets = batchSelectedListings.value; + if (!targets.length || mutationRunning.value) return; + const pluginIds = new Set(); + const duplicateIds = new Set(); + for (const listing of marketplaceListings.value) { + if (!selectedListingKeys.value.has(listing.key) || !isBatchSelectableListing(listing.status)) continue; + if (pluginIds.has(listing.plugin.id)) duplicateIds.add(listing.plugin.id); + pluginIds.add(listing.plugin.id); + } + if (duplicateIds.size) return toast(t("pluginPlatform.batchDuplicateSources", { names: [...duplicateIds].join("、") }), 8000); + batchRunning.value = true; + error.value = ""; + try { + const outcome = await runBatch( + targets, + (listing) => listing.name, + async (listing) => { + await installListing(listing); + }, + ); + clearBatchSelection(); + reportBatchSummary(outcome); + await refreshAfterBatch(); + } finally { + batchRunning.value = false; + } +} + +async function runBatchUninstall() { + const targets = batchSelectedInstalled.value; + if (!targets.length || mutationRunning.value) return; + const names = targets.map((definition) => definition.plugin.manifest.name).join("、"); + if (!window.confirm(t("pluginPlatform.batchUninstallConfirm", { count: targets.length, names }))) return; + batchRunning.value = true; + error.value = ""; + try { + const outcome = await runBatch( + targets, + (definition) => definition.plugin.manifest.name, + async (definition) => { + await api.uninstallPlugin(definition.plugin.manifest.id); + }, + ); + clearBatchSelection(); + reportBatchSummary(outcome); + await refreshAfterBatch(); + } finally { + batchRunning.value = false; + } +} + async function saveRepository() { + if (mutationRunning.value) return; const id = repositoryId.value.trim(); const name = repositoryName.value.trim(); const catalogUrl = repositoryCatalogUrl.value.trim(); @@ -215,7 +343,7 @@ async function saveRepository() { } async function toggleRepository(repository: PluginRepository) { - if (repository.managed) return; + if (mutationRunning.value || repository.managed) return; operating.value = true; try { repositories.value = await api.savePluginRepository({ ...repository, enabled: !repository.enabled }); @@ -228,7 +356,7 @@ async function toggleRepository(repository: PluginRepository) { } async function removeRepository(repository: PluginRepository) { - if (repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return; + if (mutationRunning.value || repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return; operating.value = true; try { repositories.value = await api.removePluginRepository(repository.id); @@ -243,6 +371,7 @@ async function removeRepository(repository: PluginRepository) { } async function saveTrustedKey() { + if (mutationRunning.value) return; const keyId = trustedKeyId.value.trim(); const publicKey = trustedPublicKey.value.trim(); if (!keyId || !publicKey) return; @@ -260,7 +389,7 @@ async function saveTrustedKey() { } async function removeTrustedKey(keyId: string) { - if (!window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return; + if (mutationRunning.value || !window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return; operating.value = true; try { trustedKeys.value = await api.removePluginTrustedKey(keyId); @@ -343,6 +472,7 @@ function openWorkbench(pluginId: string, contributionId: string, label: string) } async function choosePluginPackage() { + if (mutationRunning.value) return; if (!isTauriRuntime()) { webFileInput.value?.click(); return; @@ -385,6 +515,7 @@ async function finishInstall(result: PluginInstallResult) { } async function installPlugin(source: string | File) { + if (mutationRunning.value) return; installing.value = true; try { const result = await api.installPluginPackage(source, allowUnsigned.value); @@ -407,7 +538,7 @@ function isHttpPackageUrl(value: string): boolean { async function installPluginFromUrl() { const url = installUrl.value.trim(); - if (!url || installing.value || urlInstalling.value) return; + if (!url || mutationRunning.value) return; if (!isHttpPackageUrl(url)) return toast(t("pluginPlatform.invalidPackageUrl")); urlInstalling.value = true; urlDownloadProgress.value = { downloaded: 0, total: null }; @@ -484,7 +615,7 @@ function onWebDrop(event: DragEvent) { } event.preventDefault(); event.stopPropagation(); - if (installing.value || urlInstalling.value) return; + if (mutationRunning.value) return; void installPlugin(file); } @@ -511,13 +642,13 @@ function onTauriPluginDrop(event: Event) { } draggingPackage.value = false; const path = payload.paths.find(isPluginPackagePath); - if (!inside || !path || installing.value || urlInstalling.value) return; + if (!inside || !path || mutationRunning.value) return; routedEvent.preventDefault(); void installPlugin(path); } async function rollbackSelectedPlugin() { - if (!selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return; + if (mutationRunning.value || !selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return; operating.value = true; try { const result = await api.rollbackPlugin(selectedPluginId.value); @@ -534,7 +665,7 @@ async function rollbackSelectedPlugin() { async function uninstallSelectedPlugin() { const definition = selectedDefinition.value; - if (!definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return; + if (mutationRunning.value || !definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return; operating.value = true; try { installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id); @@ -650,10 +781,20 @@ onBeforeUnmount(() => { + +
+ {{ t("pluginPlatform.batchSelected", { count: batchSelectedListings.length }) }} +
+ + + +
+
+
@@ -670,6 +811,18 @@ onBeforeUnmount(() => {
+
@@ -724,7 +877,7 @@ onBeforeUnmount(() => { type="button" class="inline-flex h-7 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800" :class="marketplaceActionClass(listing)" - :disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey" + :disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning" @click="installMarketplaceListing(listing)" > @@ -735,6 +888,18 @@ onBeforeUnmount(() => {
+
@@ -779,7 +944,7 @@ onBeforeUnmount(() => { type="button" class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800" :class="marketplaceActionClass(listing)" - :disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey" + :disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning" @click="installMarketplaceListing(listing)" > @@ -798,120 +963,139 @@ onBeforeUnmount(() => {
{{ t("pluginPlatform.noInstalledPluginsDescription") }}
-
-
- -
- -
-
diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts new file mode 100644 index 000000000..d036a341d --- /dev/null +++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts @@ -0,0 +1,460 @@ +// @vitest-environment happy-dom + +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"; + +const mocks = vi.hoisted(() => ({ + listPlugins: vi.fn(), + listPluginTrustedKeys: vi.fn(), + listPluginRepositories: vi.fn(), + fetchPluginMarketplaceCatalogs: vi.fn(), + installMarketplacePlugin: vi.fn(), + installPluginPackage: vi.fn(), + installPluginPackageFromUrl: vi.fn(), + uninstallPlugin: vi.fn(), + rollbackPlugin: vi.fn(), + savePluginRepository: vi.fn(), + removePluginRepository: vi.fn(), + savePluginTrustedKey: vi.fn(), + removePluginTrustedKey: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => mocks); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) })); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); +vi.mock("vue-i18n", async () => { + const { ref } = await import("vue"); + return { useI18n: () => ({ locale: ref("en"), t: (key: string, values = {}) => `${key}:${JSON.stringify(values)}` }) }; +}); +vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") })); +vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") })); +vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") })); +vi.mock("@/components/ui/label", async () => ({ Label: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Label", "label") })); +vi.mock("@/components/ui/switch", async () => ({ Switch: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Switch") })); +vi.mock("@/components/ui/select", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + const stub = createPassthroughStub("Select"); + return { Select: stub, SelectContent: stub, SelectItem: stub, SelectTrigger: stub, SelectValue: stub }; +}); +vi.mock("@/components/ui/tabs", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + const stub = createPassthroughStub("Tabs"); + return { Tabs: stub, TabsContent: stub, TabsList: stub, TabsTrigger: stub }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + const stub = createPassthroughStub("Tooltip"); + return { Tooltip: stub, TooltipContent: stub, TooltipTrigger: stub }; +}); +vi.mock("@/components/plugins/PluginIcon.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginIcon") })); + +import PluginContributionsPanel from "@/components/plugins/PluginContributionsPanel.vue"; + +type PanelState = { + batchRunning: boolean; + batchMode: boolean; + marketplaceViewMode: "grid" | "list"; + marketplaceRepositoryId: string; + marketplaceListings: MarketplacePluginListing[]; + catalogResults: PluginRepositoryCatalogResult[]; + installedPlugins: InstalledPlugin[]; + selectedListingKeys: Set; + selectedInstalledIds: Set; + selectedPluginId: string; + installUrl: string; + repositoryId: string; + repositoryName: string; + repositoryCatalogUrl: string; + trustedKeyId: string; + trustedPublicKey: string; + error: string; + toggleListingSelection: (listing: MarketplacePluginListing) => void; + selectAllUpdatable: () => void; + runBatchInstallUpdate: () => Promise; + runBatchUninstall: () => Promise; + installMarketplaceListing: (listing: MarketplacePluginListing) => Promise; + installPlugin: (source: string) => Promise; + installPluginFromUrl: () => Promise; + uninstallSelectedPlugin: () => Promise; + rollbackSelectedPlugin: () => Promise; + saveRepository: () => Promise; + toggleRepository: (repository: PluginRepositoryCatalogResult["repository"]) => Promise; + removeRepository: (repository: PluginRepositoryCatalogResult["repository"]) => Promise; + saveTrustedKey: () => Promise; + removeTrustedKey: (keyId: string) => Promise; + toggleBatchMode: () => void; + toggleInstalledSelection: (pluginId: string) => void; +}; + +function installed(id: string, version = "1.0.0"): InstalledPlugin { + return { + manifest: { manifest_version: 1, id, name: id, version, publisher: "DBX", description: "", engines: { dbx: "", host_api: "" }, permissions: [], entrypoints: {}, contributions: [], drivers: [], protocol_version: 1 }, + compatibility: { compatible: true, errors: [], warnings: [], target: "darwin-arm64" }, + }; +} + +function catalog(repositoryId: string, ids: string[], version = "3.0.0"): PluginRepositoryCatalogResult { + return { + repository: { id: repositoryId, name: repositoryId, kind: "custom", enabled: true, managed: false }, + target: "darwin-arm64", + catalog: { + catalogVersion: 1, + repository: { id: repositoryId, name: repositoryId }, + plugins: ids.map((id) => ({ + id, + name: id, + description: "", + publisher: "DBX", + verified: false, + tags: [], + permissions: [], + latestVersion: version, + versions: [{ version, artifacts: [{ target: "darwin-arm64", url: "https://example.invalid/plugin.dbxp", sha256: "a".repeat(64), signingKeyId: "test.key" }] }], + })), + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +let app: App; +let host: HTMLElement; +let state: PanelState; + +async function flushUi() { + for (let index = 0; index < 8; index++) { + await Promise.resolve(); + await nextTick(); + } +} + +function button(key: string): HTMLButtonElement { + const match = [...host.querySelectorAll("button")].find((element) => element.textContent?.includes(`pluginPlatform.${key}:`)); + expect(match, key).toBeDefined(); + return match!; +} + +const mutationApis = [mocks.installMarketplacePlugin, mocks.uninstallPlugin, mocks.rollbackPlugin, mocks.installPluginPackage, mocks.installPluginPackageFromUrl]; + +function mutationCount() { + return mutationApis.reduce((count, mock) => count + mock.mock.calls.length, 0); +} + +async function expectBusyControls() { + for (const view of ["grid", "list"] as const) { + state.marketplaceViewMode = view; + await nextTick(); + for (const key of ["batchInstallUpdate", "batchUninstall", "marketplaceStatus.update", "uninstall", "rollback", "installPackage", "installFromUrl"]) { + expect(button(key).disabled, `${view}: ${key}`).toBe(true); + } + } +} + +beforeEach(async () => { + vi.resetAllMocks(); + localStorage.clear(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({})); + vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); + mocks.listPlugins.mockResolvedValue([installed("a"), installed("b"), installed("c")]); + mocks.listPluginTrustedKeys.mockResolvedValue([]); + mocks.listPluginRepositories.mockResolvedValue([]); + mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([catalog("first", ["a", "b", "c"])]); + for (const mock of mutationApis) mock.mockResolvedValue({ plugin: installed("a", "3.0.0") }); + mocks.uninstallPlugin.mockResolvedValue([]); + mocks.savePluginRepository.mockResolvedValue([]); + mocks.removePluginRepository.mockResolvedValue([]); + mocks.savePluginTrustedKey.mockResolvedValue([]); + mocks.removePluginTrustedKey.mockResolvedValue([]); + host = document.createElement("div"); + document.body.append(host); + app = createApp(PluginContributionsPanel); + const instance = app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } }; + state = instance.$.setupState; + await flushUi(); + state.batchMode = true; + state.selectedPluginId = "a"; + state.selectedListingKeys = new Set(["first:a"]); + state.selectedInstalledIds = new Set(["a"]); + state.installUrl = "https://example.invalid/plugin.dbxp"; + state.repositoryId = "custom"; + state.repositoryName = "Custom"; + state.repositoryCatalogUrl = "https://example.invalid/catalog.json"; + state.trustedKeyId = "custom"; + state.trustedPublicKey = "public-key"; + mocks.listPlugins.mockClear(); + await nextTick(); +}); + +afterEach(() => { + app?.unmount(); + host?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("PluginContributionsPanel batch source validation", () => { + it.each(["first", "second"])("does not silently resolve selected source conflicts by filtering to %s", async (repositoryId) => { + state.catalogResults = [catalog("first", ["a"]), catalog("second", ["a"], "2.0.0")]; + state.selectAllUpdatable(); + state.marketplaceRepositoryId = repositoryId; + await state.runBatchInstallUpdate(); + expect(mutationCount()).toBe(0); + expect(state.selectedListingKeys.size).toBe(2); + state.toggleListingSelection(state.marketplaceListings.find((listing) => listing.repository.id !== repositoryId)!); + await state.runBatchInstallUpdate(); + expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId, pluginId: "a", version: repositoryId === "first" ? "3.0.0" : "2.0.0" }); + }); + + it.each(["2.0.0", "3.0.0"].flatMap((version) => ["manual", "all"].map((selection) => ({ version, selection }))))("rejects $selection duplicate sources at $version before any mutation", async ({ version, selection }) => { + state.catalogResults = [catalog("first", ["a", "b"]), catalog("second", ["b"], version)]; + state.selectedListingKeys = new Set(); + if (selection === "all") state.selectAllUpdatable(); + else for (const listing of state.marketplaceListings) state.toggleListingSelection(listing); + await state.runBatchInstallUpdate(); + expect(mutationCount()).toBe(0); + expect(mocks.listPlugins).not.toHaveBeenCalled(); + expect(state.selectedListingKeys.size).toBe(3); + expect(state.batchRunning).toBe(false); + expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining('pluginPlatform.batchDuplicateSources:{"names":"b"}'), 8000); + + state.toggleListingSelection(state.marketplaceListings.find((listing) => listing.key === "first:b")!); + await state.runBatchInstallUpdate(); + expect(mocks.installMarketplacePlugin.mock.calls.map(([request]) => request)).toEqual([ + { repositoryId: "first", pluginId: "a", version: "3.0.0" }, + { repositoryId: "second", pluginId: "b", version }, + ]); + expect(state.selectedListingKeys.size).toBe(0); + }); +}); + +const batches = ["install", "uninstall"] as const; +type Batch = (typeof batches)[number]; +const singles = ["marketplace", "uninstall", "rollback", "package", "url"] as const; +type Single = (typeof singles)[number]; + +function startBatch(batch: Batch) { + return batch === "install" ? state.runBatchInstallUpdate() : state.runBatchUninstall(); +} + +function startSingle(single: Single) { + switch (single) { + case "marketplace": + return state.installMarketplaceListing(state.marketplaceListings[0]); + case "uninstall": + return state.uninstallSelectedPlugin(); + case "rollback": + return state.rollbackSelectedPlugin(); + case "package": + return state.installPlugin("plugin.dbxp"); + case "url": + return state.installPluginFromUrl(); + } +} + +function singleApi(single: Single) { + return { marketplace: mocks.installMarketplacePlugin, uninstall: mocks.uninstallPlugin, rollback: mocks.rollbackPlugin, package: mocks.installPluginPackage, url: mocks.installPluginPackageFromUrl }[single]; +} + +describe("PluginContributionsPanel mutation exclusion", () => { + it.each(singles.flatMap((single) => [false, true].map((reject) => ({ single, reject }))))("releases single $single exclusion after rejection=$reject", async ({ single, reject }) => { + if (reject) singleApi(single).mockRejectedValueOnce(new Error("single denied")); + await startSingle(single); + expect(mutationCount()).toBe(1); + if (reject) expect(mocks.toast).toHaveBeenLastCalledWith("single denied", expect.any(Number)); + await nextTick(); + expect(button("batchInstallUpdate").disabled).toBe(false); + await state.runBatchInstallUpdate(); + expect(mutationCount()).toBe(2); + expect(state.batchRunning).toBe(false); + }); + + it.each(batches.flatMap((batch) => singles.map((single) => ({ batch, single }))))("blocks $single while batch $batch is pending", async ({ batch, single }) => { + const pending = deferred(); + (batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise); + const running = startBatch(batch); + try { + await startSingle(single); + expect(mutationCount()).toBe(1); + await expectBusyControls(); + } finally { + pending.resolve(batch === "install" ? { plugin: installed("a") } : []); + await running; + } + }); + + it.each(singles.flatMap((single) => batches.map((batch) => ({ single, batch }))))("blocks batch $batch while single $single is pending", async ({ single, batch }) => { + const pending = deferred(); + singleApi(single).mockReturnValueOnce(pending.promise); + const running = startSingle(single); + try { + await startBatch(batch); + expect(mutationCount()).toBe(1); + await expectBusyControls(); + } finally { + pending.resolve(single === "uninstall" ? [] : { plugin: installed("a") }); + await running; + } + }); + + it.each(batches.flatMap((first) => batches.map((second) => ({ first, second }))))("blocks batch $second while batch $first is pending", async ({ first, second }) => { + const pending = deferred(); + (first === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise); + const running = startBatch(first); + try { + await startBatch(second); + expect(mutationCount()).toBe(1); + } finally { + pending.resolve(first === "install" ? { plugin: installed("a") } : []); + await running; + } + }); + + it.each(batches)("keeps selections stable and blocks settings mutations during batch %s", async (batch) => { + const pending = deferred(); + (batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise); + const running = startBatch(batch); + try { + state.toggleBatchMode(); + state.selectAllUpdatable(); + state.toggleListingSelection(state.marketplaceListings[1]); + state.toggleInstalledSelection("b"); + expect(state.batchMode).toBe(true); + expect([...state.selectedListingKeys]).toEqual(["first:a"]); + expect([...state.selectedInstalledIds]).toEqual(["a"]); + await state.saveRepository(); + await state.toggleRepository(catalog("custom", []).repository); + await state.removeRepository(catalog("custom", []).repository); + await state.saveTrustedKey(); + await state.removeTrustedKey("custom"); + for (const mock of [mocks.savePluginRepository, mocks.removePluginRepository, mocks.savePluginTrustedKey, mocks.removePluginTrustedKey]) expect(mock).not.toHaveBeenCalled(); + await nextTick(); + for (const key of ["batchDone", "batchSelectAllUpdatable", "addRepository"]) expect(button(key).disabled).toBe(true); + } finally { + pending.resolve(batch === "install" ? { plugin: installed("a") } : []); + await running; + } + }); + + it.each(batches)("blocks batch %s while repository settings are pending", async (batch) => { + const pending = deferred<[]>(); + mocks.savePluginRepository.mockReturnValueOnce(pending.promise); + const running = state.saveRepository(); + try { + await startBatch(batch); + expect(mutationCount()).toBe(0); + await expectBusyControls(); + } finally { + pending.resolve([]); + await running; + } + await startBatch(batch); + expect(mutationCount()).toBe(1); + }); +}); + +describe("PluginContributionsPanel completed batch outcomes", () => { + it.each(batches.flatMap((batch) => [0, 1, 3].map((failures) => ({ batch, failures }))))("preserves batch $batch outcomes with $failures failures when refresh rejects", async ({ batch, failures }) => { + state.selectAllUpdatable(); + state.selectedInstalledIds = new Set(["a", "b", "c"]); + const mutation = batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin; + for (let index = 0; index < failures; index++) mutation.mockRejectedValueOnce(index === 0 ? "denied" : new Error("denied")); + const refresh = deferred(); + mocks.listPlugins.mockReturnValueOnce(refresh.promise); + const running = startBatch(batch); + await flushUi(); + const summary = `pluginPlatform.${failures ? "batchSummaryWithFailures" : "batchSummary"}:${JSON.stringify({ success: 3 - failures, failed: failures, names: ["a", "b", "c"].slice(0, failures).join("、") })}`; + try { + expect(mocks.toast).toHaveBeenLastCalledWith(summary, failures ? 8000 : 4000); + expect(state.selectedListingKeys.size).toBe(0); + expect(state.selectedInstalledIds.size).toBe(0); + expect(state.batchRunning).toBe(true); + await state.installMarketplaceListing(state.marketplaceListings[0]); + expect(mutationCount()).toBe(3); + } finally { + refresh.reject(new Error("refresh offline")); + await expect(running).resolves.toBeUndefined(); + } + expect(mutation).toHaveBeenCalledTimes(3); + expect(mocks.toast).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenLastCalledWith(summary, failures ? 8000 : 4000); + expect(state.error).toBe('pluginPlatform.batchRefreshFailed:{"error":"refresh offline"}'); + await nextTick(); + expect(host.textContent).toContain(state.error); + expect(state.batchRunning).toBe(false); + state.selectedListingKeys = new Set(["first:a"]); + await state.runBatchInstallUpdate(); + expect(state.error).toBe(""); + }); + + it.each(batches)("runs batch %s in order, continues after rejection and refreshes once", async (batch) => { + state.selectAllUpdatable(); + state.selectedInstalledIds = new Set(["a", "b", "c"]); + const mutation = batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin; + const first = deferred(); + mutation.mockReturnValueOnce(first.promise).mockRejectedValueOnce(new Error("denied")); + const running = startBatch(batch); + await flushUi(); + expect(mutation).toHaveBeenCalledTimes(1); + expect(mocks.listPlugins).not.toHaveBeenCalled(); + first.resolve(batch === "install" ? { plugin: installed("a") } : []); + await running; + expect(mutation.mock.calls.map(([request]) => (batch === "install" ? request.pluginId : request))).toEqual(["a", "b", "c"]); + expect(mocks.listPlugins).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.batchSummaryWithFailures:{"success":2,"failed":1,"names":"b"}', 8000); + expect(state.error).toBe(""); + expect(state.batchRunning).toBe(false); + expect(state.selectedListingKeys.size).toBe(0); + expect(state.selectedInstalledIds.size).toBe(0); + }); + + it("does nothing for empty selections or cancelled uninstall", async () => { + state.selectedListingKeys = new Set(); + await state.runBatchInstallUpdate(); + vi.mocked(window.confirm).mockReturnValueOnce(false); + await state.runBatchUninstall(); + expect(state.selectedInstalledIds.size).toBe(1); + state.selectedInstalledIds = new Set(); + await state.runBatchUninstall(); + expect(mutationCount()).toBe(0); + expect(mocks.listPlugins).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("ignores installed and unsupported listings in single and batch handlers", async () => { + state.catalogResults = [catalog("first", ["a"], "1.0.0"), { ...catalog("second", ["b"]), target: "unsupported" }]; + state.selectedListingKeys = new Set(); + for (const listing of state.marketplaceListings) { + state.toggleListingSelection(listing); + await state.installMarketplaceListing(listing); + } + state.selectAllUpdatable(); + await state.runBatchInstallUpdate(); + expect(state.selectedListingKeys.size).toBe(0); + expect(mutationCount()).toBe(0); + }); + + it("installs newly selected plugins through the rendered batch button", async () => { + state.installedPlugins = []; + state.selectedListingKeys = new Set(); + state.toggleListingSelection(state.marketplaceListings[0]); + await nextTick(); + button("batchInstallUpdate").click(); + await flushUi(); + expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId: "first", pluginId: "a", version: "3.0.0" }); + expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.batchSummary:{"success":1,"failed":0,"names":""}', 4000); + expect(state.batchRunning).toBe(false); + }); +}); diff --git a/apps/desktop/src/i18n/__tests__/pluginBatchMessages.spec.ts b/apps/desktop/src/i18n/__tests__/pluginBatchMessages.spec.ts new file mode 100644 index 000000000..a5b51accc --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/pluginBatchMessages.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createI18n } from "vue-i18n"; +import az from "@/i18n/locales/az"; +import en from "@/i18n/locales/en"; +import es from "@/i18n/locales/es"; +import itLocale from "@/i18n/locales/it"; +import ja from "@/i18n/locales/ja"; +import ko from "@/i18n/locales/ko"; +import ptBR from "@/i18n/locales/pt-BR"; +import tr from "@/i18n/locales/tr"; +import zhCN from "@/i18n/locales/zh-CN"; +import zhTW from "@/i18n/locales/zh-TW"; + +describe("plugin batch messages", () => { + it.each(Object.entries({ az, en, es, it: itLocale, ja, ko, "pt-BR": ptBR, tr, "zh-CN": zhCN, "zh-TW": zhTW }))("interpolates source conflicts and refresh errors in %s", (locale, messages) => { + const pluginPlatform = messages.pluginPlatform as Pick; + const i18n = createI18n({ legacy: false, locale, messages: { [locale]: { pluginPlatform } } }); + expect(pluginPlatform.batchDuplicateSources.match(/\{[^}]+\}/g)).toEqual(["{names}"]); + expect(pluginPlatform.batchRefreshFailed.match(/\{[^}]+\}/g)).toEqual(["{error}"]); + expect(i18n.global.t("pluginPlatform.batchDuplicateSources", { names: "example.plugin" })).toContain("example.plugin"); + expect(i18n.global.t("pluginPlatform.batchRefreshFailed", { error: "refresh offline" })).toContain("refresh offline"); + i18n.dispose(); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9db5943b2..2a94e32af 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -257,6 +257,17 @@ export default { uninstallConfirm: "Uninstall {name}?", uninstallSuccess: "Uninstalled {name}", installSuccess: "Installed {name} {version}", + batchManage: "Batch", + batchDone: "Done", + batchSelected: "{count} selected", + batchSelectAllUpdatable: "Select all updatable", + batchInstallUpdate: "Install / update", + batchUninstall: "Uninstall selected", + batchUninstallConfirm: "Uninstall {count} plugin(s)?\n{names}", + batchSummary: "Done: {success} succeeded", + batchSummaryWithFailures: "Done: {success} succeeded, {failed} failed ({names})", + batchDuplicateSources: "Multiple sources selected for: {names}. Keep only one source selected per plugin before continuing.", + batchRefreshFailed: "The batch has finished, but the plugin list could not be refreshed: {error}", selectConnectionProvider: "Select a connection provider first", connectionProvider: "Connection provider", newConnection: "New", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ac2749981..d7b1b3851 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -251,6 +251,17 @@ export default withEnglishFallback({ uninstallConfirm: "¿Desinstalar {name}?", uninstallSuccess: "Se desinstaló {name}", installSuccess: "Se instaló {name} {version}", + batchManage: "Lote", + batchDone: "Listo", + batchSelected: "{count} seleccionados", + batchSelectAllUpdatable: "Seleccionar todos actualizables", + batchInstallUpdate: "Instalar / actualizar", + batchUninstall: "Desinstalar seleccionados", + batchUninstallConfirm: "¿Desinstalar {count} complemento(s)? {names}", + batchSummary: "Listo: {success} con éxito", + batchSummaryWithFailures: "Listo: {success} con éxito, {failed} con error ({names})", + batchDuplicateSources: "Se seleccionaron varias fuentes para: {names}. Mantén seleccionada solo una fuente por complemento antes de continuar.", + batchRefreshFailed: "El lote ha finalizado, pero no se pudo actualizar la lista de complementos: {error}", selectConnectionProvider: "Selecciona primero un proveedor de conexión", connectionProvider: "Proveedor de conexión", newConnection: "Nueva", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index afd50f5b6..2e1ac4bb1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -250,6 +250,17 @@ export default withEnglishFallback({ uninstallConfirm: "Disinstallare {name}?", uninstallSuccess: "{name} disinstallato", installSuccess: "Installato {name} {version}", + batchManage: "Batch", + batchDone: "Fine", + batchSelected: "{count} selezionati", + batchSelectAllUpdatable: "Seleziona tutti aggiornabili", + batchInstallUpdate: "Installa / aggiorna", + batchUninstall: "Disinstalla selezionati", + batchUninstallConfirm: "Disinstallare {count} plug-in? {names}", + batchSummary: "Completato: {success} riusciti", + batchSummaryWithFailures: "Completato: {success} riusciti, {failed} falliti ({names})", + batchDuplicateSources: "Sono state selezionate più fonti per: {names}. Mantieni selezionata una sola fonte per plug-in prima di continuare.", + batchRefreshFailed: "Le operazioni in gruppo sono terminate, ma non è stato possibile aggiornare l'elenco dei plug-in: {error}", selectConnectionProvider: "Seleziona prima un provider di connessione", connectionProvider: "Provider di connessione", newConnection: "Nuova", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9c6f6fb69..7355981ad 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -251,6 +251,17 @@ export default withEnglishFallback({ uninstallConfirm: "{name} をアンインストールしますか?", uninstallSuccess: "{name} をアンインストールしました", installSuccess: "{name} {version} をインストールしました", + batchManage: "一括", + batchDone: "完了", + batchSelected: "{count} 件選択中", + batchSelectAllUpdatable: "更新可能をすべて選択", + batchInstallUpdate: "インストール / 更新", + batchUninstall: "選択をアンインストール", + batchUninstallConfirm: "{count} 個のプラグインをアンインストールしますか? {names}", + batchSummary: "完了:{success} 件成功", + batchSummaryWithFailures: "完了:{success} 件成功、{failed} 件失敗({names})", + batchDuplicateSources: "複数の配布元が選択されています:{names}。続行する前に、各プラグインの配布元を1つだけ選択してください。", + batchRefreshFailed: "一括処理は完了しましたが、プラグイン一覧を更新できませんでした:{error}", selectConnectionProvider: "先に接続プロバイダーを選択してください", connectionProvider: "接続プロバイダー", newConnection: "新規", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index a5a304637..009dc2935 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -259,6 +259,17 @@ export default withEnglishFallback({ uninstallConfirm: "{name}을 제거하시겠습니까?", uninstallSuccess: "{name}을 제거했습니다", installSuccess: "{name} {version}을 설치했습니다", + batchManage: "일괄", + batchDone: "완료", + batchSelected: "{count}개 선택됨", + batchSelectAllUpdatable: "업데이트 가능한 항목 모두 선택", + batchInstallUpdate: "설치 / 업데이트", + batchUninstall: "선택 항목 제거", + batchUninstallConfirm: "{count}개 플러그인을 제거할까요? {names}", + batchSummary: "완료: {success}개 성공", + batchSummaryWithFailures: "완료: {success}개 성공, {failed}개 실패 ({names})", + batchDuplicateSources: "여러 소스가 선택되었습니다: {names}. 계속하기 전에 플러그인마다 하나의 소스만 선택해 주세요.", + batchRefreshFailed: "일괄 작업은 완료되었지만 플러그인 목록을 새로 고칠 수 없습니다: {error}", selectConnectionProvider: "먼저 연결 제공자를 선택하세요", connectionProvider: "연결 제공자", newConnection: "새 연결", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 2e0e9f1f7..cec713468 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -251,6 +251,17 @@ export default withEnglishFallback({ uninstallConfirm: "Desinstalar {name}?", uninstallSuccess: "{name} desinstalado", installSuccess: "{name} {version} instalado", + batchManage: "Lote", + batchDone: "Concluído", + batchSelected: "{count} selecionados", + batchSelectAllUpdatable: "Selecionar todos atualizáveis", + batchInstallUpdate: "Instalar / atualizar", + batchUninstall: "Desinstalar selecionados", + batchUninstallConfirm: "Desinstalar {count} plugin(s)? {names}", + batchSummary: "Concluído: {success} com sucesso", + batchSummaryWithFailures: "Concluído: {success} com sucesso, {failed} com falha ({names})", + batchDuplicateSources: "Várias fontes selecionadas para: {names}. Mantenha apenas uma fonte selecionada por plugin antes de continuar.", + batchRefreshFailed: "O lote foi concluído, mas não foi possível atualizar a lista de plugins: {error}", selectConnectionProvider: "Selecione primeiro um provedor de conexão", connectionProvider: "Provedor de conexão", newConnection: "Nova", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e62d2f96d..f641d19d9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -173,6 +173,17 @@ export default withEnglishFallback({ uninstallConfirm: "卸载 {name}?", uninstallSuccess: "已卸载 {name}", installSuccess: "已安装 {name} {version}", + batchManage: "批量管理", + batchDone: "完成", + batchSelected: "已选 {count} 项", + batchSelectAllUpdatable: "全选可更新", + batchInstallUpdate: "安装 / 更新", + batchUninstall: "卸载所选", + batchUninstallConfirm: "确定卸载 {count} 个插件?\n{names}", + batchSummary: "完成:成功 {success} 个", + batchSummaryWithFailures: "完成:成功 {success} 个,失败 {failed} 个({names})", + batchDuplicateSources: "以下插件选择了多个来源:{names}。请为每个插件仅保留一个来源后继续。", + batchRefreshFailed: "批量操作已完成,但插件列表刷新失败:{error}", selectConnectionProvider: "请先选择连接提供者", connectionProvider: "连接提供者", newConnection: "新建", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 50489a6cf..5f14b2549 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -251,6 +251,17 @@ export default withEnglishFallback({ uninstallConfirm: "解除安裝 {name}?", uninstallSuccess: "已解除安裝 {name}", installSuccess: "已安裝 {name} {version}", + batchManage: "批次管理", + batchDone: "完成", + batchSelected: "已選 {count} 項", + batchSelectAllUpdatable: "全選可更新", + batchInstallUpdate: "安裝 / 更新", + batchUninstall: "解除安裝所選", + batchUninstallConfirm: "確定解除安裝 {count} 個外掛? {names}", + batchSummary: "完成:成功 {success} 個", + batchSummaryWithFailures: "完成:成功 {success} 個,失敗 {failed} 個({names})", + batchDuplicateSources: "以下外掛選取了多個來源:{names}。請為每個外掛僅保留一個來源後繼續。", + batchRefreshFailed: "批次操作已完成,但外掛清單重新整理失敗:{error}", selectConnectionProvider: "請先選擇連線提供者", connectionProvider: "連線提供者", newConnection: "新增", diff --git a/apps/desktop/src/lib/plugins/__tests__/pluginBatch.spec.ts b/apps/desktop/src/lib/plugins/__tests__/pluginBatch.spec.ts new file mode 100644 index 000000000..65e17a2ed --- /dev/null +++ b/apps/desktop/src/lib/plugins/__tests__/pluginBatch.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch"; + +describe("runBatch", () => { + it("runs items sequentially and records all successes", async () => { + const order: string[] = []; + const outcome = await runBatch( + [{ id: "a" }, { id: "b" }, { id: "c" }], + (item) => item.id, + async (item) => { + order.push(item.id); + }, + ); + expect(order).toEqual(["a", "b", "c"]); + expect(outcome.succeeded).toEqual(["a", "b", "c"]); + expect(outcome.failed).toEqual([]); + }); + + it("captures a failing item, continues, and does not roll back", async () => { + const ran: string[] = []; + const outcome = await runBatch( + [{ id: "a" }, { id: "b" }, { id: "c" }], + (item) => item.id, + async (item) => { + ran.push(item.id); + if (item.id === "b") throw new Error("boom"); + }, + ); + expect(ran).toEqual(["a", "b", "c"]); + expect(outcome.succeeded).toEqual(["a", "c"]); + expect(outcome.failed).toEqual([{ name: "b", error: "boom" }]); + }); + + it("returns an empty outcome for empty input", async () => { + const action = vi.fn(); + const outcome = await runBatch([], (item: { id: string }) => item.id, action); + expect(action).not.toHaveBeenCalled(); + expect(outcome.succeeded).toEqual([]); + expect(outcome.failed).toEqual([]); + }); +}); + +describe("isBatchSelectableListing", () => { + it("allows install and update, blocks installed and unsupported", () => { + expect(isBatchSelectableListing("install")).toBe(true); + expect(isBatchSelectableListing("update")).toBe(true); + expect(isBatchSelectableListing("installed")).toBe(false); + expect(isBatchSelectableListing("unsupported")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginBatch.ts b/apps/desktop/src/lib/plugins/pluginBatch.ts new file mode 100644 index 000000000..2c6532d2e --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginBatch.ts @@ -0,0 +1,46 @@ +// Pure, testable helpers for batch plugin operations (install / update / uninstall). +// The component owns selection state and UI; this module owns the sequential execution +// and result aggregation so partial failures can be reported without aborting the batch. + +export interface BatchItemOutcome { + name: string; + ok: boolean; + error?: string; +} + +export interface BatchOutcome { + results: BatchItemOutcome[]; + succeeded: string[]; + failed: { name: string; error: string }[]; +} + +/** + * Run `action` over `items` sequentially, recording per-item success/failure. A failing item is + * captured and the batch continues; nothing is rolled back. `nameOf` supplies a human label used + * in the summary. Empty input yields an empty outcome. + */ +export async function runBatch(items: readonly T[], nameOf: (item: T) => string, action: (item: T) => Promise): Promise { + const results: BatchItemOutcome[] = []; + for (const item of items) { + const name = nameOf(item); + try { + await action(item); + results.push({ name, ok: true }); + } catch (cause) { + results.push({ name, ok: false, error: cause instanceof Error ? cause.message : String(cause) }); + } + } + return { + results, + succeeded: results.filter((result) => result.ok).map((result) => result.name), + failed: results.filter((result) => !result.ok).map((result) => ({ name: result.name, error: result.error ?? "" })), + }; +} + +/** + * Which marketplace listing statuses are actionable in a batch (a selectable plugin that is not yet + * installed, or has an update). Installed / unsupported listings are not batch-selectable. + */ +export function isBatchSelectableListing(status: string): boolean { + return status === "install" || status === "update"; +}