fix(extensions): make plugin actions scope-aware
This commit is contained in:
parent
f2c5d52bdc
commit
847643828d
5 changed files with 94 additions and 10 deletions
|
|
@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
fetchSkillsCatalog,
|
fetchSkillsCatalog,
|
||||||
mcpBrowse,
|
mcpBrowse,
|
||||||
mcpFetchInstalled,
|
mcpFetchInstalled,
|
||||||
|
apiKeysLoading,
|
||||||
pluginCatalogLoading,
|
pluginCatalogLoading,
|
||||||
mcpBrowseLoading,
|
mcpBrowseLoading,
|
||||||
skillsLoading,
|
skillsLoading,
|
||||||
|
|
@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||||
mcpBrowse: s.mcpBrowse,
|
mcpBrowse: s.mcpBrowse,
|
||||||
mcpFetchInstalled: s.mcpFetchInstalled,
|
mcpFetchInstalled: s.mcpFetchInstalled,
|
||||||
|
apiKeysLoading: s.apiKeysLoading,
|
||||||
pluginCatalogLoading: s.pluginCatalogLoading,
|
pluginCatalogLoading: s.pluginCatalogLoading,
|
||||||
mcpBrowseLoading: s.mcpBrowseLoading,
|
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||||
skillsLoading: s.skillsLoading,
|
skillsLoading: s.skillsLoading,
|
||||||
|
|
@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
// Refresh all data (plugins + MCP browse + installed + skills)
|
// Refresh all data (plugins + MCP browse + installed + skills)
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
void fetchCliStatus();
|
||||||
|
void fetchApiKeys();
|
||||||
void fetchPluginCatalog(projectPath ?? undefined, true);
|
void fetchPluginCatalog(projectPath ?? undefined, true);
|
||||||
void mcpBrowse(); // re-fetch first page
|
void mcpBrowse(); // re-fetch first page
|
||||||
void mcpFetchInstalled(projectPath ?? undefined);
|
void mcpFetchInstalled(projectPath ?? undefined);
|
||||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||||
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
}, [
|
||||||
|
fetchApiKeys,
|
||||||
|
fetchCliStatus,
|
||||||
|
fetchPluginCatalog,
|
||||||
|
fetchSkillsCatalog,
|
||||||
|
mcpBrowse,
|
||||||
|
mcpFetchInstalled,
|
||||||
|
projectPath,
|
||||||
|
]);
|
||||||
|
|
||||||
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
const isRefreshing =
|
||||||
|
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
const cliStatusBanner = useMemo(() => {
|
const cliStatusBanner = useMemo(() => {
|
||||||
if (cliStatusLoading || cliStatus === null) {
|
if (cliStatusLoading || cliStatus === null) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -29,6 +30,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
const installPlugin = useStore((s) => s.installPlugin);
|
const installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||||
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
||||||
|
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
|
||||||
const baseStriped = index % 2 === 0;
|
const baseStriped = index % 2 === 0;
|
||||||
const smStriped = Math.floor(index / 2) % 2 === 0;
|
const smStriped = Math.floor(index / 2) % 2 === 0;
|
||||||
const xlStriped = Math.floor(index / 3) % 2 === 0;
|
const xlStriped = Math.floor(index / 3) % 2 === 0;
|
||||||
|
|
@ -112,9 +114,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={plugin.isInstalled}
|
isInstalled={isUserInstalled}
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId)}
|
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||||
size="sm"
|
size="sm"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -54,13 +55,21 @@ export const PluginDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
const {
|
||||||
|
fetchPluginReadme,
|
||||||
|
readmes,
|
||||||
|
readmeLoading,
|
||||||
|
installPlugin,
|
||||||
|
uninstallPlugin,
|
||||||
|
pluginCatalogProjectPath,
|
||||||
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
fetchPluginReadme: s.fetchPluginReadme,
|
fetchPluginReadme: s.fetchPluginReadme,
|
||||||
readmes: s.pluginReadmes,
|
readmes: s.pluginReadmes,
|
||||||
readmeLoading: s.pluginReadmeLoading,
|
readmeLoading: s.pluginReadmeLoading,
|
||||||
installPlugin: s.installPlugin,
|
installPlugin: s.installPlugin,
|
||||||
uninstallPlugin: s.uninstallPlugin,
|
uninstallPlugin: s.uninstallPlugin,
|
||||||
|
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
|
|
@ -69,6 +78,7 @@ export const PluginDetailDialog = ({
|
||||||
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||||
|
|
||||||
const [scope, setScope] = useState<InstallScope>('user');
|
const [scope, setScope] = useState<InstallScope>('user');
|
||||||
|
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (plugin && open) {
|
if (plugin && open) {
|
||||||
|
|
@ -76,12 +86,19 @@ export const PluginDetailDialog = ({
|
||||||
}
|
}
|
||||||
}, [plugin, open, fetchPluginReadme]);
|
}, [plugin, open, fetchPluginReadme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scope === 'project' && !projectScopeAvailable) {
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
}, [projectScopeAvailable, scope]);
|
||||||
|
|
||||||
if (!plugin) return <></>;
|
if (!plugin) return <></>;
|
||||||
|
|
||||||
const capabilities = inferCapabilities(plugin);
|
const capabilities = inferCapabilities(plugin);
|
||||||
const category = normalizeCategory(plugin.category);
|
const category = normalizeCategory(plugin.category);
|
||||||
const readme = readmes[plugin.pluginId];
|
const readme = readmes[plugin.pluginId];
|
||||||
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
|
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
|
||||||
|
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
|
@ -158,7 +175,11 @@ export const PluginDetailDialog = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === 'project' && !projectScopeAvailable}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -167,9 +188,23 @@ export const PluginDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={plugin.isInstalled}
|
isInstalled={isInstalledForScope}
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
|
onInstall={() =>
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
|
installPlugin({
|
||||||
|
pluginId: plugin.pluginId,
|
||||||
|
scope,
|
||||||
|
...(scope === 'project' && pluginCatalogProjectPath
|
||||||
|
? { projectPath: pluginCatalogProjectPath }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onUninstall={() =>
|
||||||
|
uninstallPlugin(
|
||||||
|
plugin.pluginId,
|
||||||
|
scope,
|
||||||
|
scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
size="default"
|
size="default"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
* Pure-function normalizers for Extension Store data.
|
* Pure-function normalizers for Extension Store data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions';
|
import type {
|
||||||
|
InstallScope,
|
||||||
|
InstalledPluginEntry,
|
||||||
|
PluginCapability,
|
||||||
|
PluginCatalogItem,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a repository URL for dedup comparison.
|
* Normalize a repository URL for dedup comparison.
|
||||||
|
|
@ -94,6 +99,16 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
||||||
return `${pluginName}@${marketplaceName}`;
|
return `${pluginName}@${marketplaceName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin has an installation for the selected scope.
|
||||||
|
*/
|
||||||
|
export function hasInstallationInScope(
|
||||||
|
installations: Pick<InstalledPluginEntry, 'scope'>[],
|
||||||
|
scope: InstallScope
|
||||||
|
): boolean {
|
||||||
|
return installations.some((installation) => installation.scope === scope);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize an MCP server display name into a CLI-safe server name.
|
* Sanitize an MCP server display name into a CLI-safe server name.
|
||||||
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
|
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
formatInstallCount,
|
formatInstallCount,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
getPrimaryCapabilityLabel,
|
getPrimaryCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
normalizeRepoUrl,
|
normalizeRepoUrl,
|
||||||
|
|
@ -149,6 +150,24 @@ describe('buildPluginId', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasInstallationInScope', () => {
|
||||||
|
it('returns true when the selected scope exists', () => {
|
||||||
|
expect(
|
||||||
|
hasInstallationInScope(
|
||||||
|
[
|
||||||
|
{ scope: 'user' },
|
||||||
|
{ scope: 'project' },
|
||||||
|
],
|
||||||
|
'project',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the selected scope is missing', () => {
|
||||||
|
expect(hasInstallationInScope([{ scope: 'user' }], 'project')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sanitizeMcpServerName', () => {
|
describe('sanitizeMcpServerName', () => {
|
||||||
it('lowercases and replaces spaces with dashes', () => {
|
it('lowercases and replaces spaces with dashes', () => {
|
||||||
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
|
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue