Files
cinny/src/app/features/settings/plugins/Plugins.tsx

665 lines
24 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Avatar, Badge, Box, Button, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Switch, Text, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { PluginTab, PluginMetadata, InstalledPlugin } from './types';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
import { stopPropagation } from '../../../utils/keyboard';
import { fuzzyFilter } from '../../../utils/fuzzy';
/**
* Renders settings UI for a plugin based on its settings schema
*/
function PluginSettingsRenderer({ pluginId }: { pluginId: string }) {
const schema = pluginRegistry.getPluginSettingsSchema(pluginId);
const [, forceUpdate] = useState({});
if (!schema || Object.keys(schema).length === 0) {
return (
<Box padding="300">
<Text size="T300" priority="300">No settings available for this plugin.</Text>
</Box>
);
}
const handleChange = (key: string, value: any) => {
pluginRegistry.setPluginSetting(pluginId, key, value);
forceUpdate({});
};
return (
<Box direction="Column" gap="300" style={{ padding: config.space.S400 }}>
{Object.entries(schema).map(([key, def]) => (
<SettingRenderer
key={key}
settingKey={key}
definition={def}
value={pluginRegistry.getPluginSetting(pluginId, key)}
onChange={(value) => handleChange(key, value)}
/>
))}
</Box>
);
}
/**
* Renders a single setting based on its type
*/
function SettingRenderer({
settingKey,
definition,
value,
onChange,
}: {
settingKey: string;
definition: SettingDefinition;
value: any;
onChange: (value: any) => void;
}) {
const [colorMenuCords, setColorMenuCords] = useState<RectCords>();
const [selectMenuCords, setSelectMenuCords] = useState<RectCords>();
const label = definition.label || settingKey;
const description = definition.description;
// Boolean - Switch
if (definition.type === 'boolean') {
return (
<SettingTile
title={label}
description={description}
after={<Switch value={value ?? definition.default ?? false} onChange={onChange} />}
/>
);
}
// String - Input
if (definition.type === 'string') {
return (
<SettingTile title={label} description={description}>
<Input
size="400"
variant="Background"
value={value ?? definition.default ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={definition.default}
/>
</SettingTile>
);
}
// Number - Input
if (definition.type === 'number') {
return (
<SettingTile title={label} description={description}>
<Input
size="400"
variant="Background"
type="number"
value={value ?? definition.default ?? 0}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
/>
</SettingTile>
);
}
// Select - Dropdown
if (definition.type === 'select' && definition.options) {
const currentValue = value ?? definition.default ?? definition.options[0]?.value;
const selectedOption = definition.options.find(opt => opt.value === currentValue);
return (
<SettingTile title={label} description={description}>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={(evt) => setSelectMenuCords(evt.currentTarget.getBoundingClientRect())}
>
<Text size="T300">{selectedOption?.label || currentValue}</Text>
</Button>
<PopOut
anchor={selectMenuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSelectMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
{definition.options.map((option) => (
<MenuItem
key={option.value}
size="300"
after={currentValue === option.value ? <Icon size="100" src={Icons.Check} /> : undefined}
radii="300"
onClick={() => {
onChange(option.value);
setSelectMenuCords(undefined);
}}
>
<Text size="T300">{option.label}</Text>
</MenuItem>
))}
</Menu>
</FocusTrap>
}
/>
</SettingTile>
);
}
// Color - Color Picker
if (definition.type === 'color') {
const currentColor = value ?? definition.default ?? '#000000';
return (
<SettingTile title={label} description={description}>
<Button
size="400"
variant="Primary"
outlined
fill="Soft"
radii="300"
before={
<Box
style={{
width: toRem(20),
height: toRem(20),
borderRadius: config.radii.R300,
backgroundColor: currentColor,
border: `1px solid ${color.Surface.ContainerLine}`,
}}
/>
}
onClick={(evt) => setColorMenuCords(evt.currentTarget.getBoundingClientRect())}
>
<Text size="T300">{currentColor}</Text>
</Button>
<PopOut
anchor={colorMenuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setColorMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Box style={{ padding: config.space.S300 }}>
<HexColorPicker color={currentColor} onChange={onChange} />
</Box>
</FocusTrap>
}
/>
</SettingTile>
);
}
return null;
}
type PluginsProps = {
requestClose: () => void;
};
export function Plugins({ requestClose }: PluginsProps) {
const [activeTab, setActiveTab] = useState<PluginTab>(PluginTab.Marketplace);
const [marketplacePlugins, setMarketplacePlugins] = useState<PluginMetadata[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [expandedSettings, setExpandedSettings] = useState<string | null>(null);
const refreshInstalledPlugins = useCallback(async () => {
try {
setInstalledPlugins(await pluginMarketplaceManager.listInstalledPlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to list installed plugins');
console.error('Failed to list installed plugins:', err);
}
}, []);
const fetchMarketplacePlugins = useCallback(async () => {
setLoading(true);
setError(null);
try {
setMarketplacePlugins(await pluginMarketplaceManager.fetchMarketplacePlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
console.error('Error fetching marketplace plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === PluginTab.Marketplace) {
fetchMarketplacePlugins();
}
}, [activeTab, fetchMarketplacePlugins]);
useEffect(() => {
refreshInstalledPlugins();
}, [refreshInstalledPlugins]);
const handleInstall = useCallback(
async (plugin: PluginMetadata) => {
try {
setLoading(true);
setError(null);
await pluginMarketplaceManager.installPlugin(plugin);
await refreshInstalledPlugins();
} catch (err) {
console.error('Failed to install plugin:', err);
setError(err instanceof Error ? err.message : 'Failed to install plugin');
} finally {
setLoading(false);
}
},
[refreshInstalledPlugins]
);
const handleUninstall = useCallback(
async (pluginId: string) => {
try {
setError(null);
await pluginMarketplaceManager.uninstallPlugin(pluginId);
await refreshInstalledPlugins();
} catch (err) {
console.error('Failed to uninstall plugin:', err);
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
}
},
[refreshInstalledPlugins]
);
const handleToggleEnabled = useCallback(
async (pluginId: string) => {
const plugin = installedPlugins.find((p) => p.id === pluginId);
if (!plugin) return;
const newEnabledState = !plugin.enabled;
try {
setError(null);
await pluginMarketplaceManager.setPluginEnabled(pluginId, newEnabledState);
await refreshInstalledPlugins();
if (newEnabledState) {
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
}
} catch (err) {
console.error(`Failed to toggle plugin ${pluginId}:`, err);
setError(err instanceof Error ? err.message : 'Failed to update plugin state');
}
},
[installedPlugins, refreshInstalledPlugins]
);
const isPluginInstalled = useCallback(
(pluginId: string) => installedPlugins.some((p) => p.id === pluginId),
[installedPlugins]
);
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
const [imageError, setImageError] = useState(false);
return (
<Avatar size="500" radii="400">
{!imageError ? (
<img
src={thumbnail}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 'inherit',
}}
onError={() => setImageError(true)}
/>
) : (
<Box
justifyContent="Center"
alignItems="Center"
style={{
width: '100%',
height: '100%',
background: `linear-gradient(135deg, ${color.Secondary.Container} 0%, ${color.Primary.Container} 100%)`,
borderRadius: 'inherit',
}}
>
<Text size="H3" priority="300" style={{ fontWeight: 600 }}>
{name.charAt(0).toUpperCase()}
</Text>
</Box>
)}
</Avatar>
);
};
const filteredMarketplacePlugins = useMemo(() => {
if (!searchQuery.trim()) return marketplacePlugins;
return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
...plugin.tags,
]).map(({ item }) => item);
}, [marketplacePlugins, searchQuery]);
const filteredInstalledPlugins = useMemo(() => {
if (!searchQuery.trim()) return installedPlugins;
return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
]).map(({ item }) => item);
}, [installedPlugins, searchQuery]);
return (
<Page>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Header size="H3" className="truncate">
Plugins
</Header>
</Box>
<Box shrink="No" gap="200">
<Button variant="Background" size="300" onClick={requestClose}>
<Text size="B300">Close</Text>
</Button>
</Box>
</PageHeader>
<Box shrink="No" style={{ padding: `0 ${config.space.S400}` }} direction="Column" gap="300">
<Box gap="200">
<Badge
style={{ cursor: 'pointer' }}
as="button"
variant="Secondary"
fill={activeTab === PluginTab.Marketplace ? 'Solid' : 'None'}
size="500"
onClick={() => setActiveTab(PluginTab.Marketplace)}
>
<Text as="span" size="L400">
Marketplace
</Text>
</Badge>
<Badge
style={{ cursor: 'pointer' }}
as="button"
variant="Secondary"
fill={activeTab === PluginTab.Installed ? 'Solid' : 'None'}
size="500"
onClick={() => setActiveTab(PluginTab.Installed)}
>
<Text as="span" size="L400">
Installed
</Text>
</Badge>
</Box>
<Input
variant="Background"
size="400"
placeholder="Search plugins..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
before={<Icon size="100" src={Icons.Search} />}
after={
searchQuery && (
<IconButton
size="300"
variant="SurfaceLow"
radii="300"
onClick={() => setSearchQuery('')}
>
<Icon size="50" src={Icons.Cross} filled />
</IconButton>
)
}
/>
</Box>
<Box grow="Yes" direction="Column">
<Scroll variant="Background" visibility="Hover" hideTrack size="300">
<PageContent>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{activeTab === PluginTab.Marketplace && (
<Box direction="Column" gap="400">
{loading && (
<Box justifyContent="Center" alignItems="Center" style={{ padding: toRem(32) }}>
<Spinner variant="Secondary" size="400" />
</Box>
)}
{error && (
<Box direction="Column" gap="200" style={{ padding: toRem(16) }}>
<Text size="T400" priority="300">
Error: {error}
</Text>
<Button onClick={fetchMarketplacePlugins} size="300" variant="Primary">
<Text size="B300">Retry</Text>
</Button>
</Box>
)}
{!loading && !error && filteredMarketplacePlugins.length === 0 && (
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
<Text size="T400" priority="300">
{searchQuery ? 'No plugins found matching your search' : 'No plugins available'}
</Text>
</Box>
)}
{!loading &&
!error &&
filteredMarketplacePlugins.map((plugin) => (
<Box
key={plugin.id}
style={{
padding: config.space.S500,
background: color.Surface.Container,
borderRadius: config.radii.R400,
border: `1px solid ${color.Surface.ContainerLine}`,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Box gap="500" alignItems="Start">
<Box shrink="No">
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
</Box>
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="H5">{plugin.name}</Text>
<Box gap="200" alignItems="Center">
<Text size="T200" priority="400">
by {plugin.author}
</Text>
<Text size="T200" priority="400"></Text>
<Text size="T200" priority="400">
v{plugin.version}
</Text>
</Box>
</Box>
<Text size="T300" priority="300">
{plugin.description}
</Text>
{plugin.tags.length > 0 && (
<Box gap="100" wrap="Wrap">
{plugin.tags.map((tag) => (
<Badge key={tag} size="300" variant="Secondary" fill="Soft">
<Text size="L400">{tag}</Text>
</Badge>
))}
</Box>
)}
</Box>
<Box shrink="No">
<Button
variant={isPluginInstalled(plugin.id) ? 'Success' : 'Primary'}
size="400"
onClick={() => handleInstall(plugin)}
disabled={isPluginInstalled(plugin.id)}
>
<Text size="B400">
{isPluginInstalled(plugin.id) ? 'Installed' : 'Install'}
</Text>
</Button>
</Box>
</Box>
</Box>
))}
</Box>
)}
{activeTab === PluginTab.Installed && (
<Box direction="Column" gap="400">
{filteredInstalledPlugins.length === 0 && (
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
<Text size="T400" priority="300">
{searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'}
</Text>
</Box>
)}
{filteredInstalledPlugins.map((plugin) => {
const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) &&
Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0;
const settingsExpanded = expandedSettings === plugin.id;
return (
<Box
key={plugin.id}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
border: `1px solid ${color.Surface.ContainerLine}`,
transition: 'all 0.2s ease',
}}
>
<Box
style={{
padding: config.space.S500,
}}
onMouseEnter={(e) => {
e.currentTarget.parentElement!.style.transform = 'translateY(-2px)';
e.currentTarget.parentElement!.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.parentElement!.style.transform = 'translateY(0)';
e.currentTarget.parentElement!.style.boxShadow = 'none';
}}
>
<Box gap="500" alignItems="Start">
<Box shrink="No">
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
</Box>
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="H5">{plugin.name}</Text>
<Box gap="200" alignItems="Center">
<Text size="T200" priority="400">
by {plugin.author}
</Text>
<Text size="T200" priority="400"></Text>
<Text size="T200" priority="400">
v{plugin.version}
</Text>
</Box>
</Box>
<Text size="T300" priority="300">
{plugin.description}
</Text>
<Box gap="100" wrap="Wrap">
<Badge
size="300"
variant={plugin.enabled ? 'Success' : 'Secondary'}
fill="Soft"
>
<Text size="L400">{plugin.enabled ? 'Enabled' : 'Disabled'}</Text>
</Badge>
</Box>
</Box>
<Box shrink="No" direction="Column" gap="200">
{hasSettings && (
<Button
variant="Secondary"
fill="Soft"
size="400"
before={<Icon size="200" src={Icons.Setting} />}
after={<Icon size="200" src={settingsExpanded ? Icons.ChevronTop : Icons.ChevronBottom} />}
onClick={() => setExpandedSettings(settingsExpanded ? null : plugin.id)}
>
<Text size="B400">Settings</Text>
</Button>
)}
<Button
variant="Secondary"
size="400"
onClick={() => handleToggleEnabled(plugin.id)}
>
<Text size="B400">{plugin.enabled ? 'Disable' : 'Enable'}</Text>
</Button>
<Button
variant="Critical"
fill="None"
size="400"
onClick={() => handleUninstall(plugin.id)}
>
<Text size="B400">Uninstall</Text>
</Button>
</Box>
</Box>
</Box>
{hasSettings && settingsExpanded && (
<Box
direction="Column"
style={{
borderTop: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.ContainerLowest,
}}
>
<PluginSettingsRenderer pluginId={plugin.id} />
</Box>
)}
</Box>
);
})}
</Box>
)}
</SequenceCard>
</PageContent>
</Scroll>
</Box>
</Page>
);
}