mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 20:45:45 +08:00
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.
This commit is contained in:
@@ -31,9 +31,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 +60,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +252,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');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 () => {
|
||||||
@@ -399,7 +402,7 @@ export default function PluginSettingsTab() {
|
|||||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
||||||
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}
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user