diff --git a/app/src/App.tsx b/app/src/App.tsx index bfebfe4..23f64f1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -21,7 +21,9 @@ import { useTerminal } from "./hooks/useTerminal"; import { useSTT } from "./hooks/useSTT"; import { useContainerProgress } from "./hooks/useContainerProgress"; 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"; export default function App() { @@ -72,6 +74,7 @@ export default function App() { useContainerProgress(); useKeyboardShortcuts(); + useMarketplaceSyncToasts(); // Initialize on mount useEffect(() => { @@ -159,6 +162,11 @@ export default function App() { /> ))} + {tabOrder.includes(MARKETPLACE_TAB_KEY) && ( + + + + )} )} diff --git a/app/src/components/layout/MainTabs.test.tsx b/app/src/components/layout/MainTabs.test.tsx index 855b789..f82dd22 100644 --- a/app/src/components/layout/MainTabs.test.tsx +++ b/app/src/components/layout/MainTabs.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; 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"; 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(); + 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]); + }); +}); diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index bf356e2..d13648b 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects"; import { useAppState, isHomeTab, + isMarketplaceTab, tabKeyId, terminalTabKey, } from "../../store/appState"; @@ -41,12 +42,13 @@ const MODE_BADGE: Record = export default function MainTabs() { const { sessions, close } = useTerminal(); const { projects, update } = useProjects(); - const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState( + const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, closeMarketplaceTab, moveTab } = useAppState( useShallow((s) => ({ tabOrder: s.tabOrder, activeTabKey: s.activeTabKey, setActiveTabKey: s.setActiveTabKey, closeHomeTab: s.closeHomeTab, + closeMarketplaceTab: s.closeMarketplaceTab, moveTab: s.moveTab, })), ); @@ -192,6 +194,7 @@ export default function MainTabs() { * worse than no ghost. */ const tabLabel = (key: string): string => { + if (isMarketplaceTab(key)) return "Marketplace"; if (isHomeTab(key)) { return projects.find((p) => p.id === tabKeyId(key))?.name ?? ""; } @@ -272,7 +275,7 @@ export default function MainTabs() { x: e.clientX - drag.offsetX, y: drag.top, label: tabLabel(drag.key), - icon: isHomeTab(drag.key) ? "⌂" : "▣", + icon: isMarketplaceTab(drag.key) ? "◈" : isHomeTab(drag.key) ? "⌂" : "▣", }); }, onPointerUp: (e: React.PointerEvent) => { @@ -314,6 +317,41 @@ export default function MainTabs() { const renderTab = (key: string, index: number) => { const active = activeTabKey === key; + if (isMarketplaceTab(key)) { + return ( +
activateTab(key)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveTabKey(key); + } + }} + {...pointerProps(key, false)} + className={tabClass(active, dragKey === key)} + > + + Marketplace + +
+ ); + } + if (isHomeTab(key)) { const projectId = tabKeyId(key); const project = projects.find((p) => p.id === projectId); diff --git a/app/src/components/layout/NotesDock.tsx b/app/src/components/layout/NotesDock.tsx index 4619bb1..e16320e 100644 --- a/app/src/components/layout/NotesDock.tsx +++ b/app/src/components/layout/NotesDock.tsx @@ -78,7 +78,7 @@ export default function NotesDock() { if (!notesDockOpen) return null; // 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; if (activeTabKey && isHomeTab(activeTabKey)) { projectId = tabKeyId(activeTabKey); diff --git a/app/src/components/marketplace/AccountsPane.test.tsx b/app/src/components/marketplace/AccountsPane.test.tsx new file mode 100644 index 0000000..5c5b3bb --- /dev/null +++ b/app/src/components/marketplace/AccountsPane.test.tsx @@ -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: () =>
add account modal
})); + +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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + expect(screen.getByText("add account modal")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/AccountsPane.tsx b/app/src/components/marketplace/AccountsPane.tsx new file mode 100644 index 0000000..6f28255 --- /dev/null +++ b/app/src/components/marketplace/AccountsPane.tsx @@ -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 = { + 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(null); + const [removing, setRemoving] = useState(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 ( +
+
+

+ Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter + containers. +

+ +
+ {accounts.length === 0 &&

No accounts yet. Public repositories need none.

} +
    + {accounts.map((a) => { + const users = usedBy(a.id); + const inUse = users.length > 0; + return ( +
  • +
    +

    {a.label}

    +

    + {METHOD_LABEL[a.method]} · {a.host} + {a.username ? ` · ${a.username}` : ""} +

    + {inUse &&

    Used by {users.join(", ")}

    } +
    +
    + + +
    +
  • + ); + })} +
+ {adding && setAdding(false)} />} +
+ ); +} diff --git a/app/src/components/marketplace/AddAccountModal.test.tsx b/app/src/components/marketplace/AddAccountModal.test.tsx new file mode 100644 index 0000000..a00e4a0 --- /dev/null +++ b/app/src/components/marketplace/AddAccountModal.test.tsx @@ -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 }) =>
container login for {projectId}
, +})); + +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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/app/src/components/marketplace/AddAccountModal.tsx b/app/src/components/marketplace/AddAccountModal.tsx new file mode 100644 index 0000000..efcf76e --- /dev/null +++ b/app/src/components/marketplace/AddAccountModal.tsx @@ -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("gh"); + const [hostGh, setHostGh] = useState(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(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 ( + void finish()} + /> + ); + } + + return ( + + + + + } + > +
+ + label="Sign-in method" + value={method} + onChange={(m) => { + setMethod(m); + setError(null); + }} + segments={[ + { value: "gh", label: "GitHub via gh" }, + { value: "token", label: "Access token" }, + ]} + /> + + {(id) => ( + setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" /> + )} + + + {(id) => setHost(e.target.value)} className={inputClass} />} + + {method === "gh" && hostGh === true && ( +

+ 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 gh auth login first. +

+ )} + {needsContainer && + (runnable.length === 0 ? ( +

+ gh is not installed on this computer. Start a project so gh can run in its container, or use an access token. +

+ ) : ( + + {(id) => ( + + )} + + ))} + {method === "token" && ( + + {(id) => ( + setToken(e.target.value)} + className={inputClass} + /> + )} + + )} + {error &&

{error}

} +
+
+ ); +} diff --git a/app/src/components/marketplace/AddMarketplaceModal.test.tsx b/app/src/components/marketplace/AddMarketplaceModal.test.tsx new file mode 100644 index 0000000..58002a9 --- /dev/null +++ b/app/src/components/marketplace/AddMarketplaceModal.test.tsx @@ -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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/app/src/components/marketplace/AddMarketplaceModal.tsx b/app/src/components/marketplace/AddMarketplaceModal.tsx new file mode 100644 index 0000000..a795307 --- /dev/null +++ b/app/src/components/marketplace/AddMarketplaceModal.tsx @@ -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(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 ( + + + + + } + > +
+ + {(id) => ( + setName(e.target.value)} className={inputClass} placeholder="Team marketplace" /> + )} + + + {(id) => ( + setUrl(e.target.value)} className={inputClass} placeholder="https://github.com/owner/repo.git" /> + )} + + + {(id) => ( + setBranch(e.target.value)} className={inputClass} placeholder="main" /> + )} + + + {(id) => ( + + )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/app/src/components/marketplace/BrowsePane.test.tsx b/app/src/components/marketplace/BrowsePane.test.tsx new file mode 100644 index 0000000..4c74e50 --- /dev/null +++ b/app/src/components/marketplace/BrowsePane.test.tsx @@ -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: () =>
install controls
})); +vi.mock("./AddMarketplaceModal", () => ({ default: () =>
add modal
})); + +import BrowsePane from "./BrowsePane"; + +const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial = {}): 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 { + 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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: /broken/ })); + expect(screen.getByText("SKILL.md missing")).toBeInTheDocument(); + }); + + it("refreshes one marketplace", () => { + const mp = api(); + render(); + 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(); + 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(); + 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"); + }); +}); diff --git a/app/src/components/marketplace/BrowsePane.tsx b/app/src/components/marketplace/BrowsePane.tsx new file mode 100644 index 0000000..08bf9ec --- /dev/null +++ b/app/src/components/marketplace/BrowsePane.tsx @@ -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("all"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState<{ marketplaceId: string; item: CatalogItem } | null>(null); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(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 ( +
+ + +
+ + label="Item kind" + value={kind} + onChange={setKind} + segments={[ + { value: "all", label: "All" }, + ...KIND_ORDER.map((k) => ({ value: k as KindFilter, label: KIND_LABELS[k] })), + ]} + /> + setQuery(e.target.value)} + placeholder="Search" + className={inputClass} + /> +
    + {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 ( +
  • + +
  • + ); + })} + {rows.length === 0 && mp.snapshots.length > 0 && ( +
  • No items match.
  • + )} +
+
+ +
+ {selected ? ( + + ) : ( +

Select an item to see what it contains and install it.

+ )} +
+ + {adding && ( + setAdding(false)} + onAdded={() => { + void mp.reloadState(); + void mp.load(); + }} + /> + )} + + {removing && ( + setRemoving(null)} + footer={ + <> + + + + } + > +

+ {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. +

+
+ )} +
+ ); +} diff --git a/app/src/components/marketplace/GhContainerLoginModal.test.tsx b/app/src/components/marketplace/GhContainerLoginModal.test.tsx new file mode 100644 index 0000000..80cc00f --- /dev/null +++ b/app/src/components/marketplace/GhContainerLoginModal.test.tsx @@ -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 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( + , + ); + 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(); + 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(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" })); + expect(cancelMarketplaceGhLogin).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/marketplace/GhContainerLoginModal.tsx b/app/src/components/marketplace/GhContainerLoginModal.tsx new file mode 100644 index 0000000..33f9bad --- /dev/null +++ b/app/src/components/marketplace/GhContainerLoginModal.tsx @@ -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(null); + const [url, setUrl] = useState(null); + const [output, setOutput] = useState(""); + const [error, setError] = useState(null); + const [running, setRunning] = useState(true); + const started = useRef(false); + + useEffect(() => { + let cancelled = false; + const unlisteners: UnlistenFn[] = []; + const register = async (name: string, handle: (p: T) => void) => { + const un = await listen(name, (e) => handle(e.payload)); + if (cancelled) un(); + else unlisteners.push(un); + }; + + void (async () => { + await register("marketplace-gh-login-code", (p) => { + setCode(p.code); + setUrl(p.url); + }); + await register("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 ( + + Cancel sign-in + + ) : ( + + ) + } + > +
+ {running && !code && } + {code && running && ( +
+

Enter this code on the GitHub device page:

+

{code}

+ {openable ? ( + + ) : ( + url &&

The sign-in URL did not point at {host}; not opening it.

+ )} +
+ )} + {error &&

{error}

} + {output && ( +
+            {output}
+          
+ )} +
+
+ ); +} diff --git a/app/src/components/marketplace/HookConfirmModal.tsx b/app/src/components/marketplace/HookConfirmModal.tsx new file mode 100644 index 0000000..f5f4c6e --- /dev/null +++ b/app/src/components/marketplace/HookConfirmModal.tsx @@ -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 ( + + + + + } + > + {item.hook_commands.length === 0 ? ( +

This hook declares no commands.

+ ) : ( +
    + {item.hook_commands.map((c) => ( +
  • + + {c} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/marketplace/InstallControls.test.tsx b/app/src/components/marketplace/InstallControls.test.tsx new file mode 100644 index 0000000..73aaee0 --- /dev/null +++ b/app/src/components/marketplace/InstallControls.test.tsx @@ -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 => ({ + 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 = {}) => + ({ 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(); + fireEvent.click(screen.getByRole("switch", { name: "All projects" })); + expect(mp.install).toHaveBeenCalledWith(ref, { type: "global" }); + }); + + it("installs for one project", () => { + const mp = api(); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByTestId("install-row-p1")).not.toBeInTheDocument(); + expect(screen.getByTestId("install-row-p2")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/InstallControls.tsx b/app/src/components/marketplace/InstallControls.tsx new file mode 100644 index 0000000..c154dbd --- /dev/null +++ b/app/src/components/marketplace/InstallControls.tsx @@ -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 = { + 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(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) => { + 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 ( +
+ (v ? install({ type: "global" }) : void run(() => mp.uninstall(ref, { type: "global" })))} + /> +
    + {shown.map((p) => { + const state = projectItemState(ref, globalInstalls, p); + const checked = state === "inherited" || state === "project" || state === "project_pinned_differently"; + return ( +
  • + + {STATE_LABEL[state] && ( + {STATE_LABEL[state]} + )} +
  • + ); + })} +
+ {projects.length === 0 && ( +

No projects yet — “All projects” also covers projects added later.

+ )} + {pendingHook && ( + setPendingHook(null)} + onConfirm={() => { + const scope = pendingHook; + setPendingHook(null); + void run(() => mp.install(ref, scope)); + }} + /> + )} +
+ ); +} diff --git a/app/src/components/marketplace/InstalledPane.test.tsx b/app/src/components/marketplace/InstalledPane.test.tsx new file mode 100644 index 0000000..6d94921 --- /dev/null +++ b/app/src/components/marketplace/InstalledPane.test.tsx @@ -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 }) => ( + + ), +})); + +import InstalledPane from "./InstalledPane"; + +const A = "a".repeat(40); +const B = "b".repeat(40); + +function api(patch: Partial = {}): 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Apply now" })); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error" })); + }); +}); diff --git a/app/src/components/marketplace/InstalledPane.tsx b/app/src/components/marketplace/InstalledPane.tsx new file mode 100644 index 0000000..12e39ad --- /dev/null +++ b/app/src/components/marketplace/InstalledPane.tsx @@ -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(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 ( +
  • +
    + {i.key} + + {KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)} + + {gone && Source removed} +
    +
    + {upd && !gone && ( + + )} + +
    +
  • + ); + }; + + return ( +
    +
    +

    + 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. +

    + +
    + + {removedSources.length > 0 && ( +
    +

    + Some installs come from marketplaces that were removed. They are removed from containers at their next + sync. +

    + +
    + )} + +
    +

    All projects

    + {globalInstalls.length === 0 ? ( +

    Nothing installed for all projects.

    + ) : ( +
      {globalInstalls.map((i) => row(i, { type: "global" }, "All projects", `Remove ${i.key} from all projects`))}
    + )} +
    + + {projects.map((p) => ( +
    +

    {p.name}

    + {p.marketplace_installs.length === 0 ? ( +

    + No project-only installs + {p.marketplace_disabled.length > 0 ? ` · opted out of ${p.marketplace_disabled.length} global item(s)` : ""}. +

    + ) : ( +
      + {p.marketplace_installs.map((i) => + row(i, { type: "project", project_id: p.id }, p.name, `Remove ${i.key} from ${p.name}`), + )} +
    + )} +
    + ))} + + {pending && ( + setPending(null)} + onAccept={() => mp.update(pending.update.item, pending.scope)} + /> + )} +
    + ); +} diff --git a/app/src/components/marketplace/ItemDetail.tsx b/app/src/components/marketplace/ItemDetail.tsx new file mode 100644 index 0000000..3a4647e --- /dev/null +++ b/app/src/components/marketplace/ItemDetail.tsx @@ -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 ( +
    +
    +

    + {KIND_LABELS[item.kind].replace(/s$/, "")} · {item.path} +

    +

    {item.name}

    + {item.description &&

    {item.description}

    } +
    + {item.invalid && ( +
    + +

    {item.invalid}

    +
    + )} + {item.kind === "hook" && item.hook_commands.length > 0 && ( +
    +

    Commands this hook runs

    +
      + {item.hook_commands.map((c) => ( +
    • + {c} +
    • + ))} +
    +
    + )} + {item.preview && ( +
    +          {item.preview}
    +        
    + )} +
    +

    Install

    + +

    + Running containers pick changes up on their next start or with “Apply now” on the Installed tab. Changes + apply to new Claude sessions. +

    +
    +
    + ); +} diff --git a/app/src/components/marketplace/MarketplaceView.test.tsx b/app/src/components/marketplace/MarketplaceView.test.tsx new file mode 100644 index 0000000..512d955 --- /dev/null +++ b/app/src/components/marketplace/MarketplaceView.test.tsx @@ -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: () =>
    browse pane
    })); +vi.mock("./InstalledPane", () => ({ default: () =>
    installed pane
    })); +vi.mock("./AccountsPane", () => ({ default: () =>
    accounts pane
    })); + +import MarketplaceView from "./MarketplaceView"; + +describe("MarketplaceView", () => { + beforeEach(() => vi.clearAllMocks()); + + it("loads with stale refresh when first shown and switches sub-tabs", async () => { + render(); + 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(); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/marketplace/MarketplaceView.tsx b/app/src/components/marketplace/MarketplaceView.tsx new file mode 100644 index 0000000..b33a080 --- /dev/null +++ b/app/src/components/marketplace/MarketplaceView.tsx @@ -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("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 ( +
    +
    + {SUB_TABS.map((t) => ( + + ))} +
    +
    + {tab === "browse" && } + {tab === "installed" && } + {tab === "accounts" && } +
    +
    + ); +} diff --git a/app/src/components/marketplace/UpdateDiffModal.test.tsx b/app/src/components/marketplace/UpdateDiffModal.test.tsx new file mode 100644 index 0000000..2f74188 --- /dev/null +++ b/app/src/components/marketplace/UpdateDiffModal.test.tsx @@ -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(); + 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(); + 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( + , + ); + 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(); + }); +}); diff --git a/app/src/components/marketplace/UpdateDiffModal.tsx b/app/src/components/marketplace/UpdateDiffModal.tsx new file mode 100644 index 0000000..a8b569f --- /dev/null +++ b/app/src/components/marketplace/UpdateDiffModal.tsx @@ -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; +} + +const CHANGE_LABEL: Record = { + added: "added", + removed: "removed", + modified: "modified", +}; + +export default function UpdateDiffModal({ + item, + fromCommit, + toCommit, + scopeLabel, + hookCommands, + onClose, + onAccept, +}: Props) { + const [diffs, setDiffs] = useState(null); + const [error, setError] = useState(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 ( + + + + + } + > + {error &&

    {error}

    } + {!error && diffs === null &&

    Loading changes…

    } + {hookCommands && ( +
    +

    Commands after this update

    + {hookCommands.length === 0 ? ( +

    This hook declares no commands.

    + ) : ( +
      + {hookCommands.map((c) => ( +
    • + + {c} + +
    • + ))} +
    + )} +
    + )} + {diffs && diffs.length === 0 && ( +

    No file changes (only the catalog entry changed).

    + )} + {diffs && diffs.length > 0 && ( +
    + {diffs.map((d) => ( +
    +

    + {d.path} ({CHANGE_LABEL[d.change]}) +

    + {d.unified === null ? ( +

    Binary file — no text diff

    + ) : ( +
    +                  {d.unified}
    +                
    + )} +
    + ))} +
    + )} +
    + ); +} diff --git a/app/src/components/projects/home/ConfigTab.tsx b/app/src/components/projects/home/ConfigTab.tsx index 566e078..037f228 100644 --- a/app/src/components/projects/home/ConfigTab.tsx +++ b/app/src/components/projects/home/ConfigTab.tsx @@ -5,6 +5,7 @@ import WorkspaceSection from "./config/WorkspaceSection"; import ModelSection from "./config/ModelSection"; import AccessSection from "./config/AccessSection"; import RuntimeSection from "./config/RuntimeSection"; +import MarketplaceSection from "./config/MarketplaceSection"; interface Props { project: Project; @@ -52,6 +53,7 @@ export default function ConfigTab({ project, save, saveState }: Props) { disabled={disabled} disabledReason={STOPPED_ONLY} /> + ); } diff --git a/app/src/components/projects/home/config/MarketplaceSection.test.tsx b/app/src/components/projects/home/config/MarketplaceSection.test.tsx new file mode 100644 index 0000000..e623078 --- /dev/null +++ b/app/src/components/projects/home/config/MarketplaceSection.test.tsx @@ -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) => 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/app/src/components/projects/home/config/MarketplaceSection.tsx b/app/src/components/projects/home/config/MarketplaceSection.tsx new file mode 100644 index 0000000..6c71711 --- /dev/null +++ b/app/src/components/projects/home/config/MarketplaceSection.tsx @@ -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(null); + const [busy, setBusy] = useState(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("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 ( + +
    + {globalInstalls.length > 0 && ( +
    +

    From “All projects”

    +
      + {globalInstalls.map((g) => { + const shadowed = project.marketplace_installs.some((p) => same(p, g)); + const enabled = !project.marketplace_disabled.some((d) => same(d, g)); + return ( +
    • + + {g.key}{" "} + + {kindWord(g.kind)} · {nameOf(g.marketplace_id)} + {shadowed ? " · overridden by this project's own install" : ""} + + + void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)} + /> +
    • + ); + })} +
    +
    + )} + {project.marketplace_installs.length > 0 && ( +
    +

    This project only

    +
      + {project.marketplace_installs.map((i) => ( +
    • + {i.key}{" "} + + {kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only + +
    • + ))} +
    +
    + )} + {globalInstalls.length === 0 && project.marketplace_installs.length === 0 && ( +

    Nothing installed from a marketplace.

    + )} + + {report && ( +
    +

    + Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""} +

    +

    + {report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed +

    + {report.skipped.map((s) => ( +

    + Skipped {s.item}: {s.reason} +

    + ))} + {report.errors.map((e) => ( +

    + {e} +

    + ))} +
    + )} + + +
    +
    + ); +} diff --git a/app/src/components/settings/MarketplaceSettings.test.tsx b/app/src/components/settings/MarketplaceSettings.test.tsx new file mode 100644 index 0000000..f187e4e --- /dev/null +++ b/app/src/components/settings/MarketplaceSettings.test.tsx @@ -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(); + 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); + }); +}); diff --git a/app/src/components/settings/MarketplaceSettings.tsx b/app/src/components/settings/MarketplaceSettings.tsx new file mode 100644 index 0000000..7f1dc25 --- /dev/null +++ b/app/src/components/settings/MarketplaceSettings.tsx @@ -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(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 ( +
    +

    + {plural(marketplaces, "marketplace", "marketplaces")} ·{" "} + {globalInstalls} installed for all projects + {updateCount !== null && updateCount > 0 && ( + <> · {plural(updateCount, "update available", "updates available")} + )} +

    +

    + Agents, skills, commands, hooks and plugins from git repositories, installed for all + projects or per project. Changes apply to new Claude sessions. +

    + +
    + ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 6eba2cb..21e0ab4 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -20,6 +20,7 @@ import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer"; import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; +import MarketplaceSettings from "./MarketplaceSettings"; import CertificateSettings from "./CertificateSettings"; import ExportSettingsModal from "./ExportSettingsModal"; import ImportSettingsModal from "./ImportSettingsModal"; @@ -171,6 +172,10 @@ export default function SettingsPanel() { + + + +
    diff --git a/app/src/hooks/useKeyboardShortcuts.test.tsx b/app/src/hooks/useKeyboardShortcuts.test.tsx index 98bfa9e..77c7190 100644 --- a/app/src/hooks/useKeyboardShortcuts.test.tsx +++ b/app/src/hooks/useKeyboardShortcuts.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { useKeyboardShortcuts } from "./useKeyboardShortcuts"; -import { useAppState, homeTabKey, terminalTabKey } from "../store/appState"; +import { useAppState, homeTabKey, terminalTabKey, MARKETPLACE_TAB_KEY } from "../store/appState"; vi.mock("./useTerminal", () => ({ useTerminal: () => ({ open: vi.fn(), close: vi.fn() }), @@ -86,3 +86,16 @@ describe("Ctrl+Shift+←/→", () => { 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]); + }); +}); diff --git a/app/src/hooks/useKeyboardShortcuts.ts b/app/src/hooks/useKeyboardShortcuts.ts index fbcc47a..285ffad 100644 --- a/app/src/hooks/useKeyboardShortcuts.ts +++ b/app/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useAppState, isTerminalTab, tabKeyId } from "../store/appState"; +import { useAppState, isMarketplaceTab, isTerminalTab, tabKeyId } from "../store/appState"; import { useTerminal } from "./useTerminal"; /** @@ -62,6 +62,8 @@ export function useKeyboardShortcuts() { closeTerminal(tabKeyId(key)).catch((err) => console.error("Failed to close terminal:", err), ); + } else if (isMarketplaceTab(key)) { + state.closeMarketplaceTab(); } else { state.closeHomeTab(tabKeyId(key)); } diff --git a/app/src/hooks/useMarketplace.test.ts b/app/src/hooks/useMarketplace.test.ts new file mode 100644 index 0000000..7aa27b3 --- /dev/null +++ b/app/src/hooks/useMarketplace.test.ts @@ -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"); + }); +}); diff --git a/app/src/hooks/useMarketplace.ts b/app/src/hooks/useMarketplace.ts new file mode 100644 index 0000000..e1f57ae --- /dev/null +++ b/app/src/hooks/useMarketplace.ts @@ -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; + refresh: (marketplaceId?: string) => Promise; + /** Reload settings, projects and the update list after a mutation. */ + reloadState: () => Promise; + install: (item: MarketplaceItemRef, scope: InstallScope) => Promise; + uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise; + setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise; + update: (item: MarketplaceItemRef, scope: InstallScope) => Promise; + forget: (marketplaceId: string) => Promise; + remove: (marketplaceId: string) => Promise; +} + +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([]); + const [updates, setUpdates] = useState([]); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState([]); + + 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): Promise => { + 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("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?.(); + }; + }, []); +} diff --git a/app/src/lib/marketplace.test.ts b/app/src/lib/marketplace.test.ts new file mode 100644 index 0000000..f9986e9 --- /dev/null +++ b/app/src/lib/marketplace.test.ts @@ -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 => + ({ + 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); + }); +}); diff --git a/app/src/lib/marketplace.ts b/app/src/lib/marketplace.ts new file mode 100644 index 0000000..587232e --- /dev/null +++ b/app/src/lib/marketplace.ts @@ -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 = { + 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(); + 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; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index d286b7e..c911dcb 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ 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 export const checkDocker = () => invoke("check_docker"); @@ -432,3 +432,55 @@ export const viewerWriteFile = (contentsBase64: string, baseHash: string) => invoke("viewer_write_file", { contentsBase64, baseHash }); export const viewerChooseFile = (index: number) => invoke("viewer_choose_file", { index }); + +// ---- Marketplace ---- + +export const listMarketplaceSnapshots = () => + invoke("list_marketplace_snapshots"); +export const refreshMarketplaces = (marketplaceId?: string) => + invoke("refresh_marketplaces", { marketplaceId: marketplaceId ?? null }); +export const addMarketplace = ( + name: string, + url: string, + branch: string | null, + accountId: string | null, +) => invoke("add_marketplace", { name, url, branch, accountId }); +export const updateMarketplace = (marketplace: Marketplace) => + invoke("update_marketplace", { marketplace }); +export const removeMarketplace = (marketplaceId: string) => + invoke("remove_marketplace", { marketplaceId }); +export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => + invoke("install_marketplace_item", { item, scope }); +export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => + invoke("uninstall_marketplace_item", { item, scope }); +export const setGlobalItemDisabled = ( + projectId: string, + item: MarketplaceItemRef, + disabled: boolean, +) => invoke("set_global_item_disabled", { projectId, item, disabled }); +export const forgetMarketplaceInstalls = (marketplaceId: string) => + invoke("forget_marketplace_installs", { marketplaceId }); +export const listMarketplaceUpdates = () => invoke("list_marketplace_updates"); +export const marketplaceItemDiff = ( + item: MarketplaceItemRef, + fromCommit: string, + toCommit: string, +) => invoke("marketplace_item_diff", { item, fromCommit, toCommit }); +export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) => + invoke("update_marketplace_item", { item, scope }); +export const applyMarketplaceNow = (projectId?: string) => + invoke("apply_marketplace_now", { projectId: projectId ?? null }); +export const getMarketplaceSyncReport = (projectId: string) => + invoke("get_marketplace_sync_report", { projectId }); +export const addMarketplaceTokenAccount = (label: string, host: string, token: string) => + invoke("add_marketplace_token_account", { label, host, token }); +export const addMarketplaceGhHostAccount = (label: string, host: string) => + invoke("add_marketplace_gh_host_account", { label, host }); +export const startMarketplaceGhContainerLogin = (label: string, host: string, projectId: string) => + invoke("start_marketplace_gh_container_login", { label, host, projectId }); +export const cancelMarketplaceGhLogin = () => invoke("cancel_marketplace_gh_login"); +export const testMarketplaceAccount = (accountId: string) => + invoke("test_marketplace_account", { accountId }); +export const removeMarketplaceAccount = (accountId: string) => + invoke("remove_marketplace_account", { accountId }); +export const marketplaceGhHostAvailable = () => invoke("marketplace_gh_host_available"); diff --git a/app/src/store/appState.test.ts b/app/src/store/appState.test.ts index 606680b..95f389f 100644 --- a/app/src/store/appState.test.ts +++ b/app/src/store/appState.test.ts @@ -1,5 +1,5 @@ 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 B = terminalTabKey("b"); @@ -136,3 +136,45 @@ describe("terminal focus requests", () => { 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]); + }); +}); diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index 3216c45..bf2dec3 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -85,6 +85,10 @@ export const isTerminalTab = (key: string) => key.startsWith("term:"); export const isHomeTab = (key: string) => key.startsWith("home:"); 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". */ function activation(activeTabKey: string | null) { return { @@ -160,6 +164,12 @@ interface AppState { requestTerminalFocus: (sessionId: string) => void; clearPendingTerminalFocus: () => 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; cycleTab: (delta: number) => void; focusTabIndex: (index: number) => void; @@ -386,6 +396,27 @@ export const useAppState = create((set) => ({ : state.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) => set((state) => { if (!state.tabOrder.includes(key)) return {};