Compare commits

...

8 Commits

Author SHA1 Message Date
Haileyesus
77fb193598 fix: resolve type error in MobileNav and PluginTabContent components 2026-03-09 12:59:52 +03:00
Haileyesus
ff45a1cfd7 refactor: move plugin content to /view folder 2026-03-09 12:51:48 +03:00
simosmik
951db47a91 fix(git-panel): reset changes view on project switch 2026-03-09 08:18:58 +00:00
simosmik
ca16342a20 fix(plugins): harden input validation and scan reliability
- Validate plugin names against [a-zA-Z0-9_-] allowlist in
  manifest and asset routes to prevent path traversal via URL
- Strip embedded credentials (user:pass@) from git remote URLs
  before exposing them to the client
- Skip .tmp-* directories during scan to avoid partial installs
  from in-progress updates appearing as broken plugins
- Deduplicate plugins sharing the same manifest name to prevent
  ambiguous state
- Guard RPC proxy error handler against writing to an already-sent
  response, preventing uncaught exceptions on aborted requests
2026-03-09 07:59:46 +00:00
simosmik
ca247cddae refactor(git-panel): simplify setCommitMessage with plain function 2026-03-09 07:47:17 +00:00
simosmik
4061a2761e fix(plugins): async shutdown and asset/RPC fixes
Await stopPluginServer/stopAllPlugins in signal handlers and route
handlers so process exit and state transitions wait for clean plugin
shutdown instead of racing ahead.

Validate asset paths are regular files before streaming to prevent
directory traversal returning unexpected content; add a stream error
handler to avoid unhandled crashes on read failures.

Fix RPC proxy body detection to use the content-length header instead
of Object.keys, so falsy but valid JSON payloads (null, false, 0, {})
are forwarded correctly to plugin servers.

Track in-flight start operations via a startingPlugins map to prevent
duplicate concurrent plugin starts.
2026-03-09 07:39:00 +00:00
simosmik
c368451891 fix(plugins): support concurrent plugin updates
Replace single updatingPlugin string state with a Set to allow
multiple plugins to update simultaneously. Also disable the update
button and show a descriptive tooltip when a plugin has no git
remote configured.
2026-03-09 07:35:01 +00:00
simosmik
efdee162c9 fix(plugins): harden path traversal and respect enabled state
Use realpathSync to canonicalize paths before the plugin asset
boundary check, preventing symlink-based traversal bypasses that
could escape the plugin directory.

PluginTabContent now guards on plugin.enabled before mounting the
plugin module, and re-mounts when the enabled state changes so
toggling a plugin takes effect without a page reload.

PluginIcon safely handles a missing iconFile prop and skips
processing non-OK fetch responses instead of attempting to parse
error bodies as SVG.

Register 'plugins' as a known main tab so the settings router
preserves the tab on navigation.
2026-03-09 06:51:58 +00:00
20 changed files with 248 additions and 74 deletions

View File

@@ -2545,12 +2545,12 @@ async function startServer() {
}); });
// Clean up plugin processes on shutdown // Clean up plugin processes on shutdown
const shutdownPlugins = () => { const shutdownPlugins = async () => {
stopAllPlugins(); await stopAllPlugins();
process.exit(0); process.exit(0);
}; };
process.on('SIGTERM', shutdownPlugins); process.on('SIGTERM', () => void shutdownPlugins());
process.on('SIGINT', shutdownPlugins); process.on('SIGINT', () => void shutdownPlugins());
} catch (error) { } catch (error) {
console.error('[ERROR] Failed to start server:', error); console.error('[ERROR] Failed to start server:', error);
process.exit(1); process.exit(1);

View File

@@ -39,6 +39,9 @@ router.get('/', (req, res) => {
// GET /:name/manifest — Get single plugin manifest // GET /:name/manifest — Get single plugin manifest
router.get('/:name/manifest', (req, res) => { router.get('/:name/manifest', (req, res) => {
try { try {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
return res.status(400).json({ error: 'Invalid plugin name' });
}
const plugins = scanPlugins(); const plugins = scanPlugins();
const plugin = plugins.find(p => p.name === req.params.name); const plugin = plugins.find(p => p.name === req.params.name);
if (!plugin) { if (!plugin) {
@@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => {
// GET /:name/assets/* — Serve plugin static files // GET /:name/assets/* — Serve plugin static files
router.get('/:name/assets/*', (req, res) => { router.get('/:name/assets/*', (req, res) => {
const pluginName = req.params.name; 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]; const assetPath = req.params[0];
if (!assetPath) { if (!assetPath) {
@@ -64,9 +70,26 @@ router.get('/:name/assets/*', (req, res) => {
return res.status(404).json({ error: 'Asset not found' }); 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'; const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
res.setHeader('Content-Type', contentType); 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) // 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)) { } 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); const wasRunning = isPluginRunning(pluginName);
if (wasRunning) { if (wasRunning) {
stopPluginServer(pluginName); await stopPluginServer(pluginName);
} }
const manifest = await updatePluginFromGit(pluginName); const manifest = await updatePluginFromGit(pluginName);
@@ -235,11 +258,18 @@ router.all('/:name/rpc/*', async (req, res) => {
}); });
proxyReq.on('error', (err) => { 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) // Forward body (already parsed by express JSON middleware, so re-stringify).
if (req.body && Object.keys(req.body).length > 0) { // 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); const bodyStr = JSON.stringify(req.body);
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr)); proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
proxyReq.write(bodyStr); proxyReq.write(bodyStr);

View File

@@ -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 PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; 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_TYPES = ['react', 'module'];
const ALLOWED_SLOTS = ['tab']; const ALLOWED_SLOTS = ['tab'];
@@ -31,9 +44,9 @@ export function getPluginsConfig() {
export function savePluginsConfig(config) { export function savePluginsConfig(config) {
const dir = path.dirname(PLUGINS_CONFIG_PATH); const dir = path.dirname(PLUGINS_CONFIG_PATH);
if (!fs.existsSync(dir)) { 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) { 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(', ')}` }; 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 }; return { valid: true };
} }
@@ -75,8 +105,12 @@ export function scanPlugins() {
return plugins; return plugins;
} }
const seenNames = new Set();
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; 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'); const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
if (!fs.existsSync(manifestPath)) continue; if (!fs.existsSync(manifestPath)) continue;
@@ -89,6 +123,13 @@ export function scanPlugins() {
continue; 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 // Try to read git remote URL
let repoUrl = null; let repoUrl = null;
try { try {
@@ -102,6 +143,8 @@ export function scanPlugins() {
if (repoUrl.startsWith('git@')) { if (repoUrl.startsWith('git@')) {
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
} }
// Strip embedded credentials (e.g. https://user:pass@host/...)
repoUrl = sanitizeRepoUrl(repoUrl);
} }
} }
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -143,14 +186,16 @@ export function resolvePluginAssetPath(name, assetPath) {
const resolved = path.resolve(pluginDir, assetPath); const resolved = path.resolve(pluginDir, assetPath);
// Prevent path traversal — resolved path must be within plugin directory // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) { 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; return null;
} }
if (!fs.existsSync(resolved)) return null; return realResolved;
return resolved;
} }
export function installPluginFromGit(url) { export function installPluginFromGit(url) {
@@ -233,6 +278,13 @@ export function installPluginFromGit(url) {
return reject(new Error(`Invalid manifest: ${validation.error}`)); 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. // Run npm install if package.json exists.
// --ignore-scripts prevents postinstall hooks from executing arbitrary code. // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
const packageJsonPath = path.join(tempDir, 'package.json'); const packageJsonPath = path.join(tempDir, 'package.json');

View File

@@ -4,6 +4,8 @@ import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'
// Map<pluginName, { process, port }> // Map<pluginName, { process, port }>
const runningPlugins = new Map(); const runningPlugins = new Map();
// Map<pluginName, Promise<port>> — in-flight start operations
const startingPlugins = new Map();
/** /**
* Start a plugin's server subprocess. * Start a plugin's server subprocess.
@@ -11,10 +13,16 @@ const runningPlugins = new Map();
* to stdout within 10 seconds. * to stdout within 10 seconds.
*/ */
export function startPluginServer(name, pluginDir, serverEntry) { export function startPluginServer(name, pluginDir, serverEntry) {
return new Promise((resolve, reject) => { if (runningPlugins.has(name)) {
if (runningPlugins.has(name)) { return Promise.resolve(runningPlugins.get(name).port);
return 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); 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`)); reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
} }
}); });
}).finally(() => {
startingPlugins.delete(name);
}); });
startingPlugins.set(name, startPromise);
return startPromise;
} }
/** /**

View File

@@ -1,13 +1,35 @@
import { useState, useRef, useEffect, Dispatch, SetStateAction } from '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 } from 'lucide-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 { useTasksSettings } from '../../contexts/TasksSettingsContext';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app'; 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, 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 = { type MobileNavProps = {
activeTab: AppTab; activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>; setActiveTab: Dispatch<SetStateAction<AppTab>>;
@@ -19,7 +41,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const { plugins } = usePlugins(); const { plugins } = usePlugins();
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef(null); const moreRef = useRef<HTMLDivElement | null>(null);
const enabledPlugins = plugins.filter((p) => p.enabled); const enabledPlugins = plugins.filter((p) => p.enabled);
const hasPlugins = enabledPlugins.length > 0; const hasPlugins = enabledPlugins.length > 0;
@@ -28,8 +50,9 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
// Close the menu on outside tap // Close the menu on outside tap
useEffect(() => { useEffect(() => {
if (!moreOpen) return; if (!moreOpen) return;
const handleTap = (e) => { const handleTap = (e: PointerEvent) => {
if (moreRef.current && !moreRef.current.contains(e.target)) { const target = e.target;
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
setMoreOpen(false); setMoreOpen(false);
} }
}; };
@@ -38,18 +61,21 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
}, [moreOpen]); }, [moreOpen]);
// Close menu when a plugin tab is selected // Close menu when a plugin tab is selected
const selectPlugin = (name) => { const selectPlugin = (name: string) => {
setActiveTab(`plugin:${name}`); const pluginTab = `plugin:${name}` as AppTab;
setActiveTab(pluginTab);
setMoreOpen(false); setMoreOpen(false);
}; };
const coreItems = [ const baseCoreItems: CoreNavItem[] = [
{ id: 'chat', icon: MessageSquare, label: 'Chat' }, { id: 'chat', icon: MessageSquare, label: 'Chat' },
{ id: 'shell', icon: Terminal, label: 'Shell' }, { id: 'shell', icon: Terminal, label: 'Shell' },
{ id: 'files', icon: Folder, label: 'Files' }, { id: 'files', icon: Folder, label: 'Files' },
{ id: 'git', icon: GitBranch, label: 'Git' }, { 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 ( return (
<div <div

View File

@@ -107,7 +107,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && ( {activeView === 'changes' && (
<ChangesView <ChangesView
key={selectedProject.fullPath}
isMobile={isMobile} isMobile={isMobile}
projectPath={selectedProject.fullPath}
gitStatus={gitStatus} gitStatus={gitStatus}
gitDiff={gitDiff} gitDiff={gitDiff}
isLoading={isLoading} isLoading={isLoading}

View File

@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
type ChangesViewProps = { type ChangesViewProps = {
isMobile: boolean; isMobile: boolean;
projectPath: string;
gitStatus: GitStatusResponse | null; gitStatus: GitStatusResponse | null;
gitDiff: GitDiffMap; gitDiff: GitDiffMap;
isLoading: boolean; isLoading: boolean;
@@ -27,6 +28,7 @@ type ChangesViewProps = {
export default function ChangesView({ export default function ChangesView({
isMobile, isMobile,
projectPath,
gitStatus, gitStatus,
gitDiff, gitDiff,
isLoading, isLoading,
@@ -131,6 +133,7 @@ export default function ChangesView({
<> <>
<CommitComposer <CommitComposer
isMobile={isMobile} isMobile={isMobile}
projectPath={projectPath}
selectedFileCount={selectedFiles.size} selectedFileCount={selectedFiles.size}
isHidden={hasExpandedFiles} isHidden={hasExpandedFiles}
onCommit={commitSelectedFiles} onCommit={commitSelectedFiles}

View File

@@ -3,8 +3,12 @@ import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton'; import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types'; import type { ConfirmationRequest } from '../../types/types';
// Persists commit messages across unmount/remount, keyed by project path
const commitMessageCache = new Map<string, string>();
type CommitComposerProps = { type CommitComposerProps = {
isMobile: boolean; isMobile: boolean;
projectPath: string;
selectedFileCount: number; selectedFileCount: number;
isHidden: boolean; isHidden: boolean;
onCommit: (message: string) => Promise<boolean>; onCommit: (message: string) => Promise<boolean>;
@@ -14,13 +18,24 @@ type CommitComposerProps = {
export default function CommitComposer({ export default function CommitComposer({
isMobile, isMobile,
projectPath,
selectedFileCount, selectedFileCount,
isHidden, isHidden,
onCommit, onCommit,
onGenerateMessage, onGenerateMessage,
onRequestConfirmation, onRequestConfirmation,
}: CommitComposerProps) { }: 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 [isCommitting, setIsCommitting] = useState(false);
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false); const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(isMobile); const [isCollapsed, setIsCollapsed] = useState(isMobile);

View File

@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree'; import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel'; 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 type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../../../shared/view/ui'; import { Tooltip } from '../../../../shared/view/ui';
import type { AppTab } from '../../../../types/app'; import type { AppTab } from '../../../../types/app';
import { usePlugins } from '../../../../contexts/PluginsContext'; import { usePlugins } from '../../../../contexts/PluginsContext';
import PluginIcon from '../../../plugins/PluginIcon'; import PluginIcon from '../../../plugins/view/PluginIcon';
type MainContentTabSwitcherProps = { type MainContentTabSwitcherProps = {
activeTab: AppTab; activeTab: AppTab;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { authenticatedFetch } from '../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
pluginName: string; pluginName: string;
@@ -11,15 +11,20 @@ type Props = {
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`; const url = iconFile
const [svg, setSvg] = useState<string | null>(svgCache.get(url) ?? null); ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
: '';
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
useEffect(() => { useEffect(() => {
if (svgCache.has(url)) return; if (!url || svgCache.has(url)) return;
authenticatedFetch(url) authenticatedFetch(url)
.then((r) => r.text()) .then((r) => {
if (!r.ok) return;
return r.text();
})
.then((text) => { .then((text) => {
if (text.trimStart().startsWith('<svg')) { if (text && text.trimStart().startsWith('<svg')) {
svgCache.set(url, text); svgCache.set(url, text);
setSvg(text); setSvg(text);
} }

View File

@@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ /* ─── 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 ( return (
<label className="relative inline-flex cursor-pointer select-none items-center"> <label className="relative inline-flex cursor-pointer select-none items-center">
<input <input
@@ -15,6 +15,7 @@ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: b
className="peer sr-only" className="peer sr-only"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
aria-label={ariaLabel}
/> />
<div <div
className={` className={`
@@ -141,8 +142,9 @@ function PluginCard({
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<button <button
onClick={onUpdate} onClick={onUpdate}
disabled={updating} disabled={updating || !plugin.repoUrl}
title="Pull latest from git" 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" className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
> >
{updating ? ( {updating ? (
@@ -155,6 +157,7 @@ function PluginCard({
<button <button
onClick={onUninstall} onClick={onUninstall}
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'} title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
aria-label={`Uninstall ${plugin.displayName}`}
className={`rounded p-1.5 transition-colors ${ className={`rounded p-1.5 transition-colors ${
confirmingUninstall confirmingUninstall
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30' ? '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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
</div> </div>
</div> </div>
@@ -268,17 +271,17 @@ export default function PluginSettingsTab() {
const [installingStarter, setInstallingStarter] = useState(false); const [installingStarter, setInstallingStarter] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = 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 [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
const handleUpdate = async (name: 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; }); setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name); const result = await updatePlugin(name);
if (!result.success) { if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' })); 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 () => { const handleInstall = async () => {
@@ -309,8 +312,13 @@ export default function PluginSettingsTab() {
setConfirmUninstall(name); setConfirmUninstall(name);
return; return;
} }
await uninstallPlugin(name); const result = await uninstallPlugin(name);
setConfirmUninstall(null); if (result.success) {
setConfirmUninstall(null);
} else {
setInstallError(result.error || 'Uninstall failed');
setConfirmUninstall(null);
}
}; };
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats'); const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
@@ -347,6 +355,7 @@ export default function PluginSettingsTab() {
setInstallError(null); setInstallError(null);
}} }}
placeholder="https://github.com/user/my-plugin" 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" className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') void handleInstall(); if (e.key === 'Enter') void handleInstall();
@@ -396,10 +405,10 @@ export default function PluginSettingsTab() {
key={plugin.name} key={plugin.name}
plugin={plugin} plugin={plugin}
index={index} 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)} onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)} onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugin === plugin.name} updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name} confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)} onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null} updateError={updateErrors[plugin.name] ?? null}

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { usePlugins } from '../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Project, ProjectSession } from '../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
type PluginTabContentProps = { type PluginTabContentProps = {
pluginName: string; pluginName: string;
@@ -24,10 +24,16 @@ function buildContext(
return { return {
theme: isDarkMode ? 'dark' : 'light', theme: isDarkMode ? 'dark' : 'light',
project: selectedProject project: selectedProject
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } ? {
name: selectedProject.name,
path: selectedProject.fullPath || selectedProject.path || '',
}
: null, : null,
session: selectedSession session: selectedSession
? { id: selectedSession.id, title: selectedSession.title } ? {
id: selectedSession.id,
title: selectedSession.title || selectedSession.name || selectedSession.id,
}
: null, : null,
}; };
} }
@@ -60,17 +66,18 @@ export default function PluginTabContent({
}, [isDarkMode, selectedProject, selectedSession]); }, [isDarkMode, selectedProject, selectedSession]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current || !plugin?.enabled) return;
let active = true; let active = true;
const container = containerRef.current; const container = containerRef.current;
const entryFile = plugin?.entry ?? 'index.js'; const entryFile = plugin?.entry ?? 'index.js';
const contextCallbacks = contextCallbacksRef.current;
(async () => { (async () => {
try { try {
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes). // 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. // 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); const res = await authenticatedFetch(assetUrl);
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`); if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
const jsText = await res.text(); const jsText = await res.text();
@@ -86,8 +93,8 @@ export default function PluginTabContent({
get context(): PluginContext { return contextRef.current; }, get context(): PluginContext { return contextRef.current; },
onContextChange(cb: (ctx: PluginContext) => void): () => void { onContextChange(cb: (ctx: PluginContext) => void): () => void {
contextCallbacksRef.current.add(cb); contextCallbacks.add(cb);
return () => contextCallbacksRef.current.delete(cb); return () => contextCallbacks.delete(cb);
}, },
async rpc(method: string, path: string, body?: unknown): Promise<unknown> { async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
@@ -105,11 +112,19 @@ export default function PluginTabContent({
}; };
await mod.mount?.(container, api); await mod.mount?.(container, api);
if (!active) {
try { mod.unmount?.(container); } catch { /* ignore */ }
moduleRef.current = null;
return;
}
} catch (err) { } catch (err) {
if (!active) return; if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err); console.error(`[Plugin:${pluginName}] Failed to load:`, err);
if (containerRef.current) { 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 () => { return () => {
active = false; active = false;
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ } try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
contextCallbacksRef.current.clear(); contextCallbacks.clear();
moduleRef.current = null; 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" />; return <div ref={containerRef} className="h-full w-full overflow-auto" />;
} }

View File

@@ -98,7 +98,7 @@ type CodexSettingsStorage = {
type ActiveLoginProvider = AgentProvider | ''; 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 => { const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools". // Keep backwards compatibility with older callers that still pass "tools".

View File

@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab'; import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab'; 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 { useSettingsController } from '../hooks/useSettingsController';
import type { SettingsProps } from '../types/types'; import type { SettingsProps } from '../types/types';

View File

@@ -20,7 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch }, { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key }, { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' }, { 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) { export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {

View File

@@ -104,7 +104,8 @@
"appearance": "Appearance", "appearance": "Appearance",
"git": "Git", "git": "Git",
"apiTokens": "API & Tokens", "apiTokens": "API & Tokens",
"tasks": "Tasks" "tasks": "Tasks",
"plugins": "Plugins"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "外観", "appearance": "外観",
"git": "Git", "git": "Git",
"apiTokens": "API & トークン", "apiTokens": "API & トークン",
"tasks": "タスク" "tasks": "タスク",
"plugins": "プラグイン"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "외관", "appearance": "외관",
"git": "Git", "git": "Git",
"apiTokens": "API & 토큰", "apiTokens": "API & 토큰",
"tasks": "작업" "tasks": "작업",
"plugins": "플러그인"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {

View File

@@ -104,7 +104,8 @@
"appearance": "外观", "appearance": "外观",
"git": "Git", "git": "Git",
"apiTokens": "API 和令牌", "apiTokens": "API 和令牌",
"tasks": "任务" "tasks": "任务",
"plugins": "插件"
}, },
"appearanceSettings": { "appearanceSettings": {
"darkMode": { "darkMode": {