From 5c95ab2b38430243771828aa7547dde507211507 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 7 May 2026 09:45:26 +0800 Subject: [PATCH 1/2] feat(skill): add version compare page with unified diff Introduce a dedicated `/space/$namespace/$slug/compare` page that compares two published skill versions GitHub-style: left file list + right unified diff. Backend exposes `GET /versions/compare` returning structured diff (computed via java-diff-utils) with per-file hunks, binary placeholder, and truncation flags. Frontend uses two version selectors scoped to PUBLISHED versions, a file search box, active-file highlighting, and whitespace-preserving unified view. E2E covers the publish + rerelease + approve round trip; controller/domain tests cover happy path and same-version rejection. --- .../controller/portal/SkillController.java | 72 ++++ .../dto/SkillVersionCompareFileResponse.java | 13 + .../dto/SkillVersionCompareHunkResponse.java | 11 + .../dto/SkillVersionCompareLineResponse.java | 8 + .../dto/SkillVersionCompareResponse.java | 19 + .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../controller/SkillControllerTest.java | 62 +++ server/skillhub-domain/pom.xml | 5 + .../skill/service/SkillQueryService.java | 186 ++++++++ web/e2e/helpers/test-data-builder.ts | 25 +- web/e2e/skill-version-compare.spec.ts | 74 ++++ web/package.json | 2 + web/pnpm-lock.yaml | 406 +++++++++++++++++- web/src/api/generated/schema.d.ts | 388 +++++++++++++++++ web/src/api/types.ts | 41 ++ web/src/app/router.test.ts | 6 + web/src/app/router.tsx | 12 + web/src/i18n/locales/en.json | 21 + web/src/i18n/locales/zh.json | 21 + web/src/pages/skill-detail.tsx | 7 +- web/src/pages/skill-version-compare.test.tsx | 145 +++++++ web/src/pages/skill-version-compare.tsx | 274 ++++++++++++ web/src/shared/hooks/use-skill-queries.ts | 17 +- 24 files changed, 1807 insertions(+), 10 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareFileResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareHunkResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareLineResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareResponse.java create mode 100644 web/e2e/skill-version-compare.spec.ts create mode 100644 web/src/pages/skill-version-compare.test.tsx create mode 100644 web/src/pages/skill-version-compare.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index e0788b44..7c0df9c9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -14,6 +14,10 @@ import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; +import com.iflytek.skillhub.dto.SkillVersionCompareFileResponse; +import com.iflytek.skillhub.dto.SkillVersionCompareHunkResponse; +import com.iflytek.skillhub.dto.SkillVersionCompareLineResponse; +import com.iflytek.skillhub.dto.SkillVersionCompareResponse; import com.iflytek.skillhub.dto.SkillVersionDetailResponse; import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -174,6 +178,27 @@ public class SkillController extends BaseApiController { return ok("response.success.read", response); } + @GetMapping("/{namespace}/{slug}/versions/compare") + public ApiResponse compareVersions( + @PathVariable String namespace, + @PathVariable String slug, + @RequestParam("from") String from, + @RequestParam("to") String to, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillQueryService.SkillVersionCompareDTO compare = skillQueryService.compareVersions( + namespace, + slug, + from, + to, + userId, + userNsRoles != null ? userNsRoles : Map.of() + ); + + return ok("response.success.read", toCompareResponse(compare)); + } + /** * Lists packaged files for a concrete version after visibility checks have * been applied. @@ -421,4 +446,51 @@ public class SkillController extends BaseApiController { } return new SkillLifecycleVersionResponse(projection.id(), projection.version(), projection.status()); } + + private SkillVersionCompareResponse toCompareResponse(SkillQueryService.SkillVersionCompareDTO compare) { + return new SkillVersionCompareResponse( + compare.from(), + compare.to(), + new SkillVersionCompareResponse.SkillVersionCompareSummaryResponse( + compare.summary().totalFiles(), + compare.summary().addedFiles(), + compare.summary().modifiedFiles(), + compare.summary().removedFiles(), + compare.summary().addedLines(), + compare.summary().removedLines() + ), + compare.files().stream().map(this::toCompareFileResponse).toList() + ); + } + + private SkillVersionCompareFileResponse toCompareFileResponse(SkillQueryService.SkillVersionCompareFileDTO file) { + return new SkillVersionCompareFileResponse( + file.path(), + file.changeType(), + file.oldSize(), + file.newSize(), + file.binary(), + file.truncated(), + file.hunks().stream().map(this::toCompareHunkResponse).toList() + ); + } + + private SkillVersionCompareHunkResponse toCompareHunkResponse(SkillQueryService.SkillVersionCompareHunkDTO hunk) { + return new SkillVersionCompareHunkResponse( + hunk.oldStart(), + hunk.oldLines(), + hunk.newStart(), + hunk.newLines(), + hunk.lines().stream().map(this::toCompareLineResponse).toList() + ); + } + + private SkillVersionCompareLineResponse toCompareLineResponse(SkillQueryService.SkillVersionCompareLineDTO line) { + return new SkillVersionCompareLineResponse( + line.type(), + line.content(), + line.oldLineNumber(), + line.newLineNumber() + ); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareFileResponse.java new file mode 100644 index 00000000..da4fcd6f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareFileResponse.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record SkillVersionCompareFileResponse( + String path, + String changeType, + Long oldSize, + Long newSize, + boolean binary, + boolean truncated, + List hunks +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareHunkResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareHunkResponse.java new file mode 100644 index 00000000..30d5b985 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareHunkResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record SkillVersionCompareHunkResponse( + int oldStart, + int oldLines, + int newStart, + int newLines, + List lines +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareLineResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareLineResponse.java new file mode 100644 index 00000000..db4a570c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareLineResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record SkillVersionCompareLineResponse( + String type, + String content, + Integer oldLineNumber, + Integer newLineNumber +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareResponse.java new file mode 100644 index 00000000..7a27b59f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionCompareResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record SkillVersionCompareResponse( + String from, + String to, + SkillVersionCompareSummaryResponse summary, + List files +) { + public record SkillVersionCompareSummaryResponse( + int totalFiles, + int addedFiles, + int modifiedFiles, + int removedFiles, + int addedLines, + int removedLines + ) {} +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 029bbff8..d297ecca 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -106,6 +106,7 @@ error.skill.version.notFound=Version not found: {0} error.skill.version.notPublished=Version is not published: {0} error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0} error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0} +error.skill.version.compare.same=Cannot compare a version with itself error.skill.report.reason.required=Please provide a report reason error.skill.report.unavailable=This skill cannot be reported right now: {0} error.skill.report.self=You cannot report your own skill diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index b338f6be..15e30e3e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -106,6 +106,7 @@ error.skill.version.notFound=未找到版本:{0} error.skill.version.notPublished=版本未发布:{0} error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0} error.skill.version.delete.lastVersion=无法删除最后一个版本:{0} +error.skill.version.compare.same=无法对同一版本进行对比 error.skill.report.reason.required=请填写举报原因 error.skill.report.unavailable=当前无法举报该技能:{0} error.skill.report.self=不能举报自己发布的技能 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index b57d811a..ce622374 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -239,4 +240,65 @@ class SkillControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items[0].downloadAvailable").value(false)); } + + @Test + void compareVersionsShouldReturnStructuredDiffEnvelope() throws Exception { + when(skillQueryService.compareVersions( + eq("team"), + eq("demo"), + eq("1.0.0"), + eq("1.1.0"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillVersionCompareDTO( + "1.0.0", + "1.1.0", + new SkillQueryService.SkillVersionCompareSummaryDTO(1, 0, 1, 0, 2, 1), + List.of(new SkillQueryService.SkillVersionCompareFileDTO( + "README.md", + "MODIFIED", + 10L, + 12L, + false, + false, + List.of(new SkillQueryService.SkillVersionCompareHunkDTO( + 1, + 2, + 1, + 3, + List.of( + new SkillQueryService.SkillVersionCompareLineDTO("CONTEXT", "# Demo", 1, 1), + new SkillQueryService.SkillVersionCompareLineDTO("DELETE", "old", 2, null), + new SkillQueryService.SkillVersionCompareLineDTO("ADD", "new", null, 2) + ))))))); + + mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare") + .param("from", "1.0.0") + .param("to", "1.1.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.from").value("1.0.0")) + .andExpect(jsonPath("$.data.to").value("1.1.0")) + .andExpect(jsonPath("$.data.summary.totalFiles").value(1)) + .andExpect(jsonPath("$.data.files[0].path").value("README.md")) + .andExpect(jsonPath("$.data.files[0].hunks[0].lines[1].type").value("DELETE")); + } + + @Test + void compareVersionsShouldRejectSameVersion() throws Exception { + when(skillQueryService.compareVersions( + eq("team"), + eq("demo"), + eq("1.0.0"), + eq("1.0.0"), + eq((String) null), + eq(Map.of()))) + .thenThrow(new DomainBadRequestException("error.skill.version.compare.same")); + + mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare") + .param("from", "1.0.0") + .param("to", "1.0.0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } } diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index 26b9b552..408de2c0 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -40,6 +40,11 @@ com.fasterxml.jackson.core jackson-databind + + io.github.java-diff-utils + java-diff-utils + 4.12 + org.hibernate.orm hibernate-core diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 76fe4211..7d966327 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -14,6 +14,10 @@ import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.storage.ObjectStorageService; +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.Chunk; +import com.github.difflib.patch.Patch; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -24,6 +28,7 @@ import java.io.UncheckedIOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.Arrays; import java.util.Comparator; import java.util.HexFormat; import java.util.List; @@ -31,6 +36,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; /** @@ -124,6 +130,47 @@ public class SkillQueryService { String manifestJson ) {} + public record SkillVersionCompareDTO( + String from, + String to, + SkillVersionCompareSummaryDTO summary, + List files + ) {} + + public record SkillVersionCompareSummaryDTO( + int totalFiles, + int addedFiles, + int modifiedFiles, + int removedFiles, + int addedLines, + int removedLines + ) {} + + public record SkillVersionCompareFileDTO( + String path, + String changeType, + Long oldSize, + Long newSize, + boolean binary, + boolean truncated, + List hunks + ) {} + + public record SkillVersionCompareHunkDTO( + int oldStart, + int oldLines, + int newStart, + int newLines, + List lines + ) {} + + public record SkillVersionCompareLineDTO( + String type, + String content, + Integer oldLineNumber, + Integer newLineNumber + ) {} + public record ResolvedVersionDTO( Long skillId, String namespace, @@ -268,6 +315,70 @@ public class SkillQueryService { ); } + public SkillVersionCompareDTO compareVersions( + String namespaceSlug, + String skillSlug, + String fromVersion, + String toVersion, + String currentUserId, + Map userNsRoles) { + if (Objects.equals(fromVersion, toVersion)) { + throw new DomainBadRequestException("error.skill.version.compare.same"); + } + + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + + SkillVersion from = findVersion(skill, fromVersion); + SkillVersion to = findVersion(skill, toVersion); + assertPreviewAccessible(skill, from, fromVersion, currentUserId, userNsRoles); + assertPreviewAccessible(skill, to, toVersion, currentUserId, userNsRoles); + + Map fromFiles = availableFiles(from.getId()).stream() + .collect(Collectors.toMap(SkillFile::getFilePath, file -> file)); + Map toFiles = availableFiles(to.getId()).stream() + .collect(Collectors.toMap(SkillFile::getFilePath, file -> file)); + + List files = new TreeSet() {{ + addAll(fromFiles.keySet()); + addAll(toFiles.keySet()); + }}.stream() + .map(path -> buildCompareFile(fromFiles.get(path), toFiles.get(path))) + .filter(Objects::nonNull) + .toList(); + + int addedFiles = 0; + int modifiedFiles = 0; + int removedFiles = 0; + int addedLines = 0; + int removedLines = 0; + for (SkillVersionCompareFileDTO file : files) { + if ("ADDED".equals(file.changeType())) { + addedFiles++; + } else if ("REMOVED".equals(file.changeType())) { + removedFiles++; + } else { + modifiedFiles++; + } + for (SkillVersionCompareHunkDTO hunk : file.hunks()) { + for (SkillVersionCompareLineDTO line : hunk.lines()) { + if ("ADD".equals(line.type())) { + addedLines++; + } else if ("DELETE".equals(line.type())) { + removedLines++; + } + } + } + } + + return new SkillVersionCompareDTO( + fromVersion, + toVersion, + new SkillVersionCompareSummaryDTO(files.size(), addedFiles, modifiedFiles, removedFiles, addedLines, removedLines), + files); + } + public List listFiles( String namespaceSlug, String skillSlug, @@ -491,6 +602,81 @@ public class SkillQueryService { SkillSlugResolutionService.Preference.CURRENT_USER); } + private static final long COMPARE_MAX_FILE_BYTES = 1024 * 1024; + private static final int COMPARE_MAX_LINES = 5000; + private static final Set BINARY_FILE_EXTENSIONS = Set.of( + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", + ".zip", ".tar", ".gz", ".jar", ".war", ".class", ".so", ".dll", ".exe", ".pdf" + ); + + private SkillVersionCompareFileDTO buildCompareFile(SkillFile fromFile, SkillFile toFile) { + if (fromFile != null && toFile != null && Objects.equals(fromFile.getSha256(), toFile.getSha256())) { + return null; + } + + String path = fromFile != null ? fromFile.getFilePath() : Objects.requireNonNull(toFile).getFilePath(); + String changeType = fromFile == null ? "ADDED" : toFile == null ? "REMOVED" : "MODIFIED"; + Long oldSize = fromFile != null ? fromFile.getFileSize() : null; + Long newSize = toFile != null ? toFile.getFileSize() : null; + boolean isBinary = isBinaryFile(path); + if (isBinary) { + return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, true, false, List.of()); + } + + String oldContent = fromFile != null ? readTextContent(fromFile) : ""; + String newContent = toFile != null ? readTextContent(toFile) : ""; + List oldLines = splitLines(oldContent); + List newLines = splitLines(newContent); + boolean isTruncated = (oldSize != null && oldSize > COMPARE_MAX_FILE_BYTES) + || (newSize != null && newSize > COMPARE_MAX_FILE_BYTES) + || oldLines.size() > COMPARE_MAX_LINES + || newLines.size() > COMPARE_MAX_LINES; + if (isTruncated) { + return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, false, true, List.of()); + } + + Patch patch = DiffUtils.diff(oldLines, newLines); + List hunks = patch.getDeltas().stream() + .map(this::toCompareHunk) + .toList(); + return new SkillVersionCompareFileDTO(path, changeType, oldSize, newSize, false, false, hunks); + } + + private SkillVersionCompareHunkDTO toCompareHunk(AbstractDelta delta) { + Chunk source = delta.getSource(); + Chunk target = delta.getTarget(); + List lines = new java.util.ArrayList<>(); + + int oldLine = source.getPosition() + 1; + for (String line : source.getLines()) { + lines.add(new SkillVersionCompareLineDTO("DELETE", line, oldLine++, null)); + } + + int newLine = target.getPosition() + 1; + for (String line : target.getLines()) { + lines.add(new SkillVersionCompareLineDTO("ADD", line, null, newLine++)); + } + + return new SkillVersionCompareHunkDTO( + source.getPosition() + 1, + source.size(), + target.getPosition() + 1, + target.size(), + lines); + } + + private List splitLines(String content) { + if (content == null || content.isEmpty()) { + return List.of(); + } + return Arrays.asList(content.split("\\R", -1)); + } + + private boolean isBinaryFile(String path) { + String lowerCasePath = path.toLowerCase(java.util.Locale.ROOT); + return BINARY_FILE_EXTENSIONS.stream().anyMatch(lowerCasePath::endsWith); + } + private SkillVersion findVersion(Skill skill, String version) { return skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 0123468b..b41f9743 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -466,11 +466,26 @@ export class E2eTestDataBuilder { } async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise { - await parseEnvelope( - await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { - data: { comment }, - }), - ) + let lastError: unknown + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + await parseEnvelope( + await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { + data: { comment }, + }), + ) + return + } catch (error) { + lastError = error + const message = error instanceof Error ? error.message : '' + const isScanInProgress = message.includes('扫描') || message.toLowerCase().includes('scan is still in progress') + if (!isScanInProgress) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + throw lastError instanceof Error ? lastError : new Error('approveReview timed out') } async searchNamespaceMemberCandidates(slug: string, search: string): Promise { diff --git a/web/e2e/skill-version-compare.spec.ts b/web/e2e/skill-version-compare.spec.ts new file mode 100644 index 00000000..c24da0a7 --- /dev/null +++ b/web/e2e/skill-version-compare.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { loginWithCredentials, registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +test.describe('Skill Version Compare (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens compare page for two published versions and renders unified diff data', async ({ page, browser }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = `compare-ui-${Date.now().toString(36)}` + const v1 = await builder.publishSkill(namespace.slug, { + name: skillName, + version: '1.0.0', + readmeHeading: `${skillName} v1`, + }) + + const firstReviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, v1.slug, v1.version) + await adminBuilder.approveReview(firstReviewTaskId) + + const rereleaseResponse = await page.context().request.post( + `/api/web/skills/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(v1.slug)}/versions/${encodeURIComponent(v1.version)}/rerelease`, + { + data: { + targetVersion: '1.1.0', + confirmWarnings: true, + }, + } + ) + expect(rereleaseResponse.ok()).toBe(true) + + const secondReviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, v1.slug, '1.1.0') + await adminBuilder.approveReview(secondReviewTaskId) + + await page.goto(`/space/${encodeURIComponent(namespace.slug)}/${encodeURIComponent(v1.slug)}/compare?from=1.0.0&to=1.1.0`) + + await expect(page.getByRole('heading', { name: 'Version Compare' })).toBeVisible() + await expect(page.getByLabel('Base version')).toBeVisible() + await expect(page.getByLabel('Head version')).toBeVisible() + await expect(page.getByLabel('File list')).toContainText('SKILL.md') + await expect(page.getByText('Modified').first()).toBeVisible() + await expect(page.getByText('SKILL.md').first()).toBeVisible() + } finally { + await adminBuilder.cleanup() + await adminContext.close() + await builder.cleanup() + } + }) +}) diff --git a/web/package.json b/web/package.json index aef915a4..6b05b007 100644 --- a/web/package.json +++ b/web/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@testing-library/react": "^16.3.2", "@types/mdast": "^4.0.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -55,6 +56,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^29.1.1", "openapi-typescript": "^7.6.1", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5a90170f..76f5e020 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -117,6 +120,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.5 version: 0.4.26(eslint@8.57.1) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 openapi-typescript: specifier: ^7.6.1 version: 7.13.0(typescript@5.9.3) @@ -134,7 +140,7 @@ importers: version: 6.4.1(jiti@1.21.7) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7) + version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7)(jsdom@29.1.1) packages: @@ -142,6 +148,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -229,6 +250,46 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -447,6 +508,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -995,6 +1065,28 @@ packages: '@tanstack/store@0.9.2': resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1173,6 +1265,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1190,6 +1286,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1224,6 +1323,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1339,6 +1441,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1347,6 +1453,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1356,6 +1466,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1394,9 +1507,16 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1616,6 +1736,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -1706,6 +1830,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isbot@5.1.36: resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} engines: {node: '>=18'} @@ -1731,6 +1858,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1790,6 +1926,10 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1798,6 +1938,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1852,6 +1996,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -2032,6 +2179,9 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2142,6 +2292,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2192,6 +2346,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -2294,6 +2451,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2394,6 +2555,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -2440,10 +2604,25 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2479,6 +2658,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2623,6 +2806,22 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2640,6 +2839,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2683,6 +2889,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2797,6 +3023,34 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 @@ -2972,6 +3226,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3461,6 +3717,29 @@ snapshots: '@tanstack/store@0.9.2': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -3683,6 +3962,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3698,6 +3979,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -3725,6 +4010,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} brace-expansion@1.1.12: @@ -3837,16 +4126,30 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 10.2.2 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -3877,8 +4180,12 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + electron-to-chromium@1.5.307: {} + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4162,6 +4469,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -4238,6 +4551,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + isbot@5.1.36: {} isexe@2.0.0: {} @@ -4254,6 +4569,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -4301,6 +4642,8 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4309,6 +4652,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4479,6 +4824,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + memoize-one@6.0.0: {} merge2@1.4.1: {} @@ -4782,6 +5129,10 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -4855,6 +5206,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4906,6 +5263,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -5070,6 +5429,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -5150,6 +5513,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwindcss@3.4.19: @@ -5209,10 +5574,24 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5235,6 +5614,8 @@ snapshots: typescript@5.9.3: {} + undici@7.25.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -5349,7 +5730,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7): + vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7)(jsdom@29.1.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -5376,6 +5757,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 + jsdom: 29.1.1 transitivePeerDependencies: - jiti - less @@ -5392,6 +5774,22 @@ snapshots: void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5405,6 +5803,10 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml-ast-parser@0.0.43: {} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 316f17b7..1903a916 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4,6 +4,38 @@ */ export interface paths { + "/api/v1/skills/{skillId}/subscription": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["checkSubscribed"]; + put: operations["subscribeSkill"]; + post?: never; + delete: operations["unsubscribeSkill"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{skillId}/subscription": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["checkSubscribed_1"]; + put: operations["subscribeSkill_1"]; + post?: never; + delete: operations["unsubscribeSkill_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/{skillId}/star": { parameters: { query?: never; @@ -1764,6 +1796,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{namespace}/{slug}/versions/compare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["compareVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/versions/compare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["compareVersions_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/versions": { parameters: { query?: never; @@ -2436,6 +2500,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/me/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySubscriptions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMySubscriptions_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/me/stars": { parameters: { query?: never; @@ -3778,6 +3874,7 @@ export interface components { slug?: string; displayName?: string; summary?: string; + visibility?: string; status?: string; /** Format: int64 */ downloadCount?: number; @@ -3860,6 +3957,65 @@ export interface components { parsedMetadataJson?: string; manifestJson?: string; }; + ApiResponseSkillVersionCompareResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillVersionCompareResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillVersionCompareFileResponse: { + path?: string; + changeType?: string; + /** Format: int64 */ + oldSize?: number; + /** Format: int64 */ + newSize?: number; + binary?: boolean; + truncated?: boolean; + hunks?: components["schemas"]["SkillVersionCompareHunkResponse"][]; + }; + SkillVersionCompareHunkResponse: { + /** Format: int32 */ + oldStart?: number; + /** Format: int32 */ + oldLines?: number; + /** Format: int32 */ + newStart?: number; + /** Format: int32 */ + newLines?: number; + lines?: components["schemas"]["SkillVersionCompareLineResponse"][]; + }; + SkillVersionCompareLineResponse: { + type?: string; + content?: string; + /** Format: int32 */ + oldLineNumber?: number; + /** Format: int32 */ + newLineNumber?: number; + }; + SkillVersionCompareResponse: { + from?: string; + to?: string; + summary?: components["schemas"]["SkillVersionCompareSummaryResponse"]; + files?: components["schemas"]["SkillVersionCompareFileResponse"][]; + }; + SkillVersionCompareSummaryResponse: { + /** Format: int32 */ + totalFiles?: number; + /** Format: int32 */ + addedFiles?: number; + /** Format: int32 */ + modifiedFiles?: number; + /** Format: int32 */ + removedFiles?: number; + /** Format: int32 */ + addedLines?: number; + /** Format: int32 */ + removedLines?: number; + }; ApiResponsePageResponseSkillVersionResponse: { /** Format: int32 */ code?: number; @@ -3954,6 +4110,8 @@ export interface components { downloadCount?: number; /** Format: int32 */ starCount?: number; + /** Format: int32 */ + subscriptionCount?: number; ratingAvg?: number; /** Format: int32 */ ratingCount?: number; @@ -4649,6 +4807,138 @@ export interface components { } export type $defs = Record; export interface operations { + checkSubscribed: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBoolean"]; + }; + }; + }; + }; + subscribeSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + unsubscribeSkill: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + checkSubscribed_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBoolean"]; + }; + }; + }; + }; + subscribeSkill_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + unsubscribeSkill_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; checkStarred: { parameters: { query?: never; @@ -8047,6 +8337,58 @@ export interface operations { }; }; }; + compareVersions: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + }; + }; + }; + }; + compareVersions_1: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + }; + }; + }; + }; listVersions: { parameters: { query?: { @@ -9077,6 +9419,52 @@ export interface operations { }; }; }; + listMySubscriptions: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + }; + }; + }; + }; + listMySubscriptions_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + }; + }; + }; + }; listMyStars: { parameters: { query?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0cba6fcc..8da6b272 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -274,6 +274,47 @@ export interface SkillFile { sha256: string } +export interface SkillVersionCompareLine { + type: 'CONTEXT' | 'ADD' | 'DELETE' | string + content: string + oldLineNumber: number | null + newLineNumber: number | null +} + +export interface SkillVersionCompareHunk { + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: SkillVersionCompareLine[] +} + +export interface SkillVersionCompareFile { + path: string + changeType: 'ADDED' | 'MODIFIED' | 'REMOVED' | string + oldSize: number | null + newSize: number | null + binary: boolean + truncated: boolean + hunks: SkillVersionCompareHunk[] +} + +export interface SkillVersionCompareSummary { + totalFiles: number + addedFiles: number + modifiedFiles: number + removedFiles: number + addedLines: number + removedLines: number +} + +export interface SkillVersionCompare { + from: string + to: string + summary: SkillVersionCompareSummary + files: SkillVersionCompareFile[] +} + export interface SkillTag { id: number tagName: string diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index 18bf8708..79beeb6e 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -39,4 +39,10 @@ describe('router', () => { // In test environment, flatRoutes may not be populated until router is used expect(router.routeTree).toBeDefined() }) + + it('registers the skill version compare route', () => { + const children = (router.routeTree.children ?? []) as Array<{ fullPath?: string; path?: string }> + const childPaths = children.map((route) => route.fullPath ?? route.path) + expect(childPaths).toContain('/space/$namespace/$slug/compare') + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d7b564ca..8cabf0b3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -71,6 +71,7 @@ const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'Sea const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') +const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage') const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage') const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage') const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') @@ -231,6 +232,16 @@ const skillDetailRoute = createRoute({ component: SkillDetailPage, }) +const skillVersionCompareRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/space/$namespace/$slug/compare', + validateSearch: (search: Record): { from: string; to: string } => ({ + from: typeof search.from === 'string' ? search.from : '', + to: typeof search.to === 'string' ? search.to : '', + }), + component: SkillVersionComparePage, +}) + const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard', @@ -424,6 +435,7 @@ const routeTree = rootRoute.addChildren([ termsRoute, namespaceRoute, skillDetailRoute, + skillVersionCompareRoute, dashboardRoute, dashboardSkillsRoute, dashboardPublishRoute, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b54bfdac..ede94e67 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -963,6 +963,27 @@ "defaultDescription": "A useful skill" } }, + "skillCompare": { + "pageTitle": "Version Compare", + "filesChanged": "{{count}} files changed", + "searchFiles": "Search files", + "errorLoadingCompare": "Failed to load version comparison", + "loadDiff": "Load diff", + "loading": "Loading...", + "totalFiles": "Files", + "addedLines": "Added lines", + "removedLines": "Removed lines", + "baseVersion": "Base version", + "headVersion": "Head version", + "fileList": "File list", + "binaryFile": "Binary file — cannot display diff", + "truncatedFile": "Diff output is truncated", + "notEnoughPublishedVersions": "Publish at least two versions before comparing.", + "noFilesFound": "No matching files found.", + "changeTypeAdded": "Added", + "changeTypeModified": "Modified", + "changeTypeRemoved": "Removed" + }, "reports": { "title": "Skill Reports", "subtitle": "Handle user-submitted skill reports", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 593b4bc4..ad8a5962 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -964,6 +964,27 @@ "defaultDescription": "实用技能" } }, + "skillCompare": { + "pageTitle": "版本对比", + "filesChanged": "{{count}} 个文件变更", + "searchFiles": "搜索文件", + "errorLoadingCompare": "加载版本对比失败", + "loadDiff": "加载差异", + "loading": "加载中...", + "totalFiles": "文件数", + "addedLines": "新增行", + "removedLines": "删除行", + "baseVersion": "基础版本", + "headVersion": "目标版本", + "fileList": "文件列表", + "binaryFile": "二进制文件,无法展示差异", + "truncatedFile": "差异输出已截断", + "notEnoughPublishedVersions": "至少发布两个版本后才能进行对比。", + "noFilesFound": "未找到匹配文件。", + "changeTypeAdded": "新增", + "changeTypeModified": "修改", + "changeTypeRemoved": "删除" + }, "reports": { "title": "技能举报", "subtitle": "处理用户提交的技能举报", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 14ecee44..5687eaa1 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -618,8 +618,11 @@ export function SkillDetailPage() { toast.error(t('skillDetail.versionCompareUnavailableTitle'), t('skillDetail.versionCompareUnavailableDescription')) return } - setDiffSourceVersion(version) - setDiffCompareVersion(compareVersion) + navigate({ + to: '/space/$namespace/$slug/compare', + params: { namespace, slug }, + search: { from: version, to: compareVersion }, + }) } const handleSubmitPromotion = async () => { diff --git a/web/src/pages/skill-version-compare.test.tsx b/web/src/pages/skill-version-compare.test.tsx new file mode 100644 index 00000000..6535d253 --- /dev/null +++ b/web/src/pages/skill-version-compare.test.tsx @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ + +import { renderToStaticMarkup } from 'react-dom/server' +import { render, screen, fireEvent } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useSkillVersionCompareMock = vi.fn() +const useSkillVersionsMock = vi.fn() +const navigateMock = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => navigateMock, + useParams: () => ({ namespace: 'global', slug: 'demo-skill' }), + useSearch: () => ({ from: '1.0.0', to: '1.1.0' }), +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/shared/hooks/use-skill-queries', () => ({ + useSkillVersionCompare: (...args: unknown[]) => useSkillVersionCompareMock(...args), + useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), +})) + +import { SkillVersionComparePage } from './skill-version-compare' + +describe('SkillVersionComparePage', () => { + beforeEach(() => { + navigateMock.mockReset() + + useSkillVersionsMock.mockReturnValue({ + data: [ + { id: 10, version: '1.0.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 10, publishedAt: '2026-01-01T00:00:00Z', downloadAvailable: true }, + { id: 11, version: '1.1.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 12, publishedAt: '2026-01-02T00:00:00Z', downloadAvailable: true }, + { id: 12, version: '1.2.0-rc.1', status: 'UPLOADED', changelog: '', fileCount: 2, totalSize: 22, publishedAt: '2026-01-03T00:00:00Z', downloadAvailable: false }, + ], + isLoading: false, + }) + + useSkillVersionCompareMock.mockReturnValue({ + data: { + from: '1.0.0', + to: '1.1.0', + summary: { totalFiles: 2, addedFiles: 1, modifiedFiles: 1, removedFiles: 0, addedLines: 4, removedLines: 2 }, + files: [ + { + path: 'README.md', + changeType: 'MODIFIED', + oldSize: 10, + newSize: 12, + binary: false, + truncated: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { type: 'DELETE', content: 'old', oldLineNumber: 1, newLineNumber: null }, + { type: 'ADD', content: 'new', oldLineNumber: null, newLineNumber: 1 }, + ], + }, + ], + }, + { + path: 'src/index.ts', + changeType: 'ADDED', + oldSize: null, + newSize: 50, + binary: false, + truncated: false, + hunks: [ + { + oldStart: 0, + oldLines: 0, + newStart: 1, + newLines: 1, + lines: [{ type: 'ADD', content: 'console.log(1)', oldLineNumber: null, newLineNumber: 1 }], + }, + ], + }, + ], + }, + isLoading: false, + error: null, + }) + }) + + it('renders compare summary and files', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('skillCompare.totalFiles') + expect(html).toContain('+4') + expect(html).toContain('-2') + expect(html).toContain('README.md') + expect(html).toContain('src/index.ts') + }) + + it('renders search input and version selectors from published versions', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="skillCompare.searchFiles"') + expect(html).toContain('aria-label="skillCompare.baseVersion"') + expect(html).toContain('aria-label="skillCompare.headVersion"') + expect(html).toContain('v1.0.0') + expect(html).toContain('v1.1.0') + expect(html).not.toContain('v1.2.0-rc.1') + }) + + it('switches active file marker when a file item is clicked', () => { + render() + + const readmeLink = screen.getByRole('link', { name: 'README.md' }) + const sourceLink = screen.getByRole('link', { name: 'src/index.ts' }) + + expect(readmeLink.getAttribute('aria-current')).toBe('true') + expect(sourceLink.getAttribute('aria-current')).toBeNull() + + fireEvent.click(sourceLink) + + expect(sourceLink.getAttribute('aria-current')).toBe('true') + expect(readmeLink.getAttribute('aria-current')).toBeNull() + }) + + it('shows an empty state when there are not enough published versions', () => { + useSkillVersionsMock.mockReturnValueOnce({ + data: [ + { id: 10, version: '1.0.0', status: 'PUBLISHED', changelog: '', fileCount: 1, totalSize: 10, publishedAt: '2026-01-01T00:00:00Z', downloadAvailable: true }, + ], + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('skillCompare.notEnoughPublishedVersions') + }) +}) diff --git a/web/src/pages/skill-version-compare.tsx b/web/src/pages/skill-version-compare.tsx new file mode 100644 index 00000000..75eb863a --- /dev/null +++ b/web/src/pages/skill-version-compare.tsx @@ -0,0 +1,274 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams, useSearch } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useSkillVersionCompare, useSkillVersions } from '@/shared/hooks/use-skill-queries' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' + +function toDiffSectionId(path: string) { + return `diff-${encodeURIComponent(path)}` +} + +function getChangeTypeLabel(t: (key: string) => string, changeType: string) { + if (changeType === 'ADDED') { + return t('skillCompare.changeTypeAdded') + } + if (changeType === 'REMOVED') { + return t('skillCompare.changeTypeRemoved') + } + return t('skillCompare.changeTypeModified') +} + +export function SkillVersionComparePage() { + const { t } = useTranslation() + const navigate = useNavigate() + const { namespace, slug } = useParams({ from: '/space/$namespace/$slug/compare' }) + const { from, to } = useSearch({ from: '/space/$namespace/$slug/compare' }) as { from: string; to: string } + const [keyword, setKeyword] = useState('') + const [activePath, setActivePath] = useState(null) + const { data: versions, isLoading: isVersionsLoading } = useSkillVersions(namespace, slug) + + const publishedVersions = useMemo( + () => (versions ?? []).filter((version) => version.status === 'PUBLISHED'), + [versions] + ) + + const hasEnoughVersions = publishedVersions.length >= 2 + const defaultFrom = publishedVersions[1]?.version ?? publishedVersions[0]?.version ?? '' + const defaultTo = publishedVersions[0]?.version ?? '' + + const normalizedVersions = useMemo(() => { + if (!hasEnoughVersions) { + return { from: '', to: '' } + } + + const isPublishedVersion = (value: string) => publishedVersions.some((version) => version.version === value) + let nextFrom = from && isPublishedVersion(from) ? from : defaultFrom + let nextTo = to && isPublishedVersion(to) ? to : defaultTo + + if (nextFrom === nextTo) { + const alternative = publishedVersions.find((version) => version.version !== nextFrom)?.version ?? '' + if (!from && !to) { + nextFrom = defaultFrom + nextTo = defaultTo + } else if (from && !to) { + nextTo = alternative + } else if (!from && to) { + nextFrom = alternative + } else { + nextTo = alternative + } + } + + return { from: nextFrom, to: nextTo } + }, [defaultFrom, defaultTo, from, hasEnoughVersions, publishedVersions, to]) + + useEffect(() => { + if (!hasEnoughVersions || !normalizedVersions.from || !normalizedVersions.to) { + return + } + if (from === normalizedVersions.from && to === normalizedVersions.to) { + return + } + navigate({ + to: '/space/$namespace/$slug/compare', + params: { namespace, slug }, + search: { from: normalizedVersions.from, to: normalizedVersions.to }, + replace: true, + }) + }, [from, hasEnoughVersions, namespace, navigate, normalizedVersions.from, normalizedVersions.to, slug, to]) + + const { data, isLoading: isCompareLoading, error } = useSkillVersionCompare( + namespace, + slug, + normalizedVersions.from, + normalizedVersions.to, + hasEnoughVersions && !!normalizedVersions.from && !!normalizedVersions.to + ) + + const filteredFiles = useMemo(() => { + const files = data?.files ?? [] + if (!keyword.trim()) { + return files + } + return files.filter((file) => file.path.toLowerCase().includes(keyword.trim().toLowerCase())) + }, [data?.files, keyword]) + + useEffect(() => { + if (!activePath && filteredFiles.length > 0) { + setActivePath(filteredFiles[0].path) + return + } + if (activePath && !filteredFiles.some((file) => file.path === activePath)) { + setActivePath(filteredFiles[0]?.path ?? null) + } + }, [activePath, filteredFiles]) + + if (isVersionsLoading) { + return
{t('skillCompare.loading')}
+ } + + if (!hasEnoughVersions) { + return
{t('skillCompare.notEnoughPublishedVersions')}
+ } + + if (isCompareLoading) { + return
{t('skillCompare.loading')}
+ } + + if (error || !data) { + return
{t('skillCompare.errorLoadingCompare')}
+ } + + return ( +
+ + +
+ {filteredFiles.length > 0 ? ( + filteredFiles.map((file) => ( +
+
+
{file.path}
+
{getChangeTypeLabel(t, file.changeType)}
+
+ + {file.binary ? ( +
{t('skillCompare.binaryFile')}
+ ) : file.truncated ? ( +
{t('skillCompare.truncatedFile')}
+ ) : ( +
+ {file.hunks.flatMap((hunk) => hunk.lines).map((line, index) => ( +
+ {line.oldLineNumber ?? ''} + {line.newLineNumber ?? ''} + + {line.type === 'ADD' ? '+' : line.type === 'DELETE' ? '-' : ' '} + {line.content} + +
+ ))} +
+ )} +
+ )) + ) : ( +
+ {t('skillCompare.noFilesFound')} +
+ )} +
+
+ ) +} diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index cf19d18c..63642de6 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types' +import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillVersionCompare, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow' import { getSkillDetailQueryKey } from './query-keys' @@ -32,6 +32,13 @@ async function getSkillVersionDetail(namespace: string, slug: string, version: s return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`) } +async function getSkillVersionCompare(namespace: string, slug: string, from: string, to: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + return fetchJson( + `${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` + ) +} + async function getSkillDocumentation(namespace: string, slug: string, version: string, path: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) @@ -116,6 +123,14 @@ export function useSkillVersionDetail(namespace: string, slug: string, version?: }) } +export function useSkillVersionCompare(namespace: string, slug: string, from?: string, to?: string, enabled = true) { + return useQuery({ + queryKey: ['skills', namespace, slug, 'versions', 'compare', from, to], + queryFn: () => getSkillVersionCompare(namespace, slug, from!, to!), + enabled: enabled && !!namespace && !!slug && !!from && !!to, + }) +} + export function usePublishSkill() { const queryClient = useQueryClient() From 13e148741d29f54f6d90d3620db08bbc5c06b09f Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 7 May 2026 15:58:39 +0800 Subject: [PATCH 2/2] style(skill-compare): add background colors for diff add/delete lines --- web/src/pages/skill-version-compare.tsx | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/web/src/pages/skill-version-compare.tsx b/web/src/pages/skill-version-compare.tsx index 75eb863a..483b0640 100644 --- a/web/src/pages/skill-version-compare.tsx +++ b/web/src/pages/skill-version-compare.tsx @@ -248,12 +248,30 @@ export function SkillVersionComparePage() { ) : file.truncated ? (
{t('skillCompare.truncatedFile')}
) : ( -
+
{file.hunks.flatMap((hunk) => hunk.lines).map((line, index) => ( -
- {line.oldLineNumber ?? ''} - {line.newLineNumber ?? ''} - +
+ + {line.oldLineNumber ?? ''} + + + {line.newLineNumber ?? ''} + + {line.type === 'ADD' ? '+' : line.type === 'DELETE' ? '-' : ' '} {line.content}