mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
import { useEffect, useMemo } from 'react'
|
|
import { create } from 'zustand'
|
|
import type { PluginHostListEntry, PluginHostPanel } from '../../../preload/api-types'
|
|
|
|
/** A panel contribution from an enabled plugin, flattened for sidebar use. */
|
|
export type ActivePluginPanel = PluginHostPanel & {
|
|
pluginKey: string
|
|
pluginName: string
|
|
}
|
|
|
|
export type ActivePluginCommand = PluginHostListEntry['commands'][number] & {
|
|
pluginKey: string
|
|
pluginName: string
|
|
}
|
|
|
|
export type PluginPanelsFetchStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
export type PluginPanelHealth = 'healthy' | 'error'
|
|
|
|
type PluginPanelsState = {
|
|
plugins: PluginHostListEntry[]
|
|
panelErrors: Record<string, true>
|
|
fetchStatus: PluginPanelsFetchStatus
|
|
fetchPlugins: () => Promise<void>
|
|
setPlugins: (plugins: PluginHostListEntry[]) => void
|
|
setPanelHealth: (tabKey: string, health: PluginPanelHealth) => void
|
|
}
|
|
|
|
let pluginListGeneration = 0
|
|
let pluginListRetryAttempt = 0
|
|
let pluginListRetryTimer: ReturnType<typeof setTimeout> | null = null
|
|
const PLUGIN_LIST_MAX_RETRIES = 2
|
|
|
|
function schedulePluginListRetry(generation: number): void {
|
|
if (pluginListRetryAttempt >= PLUGIN_LIST_MAX_RETRIES) {
|
|
pluginListRetryAttempt = 0
|
|
return
|
|
}
|
|
pluginListRetryAttempt += 1
|
|
const delayMs = 250 * 2 ** (pluginListRetryAttempt - 1)
|
|
pluginListRetryTimer = setTimeout(() => {
|
|
pluginListRetryTimer = null
|
|
const state = usePluginPanelsStore.getState()
|
|
if (generation === pluginListGeneration && state.fetchStatus === 'error') {
|
|
void state.fetchPlugins()
|
|
}
|
|
}, delayMs)
|
|
}
|
|
|
|
export const usePluginPanelsStore = create<PluginPanelsState>()((set) => ({
|
|
plugins: [],
|
|
panelErrors: {},
|
|
fetchStatus: 'idle',
|
|
fetchPlugins: async () => {
|
|
const generation = ++pluginListGeneration
|
|
// Why: preload may predate the plugins namespace (web client pairing an
|
|
// older desktop build); treat a missing bridge as "no plugins" fail-soft.
|
|
const pluginsApi = window.api?.plugins
|
|
if (!pluginsApi) {
|
|
if (generation === pluginListGeneration) {
|
|
pluginListRetryAttempt = 0
|
|
set({ fetchStatus: 'ready', plugins: [], panelErrors: {} })
|
|
}
|
|
return
|
|
}
|
|
set({ fetchStatus: 'loading' })
|
|
try {
|
|
const plugins = await pluginsApi.list()
|
|
if (generation === pluginListGeneration) {
|
|
pluginListRetryAttempt = 0
|
|
set((state) => ({
|
|
plugins,
|
|
fetchStatus: 'ready',
|
|
panelErrors: retainInstalledPanelErrors(state.panelErrors, plugins)
|
|
}))
|
|
}
|
|
} catch {
|
|
if (generation === pluginListGeneration) {
|
|
// A failed authority refresh must not leave previously enabled panel
|
|
// documents mounted from stale state. Bounded retries recover from a
|
|
// transient preload/main startup race without spinning indefinitely.
|
|
set({ plugins: [], panelErrors: {}, fetchStatus: 'error' })
|
|
schedulePluginListRetry(generation)
|
|
}
|
|
}
|
|
},
|
|
setPlugins: (plugins) => {
|
|
pluginListGeneration += 1
|
|
pluginListRetryAttempt = 0
|
|
if (pluginListRetryTimer) {
|
|
clearTimeout(pluginListRetryTimer)
|
|
pluginListRetryTimer = null
|
|
}
|
|
set((state) => ({
|
|
plugins,
|
|
fetchStatus: 'ready',
|
|
panelErrors: retainInstalledPanelErrors(state.panelErrors, plugins)
|
|
}))
|
|
},
|
|
setPanelHealth: (tabKey, health) => {
|
|
set((state) => {
|
|
const panelErrors = { ...state.panelErrors }
|
|
if (health === 'error') {
|
|
panelErrors[tabKey] = true
|
|
} else {
|
|
delete panelErrors[tabKey]
|
|
}
|
|
return { panelErrors }
|
|
})
|
|
}
|
|
}))
|
|
|
|
function retainInstalledPanelErrors(
|
|
panelErrors: Readonly<Record<string, true>>,
|
|
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>
|
|
}
|
|
|
|
let changeSubscriptionStarted = false
|
|
|
|
/** Kicks off the startup fetch and subscribes to main-process change pushes
|
|
* (install/remove/enable/dev-reload); safe to call repeatedly. */
|
|
export function ensurePluginPanelsLoaded(): void {
|
|
const { fetchStatus, fetchPlugins } = usePluginPanelsStore.getState()
|
|
if (fetchStatus === 'idle') {
|
|
void fetchPlugins()
|
|
}
|
|
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
|
|
changeSubscriptionStarted = true
|
|
window.api.plugins.onChanged(() => {
|
|
void usePluginPanelsStore.getState().fetchPlugins()
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Panels of plugins the user enabled (consented + not disabled). `idle` and
|
|
* `restarting` still show panels — the panel itself never needs a worker. */
|
|
export function collectActivePluginPanels(plugins: 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
|
|
}))
|
|
)
|
|
}
|
|
|
|
/** Tab keys of every installed plugin panel (any status) — used by the
|
|
* persisted-route normalizer to drop keys of uninstalled plugins while
|
|
* keeping keys that are merely disabled. */
|
|
export function collectInstalledPluginTabKeys(
|
|
plugins: readonly PluginHostListEntry[]
|
|
): Set<string> {
|
|
return new Set(plugins.flatMap((plugin) => plugin.panels.map((panel) => panel.tabKey)))
|
|
}
|
|
|
|
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
|
|
}))
|
|
)
|
|
}
|
|
|
|
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
|
|
}))
|
|
)
|
|
}
|
|
|
|
/** Panel contributions of enabled plugins, loading the list on first use. */
|
|
export function usePluginPanels(): ActivePluginPanel[] {
|
|
const plugins = usePluginPanelsStore((s) => s.plugins)
|
|
useEffect(() => {
|
|
ensurePluginPanelsLoaded()
|
|
}, [])
|
|
// Why: derive in useMemo (not the selector) so the store snapshot stays
|
|
// referentially stable and doesn't retrigger useSyncExternalStore loops.
|
|
return useMemo(() => collectActivePluginPanels(plugins), [plugins])
|
|
}
|
|
|
|
/** 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])
|
|
}
|
|
|
|
/** 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])
|
|
}
|