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:
@@ -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));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user