feat(skills): multi-skill ZIP upload with hash-based idempotency (#846)

feat(skills): multi-skill ZIP upload with hash-based idempotency

- Multi-skill ZIP detection: upload single ZIP containing multiple skill directories
- Hash-based idempotency: SHA-256 of SKILL.md content deduplicates re-uploads
- Grouped upload UI: ZIP group headers with per-skill badges (NEW/UNCHANGED/ERROR)
- Safety: 50-skill per-ZIP limit, TOCTOU-safe hash check under lock
- Performance: cached ZIP parsing O(N) vs O(N*M)
- Tests: 17 frontend + 9 backend tests

Closes #845
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-12 17:26:31 +07:00
1 parent 034187ac9c
commit cbbdbc992f
28 files changed
+1669 -173

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

+24 -5
View File
@@ -56,13 +56,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
defer os.Remove(tmp.Name())
defer tmp.Close()
hasher := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, hasher), file)
size, err := io.Copy(tmp, file)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")})
return
}
fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
// Open as zip
zr, err := zip.OpenReader(tmp.Name())
@@ -126,11 +124,30 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Compute content hash of SKILL.md for idempotency check.
// Using SKILL.md content (not ZIP hash) so content-identical uploads are deduplicated
// even when packaged into different ZIP files (e.g. multi-skill split upload).
skillHash := fmt.Sprintf("%x", sha256.Sum256([]byte(skillContent)))
tenantSkillsBase := h.tenantSkillsDir(r)
uploadLock := h.skillUploadLock(filepath.Join(tenantSkillsBase, slug))
uploadLock.Lock()
defer uploadLock.Unlock()
// Check whether content is unchanged from the current stored version.
// Performed under lock to avoid TOCTOU race where concurrent uploads
// could both pass the hash check before either creates a new version.
existingHash, existingVer, skillExists := h.skills.GetSkillHashBySlug(r.Context(), slug)
if skillExists && existingHash != "" && existingHash == skillHash {
writeJSON(w, http.StatusOK, map[string]any{
"slug": slug,
"version": existingVer,
"name": name,
"status": "unchanged",
})
return
}
// Determine version (always increment — includes archived skills so re-upload gets v2+)
version := h.skills.GetNextVersion(r.Context(), slug)
@@ -191,12 +208,14 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
Version: version,
FilePath: destDir,
FileSize: size,
FileHash: &fileHash,
FileHash: &skillHash, // SKILL.md content hash for idempotency (not ZIP hash)
Frontmatter: frontmatter,
}
// Scan and check dependencies
response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active"}
// is_new is true only when no previous version of this skill existed (first upload).
isNew := !skillExists
response := map[string]any{"slug": slug, "version": version, "name": name, "status": "active", "is_new": isNew}
depState := uploadSkillDepState{}
depsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), uploadDepsInstallTimeout)
defer cancel()
+178 -5
View File
@@ -365,11 +365,12 @@ func skillMarkdown(name, slug string) string {
}
type skillManageStoreStub struct {
baseDir string
version int64
nextBySlug map[string]int
skills map[uuid.UUID]store.SkillInfo
systemDirs map[string]string
baseDir string
version int64
nextBySlug map[string]int
skills map[uuid.UUID]store.SkillInfo
systemDirs map[string]string
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
}
func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
@@ -378,6 +379,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
nextBySlug: map[string]int{},
skills: map[uuid.UUID]store.SkillInfo{},
systemDirs: map[string]string{},
hashBySlug: map[string]string{},
}
}
@@ -444,9 +446,23 @@ func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.Ski
Enabled: true,
MissingDeps: append([]string(nil), p.MissingDeps...),
}
// Track the content hash for idempotency checks (mirrors handler behaviour).
if p.FileHash != nil {
s.hashBySlug[p.Slug] = *p.FileHash
}
return id, nil
}
func (s *skillManageStoreStub) GetSkillHashBySlug(_ context.Context, slug string) (string, int, bool) {
hash, ok := s.hashBySlug[slug]
if !ok {
return "", 0, false
}
// Find the latest version for this slug.
version := s.nextBySlug[slug]
return hash, version, true
}
func (s *skillManageStoreStub) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error {
skill, ok := s.skills[id]
if !ok {
@@ -529,3 +545,160 @@ func (s *skillManageStoreStub) ListWithGrantStatus(context.Context, uuid.UUID) (
func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) {
return "", "", 0, false, false
}
// ---------------------------------------------------------------------------
// Hash comparison / idempotency tests
// ---------------------------------------------------------------------------
func TestHandleUpload_IdenticalContent_ReturnsUnchanged(t *testing.T) {
handler, skillStore, ctx, _ := newTestUploadHandler(t)
stubUploadDepFns(t,
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
return nil, nil
},
func(*skills.SkillManifest) (bool, []string) { return true, nil },
)
files := map[string]string{
"SKILL.md": skillMarkdown("Hash Skill", "hash-skill"),
}
// First upload — expect 201 Created, version 1.
req1 := newZipUploadRequest(t, ctx, files)
w1 := httptest.NewRecorder()
handler.handleUpload(w1, req1)
if w1.Code != http.StatusCreated {
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
}
// Second upload with identical content — expect 200 unchanged.
req2 := newZipUploadRequest(t, ctx, files)
w2 := httptest.NewRecorder()
handler.handleUpload(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
}
var resp struct {
Status string `json:"status"`
Version int `json:"version"`
Slug string `json:"slug"`
Name string `json:"name"`
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "unchanged" {
t.Fatalf("status = %q, want unchanged", resp.Status)
}
if resp.Version != 1 {
t.Fatalf("version = %d, want 1 (no new version created)", resp.Version)
}
if resp.Slug != "hash-skill" {
t.Fatalf("slug = %q, want hash-skill", resp.Slug)
}
// Verify no second DB row was created.
if got := skillStore.nextBySlug["hash-skill"]; got != 1 {
t.Fatalf("nextBySlug[hash-skill] = %d, want 1 (unchanged should not create new version)", got)
}
}
func TestHandleUpload_ChangedContent_BumpsVersion(t *testing.T) {
handler, skillStore, ctx, _ := newTestUploadHandler(t)
stubUploadDepFns(t,
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
return nil, nil
},
func(*skills.SkillManifest) (bool, []string) { return true, nil },
)
// First upload.
req1 := newZipUploadRequest(t, ctx, map[string]string{
"SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nOriginal body\n",
})
w1 := httptest.NewRecorder()
handler.handleUpload(w1, req1)
if w1.Code != http.StatusCreated {
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
}
// Second upload with different SKILL.md content.
req2 := newZipUploadRequest(t, ctx, map[string]string{
"SKILL.md": "---\nname: Change Skill\nslug: change-skill\n---\nUpdated body with new description\n",
})
w2 := httptest.NewRecorder()
handler.handleUpload(w2, req2)
if w2.Code != http.StatusCreated {
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
}
var resp struct {
Status string `json:"status"`
Version int `json:"version"`
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "active" {
t.Fatalf("status = %q, want active", resp.Status)
}
if resp.Version != 2 {
t.Fatalf("version = %d, want 2", resp.Version)
}
// Verify DB has version 2.
if got := skillStore.nextBySlug["change-skill"]; got != 2 {
t.Fatalf("nextBySlug[change-skill] = %d, want 2", got)
}
}
func TestHandleUpload_ResponseIncludesIsNew(t *testing.T) {
handler, _, ctx, _ := newTestUploadHandler(t)
stubUploadDepFns(t,
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
return nil, nil
},
func(*skills.SkillManifest) (bool, []string) { return true, nil },
)
// Brand-new skill upload should have is_new=true.
req1 := newZipUploadRequest(t, ctx, map[string]string{
"SKILL.md": skillMarkdown("IsNew Skill", "isnew-skill"),
})
w1 := httptest.NewRecorder()
handler.handleUpload(w1, req1)
if w1.Code != http.StatusCreated {
t.Fatalf("first upload: status = %d, body = %s", w1.Code, w1.Body.String())
}
var resp1 struct {
IsNew bool `json:"is_new"`
}
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
t.Fatalf("decode first response: %v", err)
}
if !resp1.IsNew {
t.Fatal("first upload: expected is_new=true")
}
// Changed-content re-upload should have is_new=false.
req2 := newZipUploadRequest(t, ctx, map[string]string{
"SKILL.md": "---\nname: IsNew Skill\nslug: isnew-skill\n---\nDifferent body content\n",
})
w2 := httptest.NewRecorder()
handler.handleUpload(w2, req2)
if w2.Code != http.StatusCreated {
t.Fatalf("second upload: status = %d, body = %s", w2.Code, w2.Body.String())
}
var resp2 struct {
IsNew bool `json:"is_new"`
}
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp2.IsNew {
t.Fatal("second upload (changed content): expected is_new=false")
}
}
+16
View File
@@ -222,6 +222,22 @@ func (s *PGSkillStore) GetNextVersion(ctx context.Context, slug string) int {
return maxVersion + 1
}
// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill
// version for the given slug, scoped to the current tenant.
// Returns ok=false when no matching row exists.
func (s *PGSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) {
tid := tenantIDForInsert(ctx)
var hash string
var version int
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(file_hash, ''), version FROM skills
WHERE slug = $1 AND tenant_id = $2 AND status != 'deleted'
ORDER BY version DESC LIMIT 1`,
slug, tid,
).Scan(&hash, &version)
return hash, version, err == nil
}
// GetNextVersionLocked computes the next version atomically using an advisory lock.
// Safe for concurrent write paths (patch, create). Returns version and a cleanup func
// that MUST be called to release the lock (commits the transaction).
+3
View File
@@ -108,6 +108,9 @@ type SkillManageStore interface {
GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool)
GetNextVersion(ctx context.Context, slug string) int
GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error)
// GetSkillHashBySlug returns the content hash and version of the latest non-deleted skill
// version for the given slug (tenant-scoped). Returns ok=false if no skill exists.
GetSkillHashBySlug(ctx context.Context, slug string) (hash string, version int, ok bool)
IsSystemSkill(slug string) bool
// System skill management
ListAllSkills(ctx context.Context) []SkillInfo
+15
View File
@@ -187,6 +187,21 @@ func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (
return filePath, slug, version, isSystem, err == nil
}
// GetSkillHashBySlug returns the file_hash and version of the latest non-deleted skill
// version for the given slug.
// Returns ok=false when no matching row exists.
func (s *SQLiteSkillStore) GetSkillHashBySlug(ctx context.Context, slug string) (string, int, bool) {
var hash string
var version int
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(file_hash, ''), version FROM skills
WHERE slug = ? AND status != 'deleted'
ORDER BY version DESC LIMIT 1`,
slug,
).Scan(&hash, &version)
return hash, version, err == nil
}
func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int {
var maxVersion int
s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion)
+3
View File
@@ -57,10 +57,13 @@
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.4",
"jsdom": "^29.0.2",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"unified": "^11.0.5",
"vite": "^8.0.8",
"vitest": "^4.1.4",
"ws": "^8.20.0"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
+600
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -46,7 +46,13 @@
"validCount": "{{valid}} of {{total}} valid",
"uploadCount": "Upload ({{count}})",
"successCount": "{{success}} of {{total}} uploaded successfully",
"remove": "Remove file"
"remove": "Remove file",
"tooManySkills": "ZIP contains too many skills (max 50)",
"multiDetected": "{{count}} skills detected",
"skillUnchanged": "Content unchanged — skipped",
"slugConflict": "Slug conflicts with a system skill",
"unchanged": "Unchanged",
"new": "New"
},
"edit": {
"title": "Edit Skill",
+7 -1
View File
@@ -46,7 +46,13 @@
"validCount": "{{valid}} / {{total}} hợp lệ",
"uploadCount": "Tải lên ({{count}})",
"successCount": "{{success}} / {{total}} đã tải lên thành công",
"remove": "Xóa tệp"
"remove": "Xóa tệp",
"tooManySkills": "ZIP chứa quá nhiều kỹ năng (tối đa 50)",
"multiDetected": "Phát hiện {{count}} kỹ năng",
"skillUnchanged": "Nội dung không đổi — đã bỏ qua",
"slugConflict": "Slug trùng với kỹ năng hệ thống",
"unchanged": "Không đổi",
"new": "Mới"
},
"edit": {
"title": "Chỉnh sửa skill",
+7 -1
View File
@@ -46,7 +46,13 @@
"validCount": "{{valid}} / {{total}} 有效",
"uploadCount": "上传 ({{count}})",
"successCount": "{{success}} / {{total}} 上传成功",
"remove": "移除文件"
"remove": "移除文件",
"tooManySkills": "ZIP 包含过多技能(最多 50 个)",
"multiDetected": "检测到 {{count}} 个技能",
"skillUnchanged": "内容未更改 — 已跳过",
"slugConflict": "Slug 与系统技能冲突",
"unchanged": "未更改",
"new": "新建"
},
"edit": {
"title": "编辑Skill",
+4 -1
View File
@@ -12,11 +12,14 @@ import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
export type { SkillInfo, SkillFile, SkillVersions };
export type SkillUploadResponse = {
id: string;
/** Absent when status is "unchanged" */
id?: string;
slug: string;
version: number;
name: string;
/** "active" | "unchanged" | "archived" */
status?: string;
is_new?: boolean;
deps_warning?: string;
deps_errors?: string[];
missing_deps?: string[];
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { createSkillSubZip } from "./create-skill-sub-zip";
describe("createSkillSubZip", () => {
it("extracts only files under the specified directory", async () => {
const zip = new JSZip();
zip.file("pdf/SKILL.md", "---\nname: PDF\n---\n# PDF");
zip.file("pdf/scripts/convert.py", "print('convert')");
zip.file("csv/SKILL.md", "---\nname: CSV\n---\n# CSV");
const subFile = await createSkillSubZip(zip, "pdf");
const subZip = await JSZip.loadAsync(subFile);
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
expect(paths.sort()).toEqual(["SKILL.md", "scripts/convert.py"]);
expect(subFile.name).toBe("pdf.zip");
});
it("handles nested subdirectories within skill dir", async () => {
const zip = new JSZip();
zip.file("my-skill/SKILL.md", "content");
zip.file("my-skill/scripts/helpers/util.py", "util");
zip.file("my-skill/references/guide.md", "guide");
const subFile = await createSkillSubZip(zip, "my-skill");
const subZip = await JSZip.loadAsync(subFile);
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
expect(paths.sort()).toEqual(["SKILL.md", "references/guide.md", "scripts/helpers/util.py"]);
});
it("returns empty ZIP for non-existent directory", async () => {
const zip = new JSZip();
zip.file("other/SKILL.md", "content");
const subFile = await createSkillSubZip(zip, "missing");
const subZip = await JSZip.loadAsync(subFile);
const paths = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
expect(paths).toEqual([]);
});
it("produces a File with correct mime type", async () => {
const zip = new JSZip();
zip.file("skill-a/SKILL.md", "content");
const subFile = await createSkillSubZip(zip, "skill-a");
expect(subFile).toBeInstanceOf(File);
expect(subFile.type).toBe("application/zip");
expect(subFile.name).toBe("skill-a.zip");
});
it("does not include directory entries in extracted ZIP", async () => {
const zip = new JSZip();
// Explicitly add a directory entry alongside a file
zip.folder("tool/scripts");
zip.file("tool/scripts/run.sh", "#!/bin/bash");
const subFile = await createSkillSubZip(zip, "tool");
const subZip = await JSZip.loadAsync(subFile);
const dirEntries = Object.values(subZip.files).filter((f) => f.dir);
// Directory entries may be created implicitly by JSZip but no explicit
// empty dir entries from the source should leak as files
const fileEntries = Object.keys(subZip.files).filter((p) => !subZip.files[p]!.dir);
expect(fileEntries).toEqual(["scripts/run.sh"]);
// dirEntries may exist (JSZip auto-creates them) — just ensure files are right
expect(dirEntries.length).toBeGreaterThanOrEqual(0);
});
});
@@ -0,0 +1,27 @@
/** Utility for extracting a single skill's subdirectory into a standalone ZIP file. */
import JSZip from "jszip";
/**
* Extract files under `dir/` from a pre-parsed JSZip instance, strip the
* directory prefix, and return a new File suitable for upload.
*
* Accepts a JSZip instance (not a File) so the caller can parse the original
* ZIP once and reuse it across multiple skill extractions — O(N) instead of
* O(N*M) where M is ZIP parse cost.
*/
export async function createSkillSubZip(zip: JSZip, dir: string): Promise<File> {
const sub = new JSZip();
const prefix = dir + "/";
for (const [path, entry] of Object.entries(zip.files)) {
if (path.startsWith(prefix) && !entry.dir) {
const relativePath = path.slice(prefix.length);
if (relativePath) {
sub.file(relativePath, await entry.async("blob"));
}
}
}
const blob = await sub.generateAsync({ type: "blob" });
return new File([blob], `${dir}.zip`, { type: "application/zip" });
}
@@ -0,0 +1,29 @@
/** Shared types for the skill upload dialog and its sub-components. */
/** Per-skill lifecycle status */
export type SkillStatus =
| "validating"
| "valid" // new skill, ready to upload
| "unchanged" // server returned identical hash — skipped
| "invalid"
| "uploading"
| "success"
| "warning" // uploaded but deps_warning present
| "error";
export interface SkillEntry {
id: string;
dir: string;
status: SkillStatus;
name?: string;
slug?: string;
contentHash?: string;
error?: string;
}
export interface FileEntry {
id: string;
file: File;
/** One entry per detected skill in this ZIP */
skills: SkillEntry[];
}
@@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
// Import the functions we'll create/modify
import { validateSkillZip, validateMultiSkillZip } from "./validate-skill-zip";
// Helper: create a ZIP file from an object mapping paths to content
async function createTestZip(files: Record<string, string>): Promise<File> {
const zip = new JSZip();
for (const [path, content] of Object.entries(files)) {
zip.file(path, content);
}
const blob = await zip.generateAsync({ type: "blob" });
return new File([blob], "test.zip", { type: "application/zip" });
}
const validFrontmatter = `---
name: Test Skill
slug: test-skill
description: A test skill
---
# Test Skill Content`;
const validFrontmatter2 = `---
name: Another Skill
slug: another-skill
description: Another test
---
# Another Skill`;
const noNameFrontmatter = `---
description: Missing name
---
# No Name`;
describe("validateSkillZip (backward compat)", () => {
it("validates single-skill ZIP at root", async () => {
const file = await createTestZip({ "SKILL.md": validFrontmatter });
const result = await validateSkillZip(file);
expect(result.valid).toBe(true);
expect(result.name).toBe("Test Skill");
expect(result.slug).toBe("test-skill");
});
it("validates single-skill ZIP in subdirectory", async () => {
const file = await createTestZip({ "my-skill/SKILL.md": validFrontmatter });
const result = await validateSkillZip(file);
expect(result.valid).toBe(true);
expect(result.name).toBe("Test Skill");
});
it("rejects ZIP without SKILL.md", async () => {
const file = await createTestZip({ "readme.txt": "hello" });
const result = await validateSkillZip(file);
expect(result.valid).toBe(false);
expect(result.error).toBe("upload.noSkillMd");
});
it("rejects empty SKILL.md", async () => {
const file = await createTestZip({ "SKILL.md": " " });
const result = await validateSkillZip(file);
expect(result.valid).toBe(false);
expect(result.error).toBe("upload.emptySkillMd");
});
it("rejects missing frontmatter name", async () => {
const file = await createTestZip({ "SKILL.md": noNameFrontmatter });
const result = await validateSkillZip(file);
expect(result.valid).toBe(false);
expect(result.error).toBe("upload.nameRequired");
});
it("auto-slugifies name when no slug provided", async () => {
const fm = `---\nname: My Cool Tool\ndescription: test\n---\n# Content`;
const file = await createTestZip({ "SKILL.md": fm });
const result = await validateSkillZip(file);
expect(result.valid).toBe(true);
expect(result.slug).toBe("my-cool-tool");
});
});
describe("validateMultiSkillZip", () => {
it("detects multiple skills in ZIP", async () => {
const file = await createTestZip({
"pdf/SKILL.md": validFrontmatter,
"csv/SKILL.md": validFrontmatter2,
});
const result = await validateMultiSkillZip(file);
expect(result.skills).toHaveLength(2);
expect(result.skills.map(s => s.slug).sort()).toEqual(["another-skill", "test-skill"]);
});
it("root SKILL.md returns single entry (no subdir scan)", async () => {
const file = await createTestZip({
"SKILL.md": validFrontmatter,
"subdir/SKILL.md": validFrontmatter2,
});
const result = await validateMultiSkillZip(file);
expect(result.skills).toHaveLength(1);
expect(result.skills[0]!.slug).toBe("test-skill");
});
it("computes per-skill SHA-256 contentHash", async () => {
const file = await createTestZip({ "skill/SKILL.md": validFrontmatter });
const result = await validateMultiSkillZip(file);
expect(result.skills[0]!.contentHash).toBeDefined();
expect(result.skills[0]!.contentHash).toMatch(/^[a-f0-9]{64}$/);
});
it("ignores directories without SKILL.md", async () => {
const file = await createTestZip({
"pdf/SKILL.md": validFrontmatter,
"assets/image.png": "binary",
});
const result = await validateMultiSkillZip(file);
expect(result.skills).toHaveLength(1);
expect(result.skills[0]!.dir).toBe("pdf");
});
it("reports per-skill validation errors", async () => {
const file = await createTestZip({
"good/SKILL.md": validFrontmatter,
"bad/SKILL.md": noNameFrontmatter,
});
const result = await validateMultiSkillZip(file);
expect(result.skills).toHaveLength(2);
const good = result.skills.find(s => s.dir === "good");
const bad = result.skills.find(s => s.dir === "bad");
expect(good?.valid).toBe(true);
expect(bad?.valid).toBe(false);
expect(bad?.error).toBe("upload.nameRequired");
});
it("backward compat: single-skill ZIP returns skills array of 1", async () => {
const file = await createTestZip({ "SKILL.md": validFrontmatter });
const result = await validateMultiSkillZip(file);
expect(result.skills).toHaveLength(1);
expect(result.skills[0]!.valid).toBe(true);
expect(result.skills[0]!.name).toBe("Test Skill");
});
});
+147 -35
View File
@@ -1,5 +1,8 @@
/** Client-side validation for skill ZIP files before upload.
* Mirrors server-side checks in internal/http/skills_upload.go */
* Mirrors server-side checks in internal/http/skills_upload.go
*
* Supports both single-skill ZIPs (SKILL.md at root or one top-level dir)
* and multi-skill ZIPs (one SKILL.md per top-level directory). */
import JSZip from "jszip";
export interface SkillZipValidation {
@@ -12,70 +15,179 @@ export interface SkillZipValidation {
errorDetail?: string;
}
/** Per-skill entry returned by validateMultiSkillZip */
export interface SkillValidationEntry {
valid: boolean;
/** Top-level directory name, or "" for root-level SKILL.md */
dir: string;
name?: string;
slug?: string;
description?: string;
/** SHA-256 hex digest of the SKILL.md content */
contentHash?: string;
/** i18n key under "upload." namespace */
error?: string;
errorDetail?: string;
}
export interface MultiSkillZipValidation {
skills: SkillValidationEntry[];
/** Top-level ZIP error (corrupt file, not a ZIP, too large) */
error?: string;
}
// Constants matching server-side (internal/http/skills.go)
const MAX_SKILL_SIZE = 20 * 1024 * 1024; // 20MB
const MAX_SKILLS_PER_ZIP = 50;
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
/** Validate a skill ZIP file client-side. JSZip is lazy-loaded. */
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Validate a skill ZIP file client-side — backward-compatible single-skill path.
* Delegates to validateMultiSkillZip and returns the first skill's result.
*/
export async function validateSkillZip(file: File): Promise<SkillZipValidation> {
const multi = await validateMultiSkillZip(file);
if (multi.error) return { valid: false, error: multi.error };
const first = multi.skills[0];
if (!first) return { valid: false, error: "upload.noSkillMd" };
return {
valid: first.valid,
name: first.name,
slug: first.slug,
description: first.description,
error: first.error,
errorDetail: first.errorDetail,
};
}
/**
* Validate a ZIP that may contain one or multiple skills.
*
* Detection logic:
* - If SKILL.md exists at root → single-skill mode (root entry, dir="")
* - Otherwise → scan each top-level directory for SKILL.md
*
* Returns one SkillValidationEntry per detected SKILL.md, each independently
* validated with a SHA-256 contentHash.
*/
export async function validateMultiSkillZip(file: File): Promise<MultiSkillZipValidation> {
if (!file.name.toLowerCase().endsWith(".zip")) {
return { valid: false, error: "upload.onlyZip" };
return { skills: [], error: "upload.onlyZip" };
}
if (file.size > MAX_SKILL_SIZE) {
return { valid: false, error: "upload.tooLarge" };
return { skills: [], error: "upload.tooLarge" };
}
let zip: JSZip;
try {
zip = await JSZip.loadAsync(file);
} catch {
return { valid: false, error: "upload.invalidZip" };
return { skills: [], error: "upload.invalidZip" };
}
// Find SKILL.md at root or inside single top-level directory
const skillMdContent = await findSkillMd(zip);
if (skillMdContent === null) {
return { valid: false, error: "upload.noSkillMd" };
const entries = await findAllSkillMds(zip);
if (entries.length === 0) {
return { skills: [] };
}
if (!skillMdContent.trim()) {
return { valid: false, error: "upload.emptySkillMd" };
if (entries.length > MAX_SKILLS_PER_ZIP) {
return { skills: [], error: "upload.tooManySkills" };
}
// Parse frontmatter
const match = skillMdContent.match(FRONTMATTER_REGEX);
if (!match?.[1]) {
return { valid: false, error: "upload.noFrontmatter" };
}
const fields = parseFrontmatterFields(match[1]);
if (!fields.name) {
return { valid: false, error: "upload.nameRequired" };
}
// Validate each skill entry concurrently
const skills = await Promise.all(
entries.map(({ dir, content }) => validateSkillEntry(dir, content)),
);
const slug = fields.slug || slugify(fields.name);
if (!SLUG_REGEX.test(slug)) {
return { valid: false, error: "upload.invalidSlug", errorDetail: slug };
}
return { valid: true, name: fields.name, slug, description: fields.description };
return { skills };
}
/** Find SKILL.md content — root level or inside a single top-level directory */
async function findSkillMd(zip: JSZip): Promise<string | null> {
// Try root
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
interface SkillMdEntry {
dir: string;
content: string;
}
/**
* Find all SKILL.md files in the ZIP.
*
* Priority rule: if SKILL.md exists at root level, return only that one entry
* (single-skill mode). Otherwise collect one per top-level directory.
*/
async function findAllSkillMds(zip: JSZip): Promise<SkillMdEntry[]> {
// Root-level SKILL.md → single-skill mode
if (zip.files["SKILL.md"] && !zip.files["SKILL.md"].dir) {
return zip.files["SKILL.md"].async("string");
const content = await zip.files["SKILL.md"].async("string");
return [{ dir: "", content }];
}
// Try single top-level dir (e.g. "my-skill/SKILL.md")
// Multi-skill: collect directories that contain a SKILL.md
const paths = Object.keys(zip.files);
const topDirs = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean));
for (const dir of topDirs) {
const topDirs = new Set(
paths
.map((p) => p.split("/")[0])
.filter((d): d is string => Boolean(d)),
);
const results: SkillMdEntry[] = [];
// Process dirs in stable sorted order for deterministic output
for (const dir of [...topDirs].sort()) {
const key = dir + "/SKILL.md";
if (zip.files[key] && !zip.files[key].dir) {
return zip.files[key].async("string");
const content = await zip.files[key].async("string");
results.push({ dir, content });
}
}
return null;
return results;
}
/** Validate a single SKILL.md entry, compute its hash, return SkillValidationEntry */
async function validateSkillEntry(dir: string, content: string): Promise<SkillValidationEntry> {
if (!content.trim()) {
return { valid: false, dir, error: "upload.emptySkillMd" };
}
const match = content.match(FRONTMATTER_REGEX);
if (!match?.[1]) {
return { valid: false, dir, error: "upload.noFrontmatter" };
}
const fields = parseFrontmatterFields(match[1]);
if (!fields.name) {
return { valid: false, dir, error: "upload.nameRequired" };
}
const slug = fields.slug ?? slugify(fields.name);
if (!SLUG_REGEX.test(slug)) {
return { valid: false, dir, error: "upload.invalidSlug", errorDetail: slug };
}
const contentHash = await hashContent(content);
return {
valid: true,
dir,
name: fields.name,
slug,
description: fields.description,
contentHash,
};
}
/** SHA-256 hex digest using Web Crypto API */
async function hashContent(content: string): Promise<string> {
const data = new TextEncoder().encode(content);
const buf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Simple key: value parser matching server's parseSkillFrontmatter() */
+154 -124
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Upload, CheckCircle2, XCircle, Loader2, TriangleAlert, X } from "lucide-react";
import { Upload } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -10,20 +10,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { validateSkillZip } from "./lib/validate-skill-zip";
import { validateMultiSkillZip } from "./lib/validate-skill-zip";
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
import { uniqueId } from "@/lib/utils";
import type { SkillUploadResponse } from "./hooks/use-skills";
type FileStatus = "validating" | "valid" | "invalid" | "uploading" | "success" | "warning" | "error";
interface FileEntry {
id: string;
file: File;
status: FileStatus;
name?: string;
slug?: string;
error?: string;
}
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
import { FileEntryBlock } from "./skill-upload-entry";
import JSZip from "jszip";
interface SkillUploadDialogProps {
open: boolean;
@@ -39,43 +32,68 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
const [done, setDone] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// ---------------------------------------------------------------------------
// File handling
// ---------------------------------------------------------------------------
const addFiles = async (fileList: FileList) => {
const newFiles = Array.from(fileList);
// Build pending list before setState — no side effects inside updater
const existingNames = new Set(entries.map((e) => e.file.name));
const fresh = newFiles.filter((f) => !existingNames.has(f.name));
if (fresh.length === 0) return;
// Add placeholder entries with validating status
const pending: FileEntry[] = fresh.map((f) => ({
id: uniqueId(),
file: f,
status: "validating" as const,
skills: [{ id: uniqueId(), dir: "", status: "validating" as const }],
}));
setEntries((prev) => [...prev, ...pending]);
// Validate all files concurrently, catch per-file errors
// Validate all files concurrently
const results = await Promise.all(
pending.map(async (entry) => {
try {
return { id: entry.id, result: await validateSkillZip(entry.file) };
const validation = await validateMultiSkillZip(entry.file);
const placeholderId = entry.skills[0]?.id ?? uniqueId();
if (validation.error) {
return {
id: entry.id,
skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: validation.error }],
};
}
if (validation.skills.length === 0) {
return {
id: entry.id,
skills: [{ id: placeholderId, dir: "", status: "invalid" as SkillStatus, error: "upload.noSkillMd" }],
};
}
return {
id: entry.id,
skills: validation.skills.map((s) => ({
id: uniqueId(),
dir: s.dir,
status: s.valid ? ("valid" as SkillStatus) : ("invalid" as SkillStatus),
name: s.name,
slug: s.slug,
contentHash: s.contentHash,
error: s.error,
})),
};
} catch {
return { id: entry.id, result: { valid: false, error: "upload.invalidZip" } as const };
return {
id: entry.id,
skills: [{ id: entry.skills[0]?.id ?? uniqueId(), dir: "", status: "invalid" as SkillStatus, error: "upload.invalidZip" }],
};
}
}),
);
setEntries((prev) =>
prev.map((e) => {
const match = results.find((r) => r.id === e.id);
if (!match) return e;
const { result } = match;
return {
...e,
status: result.valid ? "valid" : "invalid",
name: "name" in result ? result.name : undefined,
slug: "slug" in result ? result.slug : undefined,
error: result.error,
};
return match ? { ...e, skills: match.skills } : e;
}),
);
};
@@ -84,43 +102,112 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
setEntries((prev) => prev.filter((e) => e.id !== id));
};
// ---------------------------------------------------------------------------
// Upload — parse each ZIP once, reuse across all skills in that file
// ---------------------------------------------------------------------------
const handleSubmit = async () => {
const validEntries = entries.filter((e) => e.status === "valid");
if (validEntries.length === 0) return;
const actionable = entries.flatMap((e) =>
e.skills
.filter((s) => s.status === "valid")
.map((s) => ({ fileEntry: e, skill: s })),
);
if (actionable.length === 0) return;
setUploading(true);
for (const entry of validEntries) {
// Cache parsed JSZip instances keyed by FileEntry id — avoids re-parsing
// the same ZIP blob for every skill in a multi-skill archive (O(N) vs O(N*M)).
const parsedZips = new Map<string, JSZip>();
for (const { fileEntry, skill } of actionable) {
setEntries((prev) =>
prev.map((e) => (e.id === entry.id ? { ...e, status: "uploading" } : e)),
prev.map((e) =>
e.id === fileEntry.id
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "uploading" as SkillStatus } : s) }
: e,
),
);
try {
const result = await onUpload(entry.file);
const detail = result.deps_warning
let uploadFile: File;
if (skill.dir && fileEntry.skills.length > 1) {
// Parse the ZIP once per FileEntry; reuse on subsequent skills
if (!parsedZips.has(fileEntry.id)) {
parsedZips.set(fileEntry.id, await JSZip.loadAsync(fileEntry.file));
}
uploadFile = await createSkillSubZip(parsedZips.get(fileEntry.id)!, skill.dir);
} else {
uploadFile = fileEntry.file;
}
const result = await onUpload(uploadFile);
if (result.status === "unchanged") {
setEntries((prev) =>
prev.map((e) =>
e.id === fileEntry.id
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "unchanged" as SkillStatus } : s) }
: e,
),
);
continue;
}
const depDetail = result.deps_warning
? result.deps_errors?.length
? `${result.deps_warning}: ${result.deps_errors.join("; ")}`
: result.deps_warning
: undefined;
setEntries((prev) =>
prev.map((e) => (e.id === entry.id ? {
...e,
status: result.deps_warning ? "warning" : "success",
error: detail,
} : e)),
prev.map((e) =>
e.id === fileEntry.id
? {
...e,
skills: e.skills.map((s) =>
s.id === skill.id
? {
...s,
status: result.deps_warning ? ("warning" as SkillStatus) : ("success" as SkillStatus),
error: depDetail,
}
: s,
),
}
: e,
),
);
} catch (err) {
setEntries((prev) =>
prev.map((e) =>
e.id === entry.id
? { ...e, status: "error", error: err instanceof Error ? err.message : t("upload.failed") }
e.id === fileEntry.id
? {
...e,
skills: e.skills.map((s) =>
s.id === skill.id
? {
...s,
status: "error" as SkillStatus,
error: err instanceof Error ? err.message : t("upload.failed"),
}
: s,
),
}
: e,
),
);
}
}
setUploading(false);
setDone(true);
};
// ---------------------------------------------------------------------------
// Dialog housekeeping
// ---------------------------------------------------------------------------
const handleClose = (v: boolean) => {
if (uploading) return;
setEntries([]);
@@ -132,21 +219,25 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files);
}
// Reset so same files can be re-selected
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
if (inputRef.current) inputRef.current.value = "";
};
const validCount = entries.filter((e) => e.status === "valid").length;
const successCount = entries.filter((e) => e.status === "success" || e.status === "warning").length;
// ---------------------------------------------------------------------------
// Derived counts (skill level, not file level)
// ---------------------------------------------------------------------------
const allSkills = entries.flatMap((e) => e.skills);
const actionableCount = allSkills.filter((s) => s.status === "valid").length;
const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Dialog open={open} onOpenChange={handleClose}>
@@ -156,7 +247,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
<DialogDescription>{t("upload.description")}</DialogDescription>
</DialogHeader>
{/* Drop zone — always visible unless uploading/done */}
{/* Drop zone — hidden once upload starts or finishes */}
{!uploading && !done && (
<div
role="button"
@@ -165,7 +256,12 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
dragging ? "border-primary bg-primary/5" : "hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
@@ -186,11 +282,11 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
</div>
)}
{/* File list */}
{/* File + skill list */}
{entries.length > 0 && (
<div className="flex flex-col gap-1 overflow-y-auto max-h-[40dvh]">
{entries.map((entry) => (
<FileEntryRow
<FileEntryBlock
key={entry.id}
entry={entry}
onRemove={() => removeEntry(entry.id)}
@@ -201,15 +297,15 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
</div>
)}
{/* Summary */}
{/* Summary line */}
{entries.length > 0 && !done && !uploading && (
<p className="text-xs text-muted-foreground">
{t("upload.validCount", { valid: validCount, total: entries.length })}
{t("upload.validCount", { valid: actionableCount, total: allSkills.length })}
</p>
)}
{done && (
<p className="text-sm font-medium text-muted-foreground">
{t("upload.successCount", { success: successCount, total: entries.length })}
{t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })}
</p>
)}
@@ -220,10 +316,10 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
{done ? (
<Button onClick={() => handleClose(false)}>{t("upload.done")}</Button>
) : (
<Button onClick={handleSubmit} disabled={validCount === 0 || uploading}>
<Button onClick={handleSubmit} disabled={actionableCount === 0 || uploading}>
{uploading
? t("upload.uploading")
: t("upload.uploadCount", { count: validCount })}
: t("upload.uploadCount", { count: actionableCount })}
</Button>
)}
</DialogFooter>
@@ -231,69 +327,3 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
</Dialog>
);
}
/** Single file entry row with status icon, name, and remove button */
function FileEntryRow({
entry,
onRemove,
uploading,
t,
}: {
entry: FileEntry;
onRemove: () => void;
uploading: boolean;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const sizeKB = (entry.file.size / 1024).toFixed(1);
return (
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<StatusIcon status={entry.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{entry.name || entry.file.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{sizeKB} KB</span>
</div>
{entry.status === "invalid" || entry.status === "error" ? (
<p className="text-xs text-destructive truncate">{entry.error ? t(entry.error) : t("upload.failed")}</p>
) : entry.status === "warning" ? (
<p className="text-xs text-amber-600 truncate">{entry.error ?? t("upload.failed")}</p>
) : entry.status === "validating" ? (
<p className="text-xs text-muted-foreground">{t("upload.validating")}</p>
) : entry.name && entry.status !== "success" ? (
<p className="text-xs text-muted-foreground truncate">{entry.file.name}</p>
) : null}
</div>
{/* Remove button — hidden during upload and for completed entries */}
{!uploading && entry.status !== "uploading" && entry.status !== "success" && (
<button
type="button"
aria-label={t("upload.remove")}
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="shrink-0 rounded-sm p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
function StatusIcon({ status }: { status: FileStatus }) {
switch (status) {
case "validating":
case "uploading":
return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
case "valid":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />;
case "success":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />;
case "warning":
return <TriangleAlert className="h-4 w-4 shrink-0 text-amber-600" />;
case "invalid":
case "error":
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
}
}
@@ -0,0 +1,221 @@
/** Sub-components for rendering individual file entries and skill rows in the upload dialog. */
import { CheckCircle2, XCircle, Loader2, TriangleAlert, X, Package } from "lucide-react";
import type { FileEntry, SkillEntry, SkillStatus } from "./lib/skill-upload-types";
type TFunc = (key: string, opts?: Record<string, unknown>) => string;
// ---------------------------------------------------------------------------
// FileEntryBlock — renders a ZIP file with its skill rows
// ---------------------------------------------------------------------------
export function FileEntryBlock({
entry,
onRemove,
uploading,
t,
}: {
entry: FileEntry;
onRemove: () => void;
uploading: boolean;
t: TFunc;
}) {
const isMulti = entry.skills.length > 1;
const sizeKB = (entry.file.size / 1024).toFixed(1);
const isValidating = entry.skills.some((s) => s.status === "validating");
if (!isMulti) {
// Single-skill: one row, ZIP filename as subtitle
const skill = entry.skills[0]!;
return (
<SkillRow
skill={skill}
subtitle={skill.name ? entry.file.name : undefined}
primaryLabel={skill.name || entry.file.name}
sizeKB={sizeKB}
showSize
onRemove={onRemove}
uploading={uploading}
t={t}
/>
);
}
// Multi-skill: group header + individual skill rows
return (
<div className="rounded-md border overflow-hidden">
{/* ZIP group header */}
<div className="flex items-center gap-2 bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground">
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate font-medium">{entry.file.name}</span>
<span className="shrink-0">{sizeKB} KB</span>
{isValidating ? null : (
<span className="shrink-0">
{t("upload.multiDetected", { count: entry.skills.length })}
</span>
)}
{!uploading && (
<button
type="button"
aria-label={t("upload.remove")}
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Individual skill rows — indented */}
<div className="flex flex-col divide-y">
{entry.skills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
primaryLabel={skill.name || skill.dir || skill.slug || "…"}
subtitle={skill.dir || undefined}
showSize={false}
onRemove={undefined}
uploading={uploading}
t={t}
indent
/>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SkillRow — single skill entry row
// ---------------------------------------------------------------------------
export function SkillRow({
skill,
primaryLabel,
subtitle,
sizeKB,
showSize,
onRemove,
uploading,
t,
indent = false,
}: {
skill: SkillEntry;
primaryLabel: string;
subtitle?: string;
sizeKB?: string;
showSize: boolean;
onRemove?: () => void;
uploading: boolean;
t: TFunc;
indent?: boolean;
}) {
const canRemove = !uploading && skill.status !== "uploading" && skill.status !== "success" && onRemove;
return (
<div className={`flex items-center gap-2 px-3 py-2 text-sm ${indent ? "pl-6" : "rounded-md border"}`}>
<SkillStatusIcon status={skill.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{primaryLabel}</span>
<SkillBadge status={skill.status} t={t} />
{showSize && sizeKB && (
<span className="shrink-0 text-xs text-muted-foreground">{sizeKB} KB</span>
)}
</div>
<SkillSubtitle skill={skill} subtitle={subtitle} t={t} />
</div>
{canRemove && (
<button
type="button"
aria-label={t("upload.remove")}
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="shrink-0 rounded-sm p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// SkillSubtitle
// ---------------------------------------------------------------------------
export function SkillSubtitle({
skill,
subtitle,
t,
}: {
skill: SkillEntry;
subtitle?: string;
t: TFunc;
}) {
if (skill.status === "invalid" || skill.status === "error") {
return (
<p className="text-xs text-destructive truncate">
{skill.error ? t(skill.error) : t("upload.failed")}
</p>
);
}
if (skill.status === "warning") {
return <p className="text-xs text-amber-600 truncate">{skill.error ?? t("upload.failed")}</p>;
}
if (skill.status === "validating") {
return <p className="text-xs text-muted-foreground">{t("upload.validating")}</p>;
}
if (skill.status === "unchanged") {
return (
<p className="text-xs text-muted-foreground truncate">
{t("upload.skillUnchanged")}
</p>
);
}
if (subtitle && skill.status !== "success") {
return <p className="text-xs text-muted-foreground truncate">{subtitle}</p>;
}
return null;
}
// ---------------------------------------------------------------------------
// SkillBadge — colored badge for NEW / UNCHANGED / ERROR states
// ---------------------------------------------------------------------------
export function SkillBadge({ status, t }: { status: SkillStatus; t: TFunc }) {
const base = "shrink-0 text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded";
switch (status) {
case "valid":
return <span className={`${base} bg-green-50 text-green-700 border border-green-200`}>{t("upload.new")}</span>;
case "unchanged":
return <span className={`${base} bg-purple-50 text-purple-700 border border-purple-200`}>{t("upload.unchanged")}</span>;
case "invalid":
case "error":
return <span className={`${base} bg-red-50 text-red-700 border border-red-200`}>{t("upload.failed")}</span>;
default:
return null;
}
}
// ---------------------------------------------------------------------------
// SkillStatusIcon
// ---------------------------------------------------------------------------
export function SkillStatusIcon({ status }: { status: SkillStatus }) {
switch (status) {
case "validating":
case "uploading":
return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
case "valid":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />;
case "unchanged":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
case "success":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />;
case "warning":
return <TriangleAlert className="h-4 w-4 shrink-0 text-amber-600" />;
case "invalid":
case "error":
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
// jsdom provides browser globals: crypto.subtle, Blob, File, TextEncoder
environment: "jsdom",
globals: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});