Marketplace UI: browse, item detail, install controls, hook confirmation, add marketplace
Implements Task 13: BrowsePane (marketplace list, kind/search filters, item detail), InstallControls (global/per-project install, opt-out, hook confirm gate), HookConfirmModal, AddMarketplaceModal, and ItemDetail. Also applies pre-flight ruling F6: a per-marketplace Remove button with a confirm dialog (mp.remove) warning that surviving installs become "Source removed" and can be dropped via Forget on the Installed tab, plus an inline account reassignment select (updateMarketplace + reloadState). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function InstallControls({ mp, item, marketplaceId }: 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 [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;
|
||||
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") {
|
||||
setPendingHook(scope);
|
||||
return;
|
||||
}
|
||||
void run(() => mp.install(ref, scope));
|
||||
};
|
||||
|
||||
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
|
||||
item={item}
|
||||
onCancel={() => setPendingHook(null)}
|
||||
onConfirm={() => {
|
||||
const scope = pendingHook;
|
||||
setPendingHook(null);
|
||||
void run(() => mp.install(ref, scope));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user