diff --git a/src/renderer/src/store/plugin-custom-editors.ts b/src/renderer/src/store/plugin-custom-editors.ts index 4ce300a2b4b..053e315209b 100644 --- a/src/renderer/src/store/plugin-custom-editors.ts +++ b/src/renderer/src/store/plugin-custom-editors.ts @@ -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()((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. */ diff --git a/src/renderer/src/store/plugin-new-files.ts b/src/renderer/src/store/plugin-new-files.ts index 0de7add6c1e..17f9b98dfaa 100644 --- a/src/renderer/src/store/plugin-new-files.ts +++ b/src/renderer/src/store/plugin-new-files.ts @@ -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) } diff --git a/src/renderer/src/store/plugin-panels.ts b/src/renderer/src/store/plugin-panels.ts index 0aa193d9116..d4ea32b877c 100644 --- a/src/renderer/src/store/plugin-panels.ts +++ b/src/renderer/src/store/plugin-panels.ts @@ -101,6 +101,10 @@ export const usePluginPanelsStore = create()((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 { const installed = collectInstalledPluginTabKeys(plugins) - return Object.fromEntries( - Object.entries(panelErrors).filter(([tabKey]) => installed.has(tabKey)) - ) as Record + const kept: Record = {} + 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( + 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( + 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(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) }