Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions src/components/profile-editor/BrowseMarketplaceTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -120,8 +124,10 @@ function AddMarketplacePopover({
function handleOpenChange(next: boolean) {
setOpen(next);
if (!next) {
submitTokenRef.current += 1;
setRepo("");
setError("");
setSubmitting(false);
}
}

Expand All @@ -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"));
Expand All @@ -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: "" });
Expand Down Expand Up @@ -206,7 +240,7 @@ function AddMarketplacePopover({
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSubmit();
void handleSubmit();
}
}}
/>
Expand All @@ -221,7 +255,13 @@ function AddMarketplacePopover({
>
{t("profileEditor.common.cancel")}
</Button>
<Button type="button" size="sm" onClick={handleSubmit}>
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => void handleSubmit()}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{t("profileEditor.plugins.browse.addMarketplaceSubmit")}
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ describe("BrowseMarketplaceTab", () => {
});
});

it("快速添加市场:自定义 github 仓库并自动预填名称", async () => {
it("快速添加市场:自定义 github 仓库以 marketplace.json 的 name 作为市场名", async () => {
const onAddMarketplace = vi.fn();
fetchMock.mockResolvedValueOnce({
ok: true,
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceRawUrl,
fetchMarketplaceCatalog,
fetchMarketplaceName,
loadMarketplaceCatalogCache,
parseMarketplacePluginCatalog,
saveMarketplaceCatalogCache,
Expand Down Expand Up @@ -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", []);
Expand Down
29 changes: 23 additions & 6 deletions src/components/profile-editor/marketplace-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ export function parseMarketplacePluginCatalog(
return plugins;
}

export async function fetchMarketplaceCatalog(
input: MarketplaceFetchInput,
): Promise<MarketplacePluginEntry[]> {
// 拉取 marketplace.json 原始内容;超时覆盖请求和响应体读取,取消网络请求,同时保证调用方能够结束。
async function fetchMarketplaceManifest(input: Omit<MarketplaceFetchInput, "marketplaceId">) {
const url = buildMarketplaceRawUrl(input);
if (!url) throw new Error(`unsupported marketplace source: ${input.sourceType}`);
const controller = new AbortController();
Expand All @@ -111,12 +110,11 @@ export async function fetchMarketplaceCatalog(
}, FETCH_TIMEOUT_MS);
});
try {
// 超时覆盖请求和响应体读取;取消网络请求,同时保证刷新能够结束。
return await Promise.race([
(async () => {
(async (): Promise<unknown> => {
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,
]);
Expand All @@ -125,6 +123,25 @@ export async function fetchMarketplaceCatalog(
}
}

export async function fetchMarketplaceCatalog(
input: MarketplaceFetchInput,
): Promise<MarketplacePluginEntry[]> {
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<MarketplaceFetchInput, "marketplaceId">,
): Promise<string> {
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);
Expand Down
8 changes: 6 additions & 2 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading