From c02a12b3f66049836342e749248a25cefad4c173 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:01:33 +0300 Subject: [PATCH] fix(extensions): keep skill import destination in sync --- .../extensions/skills/SkillImportDialog.tsx | 29 ++- .../skills/SkillImportDialog.test.ts | 237 ++++++++++++++++++ 2 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 test/renderer/components/extensions/skills/SkillImportDialog.test.ts diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 507b4f8c..4697dae0 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,11 @@ interface SkillImportDialogProps { onImported: (skillId: string | null) => void; } +function getFolderNameFromPath(value: string): string { + const segments = value.split(/[\\/]/u).filter(Boolean); + return segments.at(-1) ?? ''; +} + export const SkillImportDialog = ({ open, projectPath, @@ -68,6 +73,7 @@ export const SkillImportDialog = ({ const [sourceDir, setSourceDir] = useState(''); const [folderName, setFolderName] = useState(''); + const [folderNameEdited, setFolderNameEdited] = useState(false); const [scope, setScope] = useState<'user' | 'project'>('user'); const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); const [preview, setPreview] = useState(null); @@ -80,6 +86,7 @@ export const SkillImportDialog = ({ if (!open) return; setSourceDir(''); setFolderName(''); + setFolderNameEdited(false); setScope(projectPath ? 'project' : 'user'); setRootKind('claude'); setPreview(null); @@ -89,15 +96,24 @@ export const SkillImportDialog = ({ setMutationError(null); }, [open, projectPath]); + useEffect(() => { + if (!open || folderNameEdited) { + return; + } + setFolderName(getFolderNameFromPath(sourceDir)); + }, [folderNameEdited, open, sourceDir]); + + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; if (!first) return; setSourceDir(first); - if (!folderName) { - const segments = first.split(/[\\/]/u).filter(Boolean); - setFolderName(segments.at(-1) ?? ''); - } } async function handleReview(): Promise { @@ -190,7 +206,10 @@ export const SkillImportDialog = ({ setFolderName(event.target.value)} + onChange={(event) => { + setFolderNameEdited(true); + setFolderName(event.target.value); + }} placeholder="Defaults to source folder name" /> diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts new file mode 100644 index 00000000..d1be6e25 --- /dev/null +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -0,0 +1,237 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + previewSkillImport: ReturnType; + applySkillImport: ReturnType; +} + +const storeState = {} as StoreState; +const selectFoldersMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + config: { + selectFolders: (...args: unknown[]) => selectFoldersMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), + DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) => + React.createElement('label', { htmlFor }, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: () => null, + 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/skills/SkillReviewDialog', () => ({ + SkillReviewDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + FileSearch: Icon, + FolderOpen: Icon, + X: Icon, + }; +}); + +import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog'; + +describe('SkillImportDialog', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.previewSkillImport = vi.fn(); + storeState.applySkillImport = vi.fn(); + selectFoldersMock.mockReset(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('keeps destination folder name synced with the chosen source until edited manually', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + selectFoldersMock + .mockResolvedValueOnce(['/tmp/first-skill']) + .mockResolvedValueOnce(['/tmp/second-skill']) + .mockResolvedValueOnce(['/tmp/third-skill']); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const browseButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Browse') + ) as HTMLButtonElement; + const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; + const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/first-skill'); + expect(folderInput.value).toBe('first-skill'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/second-skill'); + expect(folderInput.value).toBe('second-skill'); + + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(folderInput, 'custom-name'); + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/third-skill'); + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('falls back to user scope when the project context disappears mid-dialog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(updatedScopeSelect.value).toBe('user'); + const projectOption = Array.from(updatedScopeSelect.options).find( + (option) => option.value === 'project' + ) as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});