mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 17:07:40 +00:00
Compare commits
4 Commits
ca247cddae
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fb193598 | ||
|
|
ff45a1cfd7 | ||
|
|
951db47a91 | ||
|
|
ca16342a20 |
@@ -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) {
|
||||||
@@ -252,7 +258,11 @@ 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).
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
@@ -92,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;
|
||||||
@@ -106,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 {
|
||||||
@@ -119,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 */ }
|
||||||
|
|||||||
@@ -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,6 +107,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
|
|
||||||
{activeView === 'changes' && (
|
{activeView === 'changes' && (
|
||||||
<ChangesView
|
<ChangesView
|
||||||
|
key={selectedProject.fullPath}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
projectPath={selectedProject.fullPath}
|
projectPath={selectedProject.fullPath}
|
||||||
gitStatus={gitStatus}
|
gitStatus={gitStatus}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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';
|
||||||
@@ -312,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');
|
||||||
@@ -350,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();
|
||||||
@@ -399,7 +405,7 @@ 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={updatingPlugins.has(plugin.name)}
|
updating={updatingPlugins.has(plugin.name)}
|
||||||
@@ -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);
|
||||||
@@ -65,12 +71,13 @@ export default function PluginTabContent({
|
|||||||
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> {
|
||||||
@@ -114,7 +121,10 @@ export default function PluginTabContent({
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -122,7 +132,7 @@ 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, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user