Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers #65
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { AppSettings, Project } from "../../lib/types";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
|
const applyMarketplaceNow = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
applyMarketplaceNow: (id?: string) => applyMarketplaceNow(id),
|
||||||
|
}));
|
||||||
|
vi.mock("./UpdateDiffModal", () => ({
|
||||||
|
default: ({ onAccept }: { onAccept: () => Promise<boolean> }) => (
|
||||||
|
<button onClick={() => void onAccept()}>accept diff</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import InstalledPane from "./InstalledPane";
|
||||||
|
|
||||||
|
const A = "a".repeat(40);
|
||||||
|
const B = "b".repeat(40);
|
||||||
|
|
||||||
|
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||||
|
return {
|
||||||
|
snapshots: [],
|
||||||
|
updates: [],
|
||||||
|
loading: false,
|
||||||
|
refreshing: [],
|
||||||
|
load: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
reloadState: vi.fn(),
|
||||||
|
install: vi.fn(),
|
||||||
|
uninstall: vi.fn(async () => true),
|
||||||
|
setDisabled: vi.fn(),
|
||||||
|
update: vi.fn(async () => true),
|
||||||
|
forget: vi.fn(async () => true),
|
||||||
|
remove: vi.fn(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("InstalledPane", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({
|
||||||
|
toasts: [],
|
||||||
|
appSettings: {
|
||||||
|
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
|
||||||
|
marketplace_accounts: [],
|
||||||
|
global_marketplace_installs: [
|
||||||
|
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
|
||||||
|
{ marketplace_id: "gone", kind: "skill", key: "old", commit: A },
|
||||||
|
],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "api",
|
||||||
|
status: "running",
|
||||||
|
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: B }],
|
||||||
|
marketplace_disabled: [],
|
||||||
|
},
|
||||||
|
] as unknown as Project[],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists global and project installs", () => {
|
||||||
|
render(<InstalledPane mp={api()} />);
|
||||||
|
const global = screen.getByTestId("installed-global");
|
||||||
|
expect(within(global).getByText("rev")).toBeInTheDocument();
|
||||||
|
const proj = screen.getByTestId("installed-project-p1");
|
||||||
|
expect(within(proj).getByText("cmd")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("badges and accepts an update for the matching install", async () => {
|
||||||
|
const mp = api({
|
||||||
|
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B }],
|
||||||
|
});
|
||||||
|
render(<InstalledPane mp={mp} />);
|
||||||
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks installs whose marketplace was removed and forgets them", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstalledPane mp={mp} />);
|
||||||
|
expect(screen.getByText("Source removed")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Forget installs from removed marketplaces" }));
|
||||||
|
expect(mp.forget).toHaveBeenCalledWith("gone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a project install", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstalledPane mp={mp} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove cmd from api" }));
|
||||||
|
// F7: the ref passed to uninstall must be the bare item ref, not the
|
||||||
|
// MarketplaceInstall (which also carries `commit`).
|
||||||
|
expect(mp.uninstall).toHaveBeenCalledWith(
|
||||||
|
{ marketplace_id: "m1", kind: "command", key: "cmd" },
|
||||||
|
{ type: "project", project_id: "p1" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies now and summarises the result", async () => {
|
||||||
|
applyMarketplaceNow.mockResolvedValue([
|
||||||
|
{ project_id: "p1", report: { installed: ["agent:rev"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" } },
|
||||||
|
]);
|
||||||
|
render(<InstalledPane mp={api()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||||
|
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalledWith(undefined));
|
||||||
|
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||||
|
expect(useAppState.getState().toasts[0].message).toContain("1 running project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies now with no running projects and shows an info toast", async () => {
|
||||||
|
applyMarketplaceNow.mockResolvedValue([]);
|
||||||
|
render(<InstalledPane mp={api()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||||
|
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "info" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("F4: does not toast per-project sync errors from apply now (the event listener owns that)", async () => {
|
||||||
|
applyMarketplaceNow.mockResolvedValue([
|
||||||
|
{
|
||||||
|
project_id: "p1",
|
||||||
|
report: { installed: [], updated: [], removed: [], skipped: [], errors: ["boom"], finished_at: "" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<InstalledPane mp={api()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||||
|
await waitFor(() => expect(applyMarketplaceNow).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||||
|
expect(useAppState.getState().toasts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts an error only when the apply-now call itself fails", async () => {
|
||||||
|
applyMarketplaceNow.mockRejectedValue("container unreachable");
|
||||||
|
render(<InstalledPane mp={api()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply now" }));
|
||||||
|
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,185 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { KIND_LABELS } from "../../lib/marketplace";
|
||||||
|
import { applyMarketplaceNow } from "../../lib/tauri-commands";
|
||||||
|
import type { InstallScope, ItemUpdate, MarketplaceInstall, MarketplaceItemRef } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import UpdateDiffModal from "./UpdateDiffModal";
|
||||||
|
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
install: MarketplaceInstall;
|
||||||
|
update: ItemUpdate;
|
||||||
|
scope: InstallScope;
|
||||||
|
scopeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
||||||
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
|
const projects = useAppState((s) => s.projects);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [pending, setPending] = useState<Pending | null>(null);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
|
||||||
|
const marketplaces = appSettings?.marketplaces ?? [];
|
||||||
|
const known = new Set(marketplaces.map((m) => m.id));
|
||||||
|
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
|
||||||
|
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||||
|
|
||||||
|
const updateFor = (i: MarketplaceInstall) =>
|
||||||
|
mp.updates.find(
|
||||||
|
(u) =>
|
||||||
|
u.item.marketplace_id === i.marketplace_id &&
|
||||||
|
u.item.kind === i.kind &&
|
||||||
|
u.item.key === i.key &&
|
||||||
|
u.head !== i.commit,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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 => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedSources = [
|
||||||
|
...new Set(
|
||||||
|
[...globalInstalls, ...projects.flatMap((p) => p.marketplace_installs)]
|
||||||
|
.map((i) => i.marketplace_id)
|
||||||
|
.filter((id) => !known.has(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const applyNow = async () => {
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const results = await applyMarketplaceNow(undefined);
|
||||||
|
// F4 (preflight): the backend emits `marketplace-sync-finished` for
|
||||||
|
// every project synced here, and `useMarketplaceSyncToasts` already
|
||||||
|
// toasts any errors/skips from that event. This toast is only the
|
||||||
|
// success/info summary — a second error toast here would double up.
|
||||||
|
if (results.length === 0) {
|
||||||
|
pushToast({ kind: "info", message: "No running projects — changes apply when a project starts." });
|
||||||
|
} else {
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: `Marketplace applied to ${results.length} running project${results.length === 1 ? "" : "s"}. New Claude sessions will use it.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: "Could not apply marketplace changes", detail: errorText(e) });
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = (i: MarketplaceInstall, scope: InstallScope, scopeLabel: string, removeLabel: string) => {
|
||||||
|
const upd = updateFor(i);
|
||||||
|
const gone = !known.has(i.marketplace_id);
|
||||||
|
// F7 (preflight): pass the bare item ref, not the MarketplaceInstall
|
||||||
|
// itself — `commit` is not part of the ref the backend/store expect here.
|
||||||
|
const ref: MarketplaceItemRef = { marketplace_id: i.marketplace_id, kind: i.kind, key: i.key };
|
||||||
return (
|
return (
|
||||||
<p className="p-4 text-xs text-[var(--text-secondary)]">
|
<li key={`${i.marketplace_id}/${i.kind}/${i.key}`} className="flex items-center justify-between gap-2 text-xs py-1">
|
||||||
{mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available.
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium">{i.key}</span>
|
||||||
|
<span className="ml-1 text-[var(--text-secondary)]">
|
||||||
|
{KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{gone && <span className="ml-2 text-[var(--warning)]">Source removed</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
{upd && !gone && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={`Review update for ${i.key}`}
|
||||||
|
onClick={() => setPending({ install: i, update: upd, scope, scopeLabel })}
|
||||||
|
>
|
||||||
|
Update available
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" aria-label={removeLabel} onClick={() => void mp.uninstall(ref, scope)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Installs are pinned to a commit. Containers pick up changes on their next start, or now for running ones.
|
||||||
|
Changes apply to new Claude sessions.
|
||||||
</p>
|
</p>
|
||||||
|
<Button size="md" variant="primary" disabled={applying} onClick={() => void applyNow()}>
|
||||||
|
{applying ? "Applying…" : "Apply now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{removedSources.length > 0 && (
|
||||||
|
<div className="rounded-[var(--radius-control)] border border-[var(--warning)]/40 bg-[var(--warning-muted)] p-2 text-xs space-y-1">
|
||||||
|
<p>
|
||||||
|
Some installs come from marketplaces that were removed. They are removed from containers at their next
|
||||||
|
sync.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label="Forget installs from removed marketplaces"
|
||||||
|
onClick={() => removedSources.forEach((id) => void mp.forget(id))}
|
||||||
|
>
|
||||||
|
Forget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section data-testid="installed-global">
|
||||||
|
<h3 className="text-xs font-medium mb-1">All projects</h3>
|
||||||
|
{globalInstalls.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Nothing installed for all projects.</p>
|
||||||
|
) : (
|
||||||
|
<ul>{globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{projects.map((p) => (
|
||||||
|
<section key={p.id} data-testid={`installed-project-${p.id}`}>
|
||||||
|
<h3 className="text-xs font-medium mb-1">{p.name}</h3>
|
||||||
|
{p.marketplace_installs.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
No project-only installs
|
||||||
|
{p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{p.marketplace_installs.map((i) =>
|
||||||
|
row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<UpdateDiffModal
|
||||||
|
item={pending.update.item}
|
||||||
|
fromCommit={pending.install.commit}
|
||||||
|
toCommit={pending.update.head}
|
||||||
|
scopeLabel={pending.scopeLabel}
|
||||||
|
hookCommands={hookCommandsFor(pending.update.item)}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onAccept={() => mp.update(pending.update.item, pending.scope)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
const marketplaceItemDiff = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
marketplaceItemDiff: (...a: unknown[]) => marketplaceItemDiff(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import UpdateDiffModal from "./UpdateDiffModal";
|
||||||
|
|
||||||
|
const A = "a".repeat(40);
|
||||||
|
const B = "b".repeat(40);
|
||||||
|
const item = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
|
||||||
|
|
||||||
|
describe("UpdateDiffModal", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("loads the diff from the install's pin to head and accepts", async () => {
|
||||||
|
marketplaceItemDiff.mockResolvedValue([
|
||||||
|
{ path: "notify.sh", change: "modified", unified: "-echo old\n+echo new\n" },
|
||||||
|
{ path: "icon.png", change: "added", unified: null },
|
||||||
|
]);
|
||||||
|
const onAccept = vi.fn(async () => true);
|
||||||
|
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="All projects" onClose={vi.fn()} onAccept={onAccept} />);
|
||||||
|
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalledWith(item, A, B));
|
||||||
|
expect(screen.getByText(/\+echo new/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Binary file — no text diff")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Update" }));
|
||||||
|
await waitFor(() => expect(onAccept).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a load error and keeps Update disabled", async () => {
|
||||||
|
marketplaceItemDiff.mockRejectedValue("commit not in cache");
|
||||||
|
render(<UpdateDiffModal item={item} fromCommit={A} toCommit={B} scopeLabel="p" onClose={vi.fn()} onAccept={vi.fn()} />);
|
||||||
|
expect(await screen.findByText(/commit not in cache/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the rendered commands a hook will run after the update (F8)", async () => {
|
||||||
|
marketplaceItemDiff.mockResolvedValue([]);
|
||||||
|
render(
|
||||||
|
<UpdateDiffModal
|
||||||
|
item={item}
|
||||||
|
fromCommit={A}
|
||||||
|
toCommit={B}
|
||||||
|
scopeLabel="All projects"
|
||||||
|
hookCommands={["/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onAccept={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(marketplaceItemDiff).toHaveBeenCalled());
|
||||||
|
expect(screen.getByText("Commands after this update")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("/home/claude/.claude/triple-c/hooks/notify/run.sh --new-flag"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { marketplaceItemDiff } from "../../lib/tauri-commands";
|
||||||
|
import { formatItemRef } from "../../lib/marketplace";
|
||||||
|
import type { FileDiff, MarketplaceItemRef } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: MarketplaceItemRef;
|
||||||
|
fromCommit: string;
|
||||||
|
toCommit: string;
|
||||||
|
scopeLabel: string;
|
||||||
|
/**
|
||||||
|
* Hooks only: the rendered commands the item runs at `toCommit` (head), from
|
||||||
|
* the marketplace snapshot's catalog entry. An update can change what a hook
|
||||||
|
* runs without going back through the install-time confirm list, so this is
|
||||||
|
* shown alongside the file diff — spec §3. Undefined for non-hook items.
|
||||||
|
*/
|
||||||
|
hookCommands?: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
/** Resolves true when the update was applied. */
|
||||||
|
onAccept: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_LABEL: Record<FileDiff["change"], string> = {
|
||||||
|
added: "added",
|
||||||
|
removed: "removed",
|
||||||
|
modified: "modified",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UpdateDiffModal({
|
||||||
|
item,
|
||||||
|
fromCommit,
|
||||||
|
toCommit,
|
||||||
|
scopeLabel,
|
||||||
|
hookCommands,
|
||||||
|
onClose,
|
||||||
|
onAccept,
|
||||||
|
}: Props) {
|
||||||
|
const [diffs, setDiffs] = useState<FileDiff[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
marketplaceItemDiff(item, fromCommit, toCommit)
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setDiffs(d);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(typeof e === "string" ? e : String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [item, fromCommit, toCommit]);
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (await onAccept()) onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Update ${formatItemRef(item)}`}
|
||||||
|
description={`${scopeLabel}: ${fromCommit.slice(0, 8)} → ${toCommit.slice(0, 8)}. Review the changes before accepting.`}
|
||||||
|
widthClassName="w-[52rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={() => void accept()} disabled={busy || diffs === null}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <p role="alert" className="text-xs text-[var(--error)]">{error}</p>}
|
||||||
|
{!error && diffs === null && <p className="text-xs text-[var(--text-secondary)]">Loading changes…</p>}
|
||||||
|
{hookCommands && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-medium mb-1">Commands after this update</p>
|
||||||
|
{hookCommands.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{hookCommands.map((c) => (
|
||||||
|
<li key={c}>
|
||||||
|
<code className="block font-mono text-xs break-all px-2 py-1 rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{c}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{diffs && diffs.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
|
||||||
|
)}
|
||||||
|
{diffs && diffs.length > 0 && (
|
||||||
|
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||||
|
{diffs.map((d) => (
|
||||||
|
<div key={d.path}>
|
||||||
|
<p className="text-xs font-mono mb-1">
|
||||||
|
{d.path} <span className="text-[var(--text-secondary)]">({CHANGE_LABEL[d.change]})</span>
|
||||||
|
</p>
|
||||||
|
{d.unified === null ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Binary file — no text diff</p>
|
||||||
|
) : (
|
||||||
|
<pre className="p-2 text-xs font-mono whitespace-pre overflow-auto rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{d.unified}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user