fix(extensions): keep skill import destination in sync

This commit is contained in:
777genius 2026-04-16 23:01:33 +03:00
parent 66cf1443b2
commit c02a12b3f6
2 changed files with 261 additions and 5 deletions

View file

@ -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<SkillReviewPreview | null>(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<void> {
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<void> {
@ -190,7 +206,10 @@ export const SkillImportDialog = ({
<Input
id="skill-import-folder"
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onChange={(event) => {
setFolderNameEdited(true);
setFolderName(event.target.value);
}}
placeholder="Defaults to source folder name"
/>
</div>

View file

@ -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<typeof vi.fn>;
applySkillImport: ReturnType<typeof vi.fn>;
}
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<HTMLInputElement>) =>
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<HTMLSelectElement>) => 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<SVGSVGElement>) => 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();
});
});
});