diff --git a/src/renderer/src/components/plugins/state/plugin-preview-cache.test.ts b/src/renderer/src/components/plugins/state/plugin-preview-cache.test.ts new file mode 100644 index 00000000000..9d48a8c20b6 --- /dev/null +++ b/src/renderer/src/components/plugins/state/plugin-preview-cache.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PluginMarketplaceHostListing } from '../../../../../preload/api-types' +import { pluginManifestSchema } from '../../../../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_PREVIEW_CACHE_CAP, PluginPreviewCache } from './plugin-preview-cache' +import type { PluginPagePreview } from './use-plugin-catalog' + +function listing(pluginKey: string, commit: string): PluginMarketplaceHostListing { + return { + marketplaceSourceId: 'source', + marketplaceName: 'Catalog', + marketplaceOwner: 'acme', + marketplaceCommit: commit, + pluginKey, + source: { kind: 'git', url: 'https://example.invalid/p.git', ref: 'main' }, + categories: [], + official: false, + bundled: false + } +} + +const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'dusk', + publisher: 'acme', + name: 'Dusk', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { themes: [{ id: 'dusk', title: 'Dusk', path: 'themes/dusk.json' }] } +}) + +function setup(): { cache: PluginPreviewCache; load: ReturnType } { + const load = vi.fn(async (entry: PluginMarketplaceHostListing): Promise => ({ + ok: true, + preview: { + ...entry, + resolvedCommit: 'c'.repeat(40), + contentHash: 'd'.repeat(64), + consentFingerprint: 'sha256-shown', + manifest + } + })) + return { cache: new PluginPreviewCache(load), load } +} + +describe('PluginPreviewCache', () => { + it('keeps one preview per plugin across revisions, bounded overall', async () => { + const { cache, load } = setup() + for (let revision = 0; revision < 10; revision++) { + const latest = listing('a', `c${revision}`) + cache.retainListed([latest]) + await cache.fetch(latest) + } + expect(cache.size).toBe(1) + await cache.fetch(listing('a', 'c9')) + expect(load).toHaveBeenCalledTimes(10) + + cache.retainListed([]) + for (let index = 0; index < PLUGIN_PREVIEW_CACHE_CAP * 2; index++) { + await cache.fetch(listing(`p${index}`, 'c')) + } + expect(cache.size).toBe(PLUGIN_PREVIEW_CACHE_CAP) + }) + + it('does not let a pinned older revision displace the current one', async () => { + const { cache, load } = setup() + const current = listing('a', 'new') + cache.retainListed([current]) + await cache.fetch(current) + await cache.fetch(listing('a', 'old')) + await cache.fetch(current) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/plugins/state/plugin-preview-cache.ts b/src/renderer/src/components/plugins/state/plugin-preview-cache.ts new file mode 100644 index 00000000000..7ee5c867bb7 --- /dev/null +++ b/src/renderer/src/components/plugins/state/plugin-preview-cache.ts @@ -0,0 +1,73 @@ +import type { PluginMarketplaceHostListing } from '../../../../../preload/api-types' +import type { PluginPagePreview } from './use-plugin-catalog' + +/** Plugins whose preview one Settings visit keeps; each holds a parsed manifest. */ +export const PLUGIN_PREVIEW_CACHE_CAP = 32 + +type Entry = { commit: string; request: Promise } + +function pluginId(listing: PluginMarketplaceHostListing): string { + return `${listing.marketplaceSourceId}:${listing.pluginKey}` +} + +/** + * One preview per plugin, for the catalog revision last seen: a newer revision replaces the + * older one, a refresh drops revisions the catalog no longer lists, and the least recently + * used plugin goes past the cap. Callers keep the promise they got, so eviction never + * changes a preview already on screen or approved. + */ +export class PluginPreviewCache { + private readonly entries = new Map() + // Why: a page pinned to an older listing must not displace the current revision's preview. + private currentCommits = new Map() + + constructor( + private readonly load: (listing: PluginMarketplaceHostListing) => Promise + ) {} + + get size(): number { + return this.entries.size + } + + fetch(listing: PluginMarketplaceHostListing): Promise { + const id = pluginId(listing) + const commit = listing.marketplaceCommit + const cached = this.entries.get(id) + if (cached?.commit === commit) { + this.entries.delete(id) + this.entries.set(id, cached) + return cached.request + } + const request = this.load(listing).then((result) => { + // Why: only successes are cached, so Retry asks again. + if (!result.ok && this.entries.get(id)?.request === request) { + this.entries.delete(id) + } + return result + }) + const current = this.currentCommits.get(id) + if (current === undefined || current === commit) { + this.entries.delete(id) + this.entries.set(id, { commit, request }) + for (const oldest of this.entries.keys()) { + if (this.entries.size <= PLUGIN_PREVIEW_CACHE_CAP) { + break + } + this.entries.delete(oldest) + } + } + return request + } + + /** Records the catalog's current revisions and drops previews of any other. */ + retainListed(listings: readonly PluginMarketplaceHostListing[]): void { + this.currentCommits = new Map( + listings.map((listing) => [pluginId(listing), listing.marketplaceCommit]) + ) + for (const [id, entry] of this.entries) { + if (this.currentCommits.get(id) !== entry.commit) { + this.entries.delete(id) + } + } + } +} diff --git a/src/renderer/src/components/plugins/state/use-plugin-catalog.ts b/src/renderer/src/components/plugins/state/use-plugin-catalog.ts index 3e318142f95..d6db0fece46 100644 --- a/src/renderer/src/components/plugins/state/use-plugin-catalog.ts +++ b/src/renderer/src/components/plugins/state/use-plugin-catalog.ts @@ -6,6 +6,7 @@ import type { } from '../../../../../preload/api-types' import { translate } from '@/i18n/i18n' import { usePluginPreviewRequestStore } from '@/store/plugin-preview-request' +import { PluginPreviewCache } from './plugin-preview-cache' /** A plugin page's manifest read; a failure means nothing is known, not "nothing needed". */ export type PluginPagePreview = @@ -36,6 +37,21 @@ export function usePluginCatalog({ const [error, setError] = useState(null) const mountedRef = useRef(false) const requestRef = useRef(0) + const [previewCache] = useState( + () => + new PluginPreviewCache((listing) => + window.api.plugins + .previewMarketplacePlugin({ + marketplaceSourceId: listing.marketplaceSourceId, + pluginKey: listing.pluginKey + }) + .then((preview): PluginPagePreview => ({ ok: true, preview })) + .catch((cause: unknown): PluginPagePreview => { + console.warn('[plugins] plugin page preview failed:', cause) + return { ok: false } + }) + ) + ) const loadMarketplaceData = useCallback(async (): Promise => { const requestId = ++requestRef.current @@ -47,6 +63,7 @@ export function usePluginCatalog({ if (mountedRef.current && requestId === requestRef.current) { setSources(nextSources) setListings(nextListings) + previewCache.retainListed(nextListings) setError(null) } } catch (cause) { @@ -66,7 +83,7 @@ export function usePluginCatalog({ setLoading(false) } } - }, []) + }, [previewCache]) useEffect(() => { mountedRef.current = true @@ -124,29 +141,9 @@ export function usePluginCatalog({ } }, [previewRequest, listings, loading]) - const previewCacheRef = useRef(new Map>()) const fetchPreview = useCallback( - (listing: PluginMarketplaceHostListing): Promise => { - const key = `${listing.marketplaceSourceId}:${listing.pluginKey}:${listing.marketplaceCommit}` - let request = previewCacheRef.current.get(key) - if (!request) { - request = window.api.plugins - .previewMarketplacePlugin({ - marketplaceSourceId: listing.marketplaceSourceId, - pluginKey: listing.pluginKey - }) - .then((preview): PluginPagePreview => ({ ok: true, preview })) - .catch((cause: unknown): PluginPagePreview => { - console.warn('[plugins] plugin page preview failed:', cause) - // Why: only successes are cached, so Retry asks again. - previewCacheRef.current.delete(key) - return { ok: false } - }) - previewCacheRef.current.set(key, request) - } - return request - }, - [] + (listing: PluginMarketplaceHostListing) => previewCache.fetch(listing), + [previewCache] ) return {