Marketplace UI: installed list, update diff review, apply now
Applies preflight rulings F4, F7, F8, N5: Apply now's toast shows only the success/info summary (the marketplace-sync-finished event listener already toasts per-project errors/skips, so this avoids a double toast); row removal passes the bare MarketplaceItemRef rather than the full MarketplaceInstall; UpdateDiffModal shows a hook's rendered commands at head above the file diff so an update is reviewed the same way an install is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 { 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 }) {
|
||||
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 (
|
||||
<li key={`${i.marketplace_id}/${i.kind}/${i.key}`} className="flex items-center justify-between gap-2 text-xs py-1">
|
||||
<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 (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">
|
||||
{mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available.
|
||||
</p>
|
||||
<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>
|
||||
<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