fix(extensions): keep skill import destination in sync
This commit is contained in:
parent
66cf1443b2
commit
c02a12b3f6
2 changed files with 261 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue