From 28c8f0479bf365ddbf2c72f8c908504d22ea7d34 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 09:08:12 -0700 Subject: [PATCH] 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 --- .../components/projects/home/ConfigTab.tsx | 2 + .../home/config/MarketplaceSection.test.tsx | 119 ++++++++++++ .../home/config/MarketplaceSection.tsx | 171 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 app/src/components/projects/home/config/MarketplaceSection.test.tsx create mode 100644 app/src/components/projects/home/config/MarketplaceSection.tsx diff --git a/app/src/components/projects/home/ConfigTab.tsx b/app/src/components/projects/home/ConfigTab.tsx index 566e078..037f228 100644 --- a/app/src/components/projects/home/ConfigTab.tsx +++ b/app/src/components/projects/home/ConfigTab.tsx @@ -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} /> + ); } diff --git a/app/src/components/projects/home/config/MarketplaceSection.test.tsx b/app/src/components/projects/home/config/MarketplaceSection.test.tsx new file mode 100644 index 0000000..e623078 --- /dev/null +++ b/app/src/components/projects/home/config/MarketplaceSection.test.tsx @@ -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) => 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/app/src/components/projects/home/config/MarketplaceSection.tsx b/app/src/components/projects/home/config/MarketplaceSection.tsx new file mode 100644 index 0000000..6c71711 --- /dev/null +++ b/app/src/components/projects/home/config/MarketplaceSection.tsx @@ -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(null); + const [busy, setBusy] = useState(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("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 ( + +
+ {globalInstalls.length > 0 && ( +
+

From “All projects”

+
    + {globalInstalls.map((g) => { + const shadowed = project.marketplace_installs.some((p) => same(p, g)); + const enabled = !project.marketplace_disabled.some((d) => same(d, g)); + return ( +
  • + + {g.key}{" "} + + {kindWord(g.kind)} · {nameOf(g.marketplace_id)} + {shadowed ? " · overridden by this project's own install" : ""} + + + void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)} + /> +
  • + ); + })} +
+
+ )} + {project.marketplace_installs.length > 0 && ( +
+

This project only

+
    + {project.marketplace_installs.map((i) => ( +
  • + {i.key}{" "} + + {kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only + +
  • + ))} +
+
+ )} + {globalInstalls.length === 0 && project.marketplace_installs.length === 0 && ( +

Nothing installed from a marketplace.

+ )} + + {report && ( +
+

+ Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""} +

+

+ {report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed +

+ {report.skipped.map((s) => ( +

+ Skipped {s.item}: {s.reason} +

+ ))} + {report.errors.map((e) => ( +

+ {e} +

+ ))} +
+ )} + + +
+
+ ); +}