refactor(plugins): extract the shared content-pack registry and list store (#30697)

* plugins: extract the verified artifact-pack registry from language packs

Pure refactor. Verify, bounded read, parse and per-plugin error handling
move into PluginArtifactPackRegistry; language packs become one instance,
so later data families reuse it instead of copying the file.

* plugins: reconcile content families from one list

Pure refactor. Reconcile and error reporting walk one family list in the
same order as before, so a new family is one entry.

* plugins: one line per family for declared artifacts and contribution defaults

Pure refactor. Declared artifact paths are a list of per-family rows, and
the contributes default comes from the schema (prefault) instead of a
second copy of the field list; the existing defaults test pins the result.

* plugins: extract the renderer plugin content list store

Pure refactor. The fetch generation, startup join window and content-change
subscription move into createPluginContentListStore; language packs become
one instance, and store fields are renamed to entries/fetchEntries.
This commit is contained in:
Neil
2026-10-19 04:01:12 -07:00
committed by GitHub
parent 148944c020
commit 92ed331184
13 changed files with 301 additions and 257 deletions
@@ -0,0 +1,79 @@
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
const ARTIFACT_PACK_LOAD_CONCURRENCY = 4
type ArtifactPackLoadResult<Entry> =
| { pluginKey: string; entries: Entry[] }
| { pluginKey: string; error: string }
/** Loads one data contribution family from verified plugin trees. A plugin whose content fails
* verification or parsing contributes nothing and reports one error. */
export class PluginArtifactPackRegistry<Contribution, Entry> {
private entries: Entry[] = []
private readonly errors = new Map<string, string>()
constructor(
private readonly options: {
contentVerifier: PluginContentVerifier
contributions: (plugin: ValidDiscoveredPlugin) => readonly Contribution[]
load: (plugin: ValidDiscoveredPlugin, contribution: Contribution) => Promise<Entry>
}
) {}
list(): readonly Entry[] {
return this.entries
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates: ValidDiscoveredPlugin[] = []
for (const plugin of discovered) {
if (
!isInvalidDiscoveredPlugin(plugin) &&
isApproved(plugin) &&
this.options.contributions(plugin).length > 0
) {
candidates.push(plugin)
}
}
const results = await mapWithConcurrency(
candidates,
ARTIFACT_PACK_LOAD_CONCURRENCY,
async (plugin): Promise<ArtifactPackLoadResult<Entry>> => {
try {
await this.options.contentVerifier.verify(plugin)
const entries = await Promise.all(
this.options
.contributions(plugin)
.map((contribution) => this.options.load(plugin, contribution))
)
return { pluginKey: plugin.pluginKey, entries }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
this.entries = results.flatMap((result) => ('entries' in result ? result.entries : []))
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
}
}
}
}
+20 -44
View File
@@ -17,52 +17,28 @@ type DeclaredArtifact =
| { label: string; path: string; kind: 'file'; maxBytes: number }
| { label: string; path: string; kind: 'directory' }
function declaredFile(label: string, path: string | undefined, maxBytes: number) {
return path === undefined ? [] : [{ label, path, kind: 'file' as const, maxBytes }]
}
/** One line per contribution family that names a file. */
function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
const c = manifest.contributes
return [
...(manifest.icon
? [
{
label: 'icon',
path: manifest.icon,
kind: 'file' as const,
maxBytes: PLUGIN_ICON_MAX_BYTES
}
]
: []),
...(manifest.main
? [
{
label: 'worker entry',
path: manifest.main,
kind: 'file' as const,
maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES
}
]
: []),
...manifest.contributes.panels.map((panel) => ({
label: `panel "${panel.id}" entry`,
path: panel.entry,
kind: 'file' as const,
maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES
})),
...manifest.contributes.languagePacks.map((languagePack) => ({
label: `language pack "${languagePack.locale}"`,
path: languagePack.path,
kind: 'file' as const,
maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES
})),
...manifest.contributes.vmRecipes.map((recipe) => ({
label: 'VM recipe',
path: recipe.path,
kind: 'file' as const,
maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES
})),
...manifest.contributes.agents.map((agent) => ({
label: 'agent profile',
path: agent.path,
kind: 'file' as const,
maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES
}))
...declaredFile('icon', manifest.icon, PLUGIN_ICON_MAX_BYTES),
...declaredFile('worker entry', manifest.main, PLUGIN_WORKER_ENTRY_MAX_BYTES),
...c.panels.flatMap((panel) =>
declaredFile(`panel "${panel.id}" entry`, panel.entry, PLUGIN_PANEL_ENTRY_MAX_BYTES)
),
...c.languagePacks.flatMap((pack) =>
declaredFile(`language pack "${pack.locale}"`, pack.path, PLUGIN_LANGUAGE_PACK_MAX_BYTES)
),
...c.vmRecipes.flatMap((recipe) =>
declaredFile('VM recipe', recipe.path, PLUGIN_VM_RECIPE_MAX_BYTES)
),
...c.agents.flatMap((agent) =>
declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES)
)
]
}
@@ -10,6 +10,14 @@ import { PluginCommandRegistry } from './plugin-command-registry'
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
import type { KeybindingOverrides } from '../../shared/keybindings'
type ContentFamily = {
reconcile: (
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
) => Promise<void>
error: (pluginKey: string) => string | null
}
export class PluginContentPackRegistry {
readonly languagePacks: PluginLanguagePackRegistry
readonly vmRecipes: PluginVmRecipeRegistry
@@ -70,10 +78,9 @@ export class PluginContentPackRegistry {
approvedKeys.has(plugin.pluginKey) &&
!excluded.has(plugin.pluginKey) &&
!this.isKilled(plugin.pluginKey)
const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically)
const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically)
this.commands.reconcile(discovered, approveAtomically, keybindings)
await Promise.all([languagePacks, vmRecipes])
await Promise.all(
this.families(keybindings).map((family) => family.reconcile(discovered, approveAtomically))
)
let foundNewError = false
for (const pluginKey of approvedKeys) {
@@ -95,10 +102,25 @@ export class PluginContentPackRegistry {
}
private registryError(pluginKey: string): string | null {
return (
this.languagePacks.error(pluginKey) ??
this.vmRecipes.error(pluginKey) ??
this.commands.error(pluginKey)
)
for (const family of this.families()) {
const error = family.error(pluginKey)
if (error !== null) {
return error
}
}
return null
}
/** Every contribution family, in error-reporting order; a new family is one entry here. */
private families(keybindings: KeybindingOverrides = {}): readonly ContentFamily[] {
return [
this.languagePacks,
this.vmRecipes,
{
reconcile: async (discovered, isApproved) =>
this.commands.reconcile(discovered, isApproved, keybindings),
error: (pluginKey) => this.commands.error(pluginKey)
}
]
}
}
@@ -3,94 +3,43 @@ import {
pluginLanguageResourceId,
type PluginLanguagePackRegistration
} from '../../shared/plugins/plugin-language-pack-artifact'
import type { PluginManifest } from '../../shared/plugins/plugin-manifest'
import {
PLUGIN_LANGUAGE_PACK_MAX_BYTES,
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry'
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
const LANGUAGE_PACK_LOAD_CONCURRENCY = 4
type LanguagePackContribution = PluginManifest['contributes']['languagePacks'][number]
type LanguageLoadResult =
| { pluginKey: string; packs: PluginLanguagePackRegistration[] }
| { pluginKey: string; error: string }
export class PluginLanguagePackRegistry {
private packs: PluginLanguagePackRegistration[] = []
private readonly errors = new Map<string, string>()
constructor(private readonly contentVerifier: PluginContentVerifier) {}
list(): readonly PluginLanguagePackRegistration[] {
return this.packs
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates: ValidDiscoveredPlugin[] = []
for (const plugin of discovered) {
if (
!isInvalidDiscoveredPlugin(plugin) &&
isApproved(plugin) &&
plugin.manifest.contributes.languagePacks.length > 0
) {
candidates.push(plugin)
}
}
const results = await mapWithConcurrency(
candidates,
LANGUAGE_PACK_LOAD_CONCURRENCY,
async (plugin): Promise<LanguageLoadResult> => {
try {
await this.contentVerifier.verify(plugin)
const packs = await Promise.all(
plugin.manifest.contributes.languagePacks.map(async (contribution) => {
const text = await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_LANGUAGE_PACK_MAX_BYTES
)
const parsed = parsePluginLanguagePackArtifact(text)
if (!parsed.ok) {
throw new Error(`language pack "${contribution.locale}" ${parsed.error}`)
}
const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const
return {
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: plugin.pluginKey,
locale: contribution.locale,
catalog: parsed.catalog
}
})
)
return { pluginKey: plugin.pluginKey, packs }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry<
LanguagePackContribution,
PluginLanguagePackRegistration
> {
constructor(contentVerifier: PluginContentVerifier) {
super({
contentVerifier,
contributions: (plugin) => plugin.manifest.contributes.languagePacks,
load: async (plugin, contribution) => {
const text = await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_LANGUAGE_PACK_MAX_BYTES
)
const parsed = parsePluginLanguagePackArtifact(text)
if (!parsed.ok) {
throw new Error(`language pack "${contribution.locale}" ${parsed.error}`)
}
const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const
return {
id,
resourceLanguage: pluginLanguageResourceId(id),
pluginKey: plugin.pluginKey,
locale: contribution.locale,
catalog: parsed.catalog
}
}
)
this.packs = results.flatMap((result) => ('packs' in result ? result.packs : []))
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
}
}
})
}
}
@@ -68,7 +68,7 @@ vi.mock('@/store', () => ({
vi.mock('@/store/plugin-language-packs', () => ({
usePluginLanguagePackStore: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ packs: [], loaded: true })
selector({ entries: [], loaded: true })
}))
vi.mock('@/i18n/i18n', () => ({
@@ -26,7 +26,7 @@ export function useMacTccAttributionSeveredNotice(): void {
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery)
const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null)
const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs)
const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries)
const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded)
const { i18n } = useTranslation()
const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage)
@@ -89,7 +89,7 @@ it('isolates plugin language-pack discovery from its parent render path', async
root?.render(createElement(I18nextProvider, { i18n }, createElement(ParentProbe)))
})
await act(async () => {
usePluginLanguagePackStore.setState({ packs: [], loaded: true })
usePluginLanguagePackStore.setState({ entries: [], loaded: true })
})
expect(parentRenderCount).toBe(1)
@@ -20,7 +20,7 @@ export function useMacosTccPromptNotice(): void {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null)
const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs)
const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries)
const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded)
const { i18n } = useTranslation()
const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage)
@@ -0,0 +1,97 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type { PluginsApi } from '../../../preload/api/plugin-host-api'
type PluginContentListState<Entry> = {
entries: Entry[]
loaded: boolean
fetchEntries: () => Promise<void>
}
/**
* Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin
* discovery — a wedged one must not turn `ensureLoaded` into a no-op for the session, which would
* pin every consumer on built-in content. Past this bound the pending request is wedged rather than
* slow, so a later consumer starts its own (mirrors the reasoning in
* `src/main/git/coalesced-probe.ts`).
*/
const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000
/** One renderer list of a plugin content family, refetched whenever plugin content changes. */
export function createPluginContentListStore<Entry>(options: {
/** Plural noun for logs, e.g. "language packs". */
kind: string
list: (api: PluginsApi) => (() => Promise<unknown>) | undefined
isEntry: (value: unknown) => value is Entry
}) {
let requestGeneration = 0
let latestRequestStartedAt: number | null = null
let changeSubscriptionStarted = false
const useStore = create<PluginContentListState<Entry>>()((set) => ({
entries: [],
loaded: false,
fetchEntries: async () => {
const generation = ++requestGeneration
latestRequestStartedAt = Date.now()
const api = window.api?.plugins
const list = api ? options.list(api) : undefined
if (!list) {
if (generation === requestGeneration) {
latestRequestStartedAt = null
set({ entries: [], loaded: true })
}
return
}
try {
const response = await list()
const entries = Array.isArray(response) ? response.filter(options.isEntry) : []
// Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log.
if (!Array.isArray(response)) {
console.warn(`[plugins] Ignoring non-array ${options.kind} list (${typeof response})`)
} else if (entries.length !== response.length) {
console.warn(
`[plugins] Ignoring ${response.length - entries.length} of ${response.length} malformed ${options.kind}`
)
}
if (generation === requestGeneration) {
set({ entries, loaded: true })
}
} catch {
if (generation === requestGeneration) {
set({ entries: [], loaded: true })
}
} finally {
if (generation === requestGeneration) {
latestRequestStartedAt = null
}
}
}
}))
function ensureLoaded(): void {
const state = useStore.getState()
const joinable =
latestRequestStartedAt !== null &&
Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS
if (!state.loaded && !joinable) {
void state.fetchEntries()
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void useStore.getState().fetchEntries()
}
})
}
}
function useEntries(): Entry[] {
const entries = useStore((state) => state.entries)
useEffect(() => ensureLoaded(), [])
return entries
}
return { useStore, ensureLoaded, useEntries }
}
@@ -77,8 +77,8 @@ describe('plugin language-pack startup ownership', () => {
async (settlement) => {
const bridge = installBridge()
const store = languagePacks.usePluginLanguagePackStore
const older = store.getState().fetchPacks()
const newer = store.getState().fetchPacks()
const older = store.getState().fetchEntries()
const newer = store.getState().fetchEntries()
if (settlement === 'resolve') {
bridge.requests[0].resolve([pack('old')])
} else {
@@ -93,7 +93,7 @@ describe('plugin language-pack startup ownership', () => {
}
await newer
expect(dispatches).toBe(2)
expect(store.getState().packs).toEqual([pack('current')])
expect(store.getState().entries).toEqual([pack('current')])
}
)
@@ -110,7 +110,7 @@ describe('plugin language-pack startup ownership', () => {
await bridge.requests[0].promise
languagePacks.ensurePluginLanguagePacksLoaded()
expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(2)
expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('changed')])
expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('changed')])
})
it('keeps the shared startup request alive after the first consumer unmounts', async () => {
@@ -146,7 +146,9 @@ describe('plugin language-pack startup ownership', () => {
await vi.waitFor(() =>
expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true)
)
expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('recovered')])
expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([
pack('recovered')
])
} finally {
vi.useRealTimers()
}
@@ -155,17 +157,17 @@ describe('plugin language-pack startup ownership', () => {
it('retains fail-closed loading and permits an explicit retry after rejection', async () => {
const bridge = installBridge()
const store = languagePacks.usePluginLanguagePackStore
const failed = store.getState().fetchPacks()
const failed = store.getState().fetchEntries()
bridge.requests[0].reject(new Error('bridge unavailable'))
await failed
expect(store.getState().loaded).toBe(true)
expect(store.getState().packs).toEqual([])
expect(store.getState().entries).toEqual([])
languagePacks.ensurePluginLanguagePacksLoaded()
expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1)
const retry = store.getState().fetchPacks()
const retry = store.getState().fetchEntries()
bridge.requests[1].resolve([pack('retry')])
await retry
expect(store.getState().packs).toEqual([pack('retry')])
expect(store.getState().entries).toEqual([pack('retry')])
})
it('finishes without an older bridge API and admits a later explicit refresh', async () => {
@@ -173,10 +175,10 @@ describe('plugin language-pack startup ownership', () => {
languagePacks.ensurePluginLanguagePacksLoaded()
expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true)
const bridge = installBridge()
const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchPacks()
const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchEntries()
bridge.requests[0].resolve([pack('available')])
await refresh
expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1)
expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('available')])
expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('available')])
})
})
@@ -8,7 +8,7 @@ import { usePluginLanguagePackStore } from './plugin-language-packs'
afterEach(() => {
vi.restoreAllMocks()
setRendererPluginLanguagePacks([])
usePluginLanguagePackStore.setState({ packs: [], loaded: false })
usePluginLanguagePackStore.setState({ entries: [], loaded: false })
vi.unstubAllGlobals()
})
@@ -22,12 +22,12 @@ describe('plugin language pack loading', () => {
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
await usePluginLanguagePackStore.getState().fetchEntries()
const state = usePluginLanguagePackStore.getState()
expect(state.loaded).toBe(true)
expect(state.packs).toEqual([])
expect(() => state.packs.find(() => true)).not.toThrow()
expect(state.entries).toEqual([])
expect(() => state.entries.find(() => true)).not.toThrow()
})
it('filters malformed language-pack array members without changing valid order', async () => {
@@ -55,9 +55,9 @@ describe('plugin language pack loading', () => {
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
await usePluginLanguagePackStore.getState().fetchEntries()
expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second])
expect(usePluginLanguagePackStore.getState().entries).toEqual([first, second])
})
it.each([
@@ -81,9 +81,9 @@ describe('plugin language pack loading', () => {
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
await usePluginLanguagePackStore.getState().fetchEntries()
expect(usePluginLanguagePackStore.getState().packs).toEqual([])
expect(usePluginLanguagePackStore.getState().entries).toEqual([])
})
it('rejects a structured-cloned cyclic catalog before i18next resource installation', async () => {
@@ -103,8 +103,8 @@ describe('plugin language pack loading', () => {
api: { plugins: { listLanguagePacks: vi.fn().mockResolvedValue(response) } }
})
await usePluginLanguagePackStore.getState().fetchPacks()
const packs = usePluginLanguagePackStore.getState().packs
await usePluginLanguagePackStore.getState().fetchEntries()
const packs = usePluginLanguagePackStore.getState().entries
expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow()
expect(packs).toEqual([])
@@ -127,8 +127,8 @@ describe('plugin language pack loading', () => {
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().packs)
await usePluginLanguagePackStore.getState().fetchEntries()
setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().entries)
const removeResourceBundle = vi
.spyOn(i18n, 'removeResourceBundle')
.mockImplementation((language) => {
@@ -156,8 +156,8 @@ describe('plugin language pack loading', () => {
}
})
await usePluginLanguagePackStore.getState().fetchPacks()
const packs = usePluginLanguagePackStore.getState().packs
await usePluginLanguagePackStore.getState().fetchEntries()
const packs = usePluginLanguagePackStore.getState().entries
expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow()
expect(packs).toEqual([])
@@ -193,11 +193,11 @@ describe('plugin language pack loading', () => {
}
})
const olderFetch = usePluginLanguagePackStore.getState().fetchPacks()
await usePluginLanguagePackStore.getState().fetchPacks()
const olderFetch = usePluginLanguagePackStore.getState().fetchEntries()
await usePluginLanguagePackStore.getState().fetchEntries()
resolveOlder([older])
await olderFetch
expect(usePluginLanguagePackStore.getState().packs).toEqual([latest])
expect(usePluginLanguagePackStore.getState().entries).toEqual([latest])
})
})
@@ -1,89 +1,15 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import {
isPluginLanguagePackRegistration,
type PluginLanguagePackRegistration
} from '../../../shared/plugins/plugin-language-pack-artifact'
import { createPluginContentListStore } from './plugin-content-list-store'
type PluginLanguagePackState = {
packs: PluginLanguagePackRegistration[]
loaded: boolean
fetchPacks: () => Promise<void>
}
const languagePacks = createPluginContentListStore<PluginLanguagePackRegistration>({
kind: 'language packs',
list: (api) => api.listLanguagePacks,
isEntry: isPluginLanguagePackRegistration
})
/**
* Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin
* discovery — a wedged one must not turn `ensurePluginLanguagePacksLoaded` into a no-op for the
* session, which would pin every consumer on built-in translations. Past this bound the pending
* request is wedged rather than slow, so a later consumer starts its own (mirrors the reasoning in
* `src/main/git/coalesced-probe.ts`).
*/
const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000
let requestGeneration = 0
let latestRequestStartedAt: number | null = null
let changeSubscriptionStarted = false
export const usePluginLanguagePackStore = create<PluginLanguagePackState>()((set) => ({
packs: [],
loaded: false,
fetchPacks: async () => {
const generation = ++requestGeneration
latestRequestStartedAt = Date.now()
const api = window.api?.plugins
if (!api?.listLanguagePacks) {
if (generation === requestGeneration) {
latestRequestStartedAt = null
set({ packs: [], loaded: true })
}
return
}
try {
const response = await api.listLanguagePacks()
const packs = Array.isArray(response) ? response.filter(isPluginLanguagePackRegistration) : []
// Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log.
if (!Array.isArray(response)) {
console.warn(`[plugins] Ignoring non-array language-pack list (${typeof response})`)
} else if (packs.length !== response.length) {
console.warn(
`[plugins] Ignoring ${response.length - packs.length} of ${response.length} malformed language packs`
)
}
if (generation === requestGeneration) {
set({ packs, loaded: true })
}
} catch {
if (generation === requestGeneration) {
set({ packs: [], loaded: true })
}
} finally {
if (generation === requestGeneration) {
latestRequestStartedAt = null
}
}
}
}))
export function ensurePluginLanguagePacksLoaded(): void {
const state = usePluginLanguagePackStore.getState()
const joinable =
latestRequestStartedAt !== null &&
Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS
if (!state.loaded && !joinable) {
void state.fetchPacks()
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void usePluginLanguagePackStore.getState().fetchPacks()
}
})
}
}
export function usePluginLanguagePacks(): PluginLanguagePackRegistration[] {
const packs = usePluginLanguagePackStore((state) => state.packs)
useEffect(() => ensurePluginLanguagePacksLoaded(), [])
return packs
}
export const usePluginLanguagePackStore = languagePacks.useStore
export const ensurePluginLanguagePacksLoaded = languagePacks.ensureLoaded
export const usePluginLanguagePacks = languagePacks.useEntries
+2 -9
View File
@@ -113,15 +113,8 @@ const pluginManifestV1Object = z.object({
.default([])
})
.strict()
.default(() => ({
panels: [],
commands: [],
events: [],
languagePacks: [],
keybindings: [],
vmRecipes: [],
agents: []
})),
// Why prefault: runs `{}` through the schema, so every family's own default applies.
.prefault({}),
capabilities: z.array(pluginCapabilitySchema).max(32).default([])
})