Marketplace UI: browse, item detail, install controls, hook confirmation, add marketplace
Implements Task 13: BrowsePane (marketplace list, kind/search filters, item detail), InstallControls (global/per-project install, opt-out, hook confirm gate), HookConfirmModal, AddMarketplaceModal, and ItemDetail. Also applies pre-flight ruling F6: a per-marketplace Remove button with a confirm dialog (mp.remove) warning that surviving installs become "Source removed" and can be dropped via Forget on the Installed tab, plus an inline account reassignment select (updateMarketplace + reloadState). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings } from "../../lib/types";
|
||||
|
||||
const addMarketplace = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
addMarketplace: (...a: unknown[]) => addMarketplace(...a),
|
||||
}));
|
||||
|
||||
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||
|
||||
describe("AddMarketplaceModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }],
|
||||
marketplaces: [],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings,
|
||||
});
|
||||
});
|
||||
|
||||
it("submits name, url, branch and account", async () => {
|
||||
const onAdded = vi.fn();
|
||||
addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] });
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={onAdded} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } });
|
||||
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
|
||||
fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1");
|
||||
});
|
||||
|
||||
it("rejects non-https URLs before calling the backend", () => {
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } });
|
||||
expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled();
|
||||
expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the backend error and stays open", async () => {
|
||||
addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)");
|
||||
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
import { addMarketplace } from "../../lib/tauri-commands";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { MarketplaceSnapshot } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAdded: (snapshot: MarketplaceSnapshot) => void;
|
||||
}
|
||||
|
||||
export default function AddMarketplaceModal({ onClose, onAdded }: Props) {
|
||||
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [accountId, setAccountId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const urlProblem =
|
||||
trimmedUrl !== "" && !trimmedUrl.startsWith("https://")
|
||||
? "The repository URL must start with https:// (SSH URLs are not supported)."
|
||||
: null;
|
||||
const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const snap = await addMarketplace(
|
||||
name.trim(),
|
||||
trimmedUrl,
|
||||
branch.trim() === "" ? null : branch.trim(),
|
||||
accountId === "" ? null : accountId,
|
||||
);
|
||||
onAdded(snap);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(typeof e === "string" ? e : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add marketplace"
|
||||
description="Triple-C fetches the repository now to check it can be read. Nothing is saved if that fails."
|
||||
widthClassName="w-[36rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{busy ? "Checking…" : "Add marketplace"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Name">
|
||||
{(id) => (
|
||||
<input id={id} value={name} onChange={(e) => setName(e.target.value)} className={inputClass} placeholder="Team marketplace" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Repository URL" hint={urlProblem ?? "HTTPS clone URL, e.g. https://github.com/owner/repo.git"}>
|
||||
{(id) => (
|
||||
<input id={id} value={url} onChange={(e) => setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Branch" hint="Leave empty to use the repository's default branch.">
|
||||
{(id) => (
|
||||
<input id={id} value={branch} onChange={(e) => setBranch(e.target.value)} className={inputClass} placeholder="main" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Account" hint="Needed for private repositories. Add accounts on the Accounts tab.">
|
||||
{(id) => (
|
||||
<select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value)} className={selectClass}>
|
||||
<option value="">None (public repository)</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label} — {a.host}
|
||||
{a.username ? ` (${a.username})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap leading-snug">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
|
||||
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
||||
|
||||
import BrowsePane from "./BrowsePane";
|
||||
|
||||
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||
kind,
|
||||
key,
|
||||
name: key,
|
||||
description: `${key} description`,
|
||||
path: key,
|
||||
invalid: null,
|
||||
hook_commands: [],
|
||||
preview: `${key} preview body`,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const snapshot: MarketplaceSnapshot = {
|
||||
marketplace_id: "m1",
|
||||
head_commit: "a".repeat(40),
|
||||
fetched_at: "2026-09-27T12:00:00Z",
|
||||
fetch_error: "network unreachable",
|
||||
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
|
||||
};
|
||||
|
||||
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [snapshot],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
setDisabled: vi.fn(),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(async () => true),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowsePane", () => {
|
||||
beforeEach(() => {
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
|
||||
marketplace_accounts: [],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings,
|
||||
projects: [],
|
||||
marketplaceFilterProjectId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists items, filters by kind and search, and shows detail", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
expect(screen.getByText("network unreachable")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
|
||||
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "All" }));
|
||||
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
|
||||
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
||||
expect(screen.getByText("install controls")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows why an item is invalid", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
|
||||
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes one marketplace", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
|
||||
expect(mp.refresh).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
|
||||
it("offers Add when there are no marketplaces", () => {
|
||||
useAppState.setState({
|
||||
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
|
||||
});
|
||||
render(<BrowsePane mp={api({ snapshots: [] })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
expect(screen.getByText("add modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirms before removing a marketplace (F6)", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Starter" }));
|
||||
expect(screen.getByText(/Source removed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Forget/)).toBeInTheDocument();
|
||||
expect(mp.remove).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" }));
|
||||
expect(mp.remove).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,248 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace";
|
||||
import { updateMarketplace } from "../../lib/tauri-commands";
|
||||
import type { CatalogItem, ItemKind, Marketplace } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import Modal from "../ui/Modal";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import { inputClass, selectClass } from "../ui/Field";
|
||||
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||
import ItemDetail from "./ItemDetail";
|
||||
|
||||
type KindFilter = ItemKind | "all";
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
|
||||
|
||||
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
|
||||
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||
const globalInstalls = useAppState((s) => s.appSettings?.global_marketplace_installs ?? []);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
|
||||
const [kind, setKind] = useState<KindFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Marketplace | null>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return mp.snapshots.flatMap((snap) =>
|
||||
snap.items
|
||||
.filter((i) => kind === "all" || i.kind === kind)
|
||||
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
|
||||
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
|
||||
.map((item) => ({ marketplaceId: snap.marketplace_id, item })),
|
||||
);
|
||||
}, [mp.snapshots, kind, query]);
|
||||
|
||||
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
|
||||
|
||||
const changeAccount = async (m: Marketplace, accountId: string | null) => {
|
||||
await updateMarketplace({ ...m, account_id: accountId });
|
||||
await mp.reloadState();
|
||||
};
|
||||
|
||||
/** Global + every project's installs of this marketplace, for the removal warning. */
|
||||
const installCountFor = (marketplaceId: string) => {
|
||||
const global = globalInstalls.filter((i) => i.marketplace_id === marketplaceId).length;
|
||||
const perProject = projects.reduce(
|
||||
(sum, p) => sum + p.marketplace_installs.filter((i) => i.marketplace_id === marketplaceId).length,
|
||||
0,
|
||||
);
|
||||
return global + perProject;
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">
|
||||
{mp.snapshots.length} marketplace{mp.snapshots.length === 1 ? "" : "s"} configured.
|
||||
</p>
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="w-64 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-3 overflow-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium">Marketplaces</h2>
|
||||
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
|
||||
Add marketplace
|
||||
</Button>
|
||||
</div>
|
||||
{marketplaces.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No marketplaces yet. Add a git repository to browse its items.</p>
|
||||
)}
|
||||
{marketplaces.map((m) => {
|
||||
const snap = mp.snapshots.find((s) => s.marketplace_id === m.id);
|
||||
const refreshing = mp.refreshing.includes(m.id);
|
||||
return (
|
||||
<div key={m.id} className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium truncate" title={m.url}>
|
||||
{m.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Refresh ${m.name}`}
|
||||
disabled={refreshing}
|
||||
onClick={() => void mp.refresh(m.id)}
|
||||
>
|
||||
{refreshing ? "…" : "↻"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Remove ${m.name}`}
|
||||
onClick={() => setRemoving(m)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{accounts.length > 0 ? (
|
||||
<label className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||
<span>Account</span>
|
||||
<select
|
||||
aria-label={`Account for ${m.name}`}
|
||||
value={m.account_id ?? ""}
|
||||
onChange={(e) => void changeAccount(m, e.target.value === "" ? null : e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">None (public)</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-[var(--text-secondary)]">No account (public repository)</p>
|
||||
)}
|
||||
<p className="text-[var(--text-secondary)]">Last fetched {when(snap?.fetched_at ?? null)}</p>
|
||||
{snap?.fetch_error && (
|
||||
<p className="text-[var(--error)] whitespace-pre-wrap leading-snug">{snap.fetch_error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{projects.length > 0 && (
|
||||
<label className="block text-xs space-y-1">
|
||||
<span className="text-[var(--text-secondary)]">Show install state for</span>
|
||||
<select
|
||||
value={filterId ?? ""}
|
||||
onChange={(e) => setFilterId(e.target.value === "" ? null : e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">All projects</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="w-80 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-2 overflow-auto">
|
||||
<SegmentedControl<KindFilter>
|
||||
label="Item kind"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
segments={[
|
||||
{ value: "all", label: "All" },
|
||||
...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })),
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
aria-label="Search items"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
className={inputClass}
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{rows.map(({ marketplaceId, item }) => {
|
||||
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
|
||||
const isSel =
|
||||
selected?.marketplaceId === marketplaceId &&
|
||||
selected.item.kind === item.kind &&
|
||||
selected.item.key === item.key;
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ marketplaceId, item })}
|
||||
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
|
||||
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="ml-1 text-[var(--text-secondary)]">{KIND_LABELS[item.kind].replace(/s$/, "").toLowerCase()}</span>
|
||||
{item.invalid && <span className="ml-1 text-[var(--error)]">invalid</span>}
|
||||
{mp.snapshots.length > 1 && (
|
||||
<span className="block text-[var(--text-secondary)]">{nameOf(marketplaceId)}</span>
|
||||
)}
|
||||
{item.description && (
|
||||
<span className="block text-[var(--text-secondary)] truncate">{item.description}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && mp.snapshots.length > 0 && (
|
||||
<li className="text-xs text-[var(--text-secondary)]">No items match.</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="flex-1 min-w-0 p-4 overflow-auto">
|
||||
{selected ? (
|
||||
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} />
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{adding && (
|
||||
<AddMarketplaceModal
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={() => {
|
||||
void mp.reloadState();
|
||||
void mp.load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removing && (
|
||||
<Modal
|
||||
title={`Remove marketplace “${removing.name}”?`}
|
||||
onClose={() => setRemoving(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={() => setRemoving(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
const id = removing.id;
|
||||
setRemoving(null);
|
||||
void mp.remove(id);
|
||||
}}
|
||||
>
|
||||
Remove marketplace
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{installCountFor(removing.id)} install{installCountFor(removing.id) === 1 ? "" : "s"} stay listed as
|
||||
“Source removed” and are removed from containers at their next sync. Use “Forget” on the Installed tab
|
||||
instead if you want to drop them immediately.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import type { CatalogItem } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
item: CatalogItem;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
|
||||
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
title={`Install hook “${item.name}”?`}
|
||||
description="This hook runs the commands below inside the container whenever its event fires."
|
||||
widthClassName="w-[40rem]"
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={onConfirm}>
|
||||
Install hook
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{item.hook_commands.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{item.hook_commands.map((c) => (
|
||||
<li key={c}>
|
||||
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{c}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import InstallControls from "./InstallControls";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, CatalogItem, Project } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
const C = "c".repeat(40);
|
||||
|
||||
function api(): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(async () => true),
|
||||
uninstall: vi.fn(async () => true),
|
||||
setDisabled: vi.fn(async () => true),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||
kind,
|
||||
key: "rev",
|
||||
name: "rev",
|
||||
description: "",
|
||||
path: `agents/rev.md`,
|
||||
invalid: null,
|
||||
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
||||
preview: "",
|
||||
...patch,
|
||||
});
|
||||
|
||||
const project = (id: string, patch: Partial<Project> = {}) =>
|
||||
({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project;
|
||||
|
||||
function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) {
|
||||
useAppState.setState({
|
||||
appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings,
|
||||
projects,
|
||||
marketplaceFilterProjectId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||
|
||||
describe("InstallControls", () => {
|
||||
beforeEach(() => seed([], [project("p1"), project("p2")]));
|
||||
|
||||
it("installs for all projects", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
|
||||
});
|
||||
|
||||
it("installs for one project", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" });
|
||||
});
|
||||
|
||||
it("opts a project out of a global install and back in", () => {
|
||||
const mp = api();
|
||||
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||
const row1 = screen.getByTestId("install-row-p1");
|
||||
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
||||
fireEvent.click(within(row1).getByRole("checkbox"));
|
||||
expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true);
|
||||
const row2 = screen.getByTestId("install-row-p2");
|
||||
expect(within(row2).getByText("Opted out")).toBeInTheDocument();
|
||||
fireEvent.click(within(row2).getByRole("checkbox"));
|
||||
expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false);
|
||||
});
|
||||
|
||||
it("removes a project-only install", () => {
|
||||
const mp = api();
|
||||
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
||||
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
|
||||
});
|
||||
|
||||
it("requires confirmation before installing a hook", () => {
|
||||
const mp = api();
|
||||
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||
expect(mp.install).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" });
|
||||
});
|
||||
|
||||
it("disables everything for an invalid item", () => {
|
||||
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />);
|
||||
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
||||
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows only the filtered project when a filter is set", () => {
|
||||
useAppState.setState({ marketplaceFilterProjectId: "p2" });
|
||||
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />);
|
||||
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { projectItemState, type ProjectItemState } from "../../lib/marketplace";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import HookConfirmModal from "./HookConfirmModal";
|
||||
|
||||
const STATE_LABEL: Record<ProjectItemState, string> = {
|
||||
none: "",
|
||||
inherited: "Inherited",
|
||||
opted_out: "Opted out",
|
||||
project: "This project",
|
||||
project_pinned_differently: "Pinned to a different commit",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
marketplaceId: string;
|
||||
}
|
||||
|
||||
export default function InstallControls({ mp, item, marketplaceId }: Props) {
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||
const [pendingHook, setPendingHook] = useState<InstallScope | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
||||
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||
const isGlobal = globalInstalls.some(
|
||||
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
|
||||
);
|
||||
const disabled = item.invalid !== null || busy;
|
||||
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
|
||||
|
||||
const run = async (fn: () => Promise<boolean>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Every install goes through here so a hook is always confirmed first. */
|
||||
const install = (scope: InstallScope) => {
|
||||
if (item.kind === "hook") {
|
||||
setPendingHook(scope);
|
||||
return;
|
||||
}
|
||||
void run(() => mp.install(ref, scope));
|
||||
};
|
||||
|
||||
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
||||
const scope: InstallScope = { type: "project", project_id: projectId };
|
||||
switch (state) {
|
||||
case "none":
|
||||
install(scope);
|
||||
break;
|
||||
case "inherited":
|
||||
void run(() => mp.setDisabled(projectId, ref, true));
|
||||
break;
|
||||
case "opted_out":
|
||||
void run(() => mp.setDisabled(projectId, ref, false));
|
||||
break;
|
||||
case "project":
|
||||
case "project_pinned_differently":
|
||||
void run(() => mp.uninstall(ref, scope));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
label="All projects"
|
||||
checked={isGlobal}
|
||||
disabled={disabled}
|
||||
onChange={(v) => (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))}
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{shown.map((p) => {
|
||||
const state = projectItemState(ref, globalInstalls, p);
|
||||
const checked = state === "inherited" || state === "project" || state === "project_pinned_differently";
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
data-testid={`install-row-${p.id}`}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<label className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleProject(p.id, state)}
|
||||
/>
|
||||
<span className="truncate">{p.name}</span>
|
||||
</label>
|
||||
{STATE_LABEL[state] && (
|
||||
<span className="text-[var(--text-secondary)] whitespace-nowrap">{STATE_LABEL[state]}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{projects.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
||||
)}
|
||||
{pendingHook && (
|
||||
<HookConfirmModal
|
||||
item={item}
|
||||
onCancel={() => setPendingHook(null)}
|
||||
onConfirm={() => {
|
||||
const scope = pendingHook;
|
||||
setPendingHook(null);
|
||||
void run(() => mp.install(ref, scope));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CatalogItem } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
import { KIND_LABELS } from "../../lib/marketplace";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import InstallControls from "./InstallControls";
|
||||
|
||||
interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
marketplaceId: string;
|
||||
}
|
||||
|
||||
export default function ItemDetail({ mp, item, marketplaceId }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{KIND_LABELS[item.kind].replace(/s$/, "")} · <code className="font-mono">{item.path}</code>
|
||||
</p>
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">{item.name}</h3>
|
||||
{item.description && <p className="text-xs text-[var(--text-secondary)] leading-snug">{item.description}</p>}
|
||||
</div>
|
||||
{item.invalid && (
|
||||
<div className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2">
|
||||
<StatusIndicator tone="error" label="Cannot be installed" className="text-xs" />
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">{item.invalid}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.kind === "hook" && item.hook_commands.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">Commands this hook runs</p>
|
||||
<ul className="space-y-1">
|
||||
{item.hook_commands.map((c) => (
|
||||
<li key={c}>
|
||||
<code className="block font-mono text-xs break-all">{c}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{item.preview && (
|
||||
<pre className="max-h-80 overflow-auto p-2 text-xs font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||
{item.preview}
|
||||
</pre>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1">Install</p>
|
||||
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} />
|
||||
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
|
||||
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
|
||||
apply to new Claude sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user