mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 08:27:40 +00:00
Compare commits
8 Commits
a7e8b12ef4
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fb193598 | ||
|
|
ff45a1cfd7 | ||
|
|
951db47a91 | ||
|
|
ca16342a20 | ||
|
|
ca247cddae | ||
|
|
4061a2761e | ||
|
|
c368451891 | ||
|
|
efdee162c9 |
@@ -2545,12 +2545,12 @@ async function startServer() {
|
||||
});
|
||||
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = () => {
|
||||
stopAllPlugins();
|
||||
const shutdownPlugins = async () => {
|
||||
await stopAllPlugins();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdownPlugins);
|
||||
process.on('SIGINT', shutdownPlugins);
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -39,6 +39,9 @@ router.get('/', (req, res) => {
|
||||
// GET /:name/manifest — Get single plugin manifest
|
||||
router.get('/:name/manifest', (req, res) => {
|
||||
try {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
const plugins = scanPlugins();
|
||||
const plugin = plugins.find(p => p.name === req.params.name);
|
||||
if (!plugin) {
|
||||
@@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => {
|
||||
// GET /:name/assets/* — Serve plugin static files
|
||||
router.get('/:name/assets/*', (req, res) => {
|
||||
const pluginName = req.params.name;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||
}
|
||||
const assetPath = req.params[0];
|
||||
|
||||
if (!assetPath) {
|
||||
@@ -64,9 +70,26 @@ router.get('/:name/assets/*', (req, res) => {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
fs.createReadStream(resolvedPath).pipe(res);
|
||||
const stream = fs.createReadStream(resolvedPath);
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to read asset' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
|
||||
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
|
||||
@@ -99,7 +122,7 @@ router.put('/:name/enable', async (req, res) => {
|
||||
}
|
||||
}
|
||||
} else if (!enabled && isPluginRunning(plugin.name)) {
|
||||
stopPluginServer(plugin.name);
|
||||
await stopPluginServer(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +176,7 @@ router.post('/:name/update', async (req, res) => {
|
||||
|
||||
const wasRunning = isPluginRunning(pluginName);
|
||||
if (wasRunning) {
|
||||
stopPluginServer(pluginName);
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
const manifest = await updatePluginFromGit(pluginName);
|
||||
@@ -235,11 +258,18 @@ router.all('/:name/rpc/*', async (req, res) => {
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Forward body (already parsed by express JSON middleware, so re-stringify)
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
// Forward body (already parsed by express JSON middleware, so re-stringify).
|
||||
// Check content-length to detect whether a body was actually sent, since
|
||||
// req.body can be falsy for valid payloads like 0, false, null, or {}.
|
||||
const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
|
||||
if (hasBody && req.body !== undefined) {
|
||||
const bodyStr = JSON.stringify(req.body);
|
||||
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
|
||||
proxyReq.write(bodyStr);
|
||||
|
||||
@@ -7,6 +7,19 @@ const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||
|
||||
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
|
||||
|
||||
/** Strip embedded credentials from a repo URL before exposing it to the client. */
|
||||
function sanitizeRepoUrl(raw) {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
u.username = '';
|
||||
u.password = '';
|
||||
return u.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
|
||||
return raw.replace(/\/\/[^@/]+@/, '//');
|
||||
}
|
||||
}
|
||||
const ALLOWED_TYPES = ['react', 'module'];
|
||||
const ALLOWED_SLOTS = ['tab'];
|
||||
|
||||
@@ -31,9 +44,9 @@ export function getPluginsConfig() {
|
||||
export function savePluginsConfig(config) {
|
||||
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function validateManifest(manifest) {
|
||||
@@ -60,6 +73,23 @@ export function validateManifest(manifest) {
|
||||
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
|
||||
}
|
||||
|
||||
// Validate entry is a relative path without traversal
|
||||
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
|
||||
return { valid: false, error: 'Entry must be a relative path without ".."' };
|
||||
}
|
||||
|
||||
if (manifest.server !== undefined && manifest.server !== null) {
|
||||
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
|
||||
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.permissions !== undefined) {
|
||||
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
|
||||
return { valid: false, error: 'Permissions must be an array of strings' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
@@ -75,8 +105,12 @@ export function scanPlugins() {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
const seenNames = new Set();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
// Skip transient temp directories from in-progress installs
|
||||
if (entry.name.startsWith('.tmp-')) continue;
|
||||
|
||||
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
@@ -89,6 +123,13 @@ export function scanPlugins() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip duplicate manifest names
|
||||
if (seenNames.has(manifest.name)) {
|
||||
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(manifest.name);
|
||||
|
||||
// Try to read git remote URL
|
||||
let repoUrl = null;
|
||||
try {
|
||||
@@ -102,6 +143,8 @@ export function scanPlugins() {
|
||||
if (repoUrl.startsWith('git@')) {
|
||||
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
||||
}
|
||||
// Strip embedded credentials (e.g. https://user:pass@host/...)
|
||||
repoUrl = sanitizeRepoUrl(repoUrl);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -143,14 +186,16 @@ export function resolvePluginAssetPath(name, assetPath) {
|
||||
|
||||
const resolved = path.resolve(pluginDir, assetPath);
|
||||
|
||||
// Prevent path traversal — resolved path must be within plugin directory
|
||||
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
|
||||
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
|
||||
const realResolved = fs.realpathSync(resolved);
|
||||
const realPluginDir = fs.realpathSync(pluginDir);
|
||||
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
|
||||
return resolved;
|
||||
return realResolved;
|
||||
}
|
||||
|
||||
export function installPluginFromGit(url) {
|
||||
@@ -233,6 +278,13 @@ export function installPluginFromGit(url) {
|
||||
return reject(new Error(`Invalid manifest: ${validation.error}`));
|
||||
}
|
||||
|
||||
// Reject if another installed plugin already uses this name
|
||||
const existing = scanPlugins().find(p => p.name === manifest.name);
|
||||
if (existing) {
|
||||
cleanupTemp();
|
||||
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
|
||||
}
|
||||
|
||||
// Run npm install if package.json exists.
|
||||
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
|
||||
const packageJsonPath = path.join(tempDir, 'package.json');
|
||||
|
||||
@@ -4,6 +4,8 @@ import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'
|
||||
|
||||
// Map<pluginName, { process, port }>
|
||||
const runningPlugins = new Map();
|
||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||
const startingPlugins = new Map();
|
||||
|
||||
/**
|
||||
* Start a plugin's server subprocess.
|
||||
@@ -11,10 +13,16 @@ const runningPlugins = new Map();
|
||||
* to stdout within 10 seconds.
|
||||
*/
|
||||
export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (runningPlugins.has(name)) {
|
||||
return resolve(runningPlugins.get(name).port);
|
||||
}
|
||||
if (runningPlugins.has(name)) {
|
||||
return Promise.resolve(runningPlugins.get(name).port);
|
||||
}
|
||||
|
||||
// Coalesce concurrent starts for the same plugin
|
||||
if (startingPlugins.has(name)) {
|
||||
return startingPlugins.get(name);
|
||||
}
|
||||
|
||||
const startPromise = new Promise((resolve, reject) => {
|
||||
|
||||
const serverPath = path.join(pluginDir, serverEntry);
|
||||
|
||||
@@ -88,7 +96,12 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
startingPlugins.delete(name);
|
||||
});
|
||||
|
||||
startingPlugins.set(name, startPromise);
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Folder,
|
||||
Terminal,
|
||||
GitBranch,
|
||||
ClipboardCheck,
|
||||
Ellipsis,
|
||||
Puzzle,
|
||||
Box,
|
||||
Database,
|
||||
Globe,
|
||||
Wrench,
|
||||
Zap,
|
||||
BarChart3,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import { AppTab } from '../../types/app';
|
||||
|
||||
const PLUGIN_ICON_MAP = {
|
||||
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
|
||||
};
|
||||
|
||||
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
|
||||
type CoreNavItem = {
|
||||
id: CoreTabId;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type MobileNavProps = {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
@@ -19,7 +41,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||
const { plugins } = usePlugins();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef(null);
|
||||
const moreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const enabledPlugins = plugins.filter((p) => p.enabled);
|
||||
const hasPlugins = enabledPlugins.length > 0;
|
||||
@@ -28,8 +50,9 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
// Close the menu on outside tap
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const handleTap = (e) => {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target)) {
|
||||
const handleTap = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
|
||||
setMoreOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -38,18 +61,21 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
}, [moreOpen]);
|
||||
|
||||
// Close menu when a plugin tab is selected
|
||||
const selectPlugin = (name) => {
|
||||
setActiveTab(`plugin:${name}`);
|
||||
const selectPlugin = (name: string) => {
|
||||
const pluginTab = `plugin:${name}` as AppTab;
|
||||
setActiveTab(pluginTab);
|
||||
setMoreOpen(false);
|
||||
};
|
||||
|
||||
const coreItems = [
|
||||
const baseCoreItems: CoreNavItem[] = [
|
||||
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
|
||||
{ id: 'shell', icon: Terminal, label: 'Shell' },
|
||||
{ id: 'files', icon: Folder, label: 'Files' },
|
||||
{ id: 'git', icon: GitBranch, label: 'Git' },
|
||||
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
|
||||
];
|
||||
const coreItems: CoreNavItem[] = shouldShowTasksTab
|
||||
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
|
||||
: baseCoreItems;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -107,7 +107,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
|
||||
{activeView === 'changes' && (
|
||||
<ChangesView
|
||||
key={selectedProject.fullPath}
|
||||
isMobile={isMobile}
|
||||
projectPath={selectedProject.fullPath}
|
||||
gitStatus={gitStatus}
|
||||
gitDiff={gitDiff}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
|
||||
|
||||
type ChangesViewProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
gitStatus: GitStatusResponse | null;
|
||||
gitDiff: GitDiffMap;
|
||||
isLoading: boolean;
|
||||
@@ -27,6 +28,7 @@ type ChangesViewProps = {
|
||||
|
||||
export default function ChangesView({
|
||||
isMobile,
|
||||
projectPath,
|
||||
gitStatus,
|
||||
gitDiff,
|
||||
isLoading,
|
||||
@@ -131,6 +133,7 @@ export default function ChangesView({
|
||||
<>
|
||||
<CommitComposer
|
||||
isMobile={isMobile}
|
||||
projectPath={projectPath}
|
||||
selectedFileCount={selectedFiles.size}
|
||||
isHidden={hasExpandedFiles}
|
||||
onCommit={commitSelectedFiles}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { useState } from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { ConfirmationRequest } from '../../types/types';
|
||||
|
||||
// Persists commit messages across unmount/remount, keyed by project path
|
||||
const commitMessageCache = new Map<string, string>();
|
||||
|
||||
type CommitComposerProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
selectedFileCount: number;
|
||||
isHidden: boolean;
|
||||
onCommit: (message: string) => Promise<boolean>;
|
||||
@@ -14,13 +18,24 @@ type CommitComposerProps = {
|
||||
|
||||
export default function CommitComposer({
|
||||
isMobile,
|
||||
projectPath,
|
||||
selectedFileCount,
|
||||
isHidden,
|
||||
onCommit,
|
||||
onGenerateMessage,
|
||||
onRequestConfirmation,
|
||||
}: CommitComposerProps) {
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');
|
||||
|
||||
const setCommitMessage = (msg: string) => {
|
||||
setCommitMessageRaw(msg);
|
||||
if (msg) {
|
||||
commitMessageCache.set(projectPath, msg);
|
||||
} else {
|
||||
commitMessageCache.delete(projectPath);
|
||||
}
|
||||
};
|
||||
|
||||
const [isCommitting, setIsCommitting] = useState(false);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||
|
||||
@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import PluginTabContent from '../../plugins/PluginTabContent';
|
||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '../../../../shared/view/ui';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
import PluginIcon from '../../../plugins/PluginIcon';
|
||||
import PluginIcon from '../../../plugins/view/PluginIcon';
|
||||
|
||||
type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
type Props = {
|
||||
pluginName: string;
|
||||
@@ -11,15 +11,20 @@ type Props = {
|
||||
const svgCache = new Map<string, string>();
|
||||
|
||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
const url = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`;
|
||||
const [svg, setSvg] = useState<string | null>(svgCache.get(url) ?? null);
|
||||
const url = iconFile
|
||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||
: '';
|
||||
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgCache.has(url)) return;
|
||||
if (!url || svgCache.has(url)) return;
|
||||
authenticatedFetch(url)
|
||||
.then((r) => r.text())
|
||||
.then((r) => {
|
||||
if (!r.ok) return;
|
||||
return r.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (text.trimStart().startsWith('<svg')) {
|
||||
if (text && text.trimStart().startsWith('<svg')) {
|
||||
svgCache.set(url, text);
|
||||
setSvg(text);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import type { Plugin } from '../../contexts/PluginsContext';
|
||||
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';
|
||||
|
||||
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||
return (
|
||||
<label className="relative inline-flex cursor-pointer select-none items-center">
|
||||
<input
|
||||
@@ -15,6 +15,7 @@ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: b
|
||||
className="peer sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
@@ -141,8 +142,9 @@ function PluginCard({
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
disabled={updating}
|
||||
title="Pull latest from git"
|
||||
disabled={updating || !plugin.repoUrl}
|
||||
title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'}
|
||||
aria-label={`Update ${plugin.displayName}`}
|
||||
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{updating ? (
|
||||
@@ -155,6 +157,7 @@ function PluginCard({
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
|
||||
aria-label={`Uninstall ${plugin.displayName}`}
|
||||
className={`rounded p-1.5 transition-colors ${
|
||||
confirmingUninstall
|
||||
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
|
||||
@@ -164,7 +167,7 @@ function PluginCard({
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,17 +271,17 @@ export default function PluginSettingsTab() {
|
||||
const [installingStarter, setInstallingStarter] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
|
||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleUpdate = async (name: string) => {
|
||||
setUpdatingPlugin(name);
|
||||
setUpdatingPlugins((prev) => new Set(prev).add(name));
|
||||
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
||||
const result = await updatePlugin(name);
|
||||
if (!result.success) {
|
||||
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
|
||||
}
|
||||
setUpdatingPlugin(null);
|
||||
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
@@ -309,8 +312,13 @@ export default function PluginSettingsTab() {
|
||||
setConfirmUninstall(name);
|
||||
return;
|
||||
}
|
||||
await uninstallPlugin(name);
|
||||
setConfirmUninstall(null);
|
||||
const result = await uninstallPlugin(name);
|
||||
if (result.success) {
|
||||
setConfirmUninstall(null);
|
||||
} else {
|
||||
setInstallError(result.error || 'Uninstall failed');
|
||||
setConfirmUninstall(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||
@@ -347,6 +355,7 @@ export default function PluginSettingsTab() {
|
||||
setInstallError(null);
|
||||
}}
|
||||
placeholder="https://github.com/user/my-plugin"
|
||||
aria-label="Plugin git repository URL"
|
||||
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleInstall();
|
||||
@@ -396,10 +405,10 @@ export default function PluginSettingsTab() {
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugin === plugin.name}
|
||||
updating={updatingPlugins.has(plugin.name)}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { usePlugins } from '../../contexts/PluginsContext';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
type PluginTabContentProps = {
|
||||
pluginName: string;
|
||||
@@ -24,10 +24,16 @@ function buildContext(
|
||||
return {
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
project: selectedProject
|
||||
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
|
||||
? {
|
||||
name: selectedProject.name,
|
||||
path: selectedProject.fullPath || selectedProject.path || '',
|
||||
}
|
||||
: null,
|
||||
session: selectedSession
|
||||
? { id: selectedSession.id, title: selectedSession.title }
|
||||
? {
|
||||
id: selectedSession.id,
|
||||
title: selectedSession.title || selectedSession.name || selectedSession.id,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -44,7 +50,7 @@ export default function PluginTabContent({
|
||||
// Stable refs so effects don't need context values in their dep arrays
|
||||
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
||||
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
||||
|
||||
|
||||
const moduleRef = useRef<any>(null);
|
||||
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
@@ -60,17 +66,18 @@ export default function PluginTabContent({
|
||||
}, [isDarkMode, selectedProject, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current || !plugin?.enabled) return;
|
||||
|
||||
let active = true;
|
||||
const container = containerRef.current;
|
||||
const entryFile = plugin?.entry ?? 'index.js';
|
||||
const contextCallbacks = contextCallbacksRef.current;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
|
||||
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
|
||||
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
|
||||
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
|
||||
const res = await authenticatedFetch(assetUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
|
||||
const jsText = await res.text();
|
||||
@@ -86,8 +93,8 @@ export default function PluginTabContent({
|
||||
get context(): PluginContext { return contextRef.current; },
|
||||
|
||||
onContextChange(cb: (ctx: PluginContext) => void): () => void {
|
||||
contextCallbacksRef.current.add(cb);
|
||||
return () => contextCallbacksRef.current.delete(cb);
|
||||
contextCallbacks.add(cb);
|
||||
return () => contextCallbacks.delete(cb);
|
||||
},
|
||||
|
||||
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
@@ -105,11 +112,19 @@ export default function PluginTabContent({
|
||||
};
|
||||
|
||||
await mod.mount?.(container, api);
|
||||
if (!active) {
|
||||
try { mod.unmount?.(container); } catch { /* ignore */ }
|
||||
moduleRef.current = null;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
|
||||
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
|
||||
containerRef.current.replaceChildren(errDiv);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -117,10 +132,10 @@ export default function PluginTabContent({
|
||||
return () => {
|
||||
active = false;
|
||||
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
|
||||
contextCallbacksRef.current.clear();
|
||||
contextCallbacks.clear();
|
||||
moduleRef.current = null;
|
||||
};
|
||||
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
|
||||
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ type CodexSettingsStorage = {
|
||||
|
||||
type ActiveLoginProvider = AgentProvider | '';
|
||||
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins'];
|
||||
|
||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
// Keep backwards compatibility with older callers that still pass "tools".
|
||||
|
||||
@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||
import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import type { SettingsProps } from '../types/types';
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
];
|
||||
|
||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "Appearance",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks"
|
||||
"tasks": "Tasks",
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "外観",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & トークン",
|
||||
"tasks": "タスク"
|
||||
"tasks": "タスク",
|
||||
"plugins": "プラグイン"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "외관",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업"
|
||||
"tasks": "작업",
|
||||
"plugins": "플러그인"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "外观",
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务"
|
||||
"tasks": "任务",
|
||||
"plugins": "插件"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
Reference in New Issue
Block a user