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
2 changes: 1 addition & 1 deletion docs/user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The configuration editor on the right is divided into several sections.
- **Permissions**: default mode, disable `bypassPermissions`, allow / deny / ask rules, additional directories, and recommended rule presets.
- **Sandbox**: can be enabled or disabled, with recommended presets to add; complex configurations can switch to JSON mode.
- **Hooks**: maintain Claude Code hooks with a summary view, add the built-in garbled-text check preset, and switch complex structures to JSON mode.
- **Plugin marketplaces**: maintain `extraKnownMarketplaces` with support for the official marketplace preset; each marketplace must fully specify the ID, source, repository or URL, path, package name, and install location.
- **Plugin marketplaces**: maintain `extraKnownMarketplaces` with built-in presets for the official marketplace (`claude-plugins-official`) and community marketplace (`claude-community`, repository `anthropics/claude-plugins-community`), available from both marketplace settings and Browse Marketplaces; each marketplace must fully specify the ID, source, repository or URL, path, package name, and install location.
- **Plugins**: maintain `enabledPlugins`, split into two tabs, "Configured" and "Browse marketplace". Browsing the marketplace currently supports only the `github` source; you can filter by marketplace / enabled status / category / source, and clicking enable immediately syncs it to the configured list.
- **Status line**: configure a custom status line command, and optionally enable the default status line preset (non-Windows writes `~/.claude/statusline.sh`; Windows writes `~/.claude/statusline.ps1` and automatically sets the PowerShell invocation command), and also sets a 60-second refresh interval so countdowns such as the prompt cache expiry keep updating while idle; if the target already exists with different content, you are prompted whether to overwrite.
- **Final configuration**: preview the final JSON after merging the provider with the configuration; the source JSON mode lets you maintain the entire settings object directly, and the preview automatically adds the schema URL.
Expand Down
2 changes: 1 addition & 1 deletion docs/user-manual.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Skills 对应 `~/.claude/skills/<id>/SKILL.md`。启用的 Skill 保存在 `~/.c
- **权限**:默认模式、禁用 `bypassPermissions`、允许 / 拒绝 / 询问规则、附加目录、推荐规则预设。
- **Sandbox**:可启用或关闭,可添加推荐预设;复杂配置可切换 JSON 模式。
- **Hooks**:维护 Claude Code hooks,支持摘要展示,可添加内置乱码检查预设,复杂结构可切 JSON 模式。
- **插件市场**:维护 `extraKnownMarketplaces`,支持官方市场预设;每个 Marketplace 需完整填写 ID、来源、仓库或 URL、路径、包名、安装位置。
- **插件市场**:维护 `extraKnownMarketplaces`,支持官方市场(`claude-plugins-official`)与社区市场(`claude-community`,仓库 `anthropics/claude-plugins-community`)内置预设,可从市场配置或浏览市场一键添加;每个 Marketplace 需完整填写 ID、来源、仓库或 URL、路径、包名、安装位置。
- **插件**:维护 `enabledPlugins`,分"已配置"和"浏览市场"两个 Tab。浏览市场目前仅支持 `github` 来源,可按 Marketplace / 启用状态 / 类别 / 来源筛选,点击启用会立即同步到已配置列表。
- **状态行**:配置自定义状态行命令,可启用默认状态行预设(非 Windows 写入 `~/.claude/statusline.sh`,Windows 写入 `~/.claude/statusline.ps1` 并自动设置 PowerShell 调用命令),同时写入 60 秒刷新间隔,使缓存有效期等倒计时在空闲时也能更新;目标已存在且内容不同时会提示是否覆盖。
- **最终配置**:预览供应商与配置合并后的最终 JSON;源 JSON 模式可直接维护整个 settings 对象,预览自动加入 schema 地址。
Expand Down
39 changes: 22 additions & 17 deletions src/components/profile-editor/BrowseMarketplaceTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ArrowDown, ArrowUp, ArrowUpDown, CircleCheck, Info, RefreshCw, Store } from "lucide-react";
import { ArrowDown, ArrowUp, ArrowUpDown, Info, Plus, RefreshCw, Store } from "lucide-react";
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useToast } from "@/hooks/useToast";
Expand All @@ -25,7 +25,7 @@ import MarketplacePluginRow from "./MarketplacePluginRow";
import type { MarketplacePluginEntry } from "./marketplace-catalog";
import { getProviderAffiliation } from "./marketplace-catalog";
import { estimatePluginRowSize } from "./marketplace-plugin-row-utils";
import { OFFICIAL_MARKETPLACE_ID, OFFICIAL_MARKETPLACE_REPO } from "./marketplace-presets";
import { BUILTIN_MARKETPLACES, type BuiltinMarketplace } from "./marketplace-presets";
import {
emptyPluginCatalog,
loadPluginCatalog,
Expand Down Expand Up @@ -95,7 +95,7 @@ function getAriaSort(direction: SortDirection): "ascending" | "descending" {
// owner/repo 格式校验
const GITHUB_REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/;

// 浏览页快速添加 github 插件市场的轻量 Popover:一键官方 + 自定义仓库
// 浏览页快速添加 github 插件市场的轻量 Popover:内置市场 + 自定义仓库
function AddMarketplacePopover({
existingMarketplaceIds,
onAddMarketplace,
Expand All @@ -113,7 +113,9 @@ function AddMarketplacePopover({
const [repo, setRepo] = useState("");
const [error, setError] = useState("");

const officialPresent = existingMarketplaceIds.includes(OFFICIAL_MARKETPLACE_ID);
const availablePresets = BUILTIN_MARKETPLACES.filter(
(preset) => !existingMarketplaceIds.includes(preset.marketplaceId),
);

function handleOpenChange(next: boolean) {
setOpen(next);
Expand All @@ -123,10 +125,10 @@ function AddMarketplacePopover({
}
}

function handleAddOfficial() {
function handleAddBuiltin(preset: BuiltinMarketplace) {
onAddMarketplace({
marketplaceId: OFFICIAL_MARKETPLACE_ID,
repo: OFFICIAL_MARKETPLACE_REPO,
marketplaceId: preset.marketplaceId,
repo: preset.repo,
ref: "",
path: "",
});
Expand Down Expand Up @@ -168,17 +170,20 @@ function AddMarketplacePopover({
<div className="text-sm font-semibold">
{t("profileEditor.plugins.browse.addMarketplaceTitle")}
</div>
{!officialPresent && (
{availablePresets.length > 0 && (
<>
<Button
type="button"
variant="secondary"
className="justify-start gap-1.5"
onClick={handleAddOfficial}
>
<CircleCheck className="size-3.5" aria-hidden="true" />
{t("profileEditor.plugins.browse.addMarketplaceOfficial")}
</Button>
{availablePresets.map((preset) => (
<Button
key={preset.marketplaceId}
type="button"
variant="secondary"
className="justify-start gap-1.5"
onClick={() => handleAddBuiltin(preset)}
>
<Plus data-icon="inline-start" aria-hidden="true" />
{t(preset.browseLabel)}
</Button>
))}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{t("profileEditor.plugins.browse.addMarketplaceOr")}
Expand Down
34 changes: 23 additions & 11 deletions src/components/profile-editor/MarketplaceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
type MarketplaceSourceType,
readObject,
} from "./editor-utils";
import { buildOfficialMarketplaceDraft, OFFICIAL_MARKETPLACE_ID } from "./marketplace-presets";
import {
BUILTIN_MARKETPLACES,
type BuiltinMarketplace,
buildBuiltinMarketplaceDraft,
} from "./marketplace-presets";
import RequiredBadge from "./RequiredBadge";

interface MarketplaceEditorProps {
Expand Down Expand Up @@ -265,8 +269,12 @@ function MarketplaceEditor({ value, onChange, onError, showTitle = true }: Marke
},
];
}, [draft, marketplaces]);
const hasOfficialMarketplace = useMemo(
() => marketplaces.some((marketplace) => marketplace.marketplaceId === OFFICIAL_MARKETPLACE_ID),
const availablePresets = useMemo(
() =>
BUILTIN_MARKETPLACES.filter(
(preset) =>
!marketplaces.some((marketplace) => marketplace.marketplaceId === preset.marketplaceId),
),
[marketplaces],
);

Expand Down Expand Up @@ -371,15 +379,15 @@ function MarketplaceEditor({ value, onChange, onError, showTitle = true }: Marke
resetEditor(buildNewDraft());
}

function handleAddOfficialMarketplace() {
function handleAddBuiltinMarketplace(preset: BuiltinMarketplace) {
if (blockIfDirty()) {
return;
}
if (hasOfficialMarketplace) {
if (marketplaces.some((marketplace) => marketplace.marketplaceId === preset.marketplaceId)) {
return;
}

const nextMarketplaces = [...marketplaces, buildOfficialMarketplaceDraft()];
const nextMarketplaces = [...marketplaces, buildBuiltinMarketplaceDraft(preset)];
setMarketplaces(nextMarketplaces);
onChange(buildMarketplaceRecord(nextMarketplaces));
setInteractionError("");
Expand Down Expand Up @@ -826,12 +834,16 @@ function MarketplaceEditor({ value, onChange, onError, showTitle = true }: Marke
</div>

<div className="flex flex-wrap gap-2 max-[720px]:[&>button]:w-full max-[720px]:[&>button]:justify-center">
{!hasOfficialMarketplace ? (
<Button type="button" className="" onClick={handleAddOfficialMarketplace}>
<Plus className="size-4" aria-hidden="true" />
{t("profileEditor.marketplace.addOfficial")}
{availablePresets.map((preset) => (
<Button
key={preset.marketplaceId}
type="button"
onClick={() => handleAddBuiltinMarketplace(preset)}
>
<Plus data-icon="inline-start" aria-hidden="true" />
{t(preset.editorLabel)}
</Button>
) : null}
))}
<Button type="button" variant="outline" className="" onClick={handleAddMarketplace}>
<Plus className="size-4" aria-hidden="true" />
{t("profileEditor.marketplace.addItem")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,63 @@ describe("BrowseMarketplaceTab", () => {
expect(screen.getByText(/complete description that should wrap/)).toBeInTheDocument();
});

it("社区市场快捷入口使用清单 ID,且可在空列表添加", async () => {
const onAddMarketplace = vi.fn();
renderTab({ sources: [], existingMarketplaceIds: [], onAddMarketplace });
fireEvent.click(screen.getByRole("button", { name: "添加市场" }));
fireEvent.click(await screen.findByRole("button", { name: "一键添加社区市场" }));
expect(onAddMarketplace).toHaveBeenCalledWith({
marketplaceId: "claude-community",
repo: "anthropics/claude-plugins-community",
ref: "",
path: "",
});
});

it("社区市场已配置时隐藏其快捷入口并保留官方入口", async () => {
renderTab({
sources: [],
existingMarketplaceIds: ["claude-community"],
onAddMarketplace: vi.fn(),
});
fireEvent.click(screen.getByRole("button", { name: "添加市场" }));
expect(await screen.findByRole("button", { name: "一键添加官方市场" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "一键添加社区市场" })).not.toBeInTheDocument();
});

it("社区市场清单可加载并使用正确插件 ID 启用", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
name: "claude-community",
plugins: [
{
name: "community-example",
source: { source: "url", url: "https://example.com/plugin.git" },
},
],
}),
} as Response);
const onAddPlugin = vi.fn();
renderTab({
sources: [
{
marketplaceId: "claude-community",
sourceType: "github",
repo: "anthropics/claude-plugins-community",
ref: "",
path: "",
},
],
onAddPlugin,
});
fireEvent.click(await screen.findByRole("button", { name: /添加并启用/ }));
expect(onAddPlugin).toHaveBeenCalledWith("community-example@claude-community");
expect(fetchMock).toHaveBeenCalledWith(
"https://raw.githubusercontent.com/anthropics/claude-plugins-community/main/.claude-plugin/marketplace.json",
);
});

it("无 marketplace 时显示空状态", () => {
renderTab({ sources: [] });
expect(screen.getByText("未配置插件来源")).toBeInTheDocument();
Expand Down
37 changes: 37 additions & 0 deletions src/components/profile-editor/__tests__/MarketplaceEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,43 @@ describe("MarketplaceEditor", () => {
expect(onChange).not.toHaveBeenCalled();
});

it("adds the community preset with its manifest ID and preserves existing markets", () => {
const existing = { source: { source: "github", repo: "team/plugins", ref: "stable" } };
const { onChange } = renderEditor({ value: { "team-market": existing } });
fireEvent.click(screen.getByRole("button", { name: "启用社区市场" }));
expect(onChange).toHaveBeenLastCalledWith({
"team-market": existing,
"claude-community": {
source: { source: "github", repo: "anthropics/claude-plugins-community" },
},
});
expect(screen.queryByRole("button", { name: "启用社区市场" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "启用官方市场" })).toBeInTheDocument();
});

it("preserves an existing community source without writing on mount", () => {
const { onChange } = renderEditor({
value: {
"claude-community": {
source: { source: "github", repo: "custom/community", ref: "pinned" },
},
},
});
expect(screen.queryByRole("button", { name: "启用社区市场" })).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});

it("blocks the community shortcut while a marketplace draft is dirty", () => {
const { onChange } = renderEditor({ value: {} });
fireEvent.click(screen.getByRole("button", { name: "新增 Marketplace" }));
fireEvent.change(screen.getByLabelText("Marketplace ID"), {
target: { value: "draft-market" },
});
fireEvent.click(screen.getByRole("button", { name: "启用社区市场" }));
expect(screen.getByText("请先保存或取消当前 Marketplace 编辑。")).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});

it("shows a confirmation dialog before deleting a saved marketplace", () => {
const { onChange } = renderEditor();

Expand Down
25 changes: 21 additions & 4 deletions src/components/profile-editor/marketplace-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,31 @@ export const OFFICIAL_MARKETPLACE_ID = "claude-plugins-official";
export const OFFICIAL_MARKETPLACE_REPO = "anthropics/claude-plugins-official";
export const OFFICIAL_MARKETPLACE_RAW_URL = `https://raw.githubusercontent.com/${OFFICIAL_MARKETPLACE_REPO}/main/.claude-plugin/marketplace.json`;

export function buildOfficialMarketplaceDraft(): MarketplaceDraft {
return {
id: `marketplace:${OFFICIAL_MARKETPLACE_ID}`,
export const BUILTIN_MARKETPLACES = [
{
marketplaceId: OFFICIAL_MARKETPLACE_ID,
repo: OFFICIAL_MARKETPLACE_REPO,
editorLabel: "profileEditor.marketplace.addOfficial",
browseLabel: "profileEditor.plugins.browse.addMarketplaceOfficial",
},
{
marketplaceId: "claude-community",
repo: "anthropics/claude-plugins-community",
editorLabel: "profileEditor.marketplace.addCommunity",
browseLabel: "profileEditor.plugins.browse.addMarketplaceCommunity",
},
] as const;

export type BuiltinMarketplace = (typeof BUILTIN_MARKETPLACES)[number];

export function buildBuiltinMarketplaceDraft(preset: BuiltinMarketplace): MarketplaceDraft {
return {
id: `marketplace:${preset.marketplaceId}`,
marketplaceId: preset.marketplaceId,
sourceType: "github",
url: "",
hostPattern: "",
repo: OFFICIAL_MARKETPLACE_REPO,
repo: preset.repo,
ref: "",
path: "",
packageName: "",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ const translations = {
"profileEditor.plugins.browse.addMarketplace": "添加市场",
"profileEditor.plugins.browse.addMarketplaceTitle": "添加插件市场",
"profileEditor.plugins.browse.addMarketplaceOfficial": "一键添加官方市场",
"profileEditor.plugins.browse.addMarketplaceCommunity": "一键添加社区市场",
"profileEditor.plugins.browse.addMarketplaceOr": "或",
"profileEditor.plugins.browse.addMarketplaceRepoLabel": "GitHub 仓库 (owner/repo)",
"profileEditor.plugins.browse.addMarketplaceSubmit": "添加",
Expand Down Expand Up @@ -798,6 +799,7 @@ const translations = {
"profileEditor.marketplace.newItem": "新 Marketplace",
"profileEditor.marketplace.addItem": "新增 Marketplace",
"profileEditor.marketplace.addOfficial": "启用官方市场",
"profileEditor.marketplace.addCommunity": "启用社区市场",
"profileEditor.marketplace.editAriaLabel": "编辑 Marketplace",
"profileEditor.marketplace.deleteAriaLabel": "删除 Marketplace",
"profileEditor.marketplace.deleteDialogTitle": "删除 Marketplace",
Expand Down Expand Up @@ -2448,6 +2450,7 @@ const translations = {
"profileEditor.plugins.browse.addMarketplace": "Add marketplace",
"profileEditor.plugins.browse.addMarketplaceTitle": "Add plugin marketplace",
"profileEditor.plugins.browse.addMarketplaceOfficial": "Add official marketplace",
"profileEditor.plugins.browse.addMarketplaceCommunity": "Add community marketplace",
"profileEditor.plugins.browse.addMarketplaceOr": "or",
"profileEditor.plugins.browse.addMarketplaceRepoLabel": "GitHub repo (owner/repo)",
"profileEditor.plugins.browse.addMarketplaceSubmit": "Add",
Expand Down Expand Up @@ -2539,6 +2542,7 @@ const translations = {
"profileEditor.marketplace.newItem": "New marketplace",
"profileEditor.marketplace.addItem": "Add marketplace",
"profileEditor.marketplace.addOfficial": "Enable official marketplace",
"profileEditor.marketplace.addCommunity": "Enable community marketplace",
"profileEditor.marketplace.editAriaLabel": "Edit marketplace",
"profileEditor.marketplace.deleteAriaLabel": "Delete marketplace",
"profileEditor.marketplace.deleteDialogTitle": "Delete marketplace",
Expand Down