Plugins: share the liveness check and list hook across contribution kinds (#35686)

* refactor(plugins): one liveness check and list hook for plugin contributions

Panels, commands, custom editors and new-file types each repeated the same
running/restarting/idle filter, the same plugin tagging, and the same
load-then-memo hook. They now share isLivePlugin, fromPlugin,
keyedPluginContribution and usePluginListDerived.

* perf(plugins): keep the panel-error map's identity when health is unchanged

Every panel load reported its health, and each report rebuilt the map the
sidebar badges subscribe to. Also drops an unchecked cast.
This commit is contained in:
Neil
2026-10-28 22:27:33 -07:00
committed by GitHub
parent dbdf3c3708
commit bb20dafbff
3 changed files with 75 additions and 99 deletions
@@ -1,9 +1,9 @@
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { create } from 'zustand'
import type { PluginHostCustomEditor, PluginHostListEntry } from '../../../preload/api-types'
import { matchPluginFile } from '../../../shared/plugins/contributions/plugin-file-match'
import { customEditorMayOpenByDefault } from '../../../shared/plugins/contributions/plugin-custom-editor-core-file-types'
import { ensurePluginPanelsLoaded, usePluginPanelsStore } from './plugin-panels'
import { isLivePlugin, keyedPluginContribution, usePluginListDerived } from './plugin-panels'
/** A custom editor from an enabled plugin. `key` is unique across plugins; `iconUrl` is its own
* tab icon, else the plugin's. */
@@ -19,26 +19,13 @@ export type CustomEditorViewChoice = string
const SOURCE_CHOICE = 'source'
/** Same liveness as panels: enabled and consented, whether or not a worker runs. */
export function collectActivePluginCustomEditors(
plugins: readonly PluginHostListEntry[]
): ActivePluginCustomEditor[] {
return plugins
.filter(
(plugin) =>
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
)
.filter(isLivePlugin)
.flatMap((plugin) =>
(plugin.customEditors ?? []).map((editor) => {
const iconUrl = editor.icon ?? plugin.images?.icon?.url
return {
...editor,
key: `${plugin.pluginKey}/${editor.id}`,
pluginKey: plugin.pluginKey,
pluginName: plugin.name,
...(iconUrl ? { iconUrl } : {})
}
})
(plugin.customEditors ?? []).map((editor) => keyedPluginContribution(plugin, editor))
)
}
@@ -91,11 +78,7 @@ export const useCustomEditorViewStore = create<CustomEditorViewState>()((set) =>
/** Custom editors of enabled plugins, sharing the plugin-list refresh. */
export function usePluginCustomEditors(): ActivePluginCustomEditor[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
return useMemo(() => collectActivePluginCustomEditors(plugins), [plugins])
return usePluginListDerived(collectActivePluginCustomEditors)
}
/** The enabled editors for one file and the user's pick among them. */
+10 -26
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo } from 'react'
import type { PluginHostListEntry, PluginHostNewFile } from '../../../preload/api-types'
import { ensurePluginPanelsLoaded, usePluginPanelsStore } from './plugin-panels'
import { isLivePlugin, keyedPluginContribution, usePluginListDerived } from './plugin-panels'
/** A new-file type from an enabled plugin. `key` is unique across plugins; `iconUrl` is its own
* icon, else the plugin's. */
@@ -11,38 +10,23 @@ export type ActivePluginNewFile = PluginHostNewFile & {
iconUrl?: string
}
/** Same liveness as panels and custom editors: enabled and consented. */
export function collectActivePluginNewFiles(
plugins: readonly PluginHostListEntry[]
): ActivePluginNewFile[] {
return plugins
.filter(
(plugin) =>
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
)
.filter(isLivePlugin)
.flatMap((plugin) =>
(plugin.newFiles ?? []).map((newFile) => {
const iconUrl = newFile.icon ?? plugin.images?.icon?.url
return {
...newFile,
key: `${plugin.pluginKey}/${newFile.id}`,
pluginKey: plugin.pluginKey,
pluginName: plugin.name,
...(iconUrl ? { iconUrl } : {})
}
})
(plugin.newFiles ?? []).map((newFile) => keyedPluginContribution(plugin, newFile))
)
}
// Why gated: a client without the template reader (a paired web client) cannot create them.
const collectCreatableNewFiles = (
plugins: readonly PluginHostListEntry[]
): ActivePluginNewFile[] =>
window.api?.plugins?.readNewFileTemplate ? collectActivePluginNewFiles(plugins) : []
/** New-file types of enabled plugins, sharing the plugin-list refresh. */
export function usePluginNewFiles(): ActivePluginNewFile[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
// Why gated: a client without the template reader (a paired web client) cannot create them.
return useMemo(
() => (window.api?.plugins?.readNewFileTemplate ? collectActivePluginNewFiles(plugins) : []),
[plugins]
)
return usePluginListDerived(collectCreatableNewFiles)
}
+60 -51
View File
@@ -101,6 +101,10 @@ export const usePluginPanelsStore = create<PluginPanelsState>()((set) => ({
},
setPanelHealth: (tabKey, health) => {
set((state) => {
// Why: sidebar badges subscribe to this map; an unchanged health must keep its identity.
if ((health === 'error') === (state.panelErrors[tabKey] === true)) {
return state
}
const panelErrors = { ...state.panelErrors }
if (health === 'error') {
panelErrors[tabKey] = true
@@ -117,9 +121,13 @@ function retainInstalledPanelErrors(
plugins: readonly PluginHostListEntry[]
): Record<string, true> {
const installed = collectInstalledPluginTabKeys(plugins)
return Object.fromEntries(
Object.entries(panelErrors).filter(([tabKey]) => installed.has(tabKey))
) as Record<string, true>
const kept: Record<string, true> = {}
for (const tabKey of Object.keys(panelErrors)) {
if (installed.has(tabKey)) {
kept[tabKey] = true
}
}
return kept
}
let changeSubscriptionStarted = false
@@ -139,20 +147,49 @@ export function ensurePluginPanelsLoaded(): void {
}
}
/** Enabled/consented panels remain available while workers are idle/restarting; panels need no worker. */
export function collectActivePluginPanels(plugins: PluginHostListEntry[]): ActivePluginPanel[] {
/** Enabled and consented, whether or not a worker runs: its contributions are offered. */
export function isLivePlugin(plugin: PluginHostListEntry): boolean {
return plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
}
/** A contribution tagged with the plugin it came from. */
export function fromPlugin<T>(
plugin: PluginHostListEntry,
contribution: T
): T & { pluginKey: string; pluginName: string } {
return { ...contribution, pluginKey: plugin.pluginKey, pluginName: plugin.name }
}
/** A contribution keyed across plugins, with its own icon or else the plugin's. */
export function keyedPluginContribution<T extends { id: string; icon?: string }>(
plugin: PluginHostListEntry,
contribution: T
): T & { key: string; pluginKey: string; pluginName: string; iconUrl?: string } {
const iconUrl = contribution.icon ?? plugin.images?.icon?.url
return {
...fromPlugin(plugin, contribution),
key: `${plugin.pluginKey}/${contribution.id}`,
...(iconUrl ? { iconUrl } : {})
}
}
/** The plugin list loaded on first use, derived once per change; `derive` must be stable. */
export function usePluginListDerived<T>(derive: (plugins: readonly PluginHostListEntry[]) => T): T {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
// Derive outside selector in useMemo or unstable snapshots loop through useSyncExternalStore.
return useMemo(() => derive(plugins), [derive, plugins])
}
/** Panels need no worker, so they stay available while it is idle or restarting. */
export function collectActivePluginPanels(
plugins: readonly PluginHostListEntry[]
): ActivePluginPanel[] {
return plugins
.filter(
(plugin) =>
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
)
.flatMap((plugin) =>
plugin.panels.map((panel) => ({
...panel,
pluginKey: plugin.pluginKey,
pluginName: plugin.name
}))
)
.filter(isLivePlugin)
.flatMap((plugin) => plugin.panels.map((panel) => fromPlugin(plugin, panel)))
}
/** Installed keys normalize persisted routes: remove uninstalled panels, retain merely disabled ones. */
@@ -166,58 +203,30 @@ export function collectActivePluginCommands(
plugins: readonly PluginHostListEntry[]
): ActivePluginCommand[] {
return plugins
.filter(
(plugin) =>
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
)
.flatMap((plugin) =>
plugin.commands.map((command) => ({
...command,
pluginKey: plugin.pluginKey,
pluginName: plugin.name
}))
)
.filter(isLivePlugin)
.flatMap((plugin) => plugin.commands.map((command) => fromPlugin(plugin, command)))
}
export function collectEditablePluginCommands(
plugins: readonly PluginHostListEntry[]
): ActivePluginCommand[] {
return plugins
.filter((plugin) => ['running', 'restarting', 'idle', 'errored'].includes(plugin.status))
.flatMap((plugin) =>
plugin.commands.map((command) => ({
...command,
pluginKey: plugin.pluginKey,
pluginName: plugin.name
}))
)
.filter((plugin) => isLivePlugin(plugin) || plugin.status === 'errored')
.flatMap((plugin) => plugin.commands.map((command) => fromPlugin(plugin, command)))
}
/** Panel contributions of enabled plugins, loading the list on first use. */
export function usePluginPanels(): ActivePluginPanel[] {
const plugins = usePluginPanelsStore((s) => s.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
// Derive outside selector in useMemo or unstable snapshots loop through useSyncExternalStore.
return useMemo(() => collectActivePluginPanels(plugins), [plugins])
return usePluginListDerived(collectActivePluginPanels)
}
/** Commands of enabled plugins, sharing the authoritative plugin-list refresh. */
export function usePluginCommands(): ActivePluginCommand[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
return useMemo(() => collectActivePluginCommands(plugins), [plugins])
return usePluginListDerived(collectActivePluginCommands)
}
/** Enabled commands remain editable while a chord conflict has errored the
* plugin, so a saved override can recover both conflicting plugins. */
export function useEditablePluginCommands(): ActivePluginCommand[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
return useMemo(() => collectEditablePluginCommands(plugins), [plugins])
return usePluginListDerived(collectEditablePluginCommands)
}