feat: add Radix UI Alert Dialog component and enhance SkillImportService
- Integrated the @radix-ui/react-alert-dialog package for improved alert dialog functionality. - Updated SkillImportService to include a new inspectSourceDir method for enhanced file inspection and warning generation during skill imports. - Refactored existing methods to streamline file reading and directory walking processes, improving overall performance and error handling. - Added new SkillPlanService to manage skill upsert plans, enhancing the skills mutation workflow. - Updated UI components to support new features and improve user experience in the skills management interface.
This commit is contained in:
parent
e440042c2b
commit
d53999ba45
27 changed files with 2453 additions and 428 deletions
|
|
@ -96,6 +96,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ importers:
|
|||
'@fastify/static':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -1424,6 +1427,19 @@ packages:
|
|||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
|
|
@ -7995,6 +8011,20 @@ snapshots:
|
|||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export { SkillValidator } from './skills/SkillValidator';
|
|||
export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
||||
export { SkillScaffoldService } from './skills/SkillScaffoldService';
|
||||
export { SkillImportService } from './skills/SkillImportService';
|
||||
export { SkillPlanService } from './skills/SkillPlanService';
|
||||
export { SkillReviewService } from './skills/SkillReviewService';
|
||||
export { SkillsMutationService } from './skills/SkillsMutationService';
|
||||
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ export interface ImportedSkillSourceFile {
|
|||
isBinary: boolean;
|
||||
}
|
||||
|
||||
export interface SkillImportInspection {
|
||||
files: ImportedSkillSourceFile[];
|
||||
warnings: string[];
|
||||
hiddenEntriesSkipped: number;
|
||||
}
|
||||
|
||||
const MAX_IMPORT_FILE_COUNT = 200;
|
||||
const MAX_IMPORT_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export class SkillImportService {
|
||||
constructor(private readonly scanner = new SkillScanner()) {}
|
||||
|
||||
|
|
@ -36,11 +45,11 @@ export class SkillImportService {
|
|||
return normalizedSourceDir;
|
||||
}
|
||||
|
||||
async readSourceFiles(sourceDir: string): Promise<ImportedSkillSourceFile[]> {
|
||||
const entries = await this.walkDirectory(sourceDir);
|
||||
return Promise.all(
|
||||
entries.map(async (absolutePath) => {
|
||||
const relativePath = path.relative(sourceDir, absolutePath).replace(/\\/g, '/');
|
||||
async inspectSourceDir(sourceDir: string): Promise<SkillImportInspection> {
|
||||
const normalizedSourceDir = await this.validateSourceDir(sourceDir);
|
||||
const walked = await this.walkDirectory(normalizedSourceDir);
|
||||
const files = await Promise.all(
|
||||
walked.files.map(async ({ absolutePath, relativePath }) => {
|
||||
const binary = await isBinaryFile(absolutePath);
|
||||
return {
|
||||
relativePath,
|
||||
|
|
@ -50,6 +59,31 @@ export class SkillImportService {
|
|||
};
|
||||
})
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (walked.hiddenEntriesSkipped > 0) {
|
||||
warnings.push('Hidden files and folders were skipped during import.');
|
||||
}
|
||||
if (files.some((file) => file.isBinary)) {
|
||||
warnings.push('This import includes binary files. Binary files will be copied as-is.');
|
||||
}
|
||||
if (
|
||||
files.some(
|
||||
(file) => file.relativePath === 'scripts' || file.relativePath.startsWith('scripts/')
|
||||
)
|
||||
) {
|
||||
warnings.push('This import includes scripts. Review them carefully before importing.');
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
warnings,
|
||||
hiddenEntriesSkipped: walked.hiddenEntriesSkipped,
|
||||
};
|
||||
}
|
||||
|
||||
async readSourceFiles(sourceDir: string): Promise<ImportedSkillSourceFile[]> {
|
||||
return (await this.inspectSourceDir(sourceDir)).files;
|
||||
}
|
||||
|
||||
async writeImportedFiles(
|
||||
|
|
@ -67,17 +101,57 @@ export class SkillImportService {
|
|||
}
|
||||
}
|
||||
|
||||
private async walkDirectory(rootDir: string): Promise<string[]> {
|
||||
const dirEntries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
const results = await Promise.all(
|
||||
dirEntries.map(async (entry) => {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return this.walkDirectory(fullPath);
|
||||
private async walkDirectory(
|
||||
rootDir: string
|
||||
): Promise<{
|
||||
files: Array<{ absolutePath: string; relativePath: string }>;
|
||||
hiddenEntriesSkipped: number;
|
||||
}> {
|
||||
const allFiles: Array<{ absolutePath: string; relativePath: string }> = [];
|
||||
let hiddenEntriesSkipped = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
const visit = async (currentDir: string): Promise<void> => {
|
||||
const dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
hiddenEntriesSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
return [fullPath];
|
||||
})
|
||||
);
|
||||
return results.flat().sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error('Import source cannot contain symbolic links');
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await fs.stat(fullPath);
|
||||
totalBytes += stat.size;
|
||||
if (allFiles.length + 1 > MAX_IMPORT_FILE_COUNT) {
|
||||
throw new Error(`Import source has too many files (max ${MAX_IMPORT_FILE_COUNT})`);
|
||||
}
|
||||
if (totalBytes > MAX_IMPORT_TOTAL_BYTES) {
|
||||
throw new Error(
|
||||
`Import source is too large (max ${Math.floor(MAX_IMPORT_TOTAL_BYTES / (1024 * 1024))} MB)`
|
||||
);
|
||||
}
|
||||
|
||||
allFiles.push({
|
||||
absolutePath: fullPath,
|
||||
relativePath: path.relative(rootDir, fullPath).replace(/\\/g, '/'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await visit(rootDir);
|
||||
|
||||
return {
|
||||
files: allFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath)),
|
||||
hiddenEntriesSkipped,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
412
src/main/services/extensions/skills/SkillPlanService.ts
Normal file
412
src/main/services/extensions/skills/SkillPlanService.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
SkillDraftFile,
|
||||
SkillReviewFileChange,
|
||||
SkillReviewPreview,
|
||||
SkillReviewSummary,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||
|
||||
import { SkillScanner } from './SkillScanner';
|
||||
|
||||
type SkillPlanInputFile =
|
||||
| { relativePath: string; isBinary: false; content: string }
|
||||
| { relativePath: string; isBinary: true; sourceAbsolutePath: string };
|
||||
|
||||
interface ManagedCurrentFile {
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
interface SkillExecutionChange extends SkillReviewFileChange {
|
||||
sourceAbsolutePath?: string;
|
||||
}
|
||||
|
||||
export interface SkillExecutionPlan {
|
||||
preview: SkillReviewPreview;
|
||||
changes: SkillExecutionChange[];
|
||||
}
|
||||
|
||||
const MANAGED_SUBDIRECTORIES = ['scripts', 'references', 'assets'] as const;
|
||||
export class SkillPlanService {
|
||||
constructor(private readonly scanner = new SkillScanner()) {}
|
||||
|
||||
async buildUpsertPlan(
|
||||
targetSkillDir: string,
|
||||
files: SkillDraftFile[]
|
||||
): Promise<SkillExecutionPlan> {
|
||||
const desiredFiles: SkillPlanInputFile[] = files.map((file) => ({
|
||||
relativePath: file.relativePath,
|
||||
isBinary: false,
|
||||
content: file.content,
|
||||
}));
|
||||
|
||||
return this.buildPlan(targetSkillDir, desiredFiles, 'upsert');
|
||||
}
|
||||
|
||||
async buildImportPlan(
|
||||
targetSkillDir: string,
|
||||
files: ImportedSkillSourceFile[]
|
||||
): Promise<SkillExecutionPlan> {
|
||||
const desiredFiles: SkillPlanInputFile[] = files.map((file) =>
|
||||
file.isBinary
|
||||
? {
|
||||
relativePath: file.relativePath,
|
||||
isBinary: true,
|
||||
sourceAbsolutePath: file.absolutePath,
|
||||
}
|
||||
: {
|
||||
relativePath: file.relativePath,
|
||||
isBinary: false,
|
||||
content: file.content ?? '',
|
||||
}
|
||||
);
|
||||
|
||||
return this.buildPlan(targetSkillDir, desiredFiles, 'import');
|
||||
}
|
||||
|
||||
async applyPlan(plan: SkillExecutionPlan): Promise<void> {
|
||||
const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-'));
|
||||
const createdPaths: string[] = [];
|
||||
const backups: Array<{ absolutePath: string; backupPath: string }> = [];
|
||||
|
||||
try {
|
||||
for (const [index, change] of plan.changes.entries()) {
|
||||
if (change.action !== 'create' && (await this.pathExists(change.absolutePath))) {
|
||||
const backupPath = path.join(backupRoot, String(index));
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await fs.copyFile(change.absolutePath, backupPath);
|
||||
backups.push({ absolutePath: change.absolutePath, backupPath });
|
||||
}
|
||||
|
||||
if (change.action === 'delete') {
|
||||
await fs.rm(change.absolutePath, { force: true });
|
||||
await this.cleanupManagedParents(
|
||||
path.dirname(change.absolutePath),
|
||||
plan.preview.targetSkillDir
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(change.absolutePath), { recursive: true });
|
||||
if (change.isBinary) {
|
||||
if (!change.sourceAbsolutePath) {
|
||||
throw new Error(`Missing binary source for ${change.relativePath}`);
|
||||
}
|
||||
await fs.copyFile(change.sourceAbsolutePath, change.absolutePath);
|
||||
} else {
|
||||
await fs.writeFile(change.absolutePath, change.newContent ?? '', 'utf8');
|
||||
}
|
||||
|
||||
if (change.action === 'create') {
|
||||
createdPaths.push(change.absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
await this.cleanupManagedDirectories(plan.preview.targetSkillDir);
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
createdPaths
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(async (absolutePath) => {
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
await this.cleanupManagedParents(
|
||||
path.dirname(absolutePath),
|
||||
plan.preview.targetSkillDir
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
backups
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(async ({ absolutePath, backupPath }) => {
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.copyFile(backupPath, absolutePath);
|
||||
})
|
||||
);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(backupRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPlan(
|
||||
targetSkillDir: string,
|
||||
desiredFiles: SkillPlanInputFile[],
|
||||
mode: 'upsert' | 'import'
|
||||
): Promise<SkillExecutionPlan> {
|
||||
const normalizedDesired = this.normalizeDesiredFiles(desiredFiles);
|
||||
const [currentManagedFiles, allExistingFiles] = await Promise.all([
|
||||
this.readCurrentManagedFiles(targetSkillDir),
|
||||
this.listAllRelativeFiles(targetSkillDir),
|
||||
]);
|
||||
|
||||
const changesByRelativePath = new Map<string, SkillExecutionChange>();
|
||||
|
||||
await Promise.all(
|
||||
normalizedDesired.map(async (file) => {
|
||||
const absolutePath = path.join(targetSkillDir, file.relativePath);
|
||||
const existingTextContent = file.isBinary
|
||||
? null
|
||||
: await this.readUtf8IfExists(absolutePath);
|
||||
const action = (await this.pathExists(absolutePath)) ? 'update' : 'create';
|
||||
changesByRelativePath.set(file.relativePath, {
|
||||
relativePath: file.relativePath,
|
||||
absolutePath,
|
||||
action,
|
||||
oldContent: existingTextContent,
|
||||
newContent: file.isBinary ? null : file.content,
|
||||
isBinary: file.isBinary,
|
||||
sourceAbsolutePath: file.isBinary ? file.sourceAbsolutePath : undefined,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
for (const currentFile of currentManagedFiles.values()) {
|
||||
if (changesByRelativePath.has(currentFile.relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingTextContent = await this.readUtf8IfExists(currentFile.absolutePath);
|
||||
changesByRelativePath.set(currentFile.relativePath, {
|
||||
relativePath: currentFile.relativePath,
|
||||
absolutePath: currentFile.absolutePath,
|
||||
action: 'delete',
|
||||
oldContent: existingTextContent,
|
||||
newContent: null,
|
||||
isBinary: false,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = [...changesByRelativePath.values()].sort((a, b) =>
|
||||
a.relativePath.localeCompare(b.relativePath)
|
||||
);
|
||||
const warnings = this.buildWarnings({
|
||||
changes,
|
||||
currentManagedFiles,
|
||||
allExistingFiles,
|
||||
desiredFiles: new Set(normalizedDesired.map((file) => file.relativePath)),
|
||||
mode,
|
||||
});
|
||||
|
||||
const summary = changes.reduce<SkillReviewSummary>(
|
||||
(acc, change) => {
|
||||
acc[`${change.action}d` as 'created' | 'updated' | 'deleted'] += 1;
|
||||
if (change.isBinary) {
|
||||
acc.binary += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ created: 0, updated: 0, deleted: 0, binary: 0 }
|
||||
);
|
||||
|
||||
const preview: SkillReviewPreview = {
|
||||
planId: this.buildPlanId(targetSkillDir, changes, warnings),
|
||||
targetSkillDir,
|
||||
changes: changes.map(({ sourceAbsolutePath: _sourceAbsolutePath, ...change }) => change),
|
||||
warnings,
|
||||
summary,
|
||||
};
|
||||
|
||||
return { preview, changes };
|
||||
}
|
||||
|
||||
private normalizeDesiredFiles(files: SkillPlanInputFile[]): SkillPlanInputFile[] {
|
||||
const map = new Map<string, SkillPlanInputFile>();
|
||||
for (const file of files) {
|
||||
const normalizedPath = path.normalize(file.relativePath).replace(/\\/g, '/');
|
||||
map.set(normalizedPath, { ...file, relativePath: normalizedPath });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
}
|
||||
|
||||
private async readCurrentManagedFiles(
|
||||
targetSkillDir: string
|
||||
): Promise<Map<string, ManagedCurrentFile>> {
|
||||
const files = new Map<string, ManagedCurrentFile>();
|
||||
const detectedSkillFile = await this.scanner.detectSkillFile(targetSkillDir);
|
||||
if (detectedSkillFile) {
|
||||
files.set(path.basename(detectedSkillFile), {
|
||||
relativePath: path.basename(detectedSkillFile),
|
||||
absolutePath: detectedSkillFile,
|
||||
});
|
||||
}
|
||||
|
||||
for (const directory of MANAGED_SUBDIRECTORIES) {
|
||||
const fullDirectoryPath = path.join(targetSkillDir, directory);
|
||||
const relativeFiles = await this.listAllRelativeFiles(fullDirectoryPath);
|
||||
for (const relativePath of relativeFiles) {
|
||||
const managedRelativePath = `${directory}/${relativePath}`;
|
||||
files.set(managedRelativePath, {
|
||||
relativePath: managedRelativePath,
|
||||
absolutePath: path.join(fullDirectoryPath, relativePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async listAllRelativeFiles(rootDir: string): Promise<string[]> {
|
||||
try {
|
||||
const rootStat = await fs.stat(rootDir);
|
||||
if (!rootStat.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirEntries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
const entries = await Promise.all(
|
||||
dirEntries.map(async (entry) => {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const children = await this.listAllRelativeFiles(fullPath);
|
||||
return children.map((child) => path.join(entry.name, child).replace(/\\/g, '/'));
|
||||
}
|
||||
return [entry.name];
|
||||
})
|
||||
);
|
||||
|
||||
return entries.flat().sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private buildWarnings({
|
||||
changes,
|
||||
currentManagedFiles,
|
||||
allExistingFiles,
|
||||
desiredFiles,
|
||||
mode,
|
||||
}: {
|
||||
changes: SkillExecutionChange[];
|
||||
currentManagedFiles: Map<string, ManagedCurrentFile>;
|
||||
allExistingFiles: string[];
|
||||
desiredFiles: Set<string>;
|
||||
mode: 'upsert' | 'import';
|
||||
}): string[] {
|
||||
const warnings: string[] = [];
|
||||
const deleteCount = changes.filter((change) => change.action === 'delete').length;
|
||||
const updateCount = changes.filter((change) => change.action === 'update').length;
|
||||
const binaryCount = changes.filter((change) => change.isBinary).length;
|
||||
|
||||
if (deleteCount > 0) {
|
||||
warnings.push(
|
||||
deleteCount === 1
|
||||
? '1 managed file will be removed to match this reviewed plan.'
|
||||
: `${deleteCount} managed files will be removed to match this reviewed plan.`
|
||||
);
|
||||
}
|
||||
|
||||
if (updateCount > 0) {
|
||||
warnings.push(
|
||||
updateCount === 1
|
||||
? '1 existing file will be overwritten.'
|
||||
: `${updateCount} existing files will be overwritten.`
|
||||
);
|
||||
}
|
||||
|
||||
if (binaryCount > 0) {
|
||||
warnings.push(
|
||||
binaryCount === 1
|
||||
? '1 binary file will be copied as-is.'
|
||||
: `${binaryCount} binary files will be copied as-is.`
|
||||
);
|
||||
}
|
||||
|
||||
const managedPaths = new Set(currentManagedFiles.keys());
|
||||
const unmanagedFiles = allExistingFiles.filter(
|
||||
(relativePath) => !managedPaths.has(relativePath) && !desiredFiles.has(relativePath)
|
||||
);
|
||||
if (unmanagedFiles.length > 0) {
|
||||
warnings.push(
|
||||
mode === 'import'
|
||||
? 'Existing files outside the imported plan will be kept as-is.'
|
||||
: 'Existing files outside the managed skill set will be kept as-is.'
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private buildPlanId(
|
||||
targetSkillDir: string,
|
||||
changes: SkillExecutionChange[],
|
||||
warnings: string[]
|
||||
): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(targetSkillDir);
|
||||
hash.update('\n');
|
||||
for (const change of changes) {
|
||||
hash.update(
|
||||
JSON.stringify({
|
||||
relativePath: change.relativePath,
|
||||
action: change.action,
|
||||
oldContent: change.oldContent,
|
||||
newContent: change.newContent,
|
||||
isBinary: change.isBinary,
|
||||
sourceAbsolutePath: change.sourceAbsolutePath ?? null,
|
||||
})
|
||||
);
|
||||
hash.update('\n');
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
hash.update(warning);
|
||||
hash.update('\n');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
private async readUtf8IfExists(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupManagedDirectories(targetSkillDir: string): Promise<void> {
|
||||
await Promise.all(
|
||||
MANAGED_SUBDIRECTORIES.map((directory) =>
|
||||
this.cleanupManagedParents(path.join(targetSkillDir, directory), targetSkillDir)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async cleanupManagedParents(currentDir: string, targetSkillDir: string): Promise<void> {
|
||||
let nextDir = currentDir;
|
||||
while (nextDir.startsWith(targetSkillDir) && nextDir !== targetSkillDir) {
|
||||
try {
|
||||
const entries = await fs.readdir(nextDir);
|
||||
if (entries.length > 0) {
|
||||
return;
|
||||
}
|
||||
await fs.rmdir(nextDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
nextDir = path.dirname(nextDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import { shell } from 'electron';
|
|||
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
||||
|
||||
import { SkillImportService } from './SkillImportService';
|
||||
import { SkillReviewService } from './SkillReviewService';
|
||||
import { SkillPlanService } from './SkillPlanService';
|
||||
import { SkillScaffoldService } from './SkillScaffoldService';
|
||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||
import { SkillsCatalogService } from './SkillsCatalogService';
|
||||
|
|
@ -24,7 +24,7 @@ export class SkillsMutationService {
|
|||
private readonly catalogService = new SkillsCatalogService(),
|
||||
private readonly scaffoldService = new SkillScaffoldService(rootsResolver),
|
||||
private readonly importService = new SkillImportService(),
|
||||
private readonly reviewService = new SkillReviewService()
|
||||
private readonly planService = new SkillPlanService()
|
||||
) {}
|
||||
|
||||
async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> {
|
||||
|
|
@ -36,15 +36,15 @@ export class SkillsMutationService {
|
|||
request.existingSkillId
|
||||
);
|
||||
const files = this.scaffoldService.normalizeDraftFiles(request.files);
|
||||
const changes = await this.reviewService.buildTextChanges(targetSkillDir, files);
|
||||
return {
|
||||
targetSkillDir,
|
||||
changes,
|
||||
warnings: [],
|
||||
};
|
||||
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
|
||||
return plan.preview;
|
||||
}
|
||||
|
||||
async applyUpsert(request: SkillUpsertRequest): Promise<SkillDetail | null> {
|
||||
if (!request.reviewPlanId) {
|
||||
throw new Error('Review the skill changes before saving.');
|
||||
}
|
||||
|
||||
const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
|
||||
request.scope,
|
||||
request.rootKind,
|
||||
|
|
@ -53,30 +53,33 @@ export class SkillsMutationService {
|
|||
request.existingSkillId
|
||||
);
|
||||
const files = this.scaffoldService.normalizeDraftFiles(request.files);
|
||||
await this.scaffoldService.writeTextFiles(targetSkillDir, files);
|
||||
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
|
||||
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
|
||||
await this.planService.applyPlan(plan);
|
||||
|
||||
return this.catalogService.getDetail(targetSkillDir, request.projectPath);
|
||||
}
|
||||
|
||||
async previewImport(request: SkillImportRequest): Promise<SkillReviewPreview> {
|
||||
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
|
||||
const sourceFiles = await this.importService.readSourceFiles(sourceDir);
|
||||
const changes = await this.reviewService.buildImportChanges(targetSkillDir, sourceFiles);
|
||||
const warnings = changes.some((change) => change.isBinary)
|
||||
? ['This import includes binary files. Binary files will be copied as-is.']
|
||||
: [];
|
||||
|
||||
const inspection = await this.importService.inspectSourceDir(sourceDir);
|
||||
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
|
||||
return {
|
||||
targetSkillDir,
|
||||
changes,
|
||||
warnings,
|
||||
...plan.preview,
|
||||
warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])],
|
||||
};
|
||||
}
|
||||
|
||||
async applyImport(request: SkillImportRequest): Promise<SkillDetail | null> {
|
||||
if (!request.reviewPlanId) {
|
||||
throw new Error('Review the import changes before saving.');
|
||||
}
|
||||
|
||||
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
|
||||
const sourceFiles = await this.importService.readSourceFiles(sourceDir);
|
||||
await this.importService.writeImportedFiles(targetSkillDir, sourceFiles);
|
||||
const inspection = await this.importService.inspectSourceDir(sourceDir);
|
||||
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
|
||||
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
|
||||
await this.planService.applyPlan(plan);
|
||||
|
||||
return this.catalogService.getDetail(targetSkillDir, request.projectPath);
|
||||
}
|
||||
|
|
@ -133,4 +136,12 @@ export class SkillsMutationService {
|
|||
}
|
||||
return normalizedSkillDir;
|
||||
}
|
||||
|
||||
private assertReviewedPlanMatches(reviewPlanId: string, currentPlanId: string): void {
|
||||
if (reviewPlanId !== currentPlanId) {
|
||||
throw new Error(
|
||||
'The skill files changed after review. Review the latest changes and try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
|
||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||
import { McpServersPanel } from './mcp/McpServersPanel';
|
||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||
|
|
@ -57,6 +58,39 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
|
||||
[extensionsTabProjectId, projects]
|
||||
);
|
||||
const subTabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'plugins' as const,
|
||||
label: 'Plugins',
|
||||
icon: Puzzle,
|
||||
description:
|
||||
'Small add-ons for Claude. They give the app extra features and integrations you can install when you need them.',
|
||||
},
|
||||
{
|
||||
value: 'mcp-servers' as const,
|
||||
label: 'MCP Servers',
|
||||
icon: Server,
|
||||
description:
|
||||
'Connections to outside tools and apps. They let Claude read data or do actions beyond this app.',
|
||||
},
|
||||
{
|
||||
value: 'skills' as const,
|
||||
label: 'Skills',
|
||||
icon: BookOpen,
|
||||
description:
|
||||
'Ready-made instructions for common jobs. They help Claude do specific tasks better and more consistently.',
|
||||
},
|
||||
{
|
||||
value: 'api-keys' as const,
|
||||
label: 'API Keys',
|
||||
icon: Key,
|
||||
description:
|
||||
'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Fetch plugin catalog on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -102,15 +136,15 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="size-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold text-text">Extensions</h1>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="size-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold text-text">Extensions</h1>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
|
|
@ -119,116 +153,109 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh catalog</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="px-6 py-4">
|
||||
{/* CLI not installed warning */}
|
||||
{!cliInstalled && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
Claude CLI is required to install or uninstall extensions. Install it from Settings.
|
||||
</div>
|
||||
)}
|
||||
{/* Active sessions warning */}
|
||||
{hasOngoingSessions && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
|
||||
<Info className="size-4 shrink-0" />
|
||||
Running sessions won't pick up extension changes until restarted.
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
value={tabState.activeSubTab}
|
||||
onValueChange={(v) =>
|
||||
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="plugins" className="gap-1.5">
|
||||
<Puzzle className="size-3.5" />
|
||||
Plugins
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers" className="gap-1.5">
|
||||
<Server className="size-3.5" />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
Skills
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api-keys" className="gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
API Keys
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Sub-tabs */}
|
||||
<div className="px-6 py-4">
|
||||
{/* CLI not installed warning */}
|
||||
{!cliInstalled && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-400">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
Claude CLI is required to install or uninstall extensions. Install it from Settings.
|
||||
</div>
|
||||
)}
|
||||
{/* Active sessions warning */}
|
||||
{hasOngoingSessions && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
|
||||
<Info className="size-4 shrink-0" />
|
||||
Running sessions won't pick up extension changes until restarted.
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
value={tabState.activeSubTab}
|
||||
onValueChange={(v) =>
|
||||
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
|
||||
}
|
||||
>
|
||||
<div className="-mx-6 flex items-end justify-between border-b border-border px-6">
|
||||
<TabsList className="gap-1 rounded-b-none">
|
||||
{subTabs.map((subTab) => (
|
||||
<ExtensionsSubTabTrigger
|
||||
key={subTab.value}
|
||||
value={subTab.value}
|
||||
label={subTab.label}
|
||||
icon={subTab.icon}
|
||||
description={subTab.description}
|
||||
/>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="plugins">
|
||||
<PluginsPanel
|
||||
pluginFilters={tabState.pluginFilters}
|
||||
pluginSort={tabState.pluginSort}
|
||||
selectedPluginId={tabState.selectedPluginId}
|
||||
updatePluginSearch={tabState.updatePluginSearch}
|
||||
toggleCategory={tabState.toggleCategory}
|
||||
toggleCapability={tabState.toggleCapability}
|
||||
toggleInstalledOnly={tabState.toggleInstalledOnly}
|
||||
setSelectedPluginId={tabState.setSelectedPluginId}
|
||||
clearFilters={tabState.clearFilters}
|
||||
hasActiveFilters={tabState.hasActiveFilters}
|
||||
setPluginSort={tabState.setPluginSort}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="plugins" className="mt-0 pt-4">
|
||||
<PluginsPanel
|
||||
pluginFilters={tabState.pluginFilters}
|
||||
pluginSort={tabState.pluginSort}
|
||||
selectedPluginId={tabState.selectedPluginId}
|
||||
updatePluginSearch={tabState.updatePluginSearch}
|
||||
toggleCategory={tabState.toggleCategory}
|
||||
toggleCapability={tabState.toggleCapability}
|
||||
toggleInstalledOnly={tabState.toggleInstalledOnly}
|
||||
setSelectedPluginId={tabState.setSelectedPluginId}
|
||||
clearFilters={tabState.clearFilters}
|
||||
hasActiveFilters={tabState.hasActiveFilters}
|
||||
setPluginSort={tabState.setPluginSort}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp-servers">
|
||||
<McpServersPanel
|
||||
mcpSearchQuery={tabState.mcpSearchQuery}
|
||||
mcpSearch={tabState.mcpSearch}
|
||||
mcpSearchResults={tabState.mcpSearchResults}
|
||||
mcpSearchLoading={tabState.mcpSearchLoading}
|
||||
mcpSearchWarnings={tabState.mcpSearchWarnings}
|
||||
selectedMcpServerId={tabState.selectedMcpServerId}
|
||||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp-servers" className="mt-0 pt-4">
|
||||
<McpServersPanel
|
||||
mcpSearchQuery={tabState.mcpSearchQuery}
|
||||
mcpSearch={tabState.mcpSearch}
|
||||
mcpSearchResults={tabState.mcpSearchResults}
|
||||
mcpSearchLoading={tabState.mcpSearchLoading}
|
||||
mcpSearchWarnings={tabState.mcpSearchWarnings}
|
||||
selectedMcpServerId={tabState.selectedMcpServerId}
|
||||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="api-keys" className="mt-0 pt-4">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skills">
|
||||
<SkillsPanel
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
skillsSearchQuery={tabState.skillsSearchQuery}
|
||||
setSkillsSearchQuery={tabState.setSkillsSearchQuery}
|
||||
skillsSort={tabState.skillsSort}
|
||||
setSkillsSort={tabState.setSkillsSort}
|
||||
selectedSkillId={tabState.selectedSkillId}
|
||||
setSelectedSkillId={tabState.setSelectedSkillId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="skills" className="mt-0 pt-4">
|
||||
<SkillsPanel
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
skillsSearchQuery={tabState.skillsSearchQuery}
|
||||
setSkillsSearchQuery={tabState.setSkillsSearchQuery}
|
||||
skillsSort={tabState.skillsSort}
|
||||
setSkillsSort={tabState.setSkillsSort}
|
||||
selectedSkillId={tabState.selectedSkillId}
|
||||
setSelectedSkillId={tabState.setSelectedSkillId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Custom MCP server dialog (lifted to store view level) */}
|
||||
<CustomMcpServerDialog
|
||||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
/>
|
||||
{/* Custom MCP server dialog (lifted to store view level) */}
|
||||
<CustomMcpServerDialog
|
||||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface ExtensionsSubTabTriggerProps {
|
||||
value: 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const ExtensionsSubTabTrigger = ({
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: ExtensionsSubTabTriggerProps): React.JSX.Element => {
|
||||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
className="relative gap-1.5 rounded-b-none pr-7 data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:-bottom-px data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`What is ${label}?`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="size-4.5 absolute right-2 top-1 z-10 inline-flex items-center justify-center rounded-full text-text-muted transition-colors hover:bg-[var(--color-surface)] hover:text-text"
|
||||
>
|
||||
<Info className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-pretty text-xs leading-relaxed">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsTrigger>
|
||||
);
|
||||
};
|
||||
|
|
@ -44,10 +44,10 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
onClick(plugin.pluginId);
|
||||
}
|
||||
}}
|
||||
className={`hover:bg-surface-raised/45 relative flex w-full cursor-pointer flex-col gap-3 rounded-xl border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
||||
baseStriped ? 'bg-surface-raised/10' : 'bg-transparent'
|
||||
} ${smStriped ? 'sm:bg-surface-raised/10' : 'sm:bg-transparent'} ${
|
||||
xlStriped ? 'xl:bg-surface-raised/10' : 'xl:bg-transparent'
|
||||
className={`relative flex w-full cursor-pointer flex-col gap-3 rounded-xl border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-white/[0.06] hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
||||
baseStriped ? 'bg-white/[0.045]' : 'bg-white/[0.015]'
|
||||
} ${smStriped ? 'sm:bg-white/[0.045]' : 'sm:bg-white/[0.015]'} ${
|
||||
xlStriped ? 'xl:bg-white/[0.045]' : 'xl:bg-white/[0.015]'
|
||||
} ${
|
||||
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,16 @@ const skillEditorTheme = EditorView.theme({
|
|||
interface SkillCodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
scrollRef?: React.RefObject<HTMLElement | null>;
|
||||
onScroll?: () => void;
|
||||
}
|
||||
|
||||
export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): React.JSX.Element => {
|
||||
export const SkillCodeEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
}: SkillCodeEditorProps): React.JSX.Element => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
|
@ -72,13 +79,27 @@ export const SkillCodeEditor = ({ value, onChange }: SkillCodeEditorProps): Reac
|
|||
});
|
||||
|
||||
viewRef.current = view;
|
||||
if (onScroll) {
|
||||
view.scrollDOM.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
if (scrollRef && 'current' in scrollRef) {
|
||||
const mutableRef = scrollRef as React.MutableRefObject<HTMLElement | null>;
|
||||
mutableRef.current = view.scrollDOM;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (onScroll) {
|
||||
view.scrollDOM.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
if (scrollRef && 'current' in scrollRef) {
|
||||
const mutableRef = scrollRef as React.MutableRefObject<HTMLElement | null>;
|
||||
mutableRef.current = null;
|
||||
}
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- create editor once per mount
|
||||
}, []);
|
||||
}, [onScroll, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@renderer/components/ui/alert-dialog';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
|
|
@ -34,18 +44,29 @@ export const SkillDetailDialog = ({
|
|||
}: SkillDetailDialogProps): React.JSX.Element => {
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const deleteSkill = useStore((s) => s.deleteSkill);
|
||||
const skillsMutationLoading = useStore((s) => s.skillsMutationLoading);
|
||||
const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined));
|
||||
const loading = useStore((s) =>
|
||||
skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false
|
||||
);
|
||||
const detailError = useStore((s) =>
|
||||
skillId ? (s.skillsDetailErrorById[skillId] ?? null) : null
|
||||
);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skillId) return;
|
||||
if (detail === undefined) {
|
||||
void fetchSkillDetail(skillId, projectPath ?? undefined);
|
||||
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
|
||||
}, [fetchSkillDetail, open, projectPath, skillId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDeleteError(null);
|
||||
setDeleteLoading(false);
|
||||
setDeleteConfirmOpen(false);
|
||||
}
|
||||
}, [detail, fetchSkillDetail, open, projectPath, skillId]);
|
||||
}, [open]);
|
||||
|
||||
const item = detail?.item;
|
||||
|
||||
|
|
@ -53,16 +74,32 @@ export const SkillDetailDialog = ({
|
|||
return `.${rootKind}`;
|
||||
}
|
||||
|
||||
function formatScopeLabel(scope: 'user' | 'project'): string {
|
||||
return scope === 'project' ? 'This project only' : 'Your personal skills';
|
||||
}
|
||||
|
||||
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
|
||||
return invocationMode === 'manual-only'
|
||||
? 'Claude will only use this when you explicitly ask for it.'
|
||||
: 'Claude can pick this automatically when it matches the task.';
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!item) return;
|
||||
const confirmed = window.confirm(`Delete skill "${item.name}"? It will be moved to Trash.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
await deleteSkill({
|
||||
skillId: item.id,
|
||||
projectPath: projectPath ?? undefined,
|
||||
});
|
||||
onDeleted();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteSkill({
|
||||
skillId: item.id,
|
||||
projectPath: projectPath ?? undefined,
|
||||
});
|
||||
setDeleteConfirmOpen(false);
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setDeleteError(error instanceof Error ? error.message : 'Failed to delete skill');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -79,7 +116,24 @@ export const SkillDetailDialog = ({
|
|||
<p className="text-sm text-text-muted">Loading skill details...</p>
|
||||
)}
|
||||
|
||||
{!loading && detail === null && (
|
||||
{!loading && detailError && (
|
||||
<div className="space-y-3 rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
<p>{detailError}</p>
|
||||
{skillId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !detailError && detail === null && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
Unable to load this skill.
|
||||
</div>
|
||||
|
|
@ -87,17 +141,27 @@ export const SkillDetailDialog = ({
|
|||
|
||||
{!loading && detail && item && (
|
||||
<div className="space-y-4">
|
||||
{deleteError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{item.scope}</Badge>
|
||||
<Badge variant="outline">{formatRootKind(item.rootKind)}</Badge>
|
||||
<Badge variant="secondary">{item.invocationMode}</Badge>
|
||||
{item.flags.hasScripts && <Badge variant="destructive">scripts</Badge>}
|
||||
{item.flags.hasReferences && <Badge variant="secondary">references</Badge>}
|
||||
{item.flags.hasAssets && <Badge variant="secondary">assets</Badge>}
|
||||
<Badge variant="outline">{formatScopeLabel(item.scope)}</Badge>
|
||||
<Badge variant="outline">Stored in {formatRootKind(item.rootKind)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
|
||||
</Badge>
|
||||
{item.flags.hasScripts && <Badge variant="destructive">Has scripts</Badge>}
|
||||
{item.flags.hasReferences && <Badge variant="secondary">References</Badge>}
|
||||
{item.flags.hasAssets && <Badge variant="secondary">Assets</Badge>}
|
||||
</div>
|
||||
|
||||
{item.issues.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Review this skill carefully before using it
|
||||
</p>
|
||||
{item.issues.map((issue, index) => (
|
||||
<div
|
||||
key={`${issue.code}-${index}`}
|
||||
|
|
@ -110,6 +174,35 @@ export const SkillDetailDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-border p-4 md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
Who can use it
|
||||
</p>
|
||||
<p className="text-sm text-text">{formatScopeLabel(item.scope)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
How Claude uses it
|
||||
</p>
|
||||
<p className="text-sm text-text">{formatInvocationLabel(item.invocationMode)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
What comes with it
|
||||
</p>
|
||||
<p className="text-sm text-text">
|
||||
{[
|
||||
item.flags.hasReferences ? 'references' : null,
|
||||
item.flags.hasScripts ? 'scripts' : null,
|
||||
item.flags.hasAssets ? 'assets' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Just the skill instructions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onEdit}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
|
|
@ -118,27 +211,11 @@ export const SkillDetailDialog = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.showInFolder(item.skillFile)}
|
||||
>
|
||||
<FolderOpen className="mr-1.5 size-3.5" />
|
||||
Open Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 size-3.5" />
|
||||
Open SKILL.md
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={skillsMutationLoading}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-3.5" />
|
||||
Delete
|
||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -153,15 +230,9 @@ export const SkillDetailDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CodeBlockViewer
|
||||
fileName={item.skillFile}
|
||||
content={detail.rawContent}
|
||||
maxHeight="max-h-72"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border p-3 text-sm text-text-secondary">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-text">Path</p>
|
||||
<p className="font-medium text-text">Stored at</p>
|
||||
<p className="break-all text-xs text-text-muted">{item.skillDir}</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -198,11 +269,61 @@ export const SkillDetailDialog = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="rounded-lg border border-border p-3 text-sm text-text-secondary">
|
||||
<summary className="cursor-pointer font-medium text-text">
|
||||
Advanced file details
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.showInFolder(item.skillFile)}
|
||||
>
|
||||
<FolderOpen className="mr-1.5 size-3.5" />
|
||||
Open Folder
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 size-3.5" />
|
||||
Open SKILL.md
|
||||
</Button>
|
||||
</div>
|
||||
<CodeBlockViewer
|
||||
fileName={item.skillFile}
|
||||
content={detail.rawContent}
|
||||
maxHeight="max-h-72"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete skill?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{item
|
||||
? `Delete "${item.name}" and move it to Trash? You can restore it later from Trash if needed.`
|
||||
: 'Delete this skill and move it to Trash?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteLoading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void handleDelete()} disabled={deleteLoading}>
|
||||
{deleteLoading ? 'Deleting...' : 'Delete Skill'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
|
|
@ -21,6 +20,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Textarea } from '@renderer/components/ui/textarea';
|
||||
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -29,12 +30,11 @@ import { SkillReviewDialog } from './SkillReviewDialog';
|
|||
import {
|
||||
buildSkillDraftFiles,
|
||||
buildSkillTemplate,
|
||||
readSkillTemplateInput,
|
||||
readSkillTemplateContent,
|
||||
updateSkillTemplateFrontmatter,
|
||||
} from './skillDraftUtils';
|
||||
|
||||
import type {
|
||||
SkillCatalogItem,
|
||||
SkillDetail,
|
||||
SkillInvocationMode,
|
||||
SkillReviewPreview,
|
||||
|
|
@ -60,6 +60,16 @@ function parseInitialDescription(detail: SkillDetail | null): string {
|
|||
return detail?.item.description ?? '';
|
||||
}
|
||||
|
||||
function toSuggestedFolderName(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export const SkillEditorDialog = ({
|
||||
open,
|
||||
mode,
|
||||
|
|
@ -70,11 +80,10 @@ export const SkillEditorDialog = ({
|
|||
onSaved,
|
||||
}: SkillEditorDialogProps): React.JSX.Element => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorScrollRef = useRef<HTMLElement | null>(null);
|
||||
const rawContentRef = useRef('');
|
||||
const previewSkillUpsert = useStore((s) => s.previewSkillUpsert);
|
||||
const applySkillUpsert = useStore((s) => s.applySkillUpsert);
|
||||
const skillsMutationLoading = useStore((s) => s.skillsMutationLoading);
|
||||
const skillsMutationError = useStore((s) => s.skillsMutationError);
|
||||
|
||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
||||
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
|
||||
|
|
@ -84,17 +93,31 @@ export const SkillEditorDialog = ({
|
|||
const [license, setLicense] = useState('');
|
||||
const [compatibility, setCompatibility] = useState('');
|
||||
const [invocationMode, setInvocationMode] = useState<SkillInvocationMode>('auto');
|
||||
const [whenToUse, setWhenToUse] = useState('');
|
||||
const [steps, setSteps] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [includeScripts, setIncludeScripts] = useState(false);
|
||||
const [includeReferences, setIncludeReferences] = useState(false);
|
||||
const [includeAssets, setIncludeAssets] = useState(false);
|
||||
const [rawContent, setRawContent] = useState('');
|
||||
const [folderNameEdited, setFolderNameEdited] = useState(false);
|
||||
const [customMarkdownDetected, setCustomMarkdownDetected] = useState(false);
|
||||
const [manualRawEdit, setManualRawEdit] = useState(false);
|
||||
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
|
||||
const [splitRatio, setSplitRatio] = useState(0.52);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [reviewPreview, setReviewPreview] = useState<SkillReviewPreview | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const scrollSync = useMarkdownScrollSync(
|
||||
showAdvancedEditor,
|
||||
detail?.item.id ?? (mode === 'create' ? 'create-skill' : 'edit-skill'),
|
||||
{ editorScrollRef }
|
||||
);
|
||||
|
||||
const applyMetadataToRawContent = useCallback(
|
||||
const applyFormToRawContent = useCallback(
|
||||
(
|
||||
nextValues: Partial<{
|
||||
name: string;
|
||||
|
|
@ -102,6 +125,9 @@ export const SkillEditorDialog = ({
|
|||
license: string;
|
||||
compatibility: string;
|
||||
invocationMode: SkillInvocationMode;
|
||||
whenToUse: string;
|
||||
steps: string;
|
||||
notes: string;
|
||||
}>
|
||||
) => {
|
||||
const merged = {
|
||||
|
|
@ -110,17 +136,31 @@ export const SkillEditorDialog = ({
|
|||
license,
|
||||
compatibility,
|
||||
invocationMode,
|
||||
whenToUse,
|
||||
steps,
|
||||
notes,
|
||||
...nextValues,
|
||||
};
|
||||
const nextRawContent =
|
||||
mode === 'create' && !manualRawEdit
|
||||
!manualRawEdit && !customMarkdownDetected
|
||||
? buildSkillTemplate(merged)
|
||||
: updateSkillTemplateFrontmatter(rawContentRef.current, merged);
|
||||
|
||||
rawContentRef.current = nextRawContent;
|
||||
setRawContent(nextRawContent);
|
||||
},
|
||||
[compatibility, description, invocationMode, license, manualRawEdit, mode, name]
|
||||
[
|
||||
compatibility,
|
||||
description,
|
||||
invocationMode,
|
||||
license,
|
||||
manualRawEdit,
|
||||
customMarkdownDetected,
|
||||
name,
|
||||
notes,
|
||||
steps,
|
||||
whenToUse,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -135,6 +175,9 @@ export const SkillEditorDialog = ({
|
|||
const nextLicense = item?.license ?? '';
|
||||
const nextCompatibility = item?.compatibility ?? '';
|
||||
const nextInvocationMode = item?.invocationMode ?? 'auto';
|
||||
const nextWhenToUse = 'Use this skill when the task matches these conditions.';
|
||||
const nextSteps = '1. Describe the first step.\n2. Describe the second step.';
|
||||
const nextNotes = '- Add caveats, review rules, or references.';
|
||||
const nextRawContent =
|
||||
detail?.rawContent ??
|
||||
buildSkillTemplate({
|
||||
|
|
@ -143,12 +186,18 @@ export const SkillEditorDialog = ({
|
|||
license: nextLicense,
|
||||
compatibility: nextCompatibility,
|
||||
invocationMode: nextInvocationMode,
|
||||
whenToUse: nextWhenToUse,
|
||||
steps: nextSteps,
|
||||
notes: nextNotes,
|
||||
});
|
||||
const rawInput = readSkillTemplateInput(nextRawContent);
|
||||
const rawInput = readSkillTemplateContent(nextRawContent);
|
||||
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
|
||||
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
|
||||
|
||||
setScope(nextScope);
|
||||
setRootKind(nextRootKind);
|
||||
setFolderName(nextFolderName || nextName || '');
|
||||
setFolderName(nextFolderName || suggestedFolderName || nextName || '');
|
||||
setFolderNameEdited(Boolean(item?.folderName));
|
||||
setName(rawInput.name || nextName || 'New Skill');
|
||||
setDescription(
|
||||
rawInput.description || nextDescription || 'Describe what this skill helps with.'
|
||||
|
|
@ -156,14 +205,26 @@ export const SkillEditorDialog = ({
|
|||
setLicense(rawInput.license ?? nextLicense);
|
||||
setCompatibility(rawInput.compatibility ?? nextCompatibility);
|
||||
setInvocationMode(rawInput.invocationMode ?? nextInvocationMode);
|
||||
setWhenToUse(
|
||||
hasCustomMarkdown
|
||||
? (rawInput.bodyMarkdown ?? nextRawContent)
|
||||
: (rawInput.whenToUse ?? nextWhenToUse)
|
||||
);
|
||||
setSteps(hasCustomMarkdown ? '' : (rawInput.steps ?? nextSteps));
|
||||
setNotes(hasCustomMarkdown ? '' : (rawInput.notes ?? nextNotes));
|
||||
setIncludeScripts(item?.flags.hasScripts ?? false);
|
||||
setIncludeReferences(item?.flags.hasReferences ?? false);
|
||||
setIncludeAssets(item?.flags.hasAssets ?? false);
|
||||
setCustomMarkdownDetected(hasCustomMarkdown);
|
||||
rawContentRef.current = nextRawContent;
|
||||
setRawContent(nextRawContent);
|
||||
setManualRawEdit(false);
|
||||
setShowAdvancedEditor(hasCustomMarkdown);
|
||||
setReviewPreview(null);
|
||||
setReviewOpen(false);
|
||||
setReviewLoading(false);
|
||||
setSaveLoading(false);
|
||||
setMutationError(null);
|
||||
}, [detail, mode, open, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -207,11 +268,28 @@ export const SkillEditorDialog = ({
|
|||
);
|
||||
|
||||
const canUseProjectScope = Boolean(projectPath);
|
||||
const instructionsLocked = manualRawEdit || customMarkdownDetected;
|
||||
const title = mode === 'create' ? 'Create skill' : 'Edit skill';
|
||||
const descriptionText =
|
||||
mode === 'create'
|
||||
? 'Draft a new local skill, review the filesystem changes, then save it into a supported skill root.'
|
||||
: 'Update the selected skill and review the resulting file changes before saving.';
|
||||
? 'Describe the workflow in plain language, review the files that will be created, then save it.'
|
||||
: 'Update this skill, review the resulting file changes, then save it.';
|
||||
|
||||
function validateBeforeReview(): string | null {
|
||||
if (!name.trim()) {
|
||||
return 'Add a skill name so people know what this workflow is for.';
|
||||
}
|
||||
if (!description.trim()) {
|
||||
return 'Add a short description so it is clear what this skill helps with.';
|
||||
}
|
||||
if (!folderName.trim()) {
|
||||
return 'Choose a folder name for this skill.';
|
||||
}
|
||||
if (scope === 'project' && !projectPath) {
|
||||
return 'Project skills need an active project.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent): void => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -242,16 +320,40 @@ export const SkillEditorDialog = ({
|
|||
}, [handleMouseMove, handleMouseUp, isResizing]);
|
||||
|
||||
async function handleReview(): Promise<void> {
|
||||
const preview = await previewSkillUpsert(request);
|
||||
setReviewPreview(preview);
|
||||
setReviewOpen(true);
|
||||
const validationError = validateBeforeReview();
|
||||
if (validationError) {
|
||||
setMutationError(validationError);
|
||||
return;
|
||||
}
|
||||
setReviewLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const preview = await previewSkillUpsert(request);
|
||||
setReviewPreview(preview);
|
||||
setReviewOpen(true);
|
||||
} catch (error) {
|
||||
setMutationError(error instanceof Error ? error.message : 'Failed to review skill changes');
|
||||
} finally {
|
||||
setReviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmSave(): Promise<void> {
|
||||
const saved = await applySkillUpsert(request);
|
||||
setReviewOpen(false);
|
||||
onSaved(saved?.item.id ?? detail?.item.id ?? null);
|
||||
onClose();
|
||||
setSaveLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const saved = await applySkillUpsert({
|
||||
...request,
|
||||
reviewPlanId: reviewPreview?.planId,
|
||||
});
|
||||
setReviewOpen(false);
|
||||
onSaved(saved?.item.id ?? detail?.item.id ?? null);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMutationError(error instanceof Error ? error.message : 'Failed to save skill');
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -266,9 +368,17 @@ export const SkillEditorDialog = ({
|
|||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text">1. Basics</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Give this skill a clear name, choose who can use it, and decide where it should
|
||||
live.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-scope">Scope</Label>
|
||||
<Label htmlFor="skill-scope">Who can use it</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => setScope(value as 'user' | 'project')}
|
||||
|
|
@ -289,7 +399,7 @@ export const SkillEditorDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-root">Root</Label>
|
||||
<Label htmlFor="skill-root">Where to store it</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -313,27 +423,36 @@ export const SkillEditorDialog = ({
|
|||
<Input
|
||||
id="skill-folder"
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setFolderNameEdited(true);
|
||||
setFolderName(event.target.value);
|
||||
}}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
{mode === 'create' && (
|
||||
<p className="text-xs text-text-muted">
|
||||
We suggest this automatically from the skill name so review works right
|
||||
away.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-invocation">Invocation</Label>
|
||||
<Label htmlFor="skill-invocation">How Claude should use it</Label>
|
||||
<Select
|
||||
value={invocationMode}
|
||||
onValueChange={(value) => {
|
||||
const nextValue = value as SkillInvocationMode;
|
||||
setInvocationMode(nextValue);
|
||||
applyMetadataToRawContent({ invocationMode: nextValue });
|
||||
applyFormToRawContent({ invocationMode: nextValue });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="skill-invocation">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="manual-only">Manual only</SelectItem>
|
||||
<SelectItem value="auto">Claude can use it automatically</SelectItem>
|
||||
<SelectItem value="manual-only">Only when you ask for it</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -348,7 +467,10 @@ export const SkillEditorDialog = ({
|
|||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setName(nextValue);
|
||||
applyMetadataToRawContent({ name: nextValue });
|
||||
if (mode === 'create' && !folderNameEdited) {
|
||||
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
|
||||
}
|
||||
applyFormToRawContent({ name: nextValue });
|
||||
}}
|
||||
placeholder="Write concise skill name"
|
||||
/>
|
||||
|
|
@ -361,7 +483,7 @@ export const SkillEditorDialog = ({
|
|||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setLicense(nextValue);
|
||||
applyMetadataToRawContent({ license: nextValue });
|
||||
applyFormToRawContent({ license: nextValue });
|
||||
}}
|
||||
placeholder="MIT"
|
||||
/>
|
||||
|
|
@ -377,7 +499,7 @@ export const SkillEditorDialog = ({
|
|||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDescription(nextValue);
|
||||
applyMetadataToRawContent({ description: nextValue });
|
||||
applyFormToRawContent({ description: nextValue });
|
||||
}}
|
||||
placeholder="What this skill helps with"
|
||||
/>
|
||||
|
|
@ -390,13 +512,90 @@ export const SkillEditorDialog = ({
|
|||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCompatibility(nextValue);
|
||||
applyMetadataToRawContent({ compatibility: nextValue });
|
||||
applyFormToRawContent({ compatibility: nextValue });
|
||||
}}
|
||||
placeholder="claude-code, cursor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!customMarkdownDetected && (
|
||||
<>
|
||||
<section className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text">2. Instructions</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
These sections generate the skill file for you, so you do not need to edit
|
||||
markdown unless you want to.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-when-to-use">When Claude should reach for this</Label>
|
||||
<Textarea
|
||||
id="skill-when-to-use"
|
||||
value={whenToUse}
|
||||
disabled={instructionsLocked}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setWhenToUse(nextValue);
|
||||
applyFormToRawContent({ whenToUse: nextValue });
|
||||
}}
|
||||
placeholder="Example: Use this when the task is a code review or bug triage request."
|
||||
className="min-h-[88px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-steps">Main steps Claude should follow</Label>
|
||||
<Textarea
|
||||
id="skill-steps"
|
||||
value={steps}
|
||||
disabled={instructionsLocked}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setSteps(nextValue);
|
||||
applyFormToRawContent({ steps: nextValue });
|
||||
}}
|
||||
placeholder={
|
||||
'1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.'
|
||||
}
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-notes">Extra notes or guardrails</Label>
|
||||
<Textarea
|
||||
id="skill-notes"
|
||||
value={notes}
|
||||
disabled={instructionsLocked}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setNotes(nextValue);
|
||||
applyFormToRawContent({ notes: nextValue });
|
||||
}}
|
||||
placeholder="Example: Call out missing tests, regressions, and risky assumptions."
|
||||
className="min-h-[88px]"
|
||||
/>
|
||||
{instructionsLocked && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Structured fields are locked because you switched to manual `SKILL.md`
|
||||
editing below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text">3. Extra files</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Add supporting docs, scripts, or assets only if this skill really needs them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -423,7 +622,7 @@ export const SkillEditorDialog = ({
|
|||
<div>
|
||||
<p className="font-medium text-text">References</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `references/README.md` for docs, links, and examples.
|
||||
Add supporting docs, links, or examples that Claude can look at.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -437,7 +636,8 @@ export const SkillEditorDialog = ({
|
|||
<div>
|
||||
<p className="font-medium text-text">Scripts</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `scripts/README.md` for helper commands or setup notes.
|
||||
Add helper commands or setup notes. Review carefully before sharing this
|
||||
skill.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -451,7 +651,7 @@ export const SkillEditorDialog = ({
|
|||
<div>
|
||||
<p className="font-medium text-text">Assets</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Add `assets/README.md` for screenshots or bundled media.
|
||||
Add screenshots or bundled media only if they help explain the workflow.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -473,74 +673,117 @@ export const SkillEditorDialog = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{skillsMutationError && (
|
||||
{mutationError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{skillsMutationError}
|
||||
{mutationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="skill-raw">SKILL.md</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setManualRawEdit(false);
|
||||
const nextRawContent = buildSkillTemplate({
|
||||
name,
|
||||
description,
|
||||
license,
|
||||
compatibility,
|
||||
invocationMode,
|
||||
});
|
||||
rawContentRef.current = nextRawContent;
|
||||
setRawContent(nextRawContent);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 size-3.5" />
|
||||
Reset From Template
|
||||
</Button>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">
|
||||
{customMarkdownDetected
|
||||
? '2. SKILL.md editor'
|
||||
: '4. Advanced SKILL.md editor'}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{customMarkdownDetected
|
||||
? 'This skill uses a custom markdown format, so edit it directly here.'
|
||||
: 'Most people can skip this. Open it only if you want direct control over the raw markdown file.'}
|
||||
</p>
|
||||
</div>
|
||||
{!customMarkdownDetected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedEditor((prev) => !prev)}
|
||||
>
|
||||
{showAdvancedEditor ? 'Hide Advanced Editor' : 'Show Advanced Editor'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-[520px] min-h-0 overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<div className="min-w-0" style={{ width: `${splitRatio * 100}%` }}>
|
||||
<SkillCodeEditor
|
||||
value={rawContent}
|
||||
onChange={(value) => {
|
||||
setManualRawEdit(true);
|
||||
rawContentRef.current = value;
|
||||
setRawContent(value);
|
||||
{showAdvancedEditor && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="skill-raw">SKILL.md</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setManualRawEdit(false);
|
||||
const nextRawContent = buildSkillTemplate({
|
||||
name,
|
||||
description,
|
||||
license,
|
||||
compatibility,
|
||||
invocationMode,
|
||||
whenToUse,
|
||||
steps,
|
||||
notes,
|
||||
});
|
||||
rawContentRef.current = nextRawContent;
|
||||
setRawContent(nextRawContent);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 size-3.5" />
|
||||
Reset From Structured Fields
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
const rawInput = readSkillTemplateInput(value);
|
||||
if (rawInput.name !== undefined) setName(rawInput.name);
|
||||
if (rawInput.description !== undefined)
|
||||
setDescription(rawInput.description);
|
||||
if (rawInput.license !== undefined) setLicense(rawInput.license);
|
||||
if (rawInput.compatibility !== undefined)
|
||||
setCompatibility(rawInput.compatibility);
|
||||
if (rawInput.invocationMode !== undefined)
|
||||
setInvocationMode(rawInput.invocationMode);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-[520px] min-h-0 overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<div className="min-w-0" style={{ width: `${splitRatio * 100}%` }}>
|
||||
<SkillCodeEditor
|
||||
value={rawContent}
|
||||
scrollRef={editorScrollRef}
|
||||
onScroll={scrollSync.handleCodeScroll}
|
||||
onChange={(value) => {
|
||||
setManualRawEdit(true);
|
||||
rawContentRef.current = value;
|
||||
setRawContent(value);
|
||||
|
||||
const rawInput = readSkillTemplateContent(value);
|
||||
setCustomMarkdownDetected(rawInput.hasUnstructuredBody);
|
||||
if (rawInput.name !== undefined) setName(rawInput.name);
|
||||
if (rawInput.description !== undefined)
|
||||
setDescription(rawInput.description);
|
||||
if (rawInput.license !== undefined) setLicense(rawInput.license);
|
||||
if (rawInput.compatibility !== undefined)
|
||||
setCompatibility(rawInput.compatibility);
|
||||
if (rawInput.invocationMode !== undefined)
|
||||
setInvocationMode(rawInput.invocationMode);
|
||||
if (rawInput.whenToUse !== undefined)
|
||||
setWhenToUse(rawInput.whenToUse);
|
||||
if (rawInput.steps !== undefined) setSteps(rawInput.steps);
|
||||
if (rawInput.notes !== undefined) setNotes(rawInput.notes);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1 shrink-0 cursor-col-resize border-x border-border ${
|
||||
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setIsResizing(true);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<MarkdownPreviewPane
|
||||
content={rawContent}
|
||||
baseDir={detail?.item.skillDir}
|
||||
scrollRef={scrollSync.previewScrollRef}
|
||||
onScroll={scrollSync.handlePreviewScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1 shrink-0 cursor-col-resize border-x border-border ${
|
||||
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setIsResizing(true);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<MarkdownPreviewPane content={rawContent} baseDir={detail?.item.skillDir} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -549,12 +792,15 @@ export const SkillEditorDialog = ({
|
|||
<X className="mr-1.5 size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<p className="min-w-[16rem] flex-1 text-sm text-text-muted">
|
||||
Review the file changes first, then confirm save in the next step.
|
||||
</p>
|
||||
<Button onClick={() => void handleReview()} disabled={skillsMutationLoading}>
|
||||
<div className="min-w-[16rem] flex-1">
|
||||
<p className="text-sm text-text-muted">
|
||||
Review the file changes first, then confirm save in the next step.
|
||||
</p>
|
||||
{mutationError && <p className="mt-1 text-sm text-red-400">{mutationError}</p>}
|
||||
</div>
|
||||
<Button onClick={() => void handleReview()} disabled={reviewLoading || saveLoading}>
|
||||
<FileSearch className="mr-1.5 size-3.5" />
|
||||
{skillsMutationLoading
|
||||
{reviewLoading
|
||||
? 'Preparing...'
|
||||
: mode === 'create'
|
||||
? 'Review And Create'
|
||||
|
|
@ -568,7 +814,8 @@ export const SkillEditorDialog = ({
|
|||
<SkillReviewDialog
|
||||
open={reviewOpen}
|
||||
preview={reviewPreview}
|
||||
loading={skillsMutationLoading}
|
||||
loading={saveLoading}
|
||||
error={mutationError}
|
||||
onClose={() => setReviewOpen(false)}
|
||||
onConfirm={() => void handleConfirmSave()}
|
||||
confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,28 @@ import { SkillReviewDialog } from './SkillReviewDialog';
|
|||
|
||||
import type { SkillReviewPreview } from '@shared/types/extensions';
|
||||
|
||||
function getFriendlyImportError(message: string): string {
|
||||
if (message.includes('valid skill file')) {
|
||||
return 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
|
||||
}
|
||||
if (message.includes('symbolic links')) {
|
||||
return 'This folder contains symbolic links. Import the real files instead of links.';
|
||||
}
|
||||
if (message.includes('too many files')) {
|
||||
return 'This skill folder is too large to import at once. Remove extra files and try again.';
|
||||
}
|
||||
if (message.includes('too large')) {
|
||||
return 'This skill folder is too large to import safely. Trim large assets and try again.';
|
||||
}
|
||||
if (message.includes('Invalid folder name')) {
|
||||
return 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
|
||||
}
|
||||
if (message.includes('must be a directory')) {
|
||||
return 'Choose a folder to import, not a single file.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
interface SkillImportDialogProps {
|
||||
open: boolean;
|
||||
projectPath: string | null;
|
||||
|
|
@ -43,8 +65,6 @@ export const SkillImportDialog = ({
|
|||
}: SkillImportDialogProps): React.JSX.Element => {
|
||||
const previewSkillImport = useStore((s) => s.previewSkillImport);
|
||||
const applySkillImport = useStore((s) => s.applySkillImport);
|
||||
const skillsMutationLoading = useStore((s) => s.skillsMutationLoading);
|
||||
const skillsMutationError = useStore((s) => s.skillsMutationError);
|
||||
|
||||
const [sourceDir, setSourceDir] = useState('');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
|
|
@ -52,6 +72,9 @@ export const SkillImportDialog = ({
|
|||
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
|
||||
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -61,6 +84,9 @@ export const SkillImportDialog = ({
|
|||
setRootKind('claude');
|
||||
setPreview(null);
|
||||
setReviewOpen(false);
|
||||
setReviewLoading(false);
|
||||
setImportLoading(false);
|
||||
setMutationError(null);
|
||||
}, [open, projectPath]);
|
||||
|
||||
async function handleChooseFolder(): Promise<void> {
|
||||
|
|
@ -75,28 +101,51 @@ export const SkillImportDialog = ({
|
|||
}
|
||||
|
||||
async function handleReview(): Promise<void> {
|
||||
const nextPreview = await previewSkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
});
|
||||
setPreview(nextPreview);
|
||||
setReviewOpen(true);
|
||||
setReviewLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const nextPreview = await previewSkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
});
|
||||
setPreview(nextPreview);
|
||||
setReviewOpen(true);
|
||||
} catch (error) {
|
||||
setMutationError(
|
||||
getFriendlyImportError(
|
||||
error instanceof Error ? error.message : 'Failed to review import changes'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setReviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmImport(): Promise<void> {
|
||||
const detail = await applySkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
});
|
||||
setReviewOpen(false);
|
||||
onImported(detail?.item.id ?? null);
|
||||
onClose();
|
||||
setImportLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const detail = await applySkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
reviewPlanId: preview?.planId,
|
||||
});
|
||||
setReviewOpen(false);
|
||||
onImported(detail?.item.id ?? null);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMutationError(
|
||||
getFriendlyImportError(error instanceof Error ? error.message : 'Failed to import skill')
|
||||
);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -107,13 +156,20 @@ export const SkillImportDialog = ({
|
|||
<DialogHeader className="border-b border-border px-6 py-5">
|
||||
<DialogTitle>Import skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick an existing skill folder, review the copy plan, then import it into a supported
|
||||
root.
|
||||
Pick an existing skill folder, review what will be copied, then import it into one
|
||||
of your supported skill locations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text">1. Choose a skill folder</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
This should be a folder that already contains a `SKILL.md`, `Skill.md`, or
|
||||
`skill.md` file.
|
||||
</p>
|
||||
</section>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-source">Source folder</Label>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -139,9 +195,15 @@ export const SkillImportDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text">2. Decide where it belongs</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Personal skills work everywhere. Project skills only show up for one codebase.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-scope">Scope</Label>
|
||||
<Label htmlFor="skill-import-scope">Who can use it</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => setScope(value as 'user' | 'project')}
|
||||
|
|
@ -161,7 +223,7 @@ export const SkillImportDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-import-root">Root</Label>
|
||||
<Label htmlFor="skill-import-root">Where to store it</Label>
|
||||
<Select
|
||||
value={rootKind}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -180,9 +242,9 @@ export const SkillImportDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{skillsMutationError && (
|
||||
{mutationError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{skillsMutationError}
|
||||
{mutationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -198,10 +260,10 @@ export const SkillImportDialog = ({
|
|||
</p>
|
||||
<Button
|
||||
onClick={() => void handleReview()}
|
||||
disabled={!sourceDir || skillsMutationLoading}
|
||||
disabled={!sourceDir || reviewLoading || importLoading}
|
||||
>
|
||||
<FileSearch className="mr-1.5 size-3.5" />
|
||||
{skillsMutationLoading ? 'Preparing...' : 'Review And Import'}
|
||||
{reviewLoading ? 'Preparing...' : 'Review And Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,11 +273,13 @@ export const SkillImportDialog = ({
|
|||
<SkillReviewDialog
|
||||
open={reviewOpen}
|
||||
preview={preview}
|
||||
loading={skillsMutationLoading}
|
||||
loading={importLoading}
|
||||
error={mutationError}
|
||||
onClose={() => setReviewOpen(false)}
|
||||
onConfirm={() => void handleConfirmImport()}
|
||||
confirmLabel="Import Skill"
|
||||
reviewLabel="Importing this skill"
|
||||
backLabel="Back To Import"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,20 +17,24 @@ interface SkillReviewDialogProps {
|
|||
open: boolean;
|
||||
preview: SkillReviewPreview | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
confirmLabel: string;
|
||||
reviewLabel: string;
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
export const SkillReviewDialog = ({
|
||||
open,
|
||||
preview,
|
||||
loading = false,
|
||||
error = null,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
reviewLabel,
|
||||
backLabel = 'Back To Editor',
|
||||
}: SkillReviewDialogProps): React.JSX.Element => {
|
||||
const hasChanges = Boolean(preview && preview.changes.length > 0);
|
||||
|
||||
|
|
@ -54,6 +58,18 @@ export const SkillReviewDialog = ({
|
|||
<div className="bg-surface-raised/10 rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{preview.changes.length} file changes</Badge>
|
||||
{preview.summary.created > 0 && (
|
||||
<Badge variant="secondary">{preview.summary.created} new</Badge>
|
||||
)}
|
||||
{preview.summary.updated > 0 && (
|
||||
<Badge variant="outline">{preview.summary.updated} updated</Badge>
|
||||
)}
|
||||
{preview.summary.deleted > 0 && (
|
||||
<Badge variant="destructive">{preview.summary.deleted} removed</Badge>
|
||||
)}
|
||||
{preview.summary.binary > 0 && (
|
||||
<Badge variant="destructive">{preview.summary.binary} binary</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-md border border-border bg-surface px-3 py-2 font-mono text-xs text-text-muted">
|
||||
{preview.targetSkillDir}
|
||||
|
|
@ -73,6 +89,12 @@ export const SkillReviewDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="bg-surface-raised/10 rounded-md border border-border p-4 text-sm text-text-muted">
|
||||
No file changes detected yet.
|
||||
|
|
@ -117,7 +139,7 @@ export const SkillReviewDialog = ({
|
|||
<div className="sticky bottom-0 z-10 flex items-center justify-between gap-3 border-t border-border bg-surface px-6 py-4 shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<ChevronLeft className="mr-1.5 size-3.5" />
|
||||
Back To Editor
|
||||
{backLabel}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={loading || !preview || !hasChanges}>
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@ import { SkillDetailDialog } from './SkillDetailDialog';
|
|||
import { SkillEditorDialog } from './SkillEditorDialog';
|
||||
import { SkillImportDialog } from './SkillImportDialog';
|
||||
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
||||
|
||||
const SUCCESS_BANNER_MS = 2500;
|
||||
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
||||
const USER_SKILLS_CATALOG_KEY = '__user__';
|
||||
type SkillsQuickFilter = 'all' | 'project' | 'personal' | 'needs-attention' | 'has-scripts';
|
||||
|
||||
interface SkillsPanelProps {
|
||||
projectPath: string | null;
|
||||
|
|
@ -56,6 +59,26 @@ function formatRootKind(rootKind: SkillCatalogItem['rootKind']): string {
|
|||
return `.${rootKind}`;
|
||||
}
|
||||
|
||||
function getScopeLabel(skill: SkillCatalogItem): string {
|
||||
return skill.scope === 'project' ? 'This project' : 'Personal';
|
||||
}
|
||||
|
||||
function getInvocationLabel(skill: SkillCatalogItem): string {
|
||||
return skill.invocationMode === 'manual-only'
|
||||
? 'Only runs when you explicitly ask for it'
|
||||
: 'Claude can use this automatically when it fits';
|
||||
}
|
||||
|
||||
function getSkillStatus(skill: SkillCatalogItem): string {
|
||||
if (!skill.isValid) {
|
||||
return 'Needs attention before you rely on it';
|
||||
}
|
||||
if (skill.flags.hasScripts) {
|
||||
return 'Includes scripts, so review it carefully';
|
||||
}
|
||||
return 'Ready to use';
|
||||
}
|
||||
|
||||
export const SkillsPanel = ({
|
||||
projectPath,
|
||||
projectLabel,
|
||||
|
|
@ -66,10 +89,11 @@ export const SkillsPanel = ({
|
|||
selectedSkillId,
|
||||
setSelectedSkillId,
|
||||
}: SkillsPanelProps): React.JSX.Element => {
|
||||
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
|
||||
const skillsLoading = useStore((s) => s.skillsLoading);
|
||||
const skillsError = useStore((s) => s.skillsError);
|
||||
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
|
||||
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
|
||||
const detailById = useStore((s) => s.skillsDetailsById);
|
||||
const userSkills = useStore((s) => s.skillsUserCatalog);
|
||||
const projectSkills = useStore((s) =>
|
||||
|
|
@ -77,9 +101,12 @@ export const SkillsPanel = ({
|
|||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingDetail, setEditingDetail] = useState<SkillDetail | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||
const [quickFilter, setQuickFilter] = useState<SkillsQuickFilter>('all');
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
|
||||
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
||||
selectedSkillIdRef.current = selectedSkillId;
|
||||
|
||||
|
|
@ -101,6 +128,12 @@ export const SkillsPanel = ({
|
|||
return () => window.clearTimeout(timeoutId);
|
||||
}, [successMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedSkillId) return;
|
||||
const timeoutId = window.setTimeout(() => setHighlightedSkillId(null), NEW_SKILL_HIGHLIGHT_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [highlightedSkillId]);
|
||||
|
||||
useEffect(() => {
|
||||
const skillsApi = api.skills;
|
||||
if (!skillsApi) return;
|
||||
|
|
@ -122,7 +155,9 @@ export const SkillsPanel = ({
|
|||
|
||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||
if (selectedSkillIdRef.current) {
|
||||
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined);
|
||||
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined).catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -137,7 +172,7 @@ export const SkillsPanel = ({
|
|||
|
||||
const visibleSkills = useMemo(() => {
|
||||
const q = skillsSearchQuery.trim().toLowerCase();
|
||||
const filtered = q
|
||||
const filteredByQuery = q
|
||||
? mergedSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
|
|
@ -145,8 +180,34 @@ export const SkillsPanel = ({
|
|||
skill.folderName.toLowerCase().includes(q)
|
||||
)
|
||||
: mergedSkills;
|
||||
const filtered =
|
||||
quickFilter === 'all'
|
||||
? filteredByQuery
|
||||
: filteredByQuery.filter((skill) => {
|
||||
switch (quickFilter) {
|
||||
case 'project':
|
||||
return skill.scope === 'project';
|
||||
case 'personal':
|
||||
return skill.scope === 'user';
|
||||
case 'needs-attention':
|
||||
return !skill.isValid;
|
||||
case 'has-scripts':
|
||||
return skill.flags.hasScripts;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return sortSkills(filtered, skillsSort);
|
||||
}, [mergedSkills, skillsSearchQuery, skillsSort]);
|
||||
}, [mergedSkills, quickFilter, skillsSearchQuery, skillsSort]);
|
||||
const visibleProjectSkills = useMemo(
|
||||
() => visibleSkills.filter((skill) => skill.scope === 'project'),
|
||||
[visibleSkills]
|
||||
);
|
||||
const visibleUserSkills = useMemo(
|
||||
() => visibleSkills.filter((skill) => skill.scope === 'user'),
|
||||
[visibleSkills]
|
||||
);
|
||||
const isRefreshing = skillsLoading && mergedSkills.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -155,12 +216,18 @@ export const SkillsPanel = ({
|
|||
<div className="min-w-0 flex-1 space-y-1 xl:max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-4 text-text-muted" />
|
||||
<h2 className="text-sm font-semibold text-text">Local skills catalog</h2>
|
||||
<h2 className="text-sm font-semibold text-text">Teach Claude repeatable work</h2>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm leading-5 text-text-muted">
|
||||
Skills are reusable instructions that help Claude handle the same kind of task more
|
||||
consistently.{' '}
|
||||
{projectPath
|
||||
? `Project skills for ${projectLabel ?? projectPath} plus your user-level skills.`
|
||||
: 'User-level skills only. Select a project to include project-scoped skill roots.'}
|
||||
? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
|
||||
: 'You are seeing only your personal skills right now.'}
|
||||
</p>
|
||||
<p className="max-w-2xl text-xs leading-5 text-text-muted">
|
||||
Use personal skills for habits you want everywhere. Use project skills for workflows
|
||||
that only make sense inside one codebase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -170,7 +237,7 @@ export const SkillsPanel = ({
|
|||
<SearchInput
|
||||
value={skillsSearchQuery}
|
||||
onChange={setSkillsSearchQuery}
|
||||
placeholder="Search skills..."
|
||||
placeholder="Search by skill name or what it helps with..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -230,19 +297,41 @@ export const SkillsPanel = ({
|
|||
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted xl:justify-end">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{mergedSkills.length} discovered
|
||||
{mergedSkills.length} total
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{projectSkills.length} project
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{userSkills.length} user
|
||||
{userSkills.length} personal
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
['all', 'All skills'],
|
||||
['project', 'Project'],
|
||||
['personal', 'Personal'],
|
||||
['needs-attention', 'Needs attention'],
|
||||
['has-scripts', 'Has scripts'],
|
||||
] as Array<[SkillsQuickFilter, string]>
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={quickFilter === value ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setQuickFilter(value)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{skillsError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
{skillsError}
|
||||
|
|
@ -256,6 +345,12 @@ export const SkillsPanel = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isRefreshing && (
|
||||
<div className="rounded-md border border-blue-500/20 bg-blue-500/10 p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
Refreshing skills...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skillsLoading && visibleSkills.length === 0 && (
|
||||
<div className="rounded-lg border border-border p-6 text-sm text-text-muted">
|
||||
Loading skills...
|
||||
|
|
@ -268,77 +363,183 @@ export const SkillsPanel = ({
|
|||
<Search className="size-5 text-text-muted" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{skillsSearchQuery ? 'No skills match your search' : 'No local skills found'}
|
||||
{skillsSearchQuery ? 'No skills match your search' : 'No skills yet'}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{skillsSearchQuery
|
||||
? 'Try a different search term.'
|
||||
: 'Skills are discovered from .claude/skills, .cursor/skills, and .agents/skills roots.'}
|
||||
? 'Try a different search term or switch filters.'
|
||||
: 'Create your first skill to teach Claude a repeatable workflow, or import one you already use.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleSkills.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className="bg-surface-raised/10 rounded-xl border border-border p-4 text-left transition-colors hover:border-border-emphasis"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
<div className="space-y-6">
|
||||
{visibleProjectSkills.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Project skills</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Workflows that only make sense for this codebase.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{skill.scope}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{formatRootKind(skill.rootKind)}
|
||||
{visibleProjectSkills.length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{skill.invocationMode}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
references
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleProjectSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleUserSkills.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Personal skills</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Habits and instructions you want Claude to remember everywhere.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{visibleUserSkills.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{visibleUserSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSkillId(skill.id)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
highlightedSkillId === skill.id
|
||||
? 'border-green-500/50 bg-green-500/10 shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
|
||||
: 'bg-surface-raised/10 border-border hover:border-border-emphasis'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{skill.name}</h3>
|
||||
{!skill.isValid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Needs attention
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-text-secondary">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{getScopeLabel(skill)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||
<p>{getInvocationLabel(skill)}</p>
|
||||
<p>{getSkillStatus(skill)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Stored in {formatRootKind(skill.rootKind)}
|
||||
</Badge>
|
||||
{skill.flags.hasScripts && (
|
||||
<Badge variant="destructive" className="font-normal">
|
||||
Has scripts
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasReferences && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
References
|
||||
</Badge>
|
||||
)}
|
||||
{skill.flags.hasAssets && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
Assets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{skill.issues[0]?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -347,7 +548,12 @@ export const SkillsPanel = ({
|
|||
open={selectedSkillId !== null}
|
||||
onClose={() => setSelectedSkillId(null)}
|
||||
projectPath={projectPath}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onEdit={() => {
|
||||
if (!selectedDetail) return;
|
||||
setEditingDetail(selectedDetail);
|
||||
setSelectedSkillId(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
onDeleted={() => setSelectedSkillId(null)}
|
||||
/>
|
||||
|
||||
|
|
@ -361,7 +567,8 @@ export const SkillsPanel = ({
|
|||
onSaved={(skillId) => {
|
||||
setCreateOpen(false);
|
||||
setSuccessMessage('Skill created successfully.');
|
||||
setSelectedSkillId(skillId);
|
||||
setHighlightedSkillId(skillId);
|
||||
setSelectedSkillId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -370,10 +577,14 @@ export const SkillsPanel = ({
|
|||
mode="edit"
|
||||
projectPath={projectPath}
|
||||
projectLabel={projectLabel}
|
||||
detail={selectedDetail}
|
||||
onClose={() => setEditOpen(false)}
|
||||
detail={editingDetail}
|
||||
onClose={() => {
|
||||
setEditOpen(false);
|
||||
setEditingDetail(null);
|
||||
}}
|
||||
onSaved={(skillId) => {
|
||||
setEditOpen(false);
|
||||
setEditingDetail(null);
|
||||
setSuccessMessage('Skill saved successfully.');
|
||||
setSelectedSkillId(skillId);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import YAML from 'yaml';
|
|||
import type { SkillDraftFile, SkillDraftTemplateInput } from '@shared/types/extensions';
|
||||
|
||||
const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u;
|
||||
const SECTION_TITLES = ['When to use', 'Steps', 'Notes'] as const;
|
||||
|
||||
export interface SkillDraftOptions {
|
||||
rawContent: string;
|
||||
|
|
@ -11,6 +12,12 @@ export interface SkillDraftOptions {
|
|||
includeAssets: boolean;
|
||||
}
|
||||
|
||||
export interface SkillTemplateParseResult extends Partial<SkillDraftTemplateInput> {
|
||||
bodyMarkdown?: string;
|
||||
hasStructuredSections: boolean;
|
||||
hasUnstructuredBody: boolean;
|
||||
}
|
||||
|
||||
function trimTrailingWhitespace(value: string): string {
|
||||
return value
|
||||
.split('\n')
|
||||
|
|
@ -20,6 +27,16 @@ function trimTrailingWhitespace(value: string): string {
|
|||
}
|
||||
|
||||
export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
|
||||
const whenToUse = normalizeSectionContent(input.whenToUse, [
|
||||
'- Add the conditions where this skill should be selected.',
|
||||
]);
|
||||
const steps = normalizeSectionContent(input.steps, [
|
||||
'1. Describe the first step.',
|
||||
'2. Describe the second step.',
|
||||
]);
|
||||
const notes = normalizeSectionContent(input.notes, [
|
||||
'- Add caveats, review rules, or references.',
|
||||
]);
|
||||
const lines = [
|
||||
'---',
|
||||
`name: ${input.name || 'New Skill'}`,
|
||||
|
|
@ -34,45 +51,80 @@ export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
|
|||
input.description || 'Describe what this skill helps with.',
|
||||
'',
|
||||
'## When to use',
|
||||
'- Add the conditions where this skill should be selected.',
|
||||
...whenToUse,
|
||||
'',
|
||||
'## Steps',
|
||||
'1. Describe the first step.',
|
||||
'2. Describe the second step.',
|
||||
...steps,
|
||||
'',
|
||||
'## Notes',
|
||||
'- Add caveats, review rules, or references.',
|
||||
...notes,
|
||||
];
|
||||
|
||||
return trimTrailingWhitespace(lines.join('\n'));
|
||||
}
|
||||
|
||||
export function readSkillTemplateInput(rawContent: string): Partial<SkillDraftTemplateInput> {
|
||||
export function readSkillTemplateContent(rawContent: string): SkillTemplateParseResult {
|
||||
const content = rawContent.replace(/^\uFEFF/u, '');
|
||||
const match = content.match(SKILL_FRONTMATTER_PATTERN);
|
||||
if (!match) {
|
||||
return {};
|
||||
return {
|
||||
hasStructuredSections: false,
|
||||
hasUnstructuredBody: content.trim().length > 0,
|
||||
bodyMarkdown: content.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = YAML.parse(match[1]);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
const bodyMarkdown = (match[2] ?? '').trim() || undefined;
|
||||
return {
|
||||
hasStructuredSections: false,
|
||||
hasUnstructuredBody: Boolean(bodyMarkdown),
|
||||
bodyMarkdown,
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
const body = match[2] ?? '';
|
||||
const whenToUse = extractSection(body, 'When to use');
|
||||
const steps = extractSection(body, 'Steps');
|
||||
const notes = extractSection(body, 'Notes');
|
||||
const bodyMarkdown = body.trim() || undefined;
|
||||
const hasStructuredSections = Boolean(whenToUse || steps || notes);
|
||||
return {
|
||||
name: typeof data.name === 'string' ? data.name : undefined,
|
||||
description: typeof data.description === 'string' ? data.description : undefined,
|
||||
license: typeof data.license === 'string' ? data.license : undefined,
|
||||
compatibility: typeof data.compatibility === 'string' ? data.compatibility : undefined,
|
||||
invocationMode: data['disable-model-invocation'] === true ? 'manual-only' : 'auto',
|
||||
whenToUse,
|
||||
steps,
|
||||
notes,
|
||||
bodyMarkdown,
|
||||
hasStructuredSections,
|
||||
hasUnstructuredBody: Boolean(bodyMarkdown) && !hasStructuredSections,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
const bodyMarkdown = (match[2] ?? '').trim() || undefined;
|
||||
return {
|
||||
hasStructuredSections: false,
|
||||
hasUnstructuredBody: Boolean(bodyMarkdown),
|
||||
bodyMarkdown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function readSkillTemplateInput(rawContent: string): Partial<SkillDraftTemplateInput> {
|
||||
const {
|
||||
bodyMarkdown: _bodyMarkdown,
|
||||
hasStructuredSections: _hasStructuredSections,
|
||||
hasUnstructuredBody: _hasUnstructuredBody,
|
||||
...input
|
||||
} = readSkillTemplateContent(rawContent);
|
||||
return input;
|
||||
}
|
||||
|
||||
export function updateSkillTemplateFrontmatter(
|
||||
rawContent: string,
|
||||
input: SkillDraftTemplateInput
|
||||
|
|
@ -145,3 +197,38 @@ export function buildSkillDraftFiles(options: SkillDraftOptions): SkillDraftFile
|
|||
|
||||
return files;
|
||||
}
|
||||
|
||||
function normalizeSectionContent(value: string, fallbackLines: string[]): string[] {
|
||||
const lines = value
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(
|
||||
(line, index, allLines) =>
|
||||
line.length > 0 || allLines.some((candidate) => candidate.length > 0)
|
||||
);
|
||||
|
||||
return lines.some((line) => line.trim().length > 0) ? lines : fallbackLines;
|
||||
}
|
||||
|
||||
function extractSection(body: string, title: (typeof SECTION_TITLES)[number]): string | undefined {
|
||||
const normalizedBody = body.replace(/\r\n/g, '\n');
|
||||
const heading = `## ${title}\n`;
|
||||
const startIndex = normalizedBody.indexOf(heading);
|
||||
if (startIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bodyStartIndex = startIndex + heading.length;
|
||||
const nextSectionIndex = SECTION_TITLES.map((sectionTitle) =>
|
||||
sectionTitle === title ? -1 : normalizedBody.indexOf(`\n## ${sectionTitle}\n`, bodyStartIndex)
|
||||
)
|
||||
.filter((index) => index >= 0)
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
const rawSection =
|
||||
nextSectionIndex === undefined
|
||||
? normalizedBody.slice(bodyStartIndex)
|
||||
: normalizedBody.slice(bodyStartIndex, nextSectionIndex);
|
||||
|
||||
return rawSection.trim() || undefined;
|
||||
}
|
||||
|
|
|
|||
127
src/renderer/components/ui/alert-dialog.tsx
Normal file
127
src/renderer/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
|
||||
import * as React from 'react';
|
||||
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { buttonVariants } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pointer-events-auto grid w-full max-w-lg gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight text-[var(--color-text)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-[var(--color-text-muted)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'destructive' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
|
||||
|
|
@ -29,10 +29,16 @@ const ATTACH_RETRY_INTERVAL = 50;
|
|||
|
||||
export interface UseMarkdownScrollSyncResult {
|
||||
previewScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Attach to editor scroll container when using a local CodeMirror instance */
|
||||
handleCodeScroll: () => void;
|
||||
/** Attach to preview div's onScroll */
|
||||
handlePreviewScroll: () => void;
|
||||
}
|
||||
|
||||
interface UseMarkdownScrollSyncOptions {
|
||||
editorScrollRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
|
@ -46,13 +52,17 @@ export interface UseMarkdownScrollSyncResult {
|
|||
*/
|
||||
export function useMarkdownScrollSync(
|
||||
enabled: boolean,
|
||||
viewKey?: string | null
|
||||
viewKey?: string | null,
|
||||
options?: UseMarkdownScrollSyncOptions
|
||||
): UseMarkdownScrollSyncResult {
|
||||
const previewScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const ignoreCodeScroll = useRef(false);
|
||||
const ignorePreviewScroll = useRef(false);
|
||||
const codeRafRef = useRef(0);
|
||||
const previewRafRef = useRef(0);
|
||||
const getEditorScrollElement = useCallback(() => {
|
||||
return options?.editorScrollRef?.current ?? editorBridge.getView()?.scrollDOM ?? null;
|
||||
}, [options?.editorScrollRef]);
|
||||
|
||||
// Code → Preview: proportional scroll
|
||||
const handleCodeScroll = useCallback(() => {
|
||||
|
|
@ -62,7 +72,7 @@ export function useMarkdownScrollSync(
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollDOM = editorBridge.getView()?.scrollDOM;
|
||||
const scrollDOM = getEditorScrollElement();
|
||||
const preview = previewScrollRef.current;
|
||||
if (!scrollDOM || !preview) return;
|
||||
|
||||
|
|
@ -78,7 +88,7 @@ export function useMarkdownScrollSync(
|
|||
ignorePreviewScroll.current = true;
|
||||
preview.scrollTop = fraction * maxPreview;
|
||||
});
|
||||
}, [enabled]);
|
||||
}, [enabled, getEditorScrollElement]);
|
||||
|
||||
// Preview → Code: proportional scroll
|
||||
const handlePreviewScroll = useCallback(() => {
|
||||
|
|
@ -88,7 +98,7 @@ export function useMarkdownScrollSync(
|
|||
return;
|
||||
}
|
||||
|
||||
const scrollDOM = editorBridge.getView()?.scrollDOM;
|
||||
const scrollDOM = getEditorScrollElement();
|
||||
const preview = previewScrollRef.current;
|
||||
if (!scrollDOM || !preview) return;
|
||||
|
||||
|
|
@ -104,7 +114,7 @@ export function useMarkdownScrollSync(
|
|||
ignoreCodeScroll.current = true;
|
||||
scrollDOM.scrollTop = fraction * maxCode;
|
||||
});
|
||||
}, [enabled]);
|
||||
}, [enabled, getEditorScrollElement]);
|
||||
|
||||
// Auto-attach code scroll listener with retry on mount/viewKey change
|
||||
useEffect(() => {
|
||||
|
|
@ -115,7 +125,7 @@ export function useMarkdownScrollSync(
|
|||
let attempts = 0;
|
||||
|
||||
const tryAttach = (): void => {
|
||||
const scrollDOM = editorBridge.getView()?.scrollDOM;
|
||||
const scrollDOM = getEditorScrollElement();
|
||||
if (!scrollDOM) {
|
||||
if (attempts < MAX_ATTACH_ATTEMPTS) {
|
||||
attempts++;
|
||||
|
|
@ -138,10 +148,11 @@ export function useMarkdownScrollSync(
|
|||
cancelAnimationFrame(codeRafRef.current);
|
||||
cancelAnimationFrame(previewRafRef.current);
|
||||
};
|
||||
}, [enabled, viewKey, handleCodeScroll]);
|
||||
}, [enabled, viewKey, handleCodeScroll, getEditorScrollElement]);
|
||||
|
||||
return {
|
||||
previewScrollRef,
|
||||
handleCodeScroll,
|
||||
handlePreviewScroll,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,10 +68,13 @@ export interface ExtensionsSlice {
|
|||
// ── Skills catalog cache ──
|
||||
skillsUserCatalog: SkillCatalogItem[];
|
||||
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
|
||||
skillsCatalogLoadingByProjectPath: Record<string, boolean>;
|
||||
skillsCatalogErrorByProjectPath: Record<string, string | null>;
|
||||
skillsLoading: boolean;
|
||||
skillsError: string | null;
|
||||
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
|
||||
skillsDetailLoadingById: Record<string, boolean>;
|
||||
skillsDetailErrorById: Record<string, string | null>;
|
||||
skillsMutationLoading: boolean;
|
||||
skillsMutationError: string | null;
|
||||
|
||||
|
|
@ -123,6 +126,20 @@ export interface ExtensionsSlice {
|
|||
|
||||
let pluginFetchInFlight: Promise<void> | null = null;
|
||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||
let skillsCatalogRequestSeq = 0;
|
||||
let skillsDetailRequestSeq = 0;
|
||||
const latestSkillsCatalogRequestByKey = new Map<string, number>();
|
||||
const latestSkillsDetailRequestById = new Map<string, number>();
|
||||
|
||||
const USER_SKILLS_CATALOG_KEY = '__user__';
|
||||
|
||||
function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
|
||||
return Object.values(loadingMap).some(Boolean);
|
||||
}
|
||||
|
||||
function getSkillsCatalogKey(projectPath?: string): string {
|
||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
}
|
||||
|
||||
/** Duration to show "success" state before returning to idle */
|
||||
const SUCCESS_DISPLAY_MS = 2_000;
|
||||
|
|
@ -162,10 +179,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
skillsUserCatalog: [],
|
||||
skillsProjectCatalogByProjectPath: {},
|
||||
skillsCatalogLoadingByProjectPath: {},
|
||||
skillsCatalogErrorByProjectPath: {},
|
||||
skillsLoading: false,
|
||||
skillsError: null,
|
||||
skillsDetailsById: {},
|
||||
skillsDetailLoadingById: {},
|
||||
skillsDetailErrorById: {},
|
||||
skillsMutationLoading: false,
|
||||
skillsMutationError: null,
|
||||
|
||||
|
|
@ -317,11 +337,44 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
fetchSkillsCatalog: async (projectPath?: string) => {
|
||||
if (!api.skills) return;
|
||||
|
||||
set({ skillsLoading: true, skillsError: null });
|
||||
const requestKey = getSkillsCatalogKey(projectPath);
|
||||
const requestId = ++skillsCatalogRequestSeq;
|
||||
latestSkillsCatalogRequestByKey.set(requestKey, requestId);
|
||||
|
||||
set((prev) => {
|
||||
const nextLoadingByProjectPath = {
|
||||
...prev.skillsCatalogLoadingByProjectPath,
|
||||
[requestKey]: true,
|
||||
};
|
||||
return {
|
||||
skillsCatalogLoadingByProjectPath: nextLoadingByProjectPath,
|
||||
skillsCatalogErrorByProjectPath: {
|
||||
...prev.skillsCatalogErrorByProjectPath,
|
||||
[requestKey]: null,
|
||||
},
|
||||
skillsLoading: hasAnyLoading(nextLoadingByProjectPath),
|
||||
skillsError: null,
|
||||
};
|
||||
});
|
||||
try {
|
||||
const skills = await api.skills.list(projectPath);
|
||||
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
skillsLoading: false,
|
||||
skillsCatalogLoadingByProjectPath: {
|
||||
...prev.skillsCatalogLoadingByProjectPath,
|
||||
[requestKey]: false,
|
||||
},
|
||||
skillsCatalogErrorByProjectPath: {
|
||||
...prev.skillsCatalogErrorByProjectPath,
|
||||
[requestKey]: null,
|
||||
},
|
||||
skillsLoading: hasAnyLoading({
|
||||
...prev.skillsCatalogLoadingByProjectPath,
|
||||
[requestKey]: false,
|
||||
}),
|
||||
skillsError: null,
|
||||
skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'),
|
||||
skillsProjectCatalogByProjectPath: projectPath
|
||||
|
|
@ -332,31 +385,62 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
: prev.skillsProjectCatalogByProjectPath,
|
||||
}));
|
||||
} catch (err) {
|
||||
set({
|
||||
skillsLoading: false,
|
||||
skillsError: err instanceof Error ? err.message : 'Failed to load skills',
|
||||
});
|
||||
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Failed to load skills';
|
||||
set((prev) => ({
|
||||
skillsCatalogLoadingByProjectPath: {
|
||||
...prev.skillsCatalogLoadingByProjectPath,
|
||||
[requestKey]: false,
|
||||
},
|
||||
skillsCatalogErrorByProjectPath: {
|
||||
...prev.skillsCatalogErrorByProjectPath,
|
||||
[requestKey]: message,
|
||||
},
|
||||
skillsLoading: hasAnyLoading({
|
||||
...prev.skillsCatalogLoadingByProjectPath,
|
||||
[requestKey]: false,
|
||||
}),
|
||||
skillsError: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
fetchSkillDetail: async (skillId: string, projectPath?: string) => {
|
||||
if (!api.skills) return;
|
||||
|
||||
const requestId = ++skillsDetailRequestSeq;
|
||||
latestSkillsDetailRequestById.set(skillId, requestId);
|
||||
|
||||
set((prev) => ({
|
||||
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true },
|
||||
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
|
||||
}));
|
||||
|
||||
try {
|
||||
const detail = await api.skills.getDetail(skillId, projectPath);
|
||||
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail },
|
||||
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
|
||||
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
|
||||
}));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Failed to load skill details';
|
||||
set((prev) => ({
|
||||
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: null },
|
||||
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
|
||||
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: message },
|
||||
}));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export type {
|
|||
SkillReviewAction,
|
||||
SkillReviewFileChange,
|
||||
SkillReviewPreview,
|
||||
SkillReviewSummary,
|
||||
SkillSaveResult,
|
||||
SkillScope,
|
||||
SkillSourceType,
|
||||
|
|
|
|||
|
|
@ -85,9 +85,12 @@ export interface SkillDraftTemplateInput {
|
|||
invocationMode: SkillInvocationMode;
|
||||
license: string;
|
||||
compatibility: string;
|
||||
whenToUse: string;
|
||||
steps: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export type SkillReviewAction = 'create' | 'update';
|
||||
export type SkillReviewAction = 'create' | 'update' | 'delete';
|
||||
|
||||
export interface SkillReviewFileChange {
|
||||
relativePath: string;
|
||||
|
|
@ -98,10 +101,19 @@ export interface SkillReviewFileChange {
|
|||
isBinary: boolean;
|
||||
}
|
||||
|
||||
export interface SkillReviewSummary {
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
binary: number;
|
||||
}
|
||||
|
||||
export interface SkillReviewPreview {
|
||||
planId: string;
|
||||
targetSkillDir: string;
|
||||
changes: SkillReviewFileChange[];
|
||||
warnings: string[];
|
||||
summary: SkillReviewSummary;
|
||||
}
|
||||
|
||||
export interface SkillUpsertRequest {
|
||||
|
|
@ -111,6 +123,7 @@ export interface SkillUpsertRequest {
|
|||
folderName: string;
|
||||
existingSkillId?: string;
|
||||
files: SkillDraftFile[];
|
||||
reviewPlanId?: string;
|
||||
}
|
||||
|
||||
export interface SkillImportRequest {
|
||||
|
|
@ -119,6 +132,7 @@ export interface SkillImportRequest {
|
|||
rootKind: SkillRootKind;
|
||||
projectPath?: string;
|
||||
folderName?: string;
|
||||
reviewPlanId?: string;
|
||||
}
|
||||
|
||||
export interface SkillDeleteRequest {
|
||||
|
|
|
|||
69
test/main/ipc/skills.test.ts
Normal file
69
test/main/ipc/skills.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
initializeSkillsHandlers,
|
||||
registerSkillsHandlers,
|
||||
removeSkillsHandlers,
|
||||
} from '@main/ipc/skills';
|
||||
import { SKILLS_APPLY_UPSERT, SKILLS_LIST } from '@preload/constants/ipcChannels';
|
||||
|
||||
describe('skills IPC handlers', () => {
|
||||
it('returns a validation error when applyUpsert has no request payload', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
};
|
||||
|
||||
initializeSkillsHandlers(
|
||||
{ list: vi.fn(), getDetail: vi.fn() } as any,
|
||||
{ previewUpsert: vi.fn(), applyUpsert: vi.fn() } as any,
|
||||
{ start: vi.fn(), stop: vi.fn() } as any
|
||||
);
|
||||
registerSkillsHandlers(ipcMain as any);
|
||||
|
||||
const result = (await handlers.get(SKILLS_APPLY_UPSERT)?.({})) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('request is required');
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
removeSkillsHandlers(ipcMain as any);
|
||||
});
|
||||
|
||||
it('returns successful list results from the catalog service', async () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
};
|
||||
|
||||
initializeSkillsHandlers(
|
||||
{
|
||||
list: vi.fn().mockResolvedValue([{ id: 'skill-1' }]),
|
||||
getDetail: vi.fn(),
|
||||
} as any,
|
||||
{ previewUpsert: vi.fn(), applyUpsert: vi.fn() } as any,
|
||||
{ start: vi.fn(), stop: vi.fn() } as any
|
||||
);
|
||||
registerSkillsHandlers(ipcMain as any);
|
||||
|
||||
const result = (await handlers.get(SKILLS_LIST)?.({}, '/tmp/project')) as {
|
||||
success: boolean;
|
||||
data?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual([{ id: 'skill-1' }]);
|
||||
|
||||
removeSkillsHandlers(ipcMain as any);
|
||||
});
|
||||
});
|
||||
44
test/main/services/extensions/SkillImportService.test.ts
Normal file
44
test/main/services/extensions/SkillImportService.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SkillImportService } from '@main/services/extensions/skills/SkillImportService';
|
||||
|
||||
describe('SkillImportService', () => {
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('skips hidden entries and reports the warning', async () => {
|
||||
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-import-'));
|
||||
createdDirs.push(sourceDir);
|
||||
|
||||
await fs.writeFile(path.join(sourceDir, 'SKILL.md'), '# Demo', 'utf8');
|
||||
await fs.writeFile(path.join(sourceDir, '.DS_Store'), 'hidden', 'utf8');
|
||||
await fs.mkdir(path.join(sourceDir, '.cache'), { recursive: true });
|
||||
await fs.writeFile(path.join(sourceDir, '.cache', 'ignore.txt'), 'hidden', 'utf8');
|
||||
|
||||
const inspection = await new SkillImportService().inspectSourceDir(sourceDir);
|
||||
|
||||
expect(inspection.files.map((file) => file.relativePath)).toEqual(['SKILL.md']);
|
||||
expect(inspection.hiddenEntriesSkipped).toBe(2);
|
||||
expect(inspection.warnings).toContain('Hidden files and folders were skipped during import.');
|
||||
});
|
||||
|
||||
it('rejects symbolic links in the import source', async () => {
|
||||
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-import-'));
|
||||
createdDirs.push(sourceDir);
|
||||
|
||||
await fs.writeFile(path.join(sourceDir, 'SKILL.md'), '# Demo', 'utf8');
|
||||
await fs.writeFile(path.join(sourceDir, 'real.txt'), 'hello', 'utf8');
|
||||
await fs.symlink(path.join(sourceDir, 'real.txt'), path.join(sourceDir, 'linked.txt'));
|
||||
|
||||
await expect(new SkillImportService().inspectSourceDir(sourceDir)).rejects.toThrow(
|
||||
'symbolic links'
|
||||
);
|
||||
});
|
||||
});
|
||||
51
test/main/services/extensions/SkillPlanService.test.ts
Normal file
51
test/main/services/extensions/SkillPlanService.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SkillPlanService } from '@main/services/extensions/skills/SkillPlanService';
|
||||
|
||||
describe('SkillPlanService', () => {
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('builds a canonical upsert plan including managed deletions', async () => {
|
||||
const service = new SkillPlanService();
|
||||
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-'));
|
||||
createdDirs.push(targetDir);
|
||||
|
||||
await fs.mkdir(path.join(targetDir, 'scripts'), { recursive: true });
|
||||
await fs.writeFile(path.join(targetDir, 'SKILL.md'), '# old', 'utf8');
|
||||
await fs.writeFile(path.join(targetDir, 'scripts', 'README.md'), 'old script', 'utf8');
|
||||
await fs.writeFile(path.join(targetDir, 'notes.txt'), 'keep me', 'utf8');
|
||||
|
||||
const plan = await service.buildUpsertPlan(targetDir, [
|
||||
{ relativePath: 'SKILL.md', content: '# new' },
|
||||
{ relativePath: 'references/README.md', content: '# refs' },
|
||||
]);
|
||||
|
||||
expect(plan.preview.summary).toEqual({
|
||||
created: 1,
|
||||
updated: 1,
|
||||
deleted: 1,
|
||||
binary: 0,
|
||||
});
|
||||
expect(
|
||||
Object.fromEntries(plan.preview.changes.map((change) => [change.relativePath, change.action]))
|
||||
).toEqual({
|
||||
'SKILL.md': 'update',
|
||||
'references/README.md': 'create',
|
||||
'scripts/README.md': 'delete',
|
||||
});
|
||||
expect(plan.preview.warnings).toContain(
|
||||
'1 managed file will be removed to match this reviewed plan.'
|
||||
);
|
||||
expect(plan.preview.warnings).toContain(
|
||||
'Existing files outside the managed skill set will be kept as-is.'
|
||||
);
|
||||
});
|
||||
});
|
||||
107
test/main/services/extensions/SkillsMutationService.test.ts
Normal file
107
test/main/services/extensions/SkillsMutationService.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SkillPlanService } from '@main/services/extensions/skills/SkillPlanService';
|
||||
import { SkillScaffoldService } from '@main/services/extensions/skills/SkillScaffoldService';
|
||||
import { SkillsCatalogService } from '@main/services/extensions/skills/SkillsCatalogService';
|
||||
import { SkillsMutationService } from '@main/services/extensions/skills/SkillsMutationService';
|
||||
|
||||
import type { ResolvedSkillRoot } from '@main/services/extensions/skills/SkillRootsResolver';
|
||||
|
||||
function createResolver(rootPath: string) {
|
||||
return {
|
||||
resolve(projectPath?: string): ResolvedSkillRoot[] {
|
||||
return [
|
||||
{
|
||||
scope: 'project',
|
||||
rootKind: 'claude',
|
||||
projectRoot: projectPath ?? rootPath,
|
||||
rootPath,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillsMutationService', () => {
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('applies the reviewed plan and deletes obsolete managed files', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-mutation-'));
|
||||
createdDirs.push(projectRoot);
|
||||
|
||||
const skillsRoot = path.join(projectRoot, '.claude', 'skills');
|
||||
const skillDir = path.join(skillsRoot, 'demo');
|
||||
await fs.mkdir(path.join(skillDir, 'scripts'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# old', 'utf8');
|
||||
await fs.writeFile(path.join(skillDir, 'scripts', 'README.md'), 'old script', 'utf8');
|
||||
|
||||
const resolver = createResolver(skillsRoot);
|
||||
const mutationService = new SkillsMutationService(
|
||||
resolver as any,
|
||||
new SkillsCatalogService(resolver as any),
|
||||
new SkillScaffoldService(resolver as any),
|
||||
undefined,
|
||||
new SkillPlanService()
|
||||
);
|
||||
|
||||
const request = {
|
||||
scope: 'project' as const,
|
||||
rootKind: 'claude' as const,
|
||||
projectPath: projectRoot,
|
||||
folderName: 'demo',
|
||||
existingSkillId: skillDir,
|
||||
files: [{ relativePath: 'SKILL.md', content: '# updated' }],
|
||||
};
|
||||
|
||||
const preview = await mutationService.previewUpsert(request);
|
||||
await mutationService.applyUpsert({ ...request, reviewPlanId: preview.planId });
|
||||
|
||||
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf8')).resolves.toBe('# updated');
|
||||
await expect(fs.stat(path.join(skillDir, 'scripts', 'README.md'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects apply when the reviewed plan is stale', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-mutation-'));
|
||||
createdDirs.push(projectRoot);
|
||||
|
||||
const skillsRoot = path.join(projectRoot, '.claude', 'skills');
|
||||
const skillDir = path.join(skillsRoot, 'demo');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# old', 'utf8');
|
||||
|
||||
const resolver = createResolver(skillsRoot);
|
||||
const mutationService = new SkillsMutationService(
|
||||
resolver as any,
|
||||
new SkillsCatalogService(resolver as any),
|
||||
new SkillScaffoldService(resolver as any),
|
||||
undefined,
|
||||
new SkillPlanService()
|
||||
);
|
||||
|
||||
const request = {
|
||||
scope: 'project' as const,
|
||||
rootKind: 'claude' as const,
|
||||
projectPath: projectRoot,
|
||||
folderName: 'demo',
|
||||
existingSkillId: skillDir,
|
||||
files: [{ relativePath: 'SKILL.md', content: '# updated' }],
|
||||
};
|
||||
|
||||
const preview = await mutationService.previewUpsert(request);
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# changed after review', 'utf8');
|
||||
|
||||
await expect(
|
||||
mutationService.applyUpsert({ ...request, reviewPlanId: preview.planId })
|
||||
).rejects.toThrow('changed after review');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSkillTemplate,
|
||||
readSkillTemplateInput,
|
||||
} from '../../../../../src/renderer/components/extensions/skills/skillDraftUtils';
|
||||
|
||||
describe('skillDraftUtils', () => {
|
||||
it('builds and parses structured sections for guided editing', () => {
|
||||
const template = buildSkillTemplate({
|
||||
name: 'Review Helper',
|
||||
description: 'Helps with code review',
|
||||
license: 'MIT',
|
||||
compatibility: 'claude-code',
|
||||
invocationMode: 'manual-only',
|
||||
whenToUse: 'Use this when a PR needs review.',
|
||||
steps: '1. Read the diff.\n2. Call out the biggest risk first.',
|
||||
notes: '- Prefer concrete findings over summaries.',
|
||||
});
|
||||
|
||||
const parsed = readSkillTemplateInput(template);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
name: 'Review Helper',
|
||||
description: 'Helps with code review',
|
||||
license: 'MIT',
|
||||
compatibility: 'claude-code',
|
||||
invocationMode: 'manual-only',
|
||||
whenToUse: 'Use this when a PR needs review.',
|
||||
steps: '1. Read the diff.\n2. Call out the biggest risk first.',
|
||||
notes: '- Prefer concrete findings over summaries.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -41,7 +41,12 @@ vi.mock('../../../src/renderer/api', () => ({
|
|||
|
||||
import { api } from '../../../src/renderer/api';
|
||||
|
||||
import type { EnrichedPlugin, McpCatalogItem } from '../../../src/shared/types/extensions';
|
||||
import type {
|
||||
EnrichedPlugin,
|
||||
McpCatalogItem,
|
||||
SkillCatalogItem,
|
||||
SkillDetail,
|
||||
} from '../../../src/shared/types/extensions';
|
||||
|
||||
const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
|
||||
pluginId: 'test@marketplace',
|
||||
|
|
@ -75,6 +80,42 @@ const makeMcpServer = (overrides: Partial<McpCatalogItem>): McpCatalogItem => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const makeSkill = (overrides: Partial<SkillCatalogItem>): SkillCatalogItem => ({
|
||||
id: '/tmp/skills/demo',
|
||||
sourceType: 'filesystem',
|
||||
name: 'Demo Skill',
|
||||
description: 'Helps with demo work',
|
||||
folderName: 'demo',
|
||||
scope: 'user',
|
||||
rootKind: 'claude',
|
||||
projectRoot: null,
|
||||
discoveryRoot: '/tmp/skills',
|
||||
skillDir: '/tmp/skills/demo',
|
||||
skillFile: '/tmp/skills/demo/SKILL.md',
|
||||
metadata: {},
|
||||
invocationMode: 'auto',
|
||||
flags: {
|
||||
hasScripts: false,
|
||||
hasReferences: false,
|
||||
hasAssets: false,
|
||||
},
|
||||
isValid: true,
|
||||
issues: [],
|
||||
modifiedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSkillDetail = (overrides: Partial<SkillDetail>): SkillDetail => ({
|
||||
item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }),
|
||||
body: 'body',
|
||||
rawContent: '# Demo',
|
||||
rawFrontmatter: null,
|
||||
referencesFiles: [],
|
||||
scriptFiles: [],
|
||||
assetFiles: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('extensionsSlice', () => {
|
||||
let store: TestStore;
|
||||
|
||||
|
|
@ -296,4 +337,64 @@ describe('extensionsSlice', () => {
|
|||
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skills state hardening', () => {
|
||||
it('ignores stale catalog responses for the same project key', async () => {
|
||||
let resolveFirst: ((value: SkillCatalogItem[]) => void) | null = null;
|
||||
const firstPromise = new Promise<SkillCatalogItem[]>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondResult = [
|
||||
makeSkill({
|
||||
id: '/tmp/project/.claude/skills/newer',
|
||||
skillDir: '/tmp/project/.claude/skills/newer',
|
||||
skillFile: '/tmp/project/.claude/skills/newer/SKILL.md',
|
||||
scope: 'project',
|
||||
projectRoot: '/tmp/project',
|
||||
discoveryRoot: '/tmp/project/.claude/skills',
|
||||
name: 'Newer Skill',
|
||||
}),
|
||||
];
|
||||
|
||||
(api.skills!.list as ReturnType<typeof vi.fn>)
|
||||
.mockImplementationOnce(() => firstPromise)
|
||||
.mockResolvedValueOnce(secondResult);
|
||||
|
||||
const firstFetch = store.getState().fetchSkillsCatalog('/tmp/project');
|
||||
const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project');
|
||||
|
||||
await secondFetch;
|
||||
resolveFirst?.([
|
||||
makeSkill({
|
||||
id: '/tmp/project/.claude/skills/older',
|
||||
skillDir: '/tmp/project/.claude/skills/older',
|
||||
skillFile: '/tmp/project/.claude/skills/older/SKILL.md',
|
||||
scope: 'project',
|
||||
projectRoot: '/tmp/project',
|
||||
discoveryRoot: '/tmp/project/.claude/skills',
|
||||
name: 'Older Skill',
|
||||
}),
|
||||
]);
|
||||
await firstFetch;
|
||||
|
||||
expect(store.getState().skillsProjectCatalogByProjectPath['/tmp/project']).toEqual(
|
||||
secondResult
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the previous detail cache when a detail fetch fails', async () => {
|
||||
const cachedDetail = makeSkillDetail();
|
||||
store.setState({
|
||||
skillsDetailsById: { [cachedDetail.item.id]: cachedDetail },
|
||||
});
|
||||
(api.skills!.getDetail as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('detail fail'));
|
||||
|
||||
await expect(
|
||||
store.getState().fetchSkillDetail(cachedDetail.item.id, '/tmp/project')
|
||||
).rejects.toThrow('detail fail');
|
||||
|
||||
expect(store.getState().skillsDetailsById[cachedDetail.item.id]).toEqual(cachedDetail);
|
||||
expect(store.getState().skillsDetailErrorById[cachedDetail.item.id]).toBe('detail fail');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue