From 76cbedbe6b041e83162b446add0352ff71b84906 Mon Sep 17 00:00:00 2001 From: maguowei Date: Fri, 9 Oct 2026 09:27:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(profile-editor):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E6=97=B6=E4=BB=A5=20marketplace.json=20?= =?UTF-8?q?=E7=9A=84=20name=20=E4=BD=9C=E4=B8=BA=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../profile-editor/BrowseMarketplaceTab.tsx | 54 +++++++++++++-- .../__tests__/BrowseMarketplaceTab.test.tsx | 69 +++++++++++++++++-- .../__tests__/marketplace-catalog.test.ts | 26 +++++++ .../profile-editor/marketplace-catalog.ts | 29 ++++++-- src/i18n.ts | 8 ++- 5 files changed, 165 insertions(+), 21 deletions(-) diff --git a/src/components/profile-editor/BrowseMarketplaceTab.tsx b/src/components/profile-editor/BrowseMarketplaceTab.tsx index bb0bccd..ed0a596 100644 --- a/src/components/profile-editor/BrowseMarketplaceTab.tsx +++ b/src/components/profile-editor/BrowseMarketplaceTab.tsx @@ -20,10 +20,11 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { Spinner } from "../ui/spinner"; import { formatShortDateTime } from "../usage/format"; import MarketplacePluginRow from "./MarketplacePluginRow"; import type { MarketplacePluginEntry } from "./marketplace-catalog"; -import { getProviderAffiliation } from "./marketplace-catalog"; +import { fetchMarketplaceName, getProviderAffiliation } from "./marketplace-catalog"; import { estimatePluginRowSize } from "./marketplace-plugin-row-utils"; import { BUILTIN_MARKETPLACES, type BuiltinMarketplace } from "./marketplace-presets"; import { @@ -112,6 +113,9 @@ function AddMarketplacePopover({ const [open, setOpen] = useState(false); const [repo, setRepo] = useState(""); const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + // 关闭或重开 Popover 后作废进行中的名称解析,避免旧请求回写 + const submitTokenRef = useRef(0); const availablePresets = BUILTIN_MARKETPLACES.filter( (preset) => !existingMarketplaceIds.includes(preset.marketplaceId), @@ -120,8 +124,10 @@ function AddMarketplacePopover({ function handleOpenChange(next: boolean) { setOpen(next); if (!next) { + submitTokenRef.current += 1; setRepo(""); setError(""); + setSubmitting(false); } } @@ -136,7 +142,10 @@ function AddMarketplacePopover({ handleOpenChange(false); } - function handleSubmit() { + async function handleSubmit() { + if (submitting) { + return; + } const trimmedRepo = repo.trim(); if (!trimmedRepo) { setError(t("profileEditor.plugins.browse.addMarketplaceErrorRepoEmpty")); @@ -146,10 +155,35 @@ function AddMarketplacePopover({ setError(t("profileEditor.plugins.browse.addMarketplaceErrorRepoInvalid")); return; } - // 名称默认取仓库末段;冲突时引导用高级配置自定义 - const marketplaceId = trimmedRepo.split("/")[1]?.trim() ?? ""; + // 市场名以 marketplace.json 的 name 为准(Claude Code 用它组成插件 ID 后缀),不能取仓库名 + const token = ++submitTokenRef.current; + setSubmitting(true); + setError(""); + let marketplaceId: string; + try { + marketplaceId = await fetchMarketplaceName({ + sourceType: "github", + repo: trimmedRepo, + ref: "", + path: "", + }); + } catch { + if (token === submitTokenRef.current) { + setSubmitting(false); + setError(t("profileEditor.plugins.browse.addMarketplaceErrorManifest")); + } + return; + } + if (token !== submitTokenRef.current) { + return; + } + setSubmitting(false); if (existingMarketplaceIds.includes(marketplaceId)) { - setError(t("profileEditor.plugins.browse.addMarketplaceErrorIdDuplicate")); + setError( + formatTemplate(t("profileEditor.plugins.browse.addMarketplaceErrorIdDuplicate"), { + name: marketplaceId, + }), + ); return; } onAddMarketplace({ marketplaceId, repo: trimmedRepo, ref: "", path: "" }); @@ -206,7 +240,7 @@ function AddMarketplacePopover({ onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); - handleSubmit(); + void handleSubmit(); } }} /> @@ -221,7 +255,13 @@ function AddMarketplacePopover({ > {t("profileEditor.common.cancel")} - diff --git a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx index 0837243..2836aba 100644 --- a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx +++ b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx @@ -577,7 +577,7 @@ describe("BrowseMarketplaceTab", () => { }); }); - it("快速添加市场:自定义 github 仓库并自动预填名称", async () => { + it("快速添加市场:自定义 github 仓库以 marketplace.json 的 name 作为市场名", async () => { const onAddMarketplace = vi.fn(); fetchMock.mockResolvedValueOnce({ ok: true, @@ -586,18 +586,75 @@ describe("BrowseMarketplaceTab", () => { renderTab({ existingMarketplaceIds: [], onAddMarketplace }); await screen.findByText("alpha"); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ name: "acme-marketplace", plugins: [] }), + } as unknown as Response); fireEvent.click(screen.getByRole("button", { name: "添加市场" })); fireEvent.change(await screen.findByLabelText("GitHub 仓库 (owner/repo)"), { target: { value: "acme/plugins" }, }); fireEvent.click(screen.getByRole("button", { name: "添加" })); - expect(onAddMarketplace).toHaveBeenCalledWith({ - marketplaceId: "plugins", - repo: "acme/plugins", - ref: "", - path: "", + await waitFor(() => + expect(onAddMarketplace).toHaveBeenCalledWith({ + marketplaceId: "acme-marketplace", + repo: "acme/plugins", + ref: "", + path: "", + }), + ); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://raw.githubusercontent.com/acme/plugins/main/.claude-plugin/marketplace.json", + expect.anything(), + ); + }); + + it("快速添加市场:manifest 中的市场名已存在时内联报错且不写回", async () => { + const onAddMarketplace = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins: [{ name: "alpha" }] }), + } as unknown as Response); + renderTab({ existingMarketplaceIds: ["acme-marketplace"], onAddMarketplace }); + await screen.findByText("alpha"); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ name: "acme-marketplace", plugins: [] }), + } as unknown as Response); + fireEvent.click(screen.getByRole("button", { name: "添加市场" })); + fireEvent.change(await screen.findByLabelText("GitHub 仓库 (owner/repo)"), { + target: { value: "acme/plugins" }, }); + fireEvent.click(screen.getByRole("button", { name: "添加" })); + + expect( + await screen.findByText("插件市场 acme-marketplace 已存在,请用高级配置自定义名称"), + ).toBeInTheDocument(); + expect(onAddMarketplace).not.toHaveBeenCalled(); + }); + + it("快速添加市场:读取 manifest 失败时内联报错且不写回", async () => { + const onAddMarketplace = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins: [{ name: "alpha" }] }), + } as unknown as Response); + renderTab({ existingMarketplaceIds: [], onAddMarketplace }); + await screen.findByText("alpha"); + + fetchMock.mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response); + fireEvent.click(screen.getByRole("button", { name: "添加市场" })); + fireEvent.change(await screen.findByLabelText("GitHub 仓库 (owner/repo)"), { + target: { value: "acme/missing" }, + }); + fireEvent.click(screen.getByRole("button", { name: "添加" })); + + expect( + await screen.findByText("无法读取该仓库的 .claude-plugin/marketplace.json 或缺少 name 字段"), + ).toBeInTheDocument(); + expect(onAddMarketplace).not.toHaveBeenCalled(); }); it("快速添加市场:仓库格式非法时内联报错且不写回", async () => { diff --git a/src/components/profile-editor/__tests__/marketplace-catalog.test.ts b/src/components/profile-editor/__tests__/marketplace-catalog.test.ts index c1e7523..1297aee 100644 --- a/src/components/profile-editor/__tests__/marketplace-catalog.test.ts +++ b/src/components/profile-editor/__tests__/marketplace-catalog.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildMarketplaceRawUrl, fetchMarketplaceCatalog, + fetchMarketplaceName, loadMarketplaceCatalogCache, parseMarketplacePluginCatalog, saveMarketplaceCatalogCache, @@ -122,6 +123,31 @@ describe("marketplace-catalog", () => { ).rejects.toThrow(); }); + it("fetchMarketplaceName 返回 manifest 的 name 而非仓库名", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ name: " claude-community ", plugins: [] }), + } as unknown as Response); + await expect( + fetchMarketplaceName({ + sourceType: "github", + repo: "anthropics/claude-plugins-community", + ref: "", + path: "", + }), + ).resolves.toBe("claude-community"); + }); + + it("fetchMarketplaceName 在 manifest 缺少 name 时抛错", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ plugins: [] }), + } as unknown as Response); + await expect( + fetchMarketplaceName({ sourceType: "github", repo: "x/y", ref: "", path: "" }), + ).rejects.toThrow(); + }); + it("save / load 缓存按 marketplaceId 索引", () => { saveMarketplaceCatalogCache("a", []); saveMarketplaceCatalogCache("b", []); diff --git a/src/components/profile-editor/marketplace-catalog.ts b/src/components/profile-editor/marketplace-catalog.ts index bddd890..eb580df 100644 --- a/src/components/profile-editor/marketplace-catalog.ts +++ b/src/components/profile-editor/marketplace-catalog.ts @@ -97,9 +97,8 @@ export function parseMarketplacePluginCatalog( return plugins; } -export async function fetchMarketplaceCatalog( - input: MarketplaceFetchInput, -): Promise { +// 拉取 marketplace.json 原始内容;超时覆盖请求和响应体读取,取消网络请求,同时保证调用方能够结束。 +async function fetchMarketplaceManifest(input: Omit) { const url = buildMarketplaceRawUrl(input); if (!url) throw new Error(`unsupported marketplace source: ${input.sourceType}`); const controller = new AbortController(); @@ -111,12 +110,11 @@ export async function fetchMarketplaceCatalog( }, FETCH_TIMEOUT_MS); }); try { - // 超时覆盖请求和响应体读取;取消网络请求,同时保证刷新能够结束。 return await Promise.race([ - (async () => { + (async (): Promise => { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(`fetch failed: ${response.status}`); - return parseMarketplacePluginCatalog(await response.json(), input.marketplaceId); + return response.json(); })(), timeout, ]); @@ -125,6 +123,25 @@ export async function fetchMarketplaceCatalog( } } +export async function fetchMarketplaceCatalog( + input: MarketplaceFetchInput, +): Promise { + return parseMarketplacePluginCatalog(await fetchMarketplaceManifest(input), input.marketplaceId); +} + +// Claude Code 以 marketplace.json 的 name 作为市场名(插件 ID 的 @ 后缀),而非仓库名 +export function parseMarketplaceName(manifest: unknown): string { + return readTrim(readObject(manifest).name); +} + +export async function fetchMarketplaceName( + input: Omit, +): Promise { + const name = parseMarketplaceName(await fetchMarketplaceManifest(input)); + if (!name) throw new Error("marketplace manifest missing name"); + return name; +} + export function loadMarketplaceCatalogCache(): CacheV1["byMarketplace"] | null { try { const raw = localStorage.getItem(MARKETPLACE_CATALOG_CACHE_KEY); diff --git a/src/i18n.ts b/src/i18n.ts index 1d0c530..4670606 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -728,7 +728,9 @@ const translations = { "profileEditor.plugins.browse.addMarketplaceErrorRepoEmpty": "请填写 GitHub 仓库", "profileEditor.plugins.browse.addMarketplaceErrorRepoInvalid": "仓库格式应为 owner/repo", "profileEditor.plugins.browse.addMarketplaceErrorIdDuplicate": - "该仓库名已存在,请用高级配置自定义名称", + "插件市场 {name} 已存在,请用高级配置自定义名称", + "profileEditor.plugins.browse.addMarketplaceErrorManifest": + "无法读取该仓库的 .claude-plugin/marketplace.json 或缺少 name 字段", "profileEditor.plugins.browse.providerFilterFieldLabel": "提供方", "profileEditor.plugins.browse.providerFilterLabel": "按提供方筛选", "profileEditor.plugins.browse.providerAnthropic": "Anthropic", @@ -2473,7 +2475,9 @@ const translations = { "profileEditor.plugins.browse.addMarketplaceErrorRepoInvalid": "Repo must be in owner/repo format", "profileEditor.plugins.browse.addMarketplaceErrorIdDuplicate": - "This name already exists — use marketplace settings to customize", + "Marketplace {name} already exists — use marketplace settings to customize", + "profileEditor.plugins.browse.addMarketplaceErrorManifest": + "Could not read .claude-plugin/marketplace.json from this repo, or it has no name", "profileEditor.plugins.browse.providerFilterFieldLabel": "Provider", "profileEditor.plugins.browse.providerFilterLabel": "Filter by provider", "profileEditor.plugins.browse.providerAnthropic": "Anthropic",