diff --git a/src/components/plugins/view/PluginSettingsTab.tsx b/src/components/plugins/view/PluginSettingsTab.tsx index a411b94c..106a7511 100644 --- a/src/components/plugins/view/PluginSettingsTab.tsx +++ b/src/components/plugins/view/PluginSettingsTab.tsx @@ -1,12 +1,93 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; +import { + Activity, + BarChart3, + BookOpen, + Clock, + Download, + ExternalLink, + GitBranch, + Loader2, + RefreshCw, + ServerCrash, + ShieldAlert, + Terminal, + Trash2, + type LucideIcon, +} from 'lucide-react'; + import { usePlugins } from '../../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext'; + import PluginIcon from './PluginIcon'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal'; +const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron'; +const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/claude-watch'; + +type PluginRecommendation = { + id: string; + translationKey: string; + repoUrl: string; + installedNames: string[]; + icon: LucideIcon; + source: 'official' | 'unofficial'; +}; + +const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [ + { + id: 'project-stats', + translationKey: 'starterPlugin', + repoUrl: STARTER_PLUGIN_URL, + installedNames: ['project-stats'], + icon: BarChart3, + source: 'official', + }, + { + id: 'web-terminal', + translationKey: 'terminalPlugin', + repoUrl: TERMINAL_PLUGIN_URL, + installedNames: ['web-terminal'], + icon: Terminal, + source: 'official', + }, +]; + +const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [ + { + id: 'claude-watch', + translationKey: 'claudeWatchPlugin', + repoUrl: CLAUDE_WATCH_PLUGIN_URL, + installedNames: ['claude-watch'], + icon: Activity, + source: 'unofficial', + }, + { + id: 'workspace-scheduled-prompts', + translationKey: 'scheduledPromptPlugin', + repoUrl: SCHEDULED_PROMPT_PLUGIN_URL, + installedNames: ['workspace-scheduled-prompts'], + icon: Clock, + source: 'unofficial', + }, +]; + +function repoSlug(repoUrl: string) { + return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, ''); +} + +function normalizeRepoUrl(repoUrl: string | null) { + return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null; +} + +function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) { + return ( + recommendation.installedNames.includes(plugin.name) + || normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl) + ); +} /* ─── Toggle Switch ─────────────────────────────────────────────────────── */ function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { @@ -208,117 +289,93 @@ function PluginCard({ ); } -/* ─── Starter Plugin Card ───────────────────────────────────────────────── */ -function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { - const { t } = useTranslation('settings'); - +/* ─── Recommendation Section ────────────────────────────────────────────── */ +function RecommendationSection({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { return ( -
-
-
-
-
-
- -
-
-
- - {t('pluginSettings.starterPlugin.name')} - - - {t('pluginSettings.starterPlugin.badge')} - - - {t('pluginSettings.tab')} - -
-

- {t('pluginSettings.starterPlugin.description')} -

- - - cloudcli-ai/cloudcli-plugin-starter - -
-
- -
+
+
+

+ {title} +

+

+ {description} +

-
+
+ {children} +
+ ); } -/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */ -function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) { +/* ─── Plugin Recommendation Card ────────────────────────────────────────── */ +function PluginRecommendationCard({ + recommendation, + onInstall, + installing, +}: { + recommendation: PluginRecommendation; + onInstall: () => void; + installing: boolean; +}) { const { t } = useTranslation('settings'); + const Icon = recommendation.icon; + const isOfficial = recommendation.source === 'official'; + const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40'; + const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500'; + const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500'; return ( -
-
+
+
-
- - - - - +
+
- {t('pluginSettings.terminalPlugin.name')} - - - {t('pluginSettings.terminalPlugin.badge')} + {t(`pluginSettings.${recommendation.translationKey}.name`)} {t('pluginSettings.tab')}

- {t('pluginSettings.terminalPlugin.description')} + {t(`pluginSettings.${recommendation.translationKey}.description`)}

- cloudcli-ai/cloudcli-plugin-terminal + {repoSlug(recommendation.repoUrl)}
@@ -334,8 +391,7 @@ export default function PluginSettingsTab() { const [gitUrl, setGitUrl] = useState(''); const [installing, setInstalling] = useState(false); - const [installingStarter, setInstallingStarter] = useState(false); - const [installingTerminal, setInstallingTerminal] = useState(false); + const [installingRecommendation, setInstallingRecommendation] = useState(null); const [installError, setInstallError] = useState(null); const [confirmUninstall, setConfirmUninstall] = useState(null); const [updatingPlugins, setUpdatingPlugins] = useState>(new Set()); @@ -364,24 +420,14 @@ export default function PluginSettingsTab() { setInstalling(false); }; - const handleInstallStarter = async () => { - setInstallingStarter(true); + const handleInstallRecommendation = async (recommendation: PluginRecommendation) => { + setInstallingRecommendation(recommendation.id); setInstallError(null); - const result = await installPlugin(STARTER_PLUGIN_URL); + const result = await installPlugin(recommendation.repoUrl); if (!result.success) { setInstallError(result.error || t('pluginSettings.installFailed')); } - setInstallingStarter(false); - }; - - const handleInstallTerminal = async () => { - setInstallingTerminal(true); - setInstallError(null); - const result = await installPlugin(TERMINAL_PLUGIN_URL); - if (!result.success) { - setInstallError(result.error || t('pluginSettings.installFailed')); - } - setInstallingTerminal(false); + setInstallingRecommendation(null); }; const handleUninstall = async (name: string) => { @@ -398,8 +444,50 @@ export default function PluginSettingsTab() { } }; - const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); - const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal'); + const isRecommendationInstalled = (recommendation: PluginRecommendation) => { + return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation)); + }; + + const isOfficialPlugin = (plugin: Plugin) => { + return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => ( + pluginMatchesRecommendation(plugin, recommendation) + )); + }; + + const officialPlugins = plugins.filter(isOfficialPlugin); + const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin)); + const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter( + (recommendation) => !isRecommendationInstalled(recommendation), + ); + const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter( + (recommendation) => !isRecommendationInstalled(recommendation), + ); + const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0; + const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0; + + const renderPluginCard = (plugin: Plugin, index: number) => { + const handleToggle = async (enabled: boolean) => { + const r = await togglePlugin(plugin.name, enabled); + if (!r.success) { + setInstallError(r.error || t('pluginSettings.toggleFailed')); + } + }; + + return ( + void handleToggle(enabled)} + onUpdate={() => void handleUpdate(plugin.name)} + onUninstall={() => void handleUninstall(plugin.name)} + updating={updatingPlugins.has(plugin.name)} + confirmingUninstall={confirmUninstall === plugin.name} + onCancelUninstall={() => setConfirmUninstall(null)} + updateError={updateErrors[plugin.name] ?? null} + /> + ); + }; return (
@@ -456,51 +544,47 @@ export default function PluginSettingsTab() {

- {/* Official plugin suggestions — above the list */} - {!loading && (!hasStarterInstalled || !hasTerminalInstalled) && ( -
- {!hasStarterInstalled && ( - - )} - {!hasTerminalInstalled && ( - - )} -
- )} - - {/* Plugin List */} + {/* Plugin sections */} {loading ? (
{t('pluginSettings.scanningPlugins')}
- ) : plugins.length === 0 ? ( -

{t('pluginSettings.noPluginsInstalled')}

) : ( -
- {plugins.map((plugin, index) => { - const handleToggle = async (enabled: boolean) => { - const r = await togglePlugin(plugin.name, enabled); - if (!r.success) { - setInstallError(r.error || t('pluginSettings.toggleFailed')); - } - }; +
+ {hasOfficialSection && ( + + {officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))} + {officialRecommendations.map((recommendation) => ( + void handleInstallRecommendation(recommendation)} + installing={installingRecommendation === recommendation.id} + /> + ))} + + )} - return ( - void handleToggle(enabled)} - onUpdate={() => void handleUpdate(plugin.name)} - onUninstall={() => void handleUninstall(plugin.name)} - updating={updatingPlugins.has(plugin.name)} - confirmingUninstall={confirmUninstall === plugin.name} - onCancelUninstall={() => setConfirmUninstall(null)} - updateError={updateErrors[plugin.name] ?? null} - /> - ); - })} + {hasOtherSection && ( + + {otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))} + {unofficialRecommendations.map((recommendation) => ( + void handleInstallRecommendation(recommendation)} + installing={installingRecommendation === recommendation.id} + /> + ))} + + )}
)} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 2e91e027..b80d17d2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -472,6 +472,12 @@ "starterPluginLabel": "Starter Plugin", "starter": "Starter", "docs": "Docs", + "sections": { + "officialTitle": "Official Plugins", + "officialDescription": "Maintained by the CloudCLI team and ready for direct install.", + "unofficialTitle": "Other Plugins", + "unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing." + }, "starterPlugin": { "name": "Project Stats", "badge": "starter", @@ -484,6 +490,18 @@ "description": "Integrated terminal with full shell access directly within the interface.", "install": "Install" }, + "scheduledPromptPlugin": { + "name": "Scheduled Prompts", + "badge": "unofficial", + "description": "Schedule workspace prompts, review run history, and manage recurring local tasks.", + "install": "Install" + }, + "claudeWatchPlugin": { + "name": "Claude Watch", + "badge": "unofficial", + "description": "Watch long-running Claude Code sessions for hangs and expose process controls.", + "install": "Install" + }, "morePlugins": "More", "enable": "Enable", "disable": "Disable",