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
|
// 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,7 +50,7 @@ export default function PluginTabContent({
|
|||||||
// Stable refs so effects don't need context values in their dep arrays
|
// Stable refs so effects don't need context values in their dep arrays
|
||||||
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
||||||
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
||||||
|
|
||||||
const moduleRef = useRef<any>(null);
|
const moduleRef = useRef<any>(null);
|
||||||
|
|
||||||
const plugin = plugins.find(p => p.name === pluginName);
|
const plugin = plugins.find(p => p.name === pluginName);
|
||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
@@ -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".
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -104,7 +104,8 @@
|
|||||||
"appearance": "外観",
|
"appearance": "外観",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & トークン",
|
"apiTokens": "API & トークン",
|
||||||
"tasks": "タスク"
|
"tasks": "タスク",
|
||||||
|
"plugins": "プラグイン"
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -104,7 +104,8 @@
|
|||||||
"appearance": "외관",
|
"appearance": "외관",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & 토큰",
|
"apiTokens": "API & 토큰",
|
||||||
"tasks": "작업"
|
"tasks": "작업",
|
||||||
|
"plugins": "플러그인"
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -104,7 +104,8 @@
|
|||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API 和令牌",
|
"apiTokens": "API 和令牌",
|
||||||
"tasks": "任务"
|
"tasks": "任务",
|
||||||
|
"plugins": "插件"
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
Reference in New Issue
Block a user