2026-09-27 08:54:02 -07:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useAppState } from "../../store/appState";
|
|
|
|
|
import { projectItemState, type ProjectItemState } from "../../lib/marketplace";
|
|
|
|
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
|
|
|
|
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
|
|
|
|
|
import Toggle from "../ui/Toggle";
|
|
|
|
|
import HookConfirmModal from "./HookConfirmModal";
|
|
|
|
|
|
|
|
|
|
const STATE_LABEL: Record<ProjectItemState, string> = {
|
|
|
|
|
none: "",
|
|
|
|
|
inherited: "Inherited",
|
|
|
|
|
opted_out: "Opted out",
|
|
|
|
|
project: "This project",
|
|
|
|
|
project_pinned_differently: "Pinned to a different commit",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
mp: MarketplaceApi;
|
|
|
|
|
item: CatalogItem;
|
|
|
|
|
marketplaceId: string;
|
2026-09-27 10:11:19 -07:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-09-27 08:54:02 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-27 10:11:19 -07:00
|
|
|
/** 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) {
|
2026-09-27 08:54:02 -07:00
|
|
|
const appSettings = useAppState((s) => s.appSettings);
|
|
|
|
|
const projects = useAppState((s) => s.projects);
|
|
|
|
|
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
2026-09-27 10:11:19 -07:00
|
|
|
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
|
2026-09-27 08:54:02 -07:00
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
|
|
|
|
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
|
|
|
|
const isGlobal = globalInstalls.some(
|
|
|
|
|
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
|
|
|
|
|
);
|
|
|
|
|
const disabled = item.invalid !== null || busy;
|
2026-09-27 10:11:19 -07:00
|
|
|
// "" never matches a head, so the backend explains that a refresh is needed.
|
|
|
|
|
const commit = headCommit ?? "";
|
2026-09-27 08:54:02 -07:00
|
|
|
const shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
|
|
|
|
|
|
|
|
|
|
const run = async (fn: () => Promise<boolean>) => {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await fn();
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Every install goes through here so a hook is always confirmed first. */
|
|
|
|
|
const install = (scope: InstallScope) => {
|
|
|
|
|
if (item.kind === "hook") {
|
2026-09-27 10:11:19 -07:00
|
|
|
setPendingHook({ scope, item, commit });
|
2026-09-27 08:54:02 -07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-27 10:11:19 -07:00
|
|
|
void run(() => mp.install(ref, scope, commit));
|
2026-09-27 08:54:02 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
|
|
|
|
const scope: InstallScope = { type: "project", project_id: projectId };
|
|
|
|
|
switch (state) {
|
|
|
|
|
case "none":
|
|
|
|
|
install(scope);
|
|
|
|
|
break;
|
|
|
|
|
case "inherited":
|
|
|
|
|
void run(() => mp.setDisabled(projectId, ref, true));
|
|
|
|
|
break;
|
|
|
|
|
case "opted_out":
|
|
|
|
|
void run(() => mp.setDisabled(projectId, ref, false));
|
|
|
|
|
break;
|
|
|
|
|
case "project":
|
|
|
|
|
case "project_pinned_differently":
|
|
|
|
|
void run(() => mp.uninstall(ref, scope));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Toggle
|
|
|
|
|
label="All projects"
|
|
|
|
|
checked={isGlobal}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onChange={(v) => (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))}
|
|
|
|
|
/>
|
|
|
|
|
<ul className="space-y-1">
|
|
|
|
|
{shown.map((p) => {
|
|
|
|
|
const state = projectItemState(ref, globalInstalls, p);
|
|
|
|
|
const checked = state === "inherited" || state === "project" || state === "project_pinned_differently";
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
key={p.id}
|
|
|
|
|
data-testid={`install-row-${p.id}`}
|
|
|
|
|
className="flex items-center justify-between gap-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<label className="flex items-center gap-2 min-w-0">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={checked}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onChange={() => toggleProject(p.id, state)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate">{p.name}</span>
|
|
|
|
|
</label>
|
|
|
|
|
{STATE_LABEL[state] && (
|
|
|
|
|
<span className="text-[var(--text-secondary)] whitespace-nowrap">{STATE_LABEL[state]}</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
{projects.length === 0 && (
|
|
|
|
|
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
|
|
|
|
)}
|
|
|
|
|
{pendingHook && (
|
|
|
|
|
<HookConfirmModal
|
2026-09-27 10:11:19 -07:00
|
|
|
item={pendingHook.item}
|
|
|
|
|
commit={pendingHook.commit}
|
2026-09-27 08:54:02 -07:00
|
|
|
onCancel={() => setPendingHook(null)}
|
|
|
|
|
onConfirm={() => {
|
2026-09-27 10:11:19 -07:00
|
|
|
const { scope, commit: reviewed } = pendingHook;
|
2026-09-27 08:54:02 -07:00
|
|
|
setPendingHook(null);
|
2026-09-27 10:11:19 -07:00
|
|
|
void run(() => mp.install(ref, scope, reviewed));
|
2026-09-27 08:54:02 -07:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|