Project Config: Marketplace section with per-project opt-out and last sync report
Shows items this project gets from "All projects" installs (with a per-item opt-out switch saved through setGlobalItemDisabled, since opting out doesn't require a stopped container) and this project's own installs. Fetches the last sync report on mount and refetches it when marketplace-sync-finished fires for this project (N7, preflight), so Apply Now and container-start syncs don't leave it stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import WorkspaceSection from "./config/WorkspaceSection";
|
||||
import ModelSection from "./config/ModelSection";
|
||||
import AccessSection from "./config/AccessSection";
|
||||
import RuntimeSection from "./config/RuntimeSection";
|
||||
import MarketplaceSection from "./config/MarketplaceSection";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -52,6 +53,7 @@ export default function ConfigTab({ project, save, saveState }: Props) {
|
||||
disabled={disabled}
|
||||
disabledReason={STOPPED_ONLY}
|
||||
/>
|
||||
<MarketplaceSection project={project} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { useAppState, MARKETPLACE_TAB_KEY } from "../../../../store/appState";
|
||||
import type { AppSettings, Project } from "../../../../lib/types";
|
||||
|
||||
const setGlobalItemDisabled = vi.fn();
|
||||
const getMarketplaceSyncReport = vi.fn();
|
||||
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||
setGlobalItemDisabled: (...a: unknown[]) => setGlobalItemDisabled(...a),
|
||||
getMarketplaceSyncReport: (id: string) => getMarketplaceSyncReport(id),
|
||||
}));
|
||||
|
||||
let syncFinishedHandler: ((event: { payload: { project_id: string; report: unknown } }) => void) | null = null;
|
||||
const listenMock = vi.fn(async (_name: string, cb: (event: { payload: { project_id: string; report: unknown } }) => void) => {
|
||||
syncFinishedHandler = cb;
|
||||
return vi.fn();
|
||||
});
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: (...a: Parameters<typeof listenMock>) => listenMock(...a),
|
||||
}));
|
||||
|
||||
import MarketplaceSection from "./MarketplaceSection";
|
||||
|
||||
const A = "a".repeat(40);
|
||||
const project = {
|
||||
id: "p1",
|
||||
name: "api",
|
||||
status: "running",
|
||||
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: A }],
|
||||
marketplace_disabled: [{ marketplace_id: "m1", kind: "hook", key: "noisy" }],
|
||||
} as unknown as Project;
|
||||
|
||||
describe("MarketplaceSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
syncFinishedHandler = null;
|
||||
useAppState.setState({
|
||||
tabOrder: [],
|
||||
activeTabKey: null,
|
||||
projects: [project],
|
||||
toasts: [],
|
||||
appSettings: {
|
||||
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
|
||||
marketplace_accounts: [],
|
||||
global_marketplace_installs: [
|
||||
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
|
||||
{ marketplace_id: "m1", kind: "hook", key: "noisy", commit: A },
|
||||
],
|
||||
} as unknown as AppSettings,
|
||||
});
|
||||
getMarketplaceSyncReport.mockResolvedValue({
|
||||
installed: ["agent:rev"],
|
||||
updated: [],
|
||||
removed: [],
|
||||
skipped: [{ item: "command:cmd", reason: "a file you created has the same name" }],
|
||||
errors: [],
|
||||
finished_at: "2026-09-27T12:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows effective items with their source and the opted-out global item", async () => {
|
||||
render(<MarketplaceSection project={project} />);
|
||||
const rev = screen.getByTestId("mp-global-agent-rev");
|
||||
expect(within(rev).getByRole("switch")).toBeChecked();
|
||||
const noisy = screen.getByTestId("mp-global-hook-noisy");
|
||||
expect(within(noisy).getByRole("switch")).not.toBeChecked();
|
||||
expect(screen.getByTestId("mp-project-command-cmd")).toHaveTextContent("This project only");
|
||||
expect(await screen.findByText(/a file you created has the same name/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opts out of a global item", async () => {
|
||||
setGlobalItemDisabled.mockResolvedValue({ ...project, marketplace_disabled: [] });
|
||||
render(<MarketplaceSection project={project} />);
|
||||
fireEvent.click(within(screen.getByTestId("mp-global-agent-rev")).getByRole("switch"));
|
||||
await waitFor(() =>
|
||||
expect(setGlobalItemDisabled).toHaveBeenCalledWith("p1", { marketplace_id: "m1", kind: "agent", key: "rev" }, true),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the Marketplace filtered to this project", () => {
|
||||
render(<MarketplaceSection project={project} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
|
||||
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
|
||||
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1");
|
||||
});
|
||||
|
||||
it("refetches the sync report when marketplace-sync-finished fires for this project", async () => {
|
||||
render(<MarketplaceSection project={project} />);
|
||||
await screen.findByText(/a file you created has the same name/);
|
||||
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||
|
||||
getMarketplaceSyncReport.mockResolvedValue({
|
||||
installed: [],
|
||||
updated: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
errors: ["boom"],
|
||||
finished_at: "2026-09-27T13:00:00Z",
|
||||
});
|
||||
|
||||
expect(syncFinishedHandler).not.toBeNull();
|
||||
syncFinishedHandler?.({ payload: { project_id: "p1", report: {} } });
|
||||
|
||||
await waitFor(() => expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText("boom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores marketplace-sync-finished events for other projects", async () => {
|
||||
render(<MarketplaceSection project={project} />);
|
||||
await screen.findByText(/a file you created has the same name/);
|
||||
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(syncFinishedHandler).not.toBeNull();
|
||||
syncFinishedHandler?.({ payload: { project_id: "p2", report: {} } });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { ConfigGroup } from "../../../ui/Field";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
import Button from "../../../ui/Button";
|
||||
import { useAppState } from "../../../../store/appState";
|
||||
import { KIND_LABELS } from "../../../../lib/marketplace";
|
||||
import { getMarketplaceSyncReport, setGlobalItemDisabled } from "../../../../lib/tauri-commands";
|
||||
import type { MarketplaceItemRef, Project, SyncReport } from "../../../../lib/types";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
interface SyncFinishedEvent {
|
||||
project_id: string;
|
||||
report: SyncReport;
|
||||
}
|
||||
|
||||
const kindWord = (k: MarketplaceItemRef["kind"]) => KIND_LABELS[k].replace(/s$/, "").toLowerCase();
|
||||
const same = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
|
||||
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
|
||||
|
||||
export default function MarketplaceSection({ project }: Props) {
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const openMarketplace = useAppState((s) => s.openMarketplace);
|
||||
const updateProjectInList = useAppState((s) => s.updateProjectInList);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const [report, setReport] = useState<SyncReport | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||
const nameOf = (id: string) => appSettings?.marketplaces.find((m) => m.id === id)?.name ?? "removed marketplace";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchReport = () => {
|
||||
getMarketplaceSyncReport(project.id)
|
||||
.then((r) => {
|
||||
if (!cancelled) setReport(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setReport(null);
|
||||
});
|
||||
};
|
||||
|
||||
fetchReport();
|
||||
|
||||
// N7 (preflight): a sync also runs outside this component's own actions
|
||||
// (container start, "Apply now" from the Marketplace tab), so without
|
||||
// this the report shown here goes stale as soon as one finishes.
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
|
||||
if (event.payload.project_id === project.id) fetchReport();
|
||||
})
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
})
|
||||
.catch(() => {
|
||||
// Not running inside Tauri (e.g. tests) — nothing to listen to.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [project.id, project.status]);
|
||||
|
||||
const toggleGlobal = async (ref: MarketplaceItemRef, enabled: boolean) => {
|
||||
const id = `${ref.kind}-${ref.key}`;
|
||||
setBusy(id);
|
||||
try {
|
||||
updateProjectInList(await setGlobalItemDisabled(project.id, ref, !enabled));
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: `Could not change ${ref.key} for “${project.name}”`, detail: String(e) });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Marketplace"
|
||||
description="Items this project gets from marketplaces. Changes apply on the next container start or with Apply now, in new Claude sessions."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{globalInstalls.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">From “All projects”</p>
|
||||
<ul className="space-y-1">
|
||||
{globalInstalls.map((g) => {
|
||||
const shadowed = project.marketplace_installs.some((p) => same(p, g));
|
||||
const enabled = !project.marketplace_disabled.some((d) => same(d, g));
|
||||
return (
|
||||
<li
|
||||
key={`${g.marketplace_id}/${g.kind}/${g.key}`}
|
||||
data-testid={`mp-global-${g.kind}-${g.key}`}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{g.key}</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{kindWord(g.kind)} · {nameOf(g.marketplace_id)}
|
||||
{shadowed ? " · overridden by this project's own install" : ""}
|
||||
</span>
|
||||
</span>
|
||||
<Toggle
|
||||
label={`Use ${g.key} in ${project.name}`}
|
||||
checked={enabled}
|
||||
disabled={busy === `${g.kind}-${g.key}`}
|
||||
onChange={(v) => void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{project.marketplace_installs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">This project only</p>
|
||||
<ul className="space-y-1">
|
||||
{project.marketplace_installs.map((i) => (
|
||||
<li
|
||||
key={`${i.marketplace_id}/${i.kind}/${i.key}`}
|
||||
data-testid={`mp-project-${i.kind}-${i.key}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="font-medium">{i.key}</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{globalInstalls.length === 0 && project.marketplace_installs.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Nothing installed from a marketplace.</p>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">
|
||||
Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""}
|
||||
</p>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed
|
||||
</p>
|
||||
{report.skipped.map((s) => (
|
||||
<p key={s.item} className="text-[var(--warning)]">
|
||||
Skipped {s.item}: {s.reason}
|
||||
</p>
|
||||
))}
|
||||
{report.errors.map((e) => (
|
||||
<p key={e} className="text-[var(--error)] whitespace-pre-wrap">
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="secondary" onClick={() => openMarketplace(project.id)}>
|
||||
Open in Marketplace
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user