diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 7b736687..84648b44 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -323,6 +323,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { void; + projectPath: string | null; } const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ @@ -57,27 +58,20 @@ export const PluginDetailDialog = ({ plugin, open, onClose, + projectPath, }: PluginDetailDialogProps): React.JSX.Element => { - const { - fetchPluginReadme, - readmes, - readmeLoading, - installPlugin, - uninstallPlugin, - pluginCatalogProjectPath, - } = useStore( + const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( useShallow((s) => ({ fetchPluginReadme: s.fetchPluginReadme, readmes: s.pluginReadmes, readmeLoading: s.pluginReadmeLoading, installPlugin: s.installPlugin, uninstallPlugin: s.uninstallPlugin, - pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); const [scope, setScope] = useState('user'); - const projectScopeAvailable = Boolean(pluginCatalogProjectPath); + const projectScopeAvailable = Boolean(projectPath); useEffect(() => { if (plugin && open) { @@ -205,16 +199,14 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope !== 'user' && pluginCatalogProjectPath - ? { projectPath: pluginCatalogProjectPath } - : {}), + ...(scope !== 'user' && projectPath ? { projectPath } : {}), }) } onUninstall={() => uninstallPlugin( plugin.pluginId, scope, - scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (projectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 5480b7c8..4fe38a2e 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -35,6 +35,7 @@ import type { } from '@shared/types/extensions'; interface PluginsPanelProps { + projectPath: string | null; pluginFilters: PluginFilters; pluginSort: { field: PluginSortField; order: 'asc' | 'desc' }; selectedPluginId: string | null; @@ -111,6 +112,7 @@ function selectFilteredPlugins( } export const PluginsPanel = ({ + projectPath, pluginFilters, pluginSort, selectedPluginId, @@ -395,6 +397,7 @@ export const PluginsPanel = ({ plugin={selectedPlugin} open={selectedPluginId !== null} onClose={() => setSelectedPluginId(null)} + projectPath={projectPath} /> ); diff --git a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts new file mode 100644 index 00000000..753761f8 --- /dev/null +++ b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts @@ -0,0 +1,269 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EnrichedPlugin } from '@shared/types/extensions'; + +interface StoreState { + fetchPluginReadme: ReturnType; + pluginReadmes: Record; + pluginReadmeLoading: Record; + installPlugin: ReturnType; + uninstallPlugin: ReturnType; + pluginCatalogProjectPath: string | null; + pluginInstallProgress: Record; + installErrors: Record; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + }, +})); + +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + MarkdownViewer: ({ content }: { content: string }) => + React.createElement('div', { 'data-testid': 'markdown' }, content), +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', { 'data-testid': 'dialog-content' }, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement( + 'option', + { + value, + disabled, + }, + children + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: ({ + isInstalled, + onInstall, + onUninstall, + }: { + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + onClick: () => (isInstalled ? onUninstall() : onInstall()), + }, + isInstalled ? 'Uninstall' : 'Install' + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({ + InstallCountBadge: ({ count }: { count: number }) => + React.createElement('span', { 'data-testid': 'install-count' }, String(count)), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => + React.createElement('span', { 'data-testid': 'source-badge' }, source), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ExternalLink: Icon, + Loader2: Icon, + Mail: Icon, + }; +}); + +import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; + +const makePlugin = (): EnrichedPlugin => ({ + pluginId: 'context7@claude-plugins-official', + marketplaceId: 'context7@claude-plugins-official', + qualifiedName: 'context7@claude-plugins-official', + name: 'Context7', + source: 'official', + description: 'Fresh docs in Claude', + category: 'docs', + author: { name: 'Anthropic', email: 'help@example.com' }, + version: '1.0.0', + homepage: 'https://example.com/context7', + tags: [], + hasLspServers: false, + hasMcpServers: true, + hasAgents: false, + hasCommands: false, + hasHooks: false, + isExternal: true, + installCount: 42, + isInstalled: false, + installations: [], +}); + +describe('PluginDetailDialog project context', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.fetchPluginReadme = vi.fn(); + storeState.pluginReadmes = {}; + storeState.pluginReadmeLoading = {}; + storeState.installPlugin = vi.fn(); + storeState.uninstallPlugin = vi.fn(); + storeState.pluginCatalogProjectPath = '/tmp/global-project'; + storeState.pluginInstallProgress = {}; + storeState.installErrors = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses the current tab project path for project-scope installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin, + open: true, + onClose: vi.fn(), + projectPath: '/tmp/tab-project', + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton).not.toBeNull(); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installPlugin).toHaveBeenCalledWith({ + pluginId: plugin.pluginId, + scope: 'project', + projectPath: '/tmp/tab-project', + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('disables project and local scopes when the current tab has no project', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin: makePlugin(), + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect).not.toBeNull(); + expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true); + expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});