From 487443c27c726e9d8f8a1e6b273dd2b7b1921893 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 08:58:38 -0700 Subject: [PATCH] Marketplace UI: installed list, update diff review, apply now Applies preflight rulings F4, F7, F8, N5: Apply now's toast shows only the success/info summary (the marketplace-sync-finished event listener already toasts per-project errors/skips, so this avoids a double toast); row removal passes the bare MarketplaceItemRef rather than the full MarketplaceInstall; UpdateDiffModal shows a hook's rendered commands at head above the file diff so an update is reviewed the same way an install is. Co-Authored-By: Claude Opus 5.5 --- .../marketplace/InstalledPane.test.tsx | 144 ++++++++++++++ .../components/marketplace/InstalledPane.tsx | 182 +++++++++++++++++- .../marketplace/UpdateDiffModal.test.tsx | 58 ++++++ .../marketplace/UpdateDiffModal.tsx | 128 ++++++++++++ 4 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 app/src/components/marketplace/InstalledPane.test.tsx create mode 100644 app/src/components/marketplace/UpdateDiffModal.test.tsx create mode 100644 app/src/components/marketplace/UpdateDiffModal.tsx diff --git a/app/src/components/marketplace/InstalledPane.test.tsx b/app/src/components/marketplace/InstalledPane.test.tsx new file mode 100644 index 0000000..6d94921 --- /dev/null +++ b/app/src/components/marketplace/InstalledPane.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useAppState } from "../../store/appState"; +import type { AppSettings, Project } from "../../lib/types"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +const applyMarketplaceNow = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + applyMarketplaceNow: (id?: string) => applyMarketplaceNow(id), +})); +vi.mock("./UpdateDiffModal", () => ({ + default: ({ onAccept }: { onAccept: () => Promise }) => ( + + ), +})); + +import InstalledPane from "./InstalledPane"; + +const A = "a".repeat(40); +const B = "b".repeat(40); + +function api(patch: Partial = {}): MarketplaceApi { + return { + snapshots: [], + updates: [], + loading: false, + refreshing: [], + load: vi.fn(), + refresh: vi.fn(), + reloadState: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(async () => true), + setDisabled: vi.fn(), + update: vi.fn(async () => true), + forget: vi.fn(async () => true), + remove: vi.fn(), + ...patch, + }; +} + +describe("InstalledPane", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppState.setState({ + 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: "gone", kind: "skill", key: "old", commit: A }, + ], + } as unknown as AppSettings, + projects: [ + { + id: "p1", + name: "api", + status: "running", + marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: B }], + marketplace_disabled: [], + }, + ] as unknown as Project[], + }); + }); + + it("lists global and project installs", () => { + render(); + const global = screen.getByTestId("installed-global"); + expect(within(global).getByText("rev")).toBeInTheDocument(); + const proj = screen.getByTestId("installed-project-p1"); + expect(within(proj).getByText("cmd")).toBeInTheDocument(); + }); + + it("badges and accepts an update for the matching install", async () => { + const mp = api({ + updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B }], + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Review update for rev" })); + fireEvent.click(screen.getByRole("button", { name: "accept diff" })); + await waitFor(() => + expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }), + ); + }); + + it("marks installs whose marketplace was removed and forgets them", () => { + const mp = api(); + render(); + expect(screen.getByText("Source removed")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Forget installs from removed marketplaces" })); + expect(mp.forget).toHaveBeenCalledWith("gone"); + }); + + it("removes a project install", () => { + const mp = api(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove cmd from api" })); + // F7: the ref passed to uninstall must be the bare item ref, not the + // MarketplaceInstall (which also carries `commit`). + expect(mp.uninstall).toHaveBeenCalledWith( + { marketplace_id: "m1", kind: "command", key: "cmd" }, + { type: "project", project_id: "p1" }, + ); + }); + + it("applies now and summarises the result", async () => { + applyMarketplaceNow.mockResolvedValue([ + { project_id: "p1", report: { installed: ["agent:rev"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" } }, + ]); + render(); + fireEvent.click(screen.getByRole("button", { name: "Apply now" })); + await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalledWith(undefined)); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" })); + expect(useAppState.getState().toasts[0].message).toContain("1 running project"); + }); + + it("applies now with no running projects and shows an info toast", async () => { + applyMarketplaceNow.mockResolvedValue([]); + render(); + fireEvent.click(screen.getByRole("button", { name: "Apply now" })); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "info" })); + }); + + it("F4: does not toast per-project sync errors from apply now (the event listener owns that)", async () => { + applyMarketplaceNow.mockResolvedValue([ + { + project_id: "p1", + report: { installed: [], updated: [], removed: [], skipped: [], errors: ["boom"], finished_at: "" }, + }, + ]); + render(); + fireEvent.click(screen.getByRole("button", { name: "Apply now" })); + await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalled()); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" })); + expect(useAppState.getState().toasts).toHaveLength(1); + }); + + it("toasts an error only when the apply-now call itself fails", async () => { + applyMarketplaceNow.mockRejectedValue("container unreachable"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Apply now" })); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error" })); + }); +}); diff --git a/app/src/components/marketplace/InstalledPane.tsx b/app/src/components/marketplace/InstalledPane.tsx index be7c5d1..12e39ad 100644 --- a/app/src/components/marketplace/InstalledPane.tsx +++ b/app/src/components/marketplace/InstalledPane.tsx @@ -1,9 +1,185 @@ +import { useState } from "react"; import type { MarketplaceApi } from "../../hooks/useMarketplace"; +import { useAppState } from "../../store/appState"; +import { KIND_LABELS } from "../../lib/marketplace"; +import { applyMarketplaceNow } from "../../lib/tauri-commands"; +import type { InstallScope, ItemUpdate, MarketplaceInstall, MarketplaceItemRef } from "../../lib/types"; +import Button from "../ui/Button"; +import UpdateDiffModal from "./UpdateDiffModal"; + +function errorText(e: unknown): string { + return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); +} + +interface Pending { + install: MarketplaceInstall; + update: ItemUpdate; + scope: InstallScope; + scopeLabel: string; +} export default function InstalledPane({ mp }: { mp: MarketplaceApi }) { + const appSettings = useAppState((s) => s.appSettings); + const projects = useAppState((s) => s.projects); + const pushToast = useAppState((s) => s.pushToast); + const [pending, setPending] = useState(null); + const [applying, setApplying] = useState(false); + + const marketplaces = appSettings?.marketplaces ?? []; + const known = new Set(marketplaces.map((m) => m.id)); + const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id; + const globalInstalls = appSettings?.global_marketplace_installs ?? []; + + const updateFor = (i: MarketplaceInstall) => + mp.updates.find( + (u) => + u.item.marketplace_id === i.marketplace_id && + u.item.kind === i.kind && + u.item.key === i.key && + u.head !== i.commit, + ); + + /** Hooks only (spec §3, preflight F8): the rendered commands at head, so the + * diff review shows what a hook will run after the update, not just the + * raw `hook.json` diff. */ + const hookCommandsFor = (item: MarketplaceItemRef): string[] | undefined => { + if (item.kind !== "hook") return undefined; + const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id); + return snap?.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands; + }; + + const removedSources = [ + ...new Set( + [...globalInstalls, ...projects.flatMap((p) => p.marketplace_installs)] + .map((i) => i.marketplace_id) + .filter((id) => !known.has(id)), + ), + ]; + + const applyNow = async () => { + setApplying(true); + try { + const results = await applyMarketplaceNow(undefined); + // F4 (preflight): the backend emits `marketplace-sync-finished` for + // every project synced here, and `useMarketplaceSyncToasts` already + // toasts any errors/skips from that event. This toast is only the + // success/info summary — a second error toast here would double up. + if (results.length === 0) { + pushToast({ kind: "info", message: "No running projects — changes apply when a project starts." }); + } else { + pushToast({ + kind: "success", + message: `Marketplace applied to ${results.length} running project${results.length === 1 ? "" : "s"}. New Claude sessions will use it.`, + }); + } + } catch (e) { + pushToast({ kind: "error", message: "Could not apply marketplace changes", detail: errorText(e) }); + } finally { + setApplying(false); + } + }; + + const row = (i: MarketplaceInstall, scope: InstallScope, scopeLabel: string, removeLabel: string) => { + const upd = updateFor(i); + const gone = !known.has(i.marketplace_id); + // F7 (preflight): pass the bare item ref, not the MarketplaceInstall + // itself — `commit` is not part of the ref the backend/store expect here. + const ref: MarketplaceItemRef = { marketplace_id: i.marketplace_id, kind: i.kind, key: i.key }; + return ( +
  • +
    + {i.key} + + {KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)} + + {gone && Source removed} +
    +
    + {upd && !gone && ( + + )} + +
    +
  • + ); + }; + return ( -

    - {mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available. -

    +
    +
    +

    + Installs are pinned to a commit. Containers pick up changes on their next start, or now for running ones. + Changes apply to new Claude sessions. +

    + +
    + + {removedSources.length > 0 && ( +
    +

    + Some installs come from marketplaces that were removed. They are removed from containers at their next + sync. +

    + +
    + )} + +
    +

    All projects

    + {globalInstalls.length === 0 ? ( +

    Nothing installed for all projects.

    + ) : ( +
      {globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}
    + )} +
    + + {projects.map((p) => ( +
    +

    {p.name}

    + {p.marketplace_installs.length === 0 ? ( +

    + No project-only installs + {p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}. +

    + ) : ( +
      + {p.marketplace_installs.map((i) => + row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`), + )} +
    + )} +
    + ))} + + {pending && ( + setPending(null)} + onAccept={() => mp.update(pending.update.item, pending.scope)} + /> + )} +
    ); } diff --git a/app/src/components/marketplace/UpdateDiffModal.test.tsx b/app/src/components/marketplace/UpdateDiffModal.test.tsx new file mode 100644 index 0000000..2f74188 --- /dev/null +++ b/app/src/components/marketplace/UpdateDiffModal.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const marketplaceItemDiff = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + marketplaceItemDiff: (...a: unknown[]) => marketplaceItemDiff(...a), +})); + +import UpdateDiffModal from "./UpdateDiffModal"; + +const A = "a".repeat(40); +const B = "b".repeat(40); +const item = { marketplace_id: "m1", kind: "hook" as const, key: "notify" }; + +describe("UpdateDiffModal", () => { + beforeEach(() => vi.clearAllMocks()); + + it("loads the diff from the install's pin to head and accepts", async () => { + marketplaceItemDiff.mockResolvedValue([ + { path: "notify.sh", change: "modified", unified: "-echo old\n+echo new\n" }, + { path: "icon.png", change: "added", unified: null }, + ]); + const onAccept = vi.fn(async () => true); + render(); + await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalledWith(item, A, B)); + expect(screen.getByText(/\+echo new/)).toBeInTheDocument(); + expect(screen.getByText("Binary file — no text diff")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Update" })); + await waitFor(() => expect(onAccept).toHaveBeenCalled()); + }); + + it("shows a load error and keeps Update disabled", async () => { + marketplaceItemDiff.mockRejectedValue("commit not in cache"); + render(); + expect(await screen.findByText(/commit not in cache/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Update" })).toBeDisabled(); + }); + + it("shows the rendered commands a hook will run after the update (F8)", async () => { + marketplaceItemDiff.mockResolvedValue([]); + render( + , + ); + await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalled()); + expect(screen.getByText("Commands after this update")).toBeInTheDocument(); + expect( + screen.getByText("/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"), + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/UpdateDiffModal.tsx b/app/src/components/marketplace/UpdateDiffModal.tsx new file mode 100644 index 0000000..a8b569f --- /dev/null +++ b/app/src/components/marketplace/UpdateDiffModal.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import { marketplaceItemDiff } from "../../lib/tauri-commands"; +import { formatItemRef } from "../../lib/marketplace"; +import type { FileDiff, MarketplaceItemRef } from "../../lib/types"; + +interface Props { + item: MarketplaceItemRef; + fromCommit: string; + toCommit: string; + scopeLabel: string; + /** + * Hooks only: the rendered commands the item runs at `toCommit` (head), from + * the marketplace snapshot's catalog entry. An update can change what a hook + * runs without going back through the install-time confirm list, so this is + * shown alongside the file diff — spec §3. Undefined for non-hook items. + */ + hookCommands?: string[]; + onClose: () => void; + /** Resolves true when the update was applied. */ + onAccept: () => Promise; +} + +const CHANGE_LABEL: Record = { + added: "added", + removed: "removed", + modified: "modified", +}; + +export default function UpdateDiffModal({ + item, + fromCommit, + toCommit, + scopeLabel, + hookCommands, + onClose, + onAccept, +}: Props) { + const [diffs, setDiffs] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + marketplaceItemDiff(item, fromCommit, toCommit) + .then((d) => { + if (!cancelled) setDiffs(d); + }) + .catch((e) => { + if (!cancelled) setError(typeof e === "string" ? e : String(e)); + }); + return () => { + cancelled = true; + }; + }, [item, fromCommit, toCommit]); + + const accept = async () => { + setBusy(true); + try { + if (await onAccept()) onClose(); + } finally { + setBusy(false); + } + }; + + return ( + + + + + } + > + {error &&

    {error}

    } + {!error && diffs === null &&

    Loading changes…

    } + {hookCommands && ( +
    +

    Commands after this update

    + {hookCommands.length === 0 ? ( +

    This hook declares no commands.

    + ) : ( +
      + {hookCommands.map((c) => ( +
    • + + {c} + +
    • + ))} +
    + )} +
    + )} + {diffs && diffs.length === 0 && ( +

    No file changes (only the catalog entry changed).

    + )} + {diffs && diffs.length > 0 && ( +
    + {diffs.map((d) => ( +
    +

    + {d.path} ({CHANGE_LABEL[d.change]}) +

    + {d.unified === null ? ( +

    Binary file — no text diff

    + ) : ( +
    +                  {d.unified}
    +                
    + )} +
    + ))} +
    + )} +
    + ); +}