Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers #65
+9
-1
@@ -21,7 +21,9 @@ import { useTerminal } from "./hooks/useTerminal";
|
|||||||
import { useSTT } from "./hooks/useSTT";
|
import { useSTT } from "./hooks/useSTT";
|
||||||
import { useContainerProgress } from "./hooks/useContainerProgress";
|
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
|
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
|
||||||
|
import MarketplaceView from "./components/marketplace/MarketplaceView";
|
||||||
|
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -72,6 +74,7 @@ export default function App() {
|
|||||||
|
|
||||||
useContainerProgress();
|
useContainerProgress();
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
|
useMarketplaceSyncToasts();
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,6 +162,11 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</PaneVisibilityProvider>
|
</PaneVisibilityProvider>
|
||||||
))}
|
))}
|
||||||
|
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
|
||||||
|
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
|
||||||
|
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
|
||||||
|
</PaneVisibilityProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import MainTabs from "./MainTabs";
|
import MainTabs from "./MainTabs";
|
||||||
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "../../store/appState";
|
||||||
import type { Project, TerminalSession } from "../../lib/types";
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
@@ -265,3 +265,20 @@ describe("MainTabs reordering", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("marketplace tab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
|
||||||
|
activeTabKey: MARKETPLACE_TAB_KEY,
|
||||||
|
activeSessionId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a Marketplace tab that closes", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
expect(screen.getByRole("tab", { name: /marketplace/i })).toHaveAttribute("aria-selected", "true");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close Marketplace tab" }));
|
||||||
|
expect(useAppState.getState().tabOrder).toEqual([HOME]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects";
|
|||||||
import {
|
import {
|
||||||
useAppState,
|
useAppState,
|
||||||
isHomeTab,
|
isHomeTab,
|
||||||
|
isMarketplaceTab,
|
||||||
tabKeyId,
|
tabKeyId,
|
||||||
terminalTabKey,
|
terminalTabKey,
|
||||||
} from "../../store/appState";
|
} from "../../store/appState";
|
||||||
@@ -41,12 +42,13 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
|||||||
export default function MainTabs() {
|
export default function MainTabs() {
|
||||||
const { sessions, close } = useTerminal();
|
const { sessions, close } = useTerminal();
|
||||||
const { projects, update } = useProjects();
|
const { projects, update } = useProjects();
|
||||||
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, closeMarketplaceTab, moveTab } = useAppState(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
tabOrder: s.tabOrder,
|
tabOrder: s.tabOrder,
|
||||||
activeTabKey: s.activeTabKey,
|
activeTabKey: s.activeTabKey,
|
||||||
setActiveTabKey: s.setActiveTabKey,
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
closeHomeTab: s.closeHomeTab,
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
closeMarketplaceTab: s.closeMarketplaceTab,
|
||||||
moveTab: s.moveTab,
|
moveTab: s.moveTab,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -192,6 +194,7 @@ export default function MainTabs() {
|
|||||||
* worse than no ghost.
|
* worse than no ghost.
|
||||||
*/
|
*/
|
||||||
const tabLabel = (key: string): string => {
|
const tabLabel = (key: string): string => {
|
||||||
|
if (isMarketplaceTab(key)) return "Marketplace";
|
||||||
if (isHomeTab(key)) {
|
if (isHomeTab(key)) {
|
||||||
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||||
}
|
}
|
||||||
@@ -272,7 +275,7 @@ export default function MainTabs() {
|
|||||||
x: e.clientX - drag.offsetX,
|
x: e.clientX - drag.offsetX,
|
||||||
y: drag.top,
|
y: drag.top,
|
||||||
label: tabLabel(drag.key),
|
label: tabLabel(drag.key),
|
||||||
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
icon: isMarketplaceTab(drag.key) ? "◈" : isHomeTab(drag.key) ? "⌂" : "▣",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -314,6 +317,41 @@ export default function MainTabs() {
|
|||||||
const renderTab = (key: string, index: number) => {
|
const renderTab = (key: string, index: number) => {
|
||||||
const active = activeTabKey === key;
|
const active = activeTabKey === key;
|
||||||
|
|
||||||
|
if (isMarketplaceTab(key)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(key)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...pointerProps(key, false)}
|
||||||
|
className={tabClass(active, dragKey === key)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">◈</span>
|
||||||
|
<span className="truncate max-w-[160px]">Marketplace</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeMarketplaceTab();
|
||||||
|
}}
|
||||||
|
aria-label="Close Marketplace tab"
|
||||||
|
title="Close tab"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isHomeTab(key)) {
|
if (isHomeTab(key)) {
|
||||||
const projectId = tabKeyId(key);
|
const projectId = tabKeyId(key);
|
||||||
const project = projects.find((p) => p.id === projectId);
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function NotesDock() {
|
|||||||
if (!notesDockOpen) return null;
|
if (!notesDockOpen) return null;
|
||||||
|
|
||||||
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||||
// is the project it belongs to.
|
// is the project it belongs to. The Marketplace tab belongs to no project.
|
||||||
let projectId: string | null = null;
|
let projectId: string | null = null;
|
||||||
if (activeTabKey && isHomeTab(activeTabKey)) {
|
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||||
projectId = tabKeyId(activeTabKey);
|
projectId = tabKeyId(activeTabKey);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { AppSettings } from "../../lib/types";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
|
const testMarketplaceAccount = vi.fn();
|
||||||
|
const removeMarketplaceAccount = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
testMarketplaceAccount: (id: string) => testMarketplaceAccount(id),
|
||||||
|
removeMarketplaceAccount: (id: string) => removeMarketplaceAccount(id),
|
||||||
|
}));
|
||||||
|
vi.mock("./AddAccountModal", () => ({ default: () => <div>add account modal</div> }));
|
||||||
|
|
||||||
|
import AccountsPane from "./AccountsPane";
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
marketplace_accounts: [
|
||||||
|
{ id: "a1", label: "Personal", host: "github.com", method: "gh_host", username: "me" },
|
||||||
|
{ id: "a2", label: "Gitea", host: "repo.example.com", method: "token", username: "jk" },
|
||||||
|
],
|
||||||
|
marketplaces: [{ id: "m1", name: "Team", url: "https://repo.example.com/t/m.git", branch: null, account_id: "a2" }],
|
||||||
|
global_marketplace_installs: [],
|
||||||
|
} as unknown as AppSettings;
|
||||||
|
|
||||||
|
describe("AccountsPane", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({ appSettings: settings, toasts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists accounts with their method and usage", () => {
|
||||||
|
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/gh on this computer/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Used by Team$/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tests an account", async () => {
|
||||||
|
testMarketplaceAccount.mockResolvedValue("me");
|
||||||
|
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Test Personal" }));
|
||||||
|
await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" }));
|
||||||
|
expect(useAppState.getState().toasts[0].message).toContain("me");
|
||||||
|
});
|
||||||
|
|
||||||
|
// F5: the backend refuses to remove an account a marketplace uses, so the
|
||||||
|
// UI must not promise otherwise with a confirm modal — Remove is disabled
|
||||||
|
// with a hint instead, and there is no confirm step to click through.
|
||||||
|
it("disables Remove for an account in use, with a hint", () => {
|
||||||
|
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
const removeGitea = screen.getByRole("button", { name: "Remove Gitea" });
|
||||||
|
expect(removeGitea).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(screen.getByText(/Used by Team.*change or remove that marketplace first/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(removeGitea);
|
||||||
|
expect(removeMarketplaceAccount).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an unused account", async () => {
|
||||||
|
removeMarketplaceAccount.mockResolvedValue({ ...settings, marketplace_accounts: [settings.marketplace_accounts[1]] });
|
||||||
|
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
const removePersonal = screen.getByRole("button", { name: "Remove Personal" });
|
||||||
|
expect(removePersonal).not.toHaveAttribute("aria-disabled");
|
||||||
|
fireEvent.click(removePersonal);
|
||||||
|
await waitFor(() => expect(removeMarketplaceAccount).toHaveBeenCalledWith("a1"));
|
||||||
|
await waitFor(() => expect(useAppState.getState().appSettings!.marketplace_accounts).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the add dialog", () => {
|
||||||
|
render(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||||
|
expect(screen.getByText("add account modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { removeMarketplaceAccount, testMarketplaceAccount } from "../../lib/tauri-commands";
|
||||||
|
import type { AccountMethod, MarketplaceAccount } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import AddAccountModal from "./AddAccountModal";
|
||||||
|
|
||||||
|
const METHOD_LABEL: Record<AccountMethod, string> = {
|
||||||
|
gh_host: "GitHub — gh on this computer",
|
||||||
|
gh_container: "GitHub — signed in via container",
|
||||||
|
token: "Access token",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
|
||||||
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
|
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const accounts = appSettings?.marketplace_accounts ?? [];
|
||||||
|
const marketplaces = appSettings?.marketplaces ?? [];
|
||||||
|
const usedBy = (id: string) => marketplaces.filter((m) => m.account_id === id).map((m) => m.name);
|
||||||
|
|
||||||
|
const test = async (a: MarketplaceAccount) => {
|
||||||
|
setTesting(a.id);
|
||||||
|
try {
|
||||||
|
const login = await testMarketplaceAccount(a.id);
|
||||||
|
pushToast({ kind: "success", message: `${a.label} works — signed in as ${login}` });
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `${a.label} could not sign in`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (a: MarketplaceAccount) => {
|
||||||
|
setRemoving(a.id);
|
||||||
|
try {
|
||||||
|
setAppSettings(await removeMarketplaceAccount(a.id));
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `Could not remove ${a.label}`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setRemoving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-3 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter
|
||||||
|
containers.
|
||||||
|
</p>
|
||||||
|
<Button size="md" variant="secondary" onClick={() => setAdding(true)}>
|
||||||
|
Add account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{accounts.length === 0 && <p className="text-xs text-[var(--text-secondary)]">No accounts yet. Public repositories need none.</p>}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{accounts.map((a) => {
|
||||||
|
const users = usedBy(a.id);
|
||||||
|
const inUse = users.length > 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between gap-2 p-2 rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 text-xs">
|
||||||
|
<p className="font-medium">{a.label}</p>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
{METHOD_LABEL[a.method]} · {a.host}
|
||||||
|
{a.username ? ` · ${a.username}` : ""}
|
||||||
|
</p>
|
||||||
|
{inUse && <p className="text-[var(--text-secondary)]">Used by {users.join(", ")}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Test ${a.label}`}
|
||||||
|
disabled={testing === a.id}
|
||||||
|
onClick={() => void test(a)}
|
||||||
|
>
|
||||||
|
{testing === a.id ? "Testing…" : "Test"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${a.label}`}
|
||||||
|
disabled={removing === a.id}
|
||||||
|
unavailable={inUse}
|
||||||
|
unavailableReason={`Used by ${users.join(", ")} — change or remove that marketplace first`}
|
||||||
|
onClick={() => void remove(a)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{adding && <AddAccountModal onClose={() => setAdding(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const marketplaceGhHostAvailable = vi.fn();
|
||||||
|
const addMarketplaceGhHostAccount = vi.fn();
|
||||||
|
const addMarketplaceTokenAccount = vi.fn();
|
||||||
|
const getSettings = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
marketplaceGhHostAvailable: () => marketplaceGhHostAvailable(),
|
||||||
|
addMarketplaceGhHostAccount: (...a: unknown[]) => addMarketplaceGhHostAccount(...a),
|
||||||
|
addMarketplaceTokenAccount: (...a: unknown[]) => addMarketplaceTokenAccount(...a),
|
||||||
|
getSettings: () => getSettings(),
|
||||||
|
}));
|
||||||
|
vi.mock("./GhContainerLoginModal", () => ({
|
||||||
|
default: ({ projectId }: { projectId: string }) => <div>container login for {projectId}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AddAccountModal from "./AddAccountModal";
|
||||||
|
|
||||||
|
const running = { id: "p1", name: "api", status: "running", container_id: "c1" } as unknown as Project;
|
||||||
|
|
||||||
|
describe("AddAccountModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getSettings.mockResolvedValue({ marketplace_accounts: [] });
|
||||||
|
useAppState.setState({ projects: [running], toasts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses host gh when available", async () => {
|
||||||
|
marketplaceGhHostAvailable.mockResolvedValue(true);
|
||||||
|
addMarketplaceGhHostAccount.mockResolvedValue({ id: "a1" });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AddAccountModal onClose={onClose} />);
|
||||||
|
expect(await screen.findByText(/gh is installed on this computer/)).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Personal" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||||
|
await waitFor(() => expect(addMarketplaceGhHostAccount).toHaveBeenCalledWith("Personal", "github.com"));
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to gh in a running container", async () => {
|
||||||
|
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||||
|
render(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
expect(await screen.findByLabelText("Run gh in")).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Work" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
expect(screen.getByText("container login for p1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a token account for any host", async () => {
|
||||||
|
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||||
|
addMarketplaceTokenAccount.mockResolvedValue({ id: "a2" });
|
||||||
|
render(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Gitea" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(addMarketplaceTokenAccount).toHaveBeenCalledWith("Gitea", "repo.anhonesthost.net", "test-token-not-real"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a validation error from the backend", async () => {
|
||||||
|
marketplaceGhHostAvailable.mockResolvedValue(false);
|
||||||
|
addMarketplaceTokenAccount.mockRejectedValue("The token was rejected by repo.anhonesthost.net (HTTP 401)");
|
||||||
|
render(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole("radio", { name: "Access token" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "G" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||||
|
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import SegmentedControl from "../ui/SegmentedControl";
|
||||||
|
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||||
|
import {
|
||||||
|
addMarketplaceGhHostAccount,
|
||||||
|
addMarketplaceTokenAccount,
|
||||||
|
getSettings,
|
||||||
|
marketplaceGhHostAvailable,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import GhContainerLoginModal from "./GhContainerLoginModal";
|
||||||
|
|
||||||
|
type Method = "gh" | "token";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddAccountModal({ onClose }: Props) {
|
||||||
|
const projects = useAppState((s) => s.projects);
|
||||||
|
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||||
|
const runnable = projects.filter((p) => p.status === "running" && p.container_id);
|
||||||
|
|
||||||
|
const [method, setMethod] = useState<Method>("gh");
|
||||||
|
const [hostGh, setHostGh] = useState<boolean | null>(null);
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [host, setHost] = useState("github.com");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState(runnable[0]?.id ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [containerLogin, setContainerLogin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
marketplaceGhHostAvailable()
|
||||||
|
.then((v) => {
|
||||||
|
if (!cancelled) setHostGh(v);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setHostGh(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadSettings = async () => setAppSettings(await getSettings());
|
||||||
|
|
||||||
|
const finish = async () => {
|
||||||
|
await reloadSettings();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (method === "gh" && !hostGh) {
|
||||||
|
setContainerLogin(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (method === "gh") {
|
||||||
|
await addMarketplaceGhHostAccount(label.trim(), host.trim());
|
||||||
|
} else {
|
||||||
|
const t = token.trim();
|
||||||
|
setToken("");
|
||||||
|
await addMarketplaceTokenAccount(label.trim(), host.trim(), t);
|
||||||
|
}
|
||||||
|
await finish();
|
||||||
|
} catch (e) {
|
||||||
|
setError(typeof e === "string" ? e : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostValid = /^[A-Za-z0-9.-]+(:[0-9]+)?$/.test(host.trim());
|
||||||
|
const needsContainer = method === "gh" && hostGh === false;
|
||||||
|
const canSubmit =
|
||||||
|
!busy &&
|
||||||
|
hostGh !== null &&
|
||||||
|
label.trim() !== "" &&
|
||||||
|
hostValid &&
|
||||||
|
(method === "gh" ? !needsContainer || projectId !== "" : token.trim() !== "");
|
||||||
|
|
||||||
|
if (containerLogin) {
|
||||||
|
const project = runnable.find((p) => p.id === projectId);
|
||||||
|
return (
|
||||||
|
<GhContainerLoginModal
|
||||||
|
label={label.trim()}
|
||||||
|
host={host.trim()}
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={project?.name ?? projectId}
|
||||||
|
onClose={onClose}
|
||||||
|
onDone={() => void finish()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Add account"
|
||||||
|
description="Accounts let Triple-C read private marketplace repositories. Credentials stay on this computer and never enter containers."
|
||||||
|
widthClassName="w-[36rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||||
|
{needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SegmentedControl<Method>
|
||||||
|
label="Sign-in method"
|
||||||
|
value={method}
|
||||||
|
onChange={(m) => {
|
||||||
|
setMethod(m);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
segments={[
|
||||||
|
{ value: "gh", label: "GitHub via gh" },
|
||||||
|
{ value: "token", label: "Access token" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Field label="Label">
|
||||||
|
{(id) => (
|
||||||
|
<input id={id} value={label} onChange={(e) => setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Host" hint={hostValid ? undefined : "Host name only, e.g. github.com or repo.example.com"}>
|
||||||
|
{(id) => <input id={id} value={host} onChange={(e) => setHost(e.target.value)} className={inputClass} />}
|
||||||
|
</Field>
|
||||||
|
{method === "gh" && hostGh === true && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
gh is installed on this computer. Triple-C asks it for a token each time it fetches, so signing out of gh
|
||||||
|
also signs this account out. If gh is not logged in yet, run <code className="font-mono">gh auth login</code> first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{needsContainer &&
|
||||||
|
(runnable.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--warning)]">
|
||||||
|
gh is not installed on this computer. Start a project so gh can run in its container, or use an access token.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label="Run gh in"
|
||||||
|
hint="gh is not installed on this computer, so the sign-in runs in this container. The token is kept in your OS keychain, not in the container."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<select id={id} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={selectClass}>
|
||||||
|
{runnable.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
{method === "token" && (
|
||||||
|
<Field
|
||||||
|
label="Token"
|
||||||
|
hint="A personal access token with read access to the repository. For GitHub SSO orgs, authorise the token for the org."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { AppSettings } from "../../lib/types";
|
||||||
|
|
||||||
|
const addMarketplace = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
addMarketplace: (...a: unknown[]) => addMarketplace(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||||
|
|
||||||
|
describe("AddMarketplaceModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({
|
||||||
|
appSettings: {
|
||||||
|
marketplace_accounts: [{ id: "acc1", label: "Work", host: "github.com", method: "token", username: "me" }],
|
||||||
|
marketplaces: [],
|
||||||
|
global_marketplace_installs: [],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits name, url, branch and account", async () => {
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
addMarketplace.mockResolvedValue({ marketplace_id: "m1", head_commit: null, fetched_at: null, fetch_error: null, items: [] });
|
||||||
|
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={onAdded} />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Starter" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/shadowdao/triple-c-marketplace.git" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Account"), { target: { value: "acc1" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(addMarketplace).toHaveBeenCalledWith("Starter", "https://github.com/shadowdao/triple-c-marketplace.git", null, "acc1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-https URLs before calling the backend", () => {
|
||||||
|
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "git@github.com:a/b.git" } });
|
||||||
|
expect(screen.getByRole("button", { name: "Add marketplace" })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/must start with https:\/\//)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the backend error and stays open", async () => {
|
||||||
|
addMarketplace.mockRejectedValue("Work cannot read this repository (HTTP 404)");
|
||||||
|
render(<AddMarketplaceModal onClose={vi.fn()} onAdded={vi.fn()} />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "x" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Repository URL"), { target: { value: "https://github.com/a/b.git" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||||
|
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||||
|
import { addMarketplace } from "../../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { MarketplaceSnapshot } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdded: (snapshot: MarketplaceSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddMarketplaceModal({ onClose, onAdded }: Props) {
|
||||||
|
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
|
const [accountId, setAccountId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const trimmedUrl = url.trim();
|
||||||
|
const urlProblem =
|
||||||
|
trimmedUrl !== "" && !trimmedUrl.startsWith("https://")
|
||||||
|
? "The repository URL must start with https:// (SSH URLs are not supported)."
|
||||||
|
: null;
|
||||||
|
const canSubmit = name.trim() !== "" && trimmedUrl !== "" && !urlProblem && !busy;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const snap = await addMarketplace(
|
||||||
|
name.trim(),
|
||||||
|
trimmedUrl,
|
||||||
|
branch.trim() === "" ? null : branch.trim(),
|
||||||
|
accountId === "" ? null : accountId,
|
||||||
|
);
|
||||||
|
onAdded(snap);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(typeof e === "string" ? e : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Add marketplace"
|
||||||
|
description="Triple-C fetches the repository now to check it can be read. Nothing is saved if that fails."
|
||||||
|
widthClassName="w-[36rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||||
|
{busy ? "Checking…" : "Add marketplace"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Name">
|
||||||
|
{(id) => (
|
||||||
|
<input id={id} value={name} onChange={(e) => setName(e.target.value)} className={inputClass} placeholder="Team marketplace" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Repository URL" hint={urlProblem ?? "HTTPS clone URL, e.g. https://github.com/owner/repo.git"}>
|
||||||
|
{(id) => (
|
||||||
|
<input id={id} value={url} onChange={(e) => setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Branch" hint="Leave empty to use the repository's default branch.">
|
||||||
|
{(id) => (
|
||||||
|
<input id={id} value={branch} onChange={(e) => setBranch(e.target.value)} className={inputClass} placeholder="main" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Account" hint="Needed for private repositories. Add accounts on the Accounts tab.">
|
||||||
|
{(id) => (
|
||||||
|
<select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value)} className={selectClass}>
|
||||||
|
<option value="">None (public repository)</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label} — {a.host}
|
||||||
|
{a.username ? ` (${a.username})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap leading-snug">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
|
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
|
||||||
|
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
||||||
|
|
||||||
|
import BrowsePane from "./BrowsePane";
|
||||||
|
|
||||||
|
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||||
|
kind,
|
||||||
|
key,
|
||||||
|
name: key,
|
||||||
|
description: `${key} description`,
|
||||||
|
path: key,
|
||||||
|
invalid: null,
|
||||||
|
hook_commands: [],
|
||||||
|
preview: `${key} preview body`,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot: MarketplaceSnapshot = {
|
||||||
|
marketplace_id: "m1",
|
||||||
|
head_commit: "a".repeat(40),
|
||||||
|
fetched_at: "2026-09-27T12:00:00Z",
|
||||||
|
fetch_error: "network unreachable",
|
||||||
|
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||||
|
return {
|
||||||
|
snapshots: [snapshot],
|
||||||
|
updates: [],
|
||||||
|
loading: false,
|
||||||
|
refreshing: [],
|
||||||
|
load: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
reloadState: vi.fn(),
|
||||||
|
install: vi.fn(),
|
||||||
|
uninstall: vi.fn(),
|
||||||
|
setDisabled: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
forget: vi.fn(),
|
||||||
|
remove: vi.fn(async () => true),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BrowsePane", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAppState.setState({
|
||||||
|
appSettings: {
|
||||||
|
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
|
||||||
|
marketplace_accounts: [],
|
||||||
|
global_marketplace_installs: [],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
projects: [],
|
||||||
|
marketplaceFilterProjectId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists items, filters by kind and search, and shows detail", () => {
|
||||||
|
render(<BrowsePane mp={api()} />);
|
||||||
|
expect(screen.getByText("network unreachable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
|
||||||
|
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "All" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
|
||||||
|
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||||
|
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("install controls")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows why an item is invalid", () => {
|
||||||
|
render(<BrowsePane mp={api()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
|
||||||
|
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes one marketplace", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<BrowsePane mp={mp} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
|
||||||
|
expect(mp.refresh).toHaveBeenCalledWith("m1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Add when there are no marketplaces", () => {
|
||||||
|
useAppState.setState({
|
||||||
|
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
|
||||||
|
});
|
||||||
|
render(<BrowsePane mp={api({ snapshots: [] })} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||||
|
expect(screen.getByText("add modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms before removing a marketplace (F6)", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<BrowsePane mp={mp} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove Starter" }));
|
||||||
|
expect(screen.getByText(/Source removed/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Forget/)).toBeInTheDocument();
|
||||||
|
expect(mp.remove).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" }));
|
||||||
|
expect(mp.remove).toHaveBeenCalledWith("m1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { KIND_LABELS, KIND_ORDER, itemRefKey } from "../../lib/marketplace";
|
||||||
|
import { updateMarketplace } from "../../lib/tauri-commands";
|
||||||
|
import type { CatalogItem, ItemKind, Marketplace } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import SegmentedControl from "../ui/SegmentedControl";
|
||||||
|
import { inputClass, selectClass } from "../ui/Field";
|
||||||
|
import AddMarketplaceModal from "./AddMarketplaceModal";
|
||||||
|
import ItemDetail from "./ItemDetail";
|
||||||
|
|
||||||
|
type KindFilter = ItemKind | "all";
|
||||||
|
|
||||||
|
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
|
||||||
|
|
||||||
|
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
|
||||||
|
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
|
||||||
|
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
|
||||||
|
const globalInstalls = useAppState((s) => s.appSettings?.global_marketplace_installs ?? []);
|
||||||
|
const projects = useAppState((s) => s.projects);
|
||||||
|
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||||
|
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
|
||||||
|
const [kind, setKind] = useState<KindFilter>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState<Marketplace | null>(null);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return mp.snapshots.flatMap((snap) =>
|
||||||
|
snap.items
|
||||||
|
.filter((i) => kind === "all" || i.kind === kind)
|
||||||
|
.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))
|
||||||
|
.map((item) => ({ marketplaceId: snap.marketplace_id, item })),
|
||||||
|
);
|
||||||
|
}, [mp.snapshots, kind, query]);
|
||||||
|
|
||||||
|
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
|
||||||
|
|
||||||
|
const changeAccount = async (m: Marketplace, accountId: string | null) => {
|
||||||
|
await updateMarketplace({ ...m, account_id: accountId });
|
||||||
|
await mp.reloadState();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Global + every project's installs of this marketplace, for the removal warning. */
|
||||||
|
const installCountFor = (marketplaceId: string) => {
|
||||||
|
const global = globalInstalls.filter((i) => i.marketplace_id === marketplaceId).length;
|
||||||
|
const perProject = projects.reduce(
|
||||||
|
(sum, p) => sum + p.marketplace_installs.filter((i) => i.marketplace_id === marketplaceId).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return global + perProject;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
<aside className="w-64 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-3 overflow-auto">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xs font-medium">Marketplaces</h2>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
|
||||||
|
Add marketplace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{marketplaces.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No marketplaces yet. Add a git repository to browse its items.</p>
|
||||||
|
)}
|
||||||
|
{marketplaces.map((m) => {
|
||||||
|
const snap = mp.snapshots.find((s) => s.marketplace_id === m.id);
|
||||||
|
const refreshing = mp.refreshing.includes(m.id);
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium truncate" title={m.url}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Refresh ${m.name}`}
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={() => void mp.refresh(m.id)}
|
||||||
|
>
|
||||||
|
{refreshing ? "…" : "↻"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${m.name}`}
|
||||||
|
onClick={() => setRemoving(m)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{accounts.length > 0 ? (
|
||||||
|
<label className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||||
|
<span>Account</span>
|
||||||
|
<select
|
||||||
|
aria-label={`Account for ${m.name}`}
|
||||||
|
value={m.account_id ?? ""}
|
||||||
|
onChange={(e) => void changeAccount(m, e.target.value === "" ? null : e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">None (public)</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-[var(--text-secondary)]">No account (public repository)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[var(--text-secondary)]">Last fetched {when(snap?.fetched_at ?? null)}</p>
|
||||||
|
{snap?.fetch_error && (
|
||||||
|
<p className="text-[var(--error)] whitespace-pre-wrap leading-snug">{snap.fetch_error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<label className="block text-xs space-y-1">
|
||||||
|
<span className="text-[var(--text-secondary)]">Show install state for</span>
|
||||||
|
<select
|
||||||
|
value={filterId ?? ""}
|
||||||
|
onChange={(e) => setFilterId(e.target.value === "" ? null : e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">All projects</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="w-80 flex-shrink-0 border-r border-[var(--border-color)] p-3 space-y-2 overflow-auto">
|
||||||
|
<SegmentedControl<KindFilter>
|
||||||
|
label="Item kind"
|
||||||
|
value={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
segments={[
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Search items"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{rows.map(({ marketplaceId, item }) => {
|
||||||
|
const key = itemRefKey({ marketplace_id: marketplaceId, kind: item.kind, key: item.key });
|
||||||
|
const isSel =
|
||||||
|
selected?.marketplaceId === marketplaceId &&
|
||||||
|
selected.item.kind === item.kind &&
|
||||||
|
selected.item.key === item.key;
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected({ marketplaceId, item })}
|
||||||
|
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)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
<span className="ml-1 text-[var(--text-secondary)]">{KIND_LABELS[item.kind].replace(/s$/, "").toLowerCase()}</span>
|
||||||
|
{item.invalid && <span className="ml-1 text-[var(--error)]">invalid</span>}
|
||||||
|
{mp.snapshots.length > 1 && (
|
||||||
|
<span className="block text-[var(--text-secondary)]">{nameOf(marketplaceId)}</span>
|
||||||
|
)}
|
||||||
|
{item.description && (
|
||||||
|
<span className="block text-[var(--text-secondary)] truncate">{item.description}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && mp.snapshots.length > 0 && (
|
||||||
|
<li className="text-xs text-[var(--text-secondary)]">No items match.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex-1 min-w-0 p-4 overflow-auto">
|
||||||
|
{selected ? (
|
||||||
|
<ItemDetail mp={mp} item={selected.item} marketplaceId={selected.marketplaceId} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Select an item to see what it contains and install it.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<AddMarketplaceModal
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
onAdded={() => {
|
||||||
|
void mp.reloadState();
|
||||||
|
void mp.load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{removing && (
|
||||||
|
<Modal
|
||||||
|
title={`Remove marketplace “${removing.name}”?`}
|
||||||
|
onClose={() => setRemoving(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={() => setRemoving(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
const id = removing.id;
|
||||||
|
setRemoving(null);
|
||||||
|
void mp.remove(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove marketplace
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{installCountFor(removing.id)} install{installCountFor(removing.id) === 1 ? "" : "s"} stay listed as
|
||||||
|
“Source removed” and are removed from containers at their next sync. Use “Forget” on the Installed tab
|
||||||
|
instead if you want to drop them immediately.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
const startMarketplaceGhContainerLogin = vi.fn();
|
||||||
|
const cancelMarketplaceGhLogin = vi.fn();
|
||||||
|
const openUrlExternal = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
startMarketplaceGhContainerLogin: (...a: unknown[]) => startMarketplaceGhContainerLogin(...a),
|
||||||
|
cancelMarketplaceGhLogin: () => cancelMarketplaceGhLogin(),
|
||||||
|
openUrlExternal: (u: string) => openUrlExternal(u),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlers = new Map<string, (e: { payload: unknown }) => void>();
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
|
||||||
|
handlers.set(name, cb);
|
||||||
|
return vi.fn();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GhContainerLoginModal from "./GhContainerLoginModal";
|
||||||
|
|
||||||
|
describe("GhContainerLoginModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
handlers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the device code, opens the URL, and finishes", async () => {
|
||||||
|
let resolve!: (v: unknown) => void;
|
||||||
|
startMarketplaceGhContainerLogin.mockReturnValue(new Promise((r) => (resolve = r)));
|
||||||
|
const onDone = vi.fn();
|
||||||
|
render(
|
||||||
|
<GhContainerLoginModal label="Work" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={onDone} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
|
||||||
|
await waitFor(() => expect(startMarketplaceGhContainerLogin).toHaveBeenCalledWith("Work", "github.com", "p1"));
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
handlers.get("marketplace-gh-login-code")!({
|
||||||
|
payload: { account_id: "unknown-yet", code: "ABCD-1234", url: "https://github.com/login/device" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("ABCD-1234")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open GitHub" }));
|
||||||
|
expect(openUrlExternal).toHaveBeenCalledWith("https://github.com/login/device");
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "acc9", label: "Work", host: "github.com", method: "gh_container", username: "me" }));
|
||||||
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to open a non-GitHub URL from the container", async () => {
|
||||||
|
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true));
|
||||||
|
act(() =>
|
||||||
|
handlers.get("marketplace-gh-login-code")!({
|
||||||
|
payload: { account_id: "x", code: "ABCD-1234", url: "https://evil.example/login" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Open GitHub" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels", async () => {
|
||||||
|
startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {}));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={onClose} onDone={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" }));
|
||||||
|
expect(cancelMarketplaceGhLogin).toHaveBeenCalled();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import StatusIndicator from "../ui/StatusIndicator";
|
||||||
|
import {
|
||||||
|
cancelMarketplaceGhLogin,
|
||||||
|
openUrlExternal,
|
||||||
|
startMarketplaceGhContainerLogin,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
|
import type { MarketplaceAccount } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (account: MarketplaceAccount) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeEvent {
|
||||||
|
account_id: string;
|
||||||
|
code: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
interface OutputEvent {
|
||||||
|
account_id: string;
|
||||||
|
chunk: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_OUTPUT = 8000;
|
||||||
|
|
||||||
|
/** Only open device-login pages on the host being signed in to. */
|
||||||
|
function safeDeviceUrl(url: string, host: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.protocol === "https:" && u.hostname === host ? u.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives `gh auth login --web` inside a running container. The command only
|
||||||
|
* resolves when the login finishes, so the new account's id is unknown while it
|
||||||
|
* runs; the modal accepts every gh-login event while open. The backend allows
|
||||||
|
* one gh login at a time, so there is never another flow's event to confuse.
|
||||||
|
*/
|
||||||
|
export default function GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }: Props) {
|
||||||
|
const [code, setCode] = useState<string | null>(null);
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState(true);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const unlisteners: UnlistenFn[] = [];
|
||||||
|
const register = async <T,>(name: string, handle: (p: T) => void) => {
|
||||||
|
const un = await listen<T>(name, (e) => handle(e.payload));
|
||||||
|
if (cancelled) un();
|
||||||
|
else unlisteners.push(un);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await register<CodeEvent>("marketplace-gh-login-code", (p) => {
|
||||||
|
setCode(p.code);
|
||||||
|
setUrl(p.url);
|
||||||
|
});
|
||||||
|
await register<OutputEvent>("marketplace-gh-login-output", (p) =>
|
||||||
|
setOutput((prev) => {
|
||||||
|
const next = prev + p.chunk;
|
||||||
|
return next.length > MAX_OUTPUT ? next.slice(next.length - MAX_OUTPUT) : next;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cancelled || started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
try {
|
||||||
|
const account = await startMarketplaceGhContainerLogin(label, host, projectId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setRunning(false);
|
||||||
|
onDone(account);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRunning(false);
|
||||||
|
setError(typeof e === "string" ? e : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const un of unlisteners) {
|
||||||
|
try {
|
||||||
|
un();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Runs once per modal instance; the props do not change while it is open.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
void cancelMarketplaceGhLogin();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openable = url ? safeDeviceUrl(url, host) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Sign in to ${host} with gh`}
|
||||||
|
description={`Running gh auth login in "${projectName}". The sign-in is not kept in that container.`}
|
||||||
|
widthClassName="w-[40rem]"
|
||||||
|
dismissible={!running}
|
||||||
|
onClose={running ? cancel : onClose}
|
||||||
|
footer={
|
||||||
|
running ? (
|
||||||
|
<Button size="md" variant="ghost" onClick={cancel}>
|
||||||
|
Cancel sign-in
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{running && !code && <StatusIndicator tone="busy" label="Starting gh…" className="text-xs" />}
|
||||||
|
{code && running && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs">Enter this code on the GitHub device page:</p>
|
||||||
|
<p className="font-mono text-lg tracking-widest select-all">{code}</p>
|
||||||
|
{openable ? (
|
||||||
|
<Button size="md" variant="primary" onClick={() => void openUrlExternal(openable)}>
|
||||||
|
Open GitHub
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
url && <p className="text-xs text-[var(--error)]">The sign-in URL did not point at {host}; not opening it.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||||
|
{output && (
|
||||||
|
<pre className="max-h-40 overflow-auto p-2 text-[11px] font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import type { CatalogItem } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: CatalogItem;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hooks run shell commands in every Claude session, so installing one is always confirmed. */
|
||||||
|
export default function HookConfirmModal({ item, onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Install hook “${item.name}”?`}
|
||||||
|
description="This hook runs the commands below inside the container whenever its event fires."
|
||||||
|
widthClassName="w-[40rem]"
|
||||||
|
onClose={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={onConfirm}>
|
||||||
|
Install hook
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.hook_commands.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">This hook declares no commands.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{item.hook_commands.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>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import InstallControls from "./InstallControls";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { AppSettings, CatalogItem, Project } from "../../lib/types";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
|
||||||
|
const C = "c".repeat(40);
|
||||||
|
|
||||||
|
function api(): MarketplaceApi {
|
||||||
|
return {
|
||||||
|
snapshots: [],
|
||||||
|
updates: [],
|
||||||
|
loading: false,
|
||||||
|
refreshing: [],
|
||||||
|
load: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
reloadState: vi.fn(),
|
||||||
|
install: vi.fn(async () => true),
|
||||||
|
uninstall: vi.fn(async () => true),
|
||||||
|
setDisabled: vi.fn(async () => true),
|
||||||
|
update: vi.fn(),
|
||||||
|
forget: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||||
|
kind,
|
||||||
|
key: "rev",
|
||||||
|
name: "rev",
|
||||||
|
description: "",
|
||||||
|
path: `agents/rev.md`,
|
||||||
|
invalid: null,
|
||||||
|
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
||||||
|
preview: "",
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = (id: string, patch: Partial<Project> = {}) =>
|
||||||
|
({ id, name: `proj-${id}`, marketplace_installs: [], marketplace_disabled: [], ...patch }) as unknown as Project;
|
||||||
|
|
||||||
|
function seed(globalInstalls: AppSettings["global_marketplace_installs"], projects: Project[]) {
|
||||||
|
useAppState.setState({
|
||||||
|
appSettings: { global_marketplace_installs: globalInstalls, marketplaces: [], marketplace_accounts: [] } as unknown as AppSettings,
|
||||||
|
projects,
|
||||||
|
marketplaceFilterProjectId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||||
|
|
||||||
|
describe("InstallControls", () => {
|
||||||
|
beforeEach(() => seed([], [project("p1"), project("p2")]));
|
||||||
|
|
||||||
|
it("installs for all projects", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
|
expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs for one project", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p2/ }));
|
||||||
|
expect(mp.install).toHaveBeenCalledWith(ref, { type: "project", project_id: "p2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opts a project out of a global install and back in", () => {
|
||||||
|
const mp = api();
|
||||||
|
seed([{ ...ref, commit: C }], [project("p1"), project("p2", { marketplace_disabled: [ref] })]);
|
||||||
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||||
|
const row1 = screen.getByTestId("install-row-p1");
|
||||||
|
expect(within(row1).getByText("Inherited")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(row1).getByRole("checkbox"));
|
||||||
|
expect(mp.setDisabled).toHaveBeenCalledWith("p1", ref, true);
|
||||||
|
const row2 = screen.getByTestId("install-row-p2");
|
||||||
|
expect(within(row2).getByText("Opted out")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(row2).getByRole("checkbox"));
|
||||||
|
expect(mp.setDisabled).toHaveBeenCalledWith("p2", ref, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a project-only install", () => {
|
||||||
|
const mp = api();
|
||||||
|
seed([], [project("p1", { marketplace_installs: [{ ...ref, commit: C }] })]);
|
||||||
|
render(<InstallControls mp={mp} item={item("agent")} marketplaceId="m1" />);
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
||||||
|
expect(mp.uninstall).toHaveBeenCalledWith(ref, { type: "project", project_id: "p1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirmation before installing a hook", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstallControls mp={mp} item={item("hook")} marketplaceId="m1" />);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
|
expect(mp.install).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("/home/claude/.claude/triple-c/hooks/rev/run.sh")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Install hook" }));
|
||||||
|
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables everything for an invalid item", () => {
|
||||||
|
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" />);
|
||||||
|
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("checkbox", { name: /proj-p1/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the filtered project when a filter is set", () => {
|
||||||
|
useAppState.setState({ marketplaceFilterProjectId: "p2" });
|
||||||
|
render(<InstallControls mp={api()} item={item("agent")} marketplaceId="m1" />);
|
||||||
|
expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("install-row-p2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +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 (
|
||||||
|
<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,56 @@
|
|||||||
|
import type { CatalogItem } from "../../lib/types";
|
||||||
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
|
import { KIND_LABELS } from "../../lib/marketplace";
|
||||||
|
import StatusIndicator from "../ui/StatusIndicator";
|
||||||
|
import InstallControls from "./InstallControls";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mp: MarketplaceApi;
|
||||||
|
item: CatalogItem;
|
||||||
|
marketplaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ItemDetail({ mp, item, marketplaceId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
{KIND_LABELS[item.kind].replace(/s$/, "")} · <code className="font-mono">{item.path}</code>
|
||||||
|
</p>
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">{item.name}</h3>
|
||||||
|
{item.description && <p className="text-xs text-[var(--text-secondary)] leading-snug">{item.description}</p>}
|
||||||
|
</div>
|
||||||
|
{item.invalid && (
|
||||||
|
<div className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2">
|
||||||
|
<StatusIndicator tone="error" label="Cannot be installed" className="text-xs" />
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">{item.invalid}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.kind === "hook" && item.hook_commands.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">Commands this hook runs</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{item.hook_commands.map((c) => (
|
||||||
|
<li key={c}>
|
||||||
|
<code className="block font-mono text-xs break-all">{c}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.preview && (
|
||||||
|
<pre className="max-h-80 overflow-auto p-2 text-xs font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{item.preview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">Install</p>
|
||||||
|
<InstallControls mp={mp} item={item} marketplaceId={marketplaceId} />
|
||||||
|
<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
|
||||||
|
apply to new Claude sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
const load = vi.fn(async () => {});
|
||||||
|
vi.mock("../../hooks/useMarketplace", () => ({
|
||||||
|
useMarketplace: () => ({
|
||||||
|
snapshots: [],
|
||||||
|
updates: [],
|
||||||
|
loading: false,
|
||||||
|
refreshing: [],
|
||||||
|
load,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
reloadState: vi.fn(),
|
||||||
|
install: vi.fn(),
|
||||||
|
uninstall: vi.fn(),
|
||||||
|
setDisabled: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
forget: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./BrowsePane", () => ({ default: () => <div>browse pane</div> }));
|
||||||
|
vi.mock("./InstalledPane", () => ({ default: () => <div>installed pane</div> }));
|
||||||
|
vi.mock("./AccountsPane", () => ({ default: () => <div>accounts pane</div> }));
|
||||||
|
|
||||||
|
import MarketplaceView from "./MarketplaceView";
|
||||||
|
|
||||||
|
describe("MarketplaceView", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("loads with stale refresh when first shown and switches sub-tabs", async () => {
|
||||||
|
render(<MarketplaceView active />);
|
||||||
|
await waitFor(() => expect(load).toHaveBeenCalledWith({ refreshStale: true }));
|
||||||
|
expect(screen.getByText("browse pane")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
|
expect(screen.getByText("installed pane")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Accounts" }));
|
||||||
|
expect(screen.getByText("accounts pane")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not load while hidden", () => {
|
||||||
|
render(<MarketplaceView active={false} />);
|
||||||
|
expect(load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useMarketplace } from "../../hooks/useMarketplace";
|
||||||
|
import BrowsePane from "./BrowsePane";
|
||||||
|
import InstalledPane from "./InstalledPane";
|
||||||
|
import AccountsPane from "./AccountsPane";
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: "browse", label: "Browse" },
|
||||||
|
{ id: "installed", label: "Installed" },
|
||||||
|
{ id: "accounts", label: "Accounts" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MarketplaceSubTab = (typeof SUB_TABS)[number]["id"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketplaceView({ active }: Props) {
|
||||||
|
const mp = useMarketplace();
|
||||||
|
const [tab, setTab] = useState<MarketplaceSubTab>("browse");
|
||||||
|
const { load } = mp;
|
||||||
|
const wasActive = useRef(false);
|
||||||
|
|
||||||
|
// Load (and refresh stale marketplaces) each time the tab comes to the front.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && !wasActive.current) void load({ refreshStale: true });
|
||||||
|
wasActive.current = active;
|
||||||
|
}, [active, load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full h-full flex flex-col min-h-0 ${active ? "" : "hidden"}`}>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Marketplace sections"
|
||||||
|
className="flex gap-1 px-3 pt-3 border-b border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
{SUB_TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-t-[var(--radius-control)] ${
|
||||||
|
tab === t.id
|
||||||
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.id === "installed" && mp.updates.length > 0 && (
|
||||||
|
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
|
||||||
|
{mp.updates.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{tab === "browse" && <BrowsePane mp={mp} />}
|
||||||
|
{tab === "installed" && <InstalledPane mp={mp} />}
|
||||||
|
{tab === "accounts" && <AccountsPane mp={mp} />}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import WorkspaceSection from "./config/WorkspaceSection";
|
|||||||
import ModelSection from "./config/ModelSection";
|
import ModelSection from "./config/ModelSection";
|
||||||
import AccessSection from "./config/AccessSection";
|
import AccessSection from "./config/AccessSection";
|
||||||
import RuntimeSection from "./config/RuntimeSection";
|
import RuntimeSection from "./config/RuntimeSection";
|
||||||
|
import MarketplaceSection from "./config/MarketplaceSection";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -52,6 +53,7 @@ export default function ConfigTab({ project, save, saveState }: Props) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
disabledReason={STOPPED_ONLY}
|
disabledReason={STOPPED_ONLY}
|
||||||
/>
|
/>
|
||||||
|
<MarketplaceSection project={project} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { useAppState, MARKETPLACE_TAB_KEY } from "../../../../store/appState";
|
||||||
|
import type { AppSettings, Project } from "../../../../lib/types";
|
||||||
|
|
||||||
|
const setGlobalItemDisabled = vi.fn();
|
||||||
|
const getMarketplaceSyncReport = vi.fn();
|
||||||
|
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||||
|
setGlobalItemDisabled: (...a: unknown[]) => setGlobalItemDisabled(...a),
|
||||||
|
getMarketplaceSyncReport: (id: string) => getMarketplaceSyncReport(id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let syncFinishedHandler: ((event: { payload: { project_id: string; report: unknown } }) => void) | null = null;
|
||||||
|
const listenMock = vi.fn(async (_name: string, cb: (event: { payload: { project_id: string; report: unknown } }) => void) => {
|
||||||
|
syncFinishedHandler = cb;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: (...a: Parameters<typeof listenMock>) => listenMock(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MarketplaceSection from "./MarketplaceSection";
|
||||||
|
|
||||||
|
const A = "a".repeat(40);
|
||||||
|
const project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api",
|
||||||
|
status: "running",
|
||||||
|
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: A }],
|
||||||
|
marketplace_disabled: [{ marketplace_id: "m1", kind: "hook", key: "noisy" }],
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
describe("MarketplaceSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
syncFinishedHandler = null;
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [],
|
||||||
|
activeTabKey: null,
|
||||||
|
projects: [project],
|
||||||
|
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: "m1", kind: "hook", key: "noisy", commit: A },
|
||||||
|
],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
});
|
||||||
|
getMarketplaceSyncReport.mockResolvedValue({
|
||||||
|
installed: ["agent:rev"],
|
||||||
|
updated: [],
|
||||||
|
removed: [],
|
||||||
|
skipped: [{ item: "command:cmd", reason: "a file you created has the same name" }],
|
||||||
|
errors: [],
|
||||||
|
finished_at: "2026-09-27T12:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows effective items with their source and the opted-out global item", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
const rev = screen.getByTestId("mp-global-agent-rev");
|
||||||
|
expect(within(rev).getByRole("switch")).toBeChecked();
|
||||||
|
const noisy = screen.getByTestId("mp-global-hook-noisy");
|
||||||
|
expect(within(noisy).getByRole("switch")).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId("mp-project-command-cmd")).toHaveTextContent("This project only");
|
||||||
|
expect(await screen.findByText(/a file you created has the same name/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opts out of a global item", async () => {
|
||||||
|
setGlobalItemDisabled.mockResolvedValue({ ...project, marketplace_disabled: [] });
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
fireEvent.click(within(screen.getByTestId("mp-global-agent-rev")).getByRole("switch"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setGlobalItemDisabled).toHaveBeenCalledWith("p1", { marketplace_id: "m1", kind: "agent", key: "rev" }, true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the Marketplace filtered to this project", () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
|
||||||
|
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the sync report when marketplace-sync-finished fires for this project", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
await screen.findByText(/a file you created has the same name/);
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
getMarketplaceSyncReport.mockResolvedValue({
|
||||||
|
installed: [],
|
||||||
|
updated: [],
|
||||||
|
removed: [],
|
||||||
|
skipped: [],
|
||||||
|
errors: ["boom"],
|
||||||
|
finished_at: "2026-09-27T13:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncFinishedHandler).not.toBeNull();
|
||||||
|
syncFinishedHandler?.({ payload: { project_id: "p1", report: {} } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(2));
|
||||||
|
expect(await screen.findByText("boom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores marketplace-sync-finished events for other projects", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
await screen.findByText(/a file you created has the same name/);
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(syncFinishedHandler).not.toBeNull();
|
||||||
|
syncFinishedHandler?.({ payload: { project_id: "p2", report: {} } });
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { ConfigGroup } from "../../../ui/Field";
|
||||||
|
import Toggle from "../../../ui/Toggle";
|
||||||
|
import Button from "../../../ui/Button";
|
||||||
|
import { useAppState } from "../../../../store/appState";
|
||||||
|
import { KIND_LABELS } from "../../../../lib/marketplace";
|
||||||
|
import { getMarketplaceSyncReport, setGlobalItemDisabled } from "../../../../lib/tauri-commands";
|
||||||
|
import type { MarketplaceItemRef, Project, SyncReport } from "../../../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncFinishedEvent {
|
||||||
|
project_id: string;
|
||||||
|
report: SyncReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindWord = (k: MarketplaceItemRef["kind"]) => KIND_LABELS[k].replace(/s$/, "").toLowerCase();
|
||||||
|
const same = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
|
||||||
|
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
|
||||||
|
|
||||||
|
export default function MarketplaceSection({ project }: Props) {
|
||||||
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
|
const openMarketplace = useAppState((s) => s.openMarketplace);
|
||||||
|
const updateProjectInList = useAppState((s) => s.updateProjectInList);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [report, setReport] = useState<SyncReport | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||||
|
const nameOf = (id: string) => appSettings?.marketplaces.find((m) => m.id === id)?.name ?? "removed marketplace";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchReport = () => {
|
||||||
|
getMarketplaceSyncReport(project.id)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setReport(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setReport(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReport();
|
||||||
|
|
||||||
|
// N7 (preflight): a sync also runs outside this component's own actions
|
||||||
|
// (container start, "Apply now" from the Marketplace tab), so without
|
||||||
|
// this the report shown here goes stale as soon as one finishes.
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
|
||||||
|
if (event.payload.project_id === project.id) fetchReport();
|
||||||
|
})
|
||||||
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not running inside Tauri (e.g. tests) — nothing to listen to.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [project.id, project.status]);
|
||||||
|
|
||||||
|
const toggleGlobal = async (ref: MarketplaceItemRef, enabled: boolean) => {
|
||||||
|
const id = `${ref.kind}-${ref.key}`;
|
||||||
|
setBusy(id);
|
||||||
|
try {
|
||||||
|
updateProjectInList(await setGlobalItemDisabled(project.id, ref, !enabled));
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `Could not change ${ref.key} for “${project.name}”`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigGroup
|
||||||
|
title="Marketplace"
|
||||||
|
description="Items this project gets from marketplaces. Changes apply on the next container start or with Apply now, in new Claude sessions."
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{globalInstalls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">From “All projects”</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{globalInstalls.map((g) => {
|
||||||
|
const shadowed = project.marketplace_installs.some((p) => same(p, g));
|
||||||
|
const enabled = !project.marketplace_disabled.some((d) => same(d, g));
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${g.marketplace_id}/${g.kind}/${g.key}`}
|
||||||
|
data-testid={`mp-global-${g.kind}-${g.key}`}
|
||||||
|
className="flex items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{g.key}</span>{" "}
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{kindWord(g.kind)} · {nameOf(g.marketplace_id)}
|
||||||
|
{shadowed ? " · overridden by this project's own install" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Toggle
|
||||||
|
label={`Use ${g.key} in ${project.name}`}
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy === `${g.kind}-${g.key}`}
|
||||||
|
onChange={(v) => void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.marketplace_installs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">This project only</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{project.marketplace_installs.map((i) => (
|
||||||
|
<li
|
||||||
|
key={`${i.marketplace_id}/${i.kind}/${i.key}`}
|
||||||
|
data-testid={`mp-project-${i.kind}-${i.key}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{i.key}</span>{" "}
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{globalInstalls.length === 0 && project.marketplace_installs.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Nothing installed from a marketplace.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
{report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed
|
||||||
|
</p>
|
||||||
|
{report.skipped.map((s) => (
|
||||||
|
<p key={s.item} className="text-[var(--warning)]">
|
||||||
|
Skipped {s.item}: {s.reason}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{report.errors.map((e) => (
|
||||||
|
<p key={e} className="text-[var(--error)] whitespace-pre-wrap">
|
||||||
|
{e}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => openMarketplace(project.id)}>
|
||||||
|
Open in Marketplace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ConfigGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import MarketplaceSettings from "./MarketplaceSettings";
|
||||||
|
import { useAppState, MARKETPLACE_TAB_KEY } from "../../store/appState";
|
||||||
|
import type { AppSettings } from "../../lib/types";
|
||||||
|
|
||||||
|
const listMarketplaceUpdates = vi.fn();
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
listMarketplaceUpdates: () => listMarketplaceUpdates(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("MarketplaceSettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [],
|
||||||
|
activeTabKey: null,
|
||||||
|
appSettings: {
|
||||||
|
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
|
||||||
|
global_marketplace_installs: [
|
||||||
|
{ marketplace_id: "m1", kind: "agent", key: "a", commit: "a".repeat(40) },
|
||||||
|
{ marketplace_id: "m1", kind: "hook", key: "h", commit: "a".repeat(40) },
|
||||||
|
],
|
||||||
|
marketplace_accounts: [],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
});
|
||||||
|
listMarketplaceUpdates.mockResolvedValue([
|
||||||
|
{ item: { marketplace_id: "m1", kind: "agent", key: "a" }, pinned: "a".repeat(40), head: "b".repeat(40) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarises and opens the Marketplace tab", async () => {
|
||||||
|
render(<MarketplaceSettings />);
|
||||||
|
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 marketplace");
|
||||||
|
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("2 installed for all projects");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 update available"),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open Marketplace" }));
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { listMarketplaceUpdates } from "../../lib/tauri-commands";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
export default function MarketplaceSettings() {
|
||||||
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
|
const openMarketplace = useAppState((s) => s.openMarketplace);
|
||||||
|
const [updateCount, setUpdateCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
listMarketplaceUpdates()
|
||||||
|
.then((u) => {
|
||||||
|
if (!cancelled) setUpdateCount(u.length);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setUpdateCount(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appSettings?.marketplaces.length]);
|
||||||
|
|
||||||
|
const marketplaces = appSettings?.marketplaces.length ?? 0;
|
||||||
|
const globalInstalls = appSettings?.global_marketplace_installs.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p data-testid="marketplace-summary" className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{plural(marketplaces, "marketplace", "marketplaces")} ·{" "}
|
||||||
|
{globalInstalls} installed for all projects
|
||||||
|
{updateCount !== null && updateCount > 0 && (
|
||||||
|
<> · {plural(updateCount, "update available", "updates available")}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Agents, skills, commands, hooks and plugins from git repositories, installed for all
|
||||||
|
projects or per project. Changes apply to new Claude sessions.
|
||||||
|
</p>
|
||||||
|
<Button size="md" variant="secondary" onClick={() => openMarketplace()}>
|
||||||
|
Open Marketplace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
|||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
|
import MarketplaceSettings from "./MarketplaceSettings";
|
||||||
import CertificateSettings from "./CertificateSettings";
|
import CertificateSettings from "./CertificateSettings";
|
||||||
import ExportSettingsModal from "./ExportSettingsModal";
|
import ExportSettingsModal from "./ExportSettingsModal";
|
||||||
import ImportSettingsModal from "./ImportSettingsModal";
|
import ImportSettingsModal from "./ImportSettingsModal";
|
||||||
@@ -171,6 +172,10 @@ export default function SettingsPanel() {
|
|||||||
<SharedAuthSettings />
|
<SharedAuthSettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="marketplace" title="Marketplace" defaultOpen={false}>
|
||||||
|
<MarketplaceSettings />
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
|
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
import { useAppState, homeTabKey, terminalTabKey } from "../store/appState";
|
import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "../store/appState";
|
||||||
|
|
||||||
vi.mock("./useTerminal", () => ({
|
vi.mock("./useTerminal", () => ({
|
||||||
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
|
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
|
||||||
@@ -86,3 +86,16 @@ describe("Ctrl+Shift+←/→", () => {
|
|||||||
expect(order()).toEqual([HOME, S1, S2]);
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ctrl+Shift+W on the Marketplace tab", () => {
|
||||||
|
it("closes the Marketplace tab", () => {
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [HOME, MARKETPLACE_TAB_KEY],
|
||||||
|
activeTabKey: MARKETPLACE_TAB_KEY,
|
||||||
|
activeSessionId: null,
|
||||||
|
});
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
press("W", { shift: true });
|
||||||
|
expect(useAppState.getState().tabOrder).toEqual([HOME]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
import { useAppState, isMarketplaceTab, isTerminalTab, tabKeyId } from "../store/appState";
|
||||||
import { useTerminal } from "./useTerminal";
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +62,8 @@ export function useKeyboardShortcuts() {
|
|||||||
closeTerminal(tabKeyId(key)).catch((err) =>
|
closeTerminal(tabKeyId(key)).catch((err) =>
|
||||||
console.error("Failed to close terminal:", err),
|
console.error("Failed to close terminal:", err),
|
||||||
);
|
);
|
||||||
|
} else if (isMarketplaceTab(key)) {
|
||||||
|
state.closeMarketplaceTab();
|
||||||
} else {
|
} else {
|
||||||
state.closeHomeTab(tabKeyId(key));
|
state.closeHomeTab(tabKeyId(key));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import type { AppSettings, MarketplaceSnapshot } from "../lib/types";
|
||||||
|
|
||||||
|
const listMarketplaceSnapshots = vi.fn();
|
||||||
|
const refreshMarketplaces = vi.fn();
|
||||||
|
const listMarketplaceUpdates = vi.fn();
|
||||||
|
const getSettings = vi.fn();
|
||||||
|
const listProjects = vi.fn();
|
||||||
|
const installMarketplaceItem = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
|
||||||
|
refreshMarketplaces: (id?: string) => refreshMarketplaces(id),
|
||||||
|
listMarketplaceUpdates: () => listMarketplaceUpdates(),
|
||||||
|
getSettings: () => getSettings(),
|
||||||
|
listProjects: () => listProjects(),
|
||||||
|
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async (_name: string, cb: (e: { payload: unknown }) => void) => {
|
||||||
|
syncHandler = cb;
|
||||||
|
return vi.fn();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useMarketplace, useMarketplaceSyncToasts } from "./useMarketplace";
|
||||||
|
|
||||||
|
const snap = (id: string, fetched_at: string | null): MarketplaceSnapshot => ({
|
||||||
|
marketplace_id: id,
|
||||||
|
head_commit: null,
|
||||||
|
fetched_at,
|
||||||
|
fetch_error: null,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useMarketplace", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppState.setState({ toasts: [], appSettings: { marketplaces: [] } as unknown as AppSettings });
|
||||||
|
listMarketplaceUpdates.mockResolvedValue([]);
|
||||||
|
getSettings.mockResolvedValue({ marketplaces: [] });
|
||||||
|
listProjects.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads snapshots and refreshes only stale ones", async () => {
|
||||||
|
const fresh = snap("m1", new Date().toISOString());
|
||||||
|
const stale = snap("m2", null);
|
||||||
|
listMarketplaceSnapshots.mockResolvedValue([fresh, stale]);
|
||||||
|
refreshMarketplaces.mockResolvedValue([{ ...stale, fetched_at: new Date().toISOString() }]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMarketplace());
|
||||||
|
await act(() => result.current.load({ refreshStale: true }));
|
||||||
|
|
||||||
|
expect(refreshMarketplaces).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshMarketplaces).toHaveBeenCalledWith("m2");
|
||||||
|
expect(result.current.snapshots.map((s) => s.marketplace_id)).toEqual(["m1", "m2"]);
|
||||||
|
expect(result.current.snapshots[1].fetched_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts and reloads after a failed mutation", async () => {
|
||||||
|
listMarketplaceSnapshots.mockResolvedValue([]);
|
||||||
|
installMarketplaceItem.mockRejectedValue("boom");
|
||||||
|
const { result } = renderHook(() => useMarketplace());
|
||||||
|
const ok = await act(() =>
|
||||||
|
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useMarketplaceSyncToasts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
syncHandler = null;
|
||||||
|
useAppState.setState({
|
||||||
|
toasts: [],
|
||||||
|
projects: [{ id: "p1", name: "api" }] as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts a sync with errors and stays quiet on a clean one", async () => {
|
||||||
|
renderHook(() => useMarketplaceSyncToasts());
|
||||||
|
await waitFor(() => expect(syncHandler).not.toBeNull());
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
syncHandler!({
|
||||||
|
payload: {
|
||||||
|
project_id: "p1",
|
||||||
|
report: { installed: ["agent:a"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(useAppState.getState().toasts).toHaveLength(0);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
syncHandler!({
|
||||||
|
payload: {
|
||||||
|
project_id: "p1",
|
||||||
|
report: {
|
||||||
|
installed: [],
|
||||||
|
updated: [],
|
||||||
|
removed: [],
|
||||||
|
skipped: [{ item: "agent:a", reason: "a file you created has the same name" }],
|
||||||
|
errors: ["claude plugin install failed"],
|
||||||
|
finished_at: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const toast = useAppState.getState().toasts[0];
|
||||||
|
expect(toast.kind).toBe("error");
|
||||||
|
expect(toast.message).toContain("api");
|
||||||
|
expect(toast.detail).toContain("claude plugin install failed");
|
||||||
|
expect(toast.detail).toContain("agent:a");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import { isStale } from "../lib/marketplace";
|
||||||
|
import type {
|
||||||
|
InstallScope,
|
||||||
|
ItemUpdate,
|
||||||
|
MarketplaceItemRef,
|
||||||
|
MarketplaceSnapshot,
|
||||||
|
SyncReport,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
export interface MarketplaceApi {
|
||||||
|
snapshots: MarketplaceSnapshot[];
|
||||||
|
updates: ItemUpdate[];
|
||||||
|
loading: boolean;
|
||||||
|
/** Ids of marketplaces currently being fetched. */
|
||||||
|
refreshing: string[];
|
||||||
|
load: (opts?: { refreshStale?: boolean }) => Promise<void>;
|
||||||
|
refresh: (marketplaceId?: string) => Promise<void>;
|
||||||
|
/** Reload settings, projects and the update list after a mutation. */
|
||||||
|
reloadState: () => Promise<void>;
|
||||||
|
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||||
|
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||||
|
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
|
||||||
|
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||||
|
forget: (marketplaceId: string) => Promise<boolean>;
|
||||||
|
remove: (marketplaceId: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketplace(): MarketplaceApi {
|
||||||
|
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||||
|
const setProjects = useAppState((s) => s.setProjects);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [snapshots, setSnapshots] = useState<MarketplaceSnapshot[]>([]);
|
||||||
|
const [updates, setUpdates] = useState<ItemUpdate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const merge = useCallback((fresh: MarketplaceSnapshot[]) => {
|
||||||
|
setSnapshots((prev) => {
|
||||||
|
const byId = new Map(prev.map((s) => [s.marketplace_id, s]));
|
||||||
|
for (const s of fresh) byId.set(s.marketplace_id, s);
|
||||||
|
return [...byId.values()];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUpdates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setUpdates(await commands.listMarketplaceUpdates());
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to list marketplace updates:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(
|
||||||
|
async (marketplaceId?: string) => {
|
||||||
|
const ids = marketplaceId ? [marketplaceId] : snapshots.map((s) => s.marketplace_id);
|
||||||
|
setRefreshing((r) => [...new Set([...r, ...ids])]);
|
||||||
|
try {
|
||||||
|
merge(await commands.refreshMarketplaces(marketplaceId));
|
||||||
|
await loadUpdates();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: "Could not refresh the marketplace", detail: errorText(e) });
|
||||||
|
} finally {
|
||||||
|
setRefreshing((r) => r.filter((id) => !ids.includes(id)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[snapshots, merge, loadUpdates, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (opts: { refreshStale?: boolean } = {}) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await commands.listMarketplaceSnapshots();
|
||||||
|
setSnapshots(list);
|
||||||
|
await loadUpdates();
|
||||||
|
if (opts.refreshStale) {
|
||||||
|
const now = Date.now();
|
||||||
|
const stale = list.filter((s) => isStale(s, now)).map((s) => s.marketplace_id);
|
||||||
|
if (stale.length > 0) {
|
||||||
|
setRefreshing(stale);
|
||||||
|
try {
|
||||||
|
// One call per marketplace so one slow or failing repo does not hold up the rest.
|
||||||
|
await Promise.all(
|
||||||
|
stale.map(async (id) => {
|
||||||
|
try {
|
||||||
|
merge(await commands.refreshMarketplaces(id));
|
||||||
|
} finally {
|
||||||
|
setRefreshing((r) => r.filter((x) => x !== id));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await loadUpdates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: "Could not load marketplaces", detail: errorText(e) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[merge, loadUpdates, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloadState = useCallback(async () => {
|
||||||
|
const [settings, projects] = await Promise.all([commands.getSettings(), commands.listProjects()]);
|
||||||
|
setAppSettings(settings);
|
||||||
|
setProjects(projects);
|
||||||
|
await loadUpdates();
|
||||||
|
}, [setAppSettings, setProjects, loadUpdates]);
|
||||||
|
|
||||||
|
/** Run a mutation; on failure toast it. Always resync local state afterwards. */
|
||||||
|
const mutate = useCallback(
|
||||||
|
async (label: string, run: () => Promise<unknown>): Promise<boolean> => {
|
||||||
|
let ok = true;
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (e) {
|
||||||
|
ok = false;
|
||||||
|
pushToast({ kind: "error", message: label, detail: errorText(e) });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reloadState();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to reload after marketplace change:", e);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
[reloadState, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshots,
|
||||||
|
updates,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
load,
|
||||||
|
refresh,
|
||||||
|
reloadState,
|
||||||
|
install: (item, scope) =>
|
||||||
|
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
|
||||||
|
uninstall: (item, scope) =>
|
||||||
|
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
|
||||||
|
setDisabled: (projectId, item, disabled) =>
|
||||||
|
mutate(`Could not change ${item.key} for this project`, () =>
|
||||||
|
commands.setGlobalItemDisabled(projectId, item, disabled),
|
||||||
|
),
|
||||||
|
update: (item, scope) =>
|
||||||
|
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
|
||||||
|
forget: (marketplaceId) =>
|
||||||
|
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
|
||||||
|
remove: async (marketplaceId) => {
|
||||||
|
const ok = await mutate("Could not remove the marketplace", () =>
|
||||||
|
commands.removeMarketplace(marketplaceId),
|
||||||
|
);
|
||||||
|
if (ok) setSnapshots((prev) => prev.filter((s) => s.marketplace_id !== marketplaceId));
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncFinishedEvent {
|
||||||
|
project_id: string;
|
||||||
|
report: SyncReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide: toast when a marketplace sync (container start or "Apply now")
|
||||||
|
* reports errors or skipped items. A clean sync is silent.
|
||||||
|
*/
|
||||||
|
export function useMarketplaceSyncToasts() {
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
void listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
|
||||||
|
const { project_id, report } = event.payload;
|
||||||
|
if (report.errors.length === 0 && report.skipped.length === 0) return;
|
||||||
|
const state = useAppState.getState();
|
||||||
|
const name = state.projects.find((p) => p.id === project_id)?.name ?? project_id;
|
||||||
|
const lines = [
|
||||||
|
...report.errors,
|
||||||
|
...report.skipped.map((s) => `${s.item}: ${s.reason}`),
|
||||||
|
];
|
||||||
|
state.pushToast({
|
||||||
|
kind: report.errors.length > 0 ? "error" : "info",
|
||||||
|
message: `Marketplace sync for “${name}” ${report.errors.length > 0 ? "had errors" : "skipped items"}`,
|
||||||
|
detail: lines.join("\n"),
|
||||||
|
dedupeKey: `marketplace-sync-${project_id}`,
|
||||||
|
});
|
||||||
|
}).then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
effectiveInstalls,
|
||||||
|
formatItemRef,
|
||||||
|
isStale,
|
||||||
|
itemRefKey,
|
||||||
|
projectItemState,
|
||||||
|
STALE_AFTER_MS,
|
||||||
|
} from "./marketplace";
|
||||||
|
import type { MarketplaceInstall, MarketplaceSnapshot, Project } from "./types";
|
||||||
|
|
||||||
|
const A = "a".repeat(40);
|
||||||
|
const B = "b".repeat(40);
|
||||||
|
|
||||||
|
const inst = (key: string, commit = A, kind: MarketplaceInstall["kind"] = "agent"): MarketplaceInstall => ({
|
||||||
|
marketplace_id: "m1",
|
||||||
|
kind,
|
||||||
|
key,
|
||||||
|
commit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = (patch: Partial<Project> = {}): Project =>
|
||||||
|
({
|
||||||
|
id: "p1",
|
||||||
|
name: "api",
|
||||||
|
marketplace_installs: [],
|
||||||
|
marketplace_disabled: [],
|
||||||
|
...patch,
|
||||||
|
}) as unknown as Project;
|
||||||
|
|
||||||
|
describe("itemRefKey / formatItemRef", () => {
|
||||||
|
it("keys and formats a ref", () => {
|
||||||
|
const r = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
|
||||||
|
expect(itemRefKey(r)).toBe("m1/hook/notify");
|
||||||
|
expect(formatItemRef(r)).toBe("hook:notify");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("projectItemState", () => {
|
||||||
|
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
|
||||||
|
|
||||||
|
it("is none when nothing installs it", () => {
|
||||||
|
expect(projectItemState(ref, [], project())).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is inherited from a global install", () => {
|
||||||
|
expect(projectItemState(ref, [inst("rev")], project())).toBe("inherited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is opted_out when the project disabled the global install", () => {
|
||||||
|
const p = project({ marketplace_disabled: [ref] });
|
||||||
|
expect(projectItemState(ref, [inst("rev")], p)).toBe("opted_out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is project for a project-only install", () => {
|
||||||
|
const p = project({ marketplace_installs: [inst("rev")] });
|
||||||
|
expect(projectItemState(ref, [], p)).toBe("project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is project when project and global share the pin", () => {
|
||||||
|
const p = project({ marketplace_installs: [inst("rev", A)] });
|
||||||
|
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a project pin that differs from the global pin", () => {
|
||||||
|
const p = project({ marketplace_installs: [inst("rev", B)] });
|
||||||
|
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project_pinned_differently");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not confuse kinds with the same key", () => {
|
||||||
|
expect(projectItemState(ref, [inst("rev", A, "skill")], project())).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("effectiveInstalls", () => {
|
||||||
|
it("merges global minus disabled plus project, project winning", () => {
|
||||||
|
const disabledRef = { marketplace_id: "m1", kind: "agent" as const, key: "off" };
|
||||||
|
const p = project({
|
||||||
|
marketplace_disabled: [disabledRef],
|
||||||
|
marketplace_installs: [inst("both", B), inst("mine")],
|
||||||
|
});
|
||||||
|
const out = effectiveInstalls([inst("glob"), inst("off"), inst("both", A)], p);
|
||||||
|
expect(out.map((i) => [i.key, i.commit, i.source])).toEqual([
|
||||||
|
["both", B, "project"],
|
||||||
|
["glob", A, "global"],
|
||||||
|
["mine", A, "project"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isStale", () => {
|
||||||
|
const snap = (fetched_at: string | null): MarketplaceSnapshot => ({
|
||||||
|
marketplace_id: "m1",
|
||||||
|
head_commit: null,
|
||||||
|
fetched_at,
|
||||||
|
fetch_error: null,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
const now = Date.parse("2026-09-27T12:00:00Z");
|
||||||
|
|
||||||
|
it("treats a never-fetched snapshot as stale", () => {
|
||||||
|
expect(isStale(snap(null), now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is fresh within 15 minutes and stale after", () => {
|
||||||
|
expect(isStale(snap(new Date(now - STALE_AFTER_MS + 1000).toISOString()), now)).toBe(false);
|
||||||
|
expect(isStale(snap(new Date(now - STALE_AFTER_MS - 1000).toISOString()), now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unparsable timestamp as stale", () => {
|
||||||
|
expect(isStale(snap("not a date"), now)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type {
|
||||||
|
ItemKind,
|
||||||
|
MarketplaceInstall,
|
||||||
|
MarketplaceItemRef,
|
||||||
|
MarketplaceSnapshot,
|
||||||
|
Project,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** How a project relates to one marketplace item. */
|
||||||
|
export type ProjectItemState =
|
||||||
|
| "none"
|
||||||
|
| "inherited"
|
||||||
|
| "opted_out"
|
||||||
|
| "project"
|
||||||
|
| "project_pinned_differently";
|
||||||
|
|
||||||
|
export const KIND_ORDER: ItemKind[] = ["agent", "skill", "command", "hook", "plugin"];
|
||||||
|
|
||||||
|
export const KIND_LABELS: Record<ItemKind, string> = {
|
||||||
|
agent: "Agents",
|
||||||
|
skill: "Skills",
|
||||||
|
command: "Commands",
|
||||||
|
hook: "Hooks",
|
||||||
|
plugin: "Plugins",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A marketplace is refreshed when its tab opens if the last fetch is older than this. */
|
||||||
|
export const STALE_AFTER_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`;
|
||||||
|
|
||||||
|
/** Same shape as the item strings in a `SyncReport`. */
|
||||||
|
export const formatItemRef = (r: MarketplaceItemRef) => `${r.kind}:${r.key}`;
|
||||||
|
|
||||||
|
const sameItem = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
|
||||||
|
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
|
||||||
|
|
||||||
|
export function projectItemState(
|
||||||
|
item: MarketplaceItemRef,
|
||||||
|
globalInstalls: MarketplaceInstall[],
|
||||||
|
project: Project,
|
||||||
|
): ProjectItemState {
|
||||||
|
const own = project.marketplace_installs.find((i) => sameItem(i, item));
|
||||||
|
const global = globalInstalls.find((i) => sameItem(i, item));
|
||||||
|
if (own) {
|
||||||
|
return global && global.commit !== own.commit ? "project_pinned_differently" : "project";
|
||||||
|
}
|
||||||
|
if (!global) return "none";
|
||||||
|
return project.marketplace_disabled.some((d) => sameItem(d, item)) ? "opted_out" : "inherited";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of the backend's `effective_installs`, tagged with where each install comes from. */
|
||||||
|
export function effectiveInstalls(
|
||||||
|
globalInstalls: MarketplaceInstall[],
|
||||||
|
project: Project,
|
||||||
|
): (MarketplaceInstall & { source: "global" | "project" })[] {
|
||||||
|
const byKey = new Map<string, MarketplaceInstall & { source: "global" | "project" }>();
|
||||||
|
for (const g of globalInstalls) {
|
||||||
|
if (project.marketplace_disabled.some((d) => sameItem(d, g))) continue;
|
||||||
|
byKey.set(itemRefKey(g), { ...g, source: "global" });
|
||||||
|
}
|
||||||
|
for (const p of project.marketplace_installs) {
|
||||||
|
byKey.set(itemRefKey(p), { ...p, source: "project" });
|
||||||
|
}
|
||||||
|
return [...byKey.entries()]
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||||
|
.map(([, v]) => v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStale(snapshot: MarketplaceSnapshot, now: number): boolean {
|
||||||
|
if (!snapshot.fetched_at) return true;
|
||||||
|
const at = Date.parse(snapshot.fetched_at);
|
||||||
|
if (Number.isNaN(at)) return true;
|
||||||
|
return now - at > STALE_AFTER_MS;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState } from "./types";
|
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -432,3 +432,55 @@ export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
|
|||||||
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
|
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
|
||||||
export const viewerChooseFile = (index: number) =>
|
export const viewerChooseFile = (index: number) =>
|
||||||
invoke<ViewerState>("viewer_choose_file", { index });
|
invoke<ViewerState>("viewer_choose_file", { index });
|
||||||
|
|
||||||
|
// ---- Marketplace ----
|
||||||
|
|
||||||
|
export const listMarketplaceSnapshots = () =>
|
||||||
|
invoke<MarketplaceSnapshot[]>("list_marketplace_snapshots");
|
||||||
|
export const refreshMarketplaces = (marketplaceId?: string) =>
|
||||||
|
invoke<MarketplaceSnapshot[]>("refresh_marketplaces", { marketplaceId: marketplaceId ?? null });
|
||||||
|
export const addMarketplace = (
|
||||||
|
name: string,
|
||||||
|
url: string,
|
||||||
|
branch: string | null,
|
||||||
|
accountId: string | null,
|
||||||
|
) => invoke<MarketplaceSnapshot>("add_marketplace", { name, url, branch, accountId });
|
||||||
|
export const updateMarketplace = (marketplace: Marketplace) =>
|
||||||
|
invoke<AppSettings>("update_marketplace", { marketplace });
|
||||||
|
export const removeMarketplace = (marketplaceId: string) =>
|
||||||
|
invoke<AppSettings>("remove_marketplace", { marketplaceId });
|
||||||
|
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||||
|
invoke<AppSettings>("install_marketplace_item", { item, scope });
|
||||||
|
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||||
|
invoke<void>("uninstall_marketplace_item", { item, scope });
|
||||||
|
export const setGlobalItemDisabled = (
|
||||||
|
projectId: string,
|
||||||
|
item: MarketplaceItemRef,
|
||||||
|
disabled: boolean,
|
||||||
|
) => invoke<Project>("set_global_item_disabled", { projectId, item, disabled });
|
||||||
|
export const forgetMarketplaceInstalls = (marketplaceId: string) =>
|
||||||
|
invoke<void>("forget_marketplace_installs", { marketplaceId });
|
||||||
|
export const listMarketplaceUpdates = () => invoke<ItemUpdate[]>("list_marketplace_updates");
|
||||||
|
export const marketplaceItemDiff = (
|
||||||
|
item: MarketplaceItemRef,
|
||||||
|
fromCommit: string,
|
||||||
|
toCommit: string,
|
||||||
|
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
|
||||||
|
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
|
||||||
|
invoke<void>("update_marketplace_item", { item, scope });
|
||||||
|
export const applyMarketplaceNow = (projectId?: string) =>
|
||||||
|
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
|
||||||
|
export const getMarketplaceSyncReport = (projectId: string) =>
|
||||||
|
invoke<SyncReport | null>("get_marketplace_sync_report", { projectId });
|
||||||
|
export const addMarketplaceTokenAccount = (label: string, host: string, token: string) =>
|
||||||
|
invoke<MarketplaceAccount>("add_marketplace_token_account", { label, host, token });
|
||||||
|
export const addMarketplaceGhHostAccount = (label: string, host: string) =>
|
||||||
|
invoke<MarketplaceAccount>("add_marketplace_gh_host_account", { label, host });
|
||||||
|
export const startMarketplaceGhContainerLogin = (label: string, host: string, projectId: string) =>
|
||||||
|
invoke<MarketplaceAccount>("start_marketplace_gh_container_login", { label, host, projectId });
|
||||||
|
export const cancelMarketplaceGhLogin = () => invoke<void>("cancel_marketplace_gh_login");
|
||||||
|
export const testMarketplaceAccount = (accountId: string) =>
|
||||||
|
invoke<string>("test_marketplace_account", { accountId });
|
||||||
|
export const removeMarketplaceAccount = (accountId: string) =>
|
||||||
|
invoke<AppSettings>("remove_marketplace_account", { accountId });
|
||||||
|
export const marketplaceGhHostAvailable = () => invoke<boolean>("marketplace_gh_host_available");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
|
import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "./appState";
|
||||||
|
|
||||||
const A = homeTabKey("a");
|
const A = homeTabKey("a");
|
||||||
const B = terminalTabKey("b");
|
const B = terminalTabKey("b");
|
||||||
@@ -136,3 +136,45 @@ describe("terminal focus requests", () => {
|
|||||||
expect(pending()).toBe("s1");
|
expect(pending()).toBe("s1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("marketplace tab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
seed([A, B], A);
|
||||||
|
useAppState.setState({ marketplaceFilterProjectId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens once, activates, and records the project filter", () => {
|
||||||
|
useAppState.getState().openMarketplace("p9");
|
||||||
|
useAppState.getState().openMarketplace("p9");
|
||||||
|
const s = useAppState.getState();
|
||||||
|
expect(s.tabOrder).toEqual([A, B, MARKETPLACE_TAB_KEY]);
|
||||||
|
expect(s.activeTabKey).toBe(MARKETPLACE_TAB_KEY);
|
||||||
|
expect(s.activeSessionId).toBeNull();
|
||||||
|
expect(s.marketplaceFilterProjectId).toBe("p9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the filter when opened without a project", () => {
|
||||||
|
useAppState.getState().openMarketplace("p9");
|
||||||
|
useAppState.getState().openMarketplace();
|
||||||
|
expect(useAppState.getState().marketplaceFilterProjectId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not select a project when activated", () => {
|
||||||
|
useAppState.getState().openMarketplace();
|
||||||
|
useAppState.getState().setActiveTabKey(MARKETPLACE_TAB_KEY);
|
||||||
|
expect(useAppState.getState().selectedProjectId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes and activates the neighbour", () => {
|
||||||
|
useAppState.getState().openMarketplace();
|
||||||
|
useAppState.getState().closeMarketplaceTab();
|
||||||
|
const s = useAppState.getState();
|
||||||
|
expect(s.tabOrder).toEqual([A, B]);
|
||||||
|
expect(s.activeTabKey).toBe(B);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing when not open is a no-op", () => {
|
||||||
|
useAppState.getState().closeMarketplaceTab();
|
||||||
|
expect(useAppState.getState().tabOrder).toEqual([A, B]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export const isTerminalTab = (key: string) => key.startsWith("term:");
|
|||||||
export const isHomeTab = (key: string) => key.startsWith("home:");
|
export const isHomeTab = (key: string) => key.startsWith("home:");
|
||||||
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
||||||
|
|
||||||
|
/** The Marketplace view is a singleton main-area tab; its key has no id part. */
|
||||||
|
export const MARKETPLACE_TAB_KEY = "marketplace";
|
||||||
|
export const isMarketplaceTab = (key: string) => key === MARKETPLACE_TAB_KEY;
|
||||||
|
|
||||||
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
||||||
function activation(activeTabKey: string | null) {
|
function activation(activeTabKey: string | null) {
|
||||||
return {
|
return {
|
||||||
@@ -160,6 +164,12 @@ interface AppState {
|
|||||||
requestTerminalFocus: (sessionId: string) => void;
|
requestTerminalFocus: (sessionId: string) => void;
|
||||||
clearPendingTerminalFocus: () => void;
|
clearPendingTerminalFocus: () => void;
|
||||||
closeHomeTab: (projectId: string) => void;
|
closeHomeTab: (projectId: string) => void;
|
||||||
|
/** Project the Marketplace view is filtered to, or null for all projects. */
|
||||||
|
marketplaceFilterProjectId: string | null;
|
||||||
|
setMarketplaceFilterProjectId: (projectId: string | null) => void;
|
||||||
|
/** Open (or focus) the singleton Marketplace tab, optionally filtered to one project. */
|
||||||
|
openMarketplace: (filterProjectId?: string | null) => void;
|
||||||
|
closeMarketplaceTab: () => void;
|
||||||
setActiveTabKey: (key: string) => void;
|
setActiveTabKey: (key: string) => void;
|
||||||
cycleTab: (delta: number) => void;
|
cycleTab: (delta: number) => void;
|
||||||
focusTabIndex: (index: number) => void;
|
focusTabIndex: (index: number) => void;
|
||||||
@@ -386,6 +396,27 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
: state.activeTabKey;
|
: state.activeTabKey;
|
||||||
return { tabOrder, ...activation(activeTabKey) };
|
return { tabOrder, ...activation(activeTabKey) };
|
||||||
}),
|
}),
|
||||||
|
marketplaceFilterProjectId: null,
|
||||||
|
setMarketplaceFilterProjectId: (projectId) => set({ marketplaceFilterProjectId: projectId }),
|
||||||
|
openMarketplace: (filterProjectId = null) =>
|
||||||
|
set((state) => ({
|
||||||
|
marketplaceFilterProjectId: filterProjectId,
|
||||||
|
tabOrder: state.tabOrder.includes(MARKETPLACE_TAB_KEY)
|
||||||
|
? state.tabOrder
|
||||||
|
: [...state.tabOrder, MARKETPLACE_TAB_KEY],
|
||||||
|
...activation(MARKETPLACE_TAB_KEY),
|
||||||
|
})),
|
||||||
|
closeMarketplaceTab: () =>
|
||||||
|
set((state) => {
|
||||||
|
const index = state.tabOrder.indexOf(MARKETPLACE_TAB_KEY);
|
||||||
|
if (index === -1) return {};
|
||||||
|
const tabOrder = state.tabOrder.filter((k) => k !== MARKETPLACE_TAB_KEY);
|
||||||
|
const activeTabKey =
|
||||||
|
state.activeTabKey === MARKETPLACE_TAB_KEY
|
||||||
|
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
|
||||||
|
: state.activeTabKey;
|
||||||
|
return { tabOrder, ...activation(activeTabKey) };
|
||||||
|
}),
|
||||||
setActiveTabKey: (key) =>
|
setActiveTabKey: (key) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.tabOrder.includes(key)) return {};
|
if (!state.tabOrder.includes(key)) return {};
|
||||||
|
|||||||
Reference in New Issue
Block a user