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.tsx b/app/src/components/marketplace/AccountsPane.tsx new file mode 100644 index 0000000..41fcb1c --- /dev/null +++ b/app/src/components/marketplace/AccountsPane.tsx @@ -0,0 +1,11 @@ +import type { MarketplaceApi } from "../../hooks/useMarketplace"; +import { useAppState } from "../../store/appState"; + +export default function AccountsPane(_props: { mp: MarketplaceApi }) { + const count = useAppState((s) => s.appSettings?.marketplace_accounts.length ?? 0); + return ( +

+ {count} account{count === 1 ? "" : "s"}. +

+ ); +} diff --git a/app/src/components/marketplace/BrowsePane.tsx b/app/src/components/marketplace/BrowsePane.tsx new file mode 100644 index 0000000..b97c93e --- /dev/null +++ b/app/src/components/marketplace/BrowsePane.tsx @@ -0,0 +1,9 @@ +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +export default function BrowsePane({ mp }: { mp: MarketplaceApi }) { + return ( +

+ {mp.snapshots.length} marketplace{mp.snapshots.length === 1 ? "" : "s"} configured. +

+ ); +} diff --git a/app/src/components/marketplace/InstalledPane.tsx b/app/src/components/marketplace/InstalledPane.tsx new file mode 100644 index 0000000..be7c5d1 --- /dev/null +++ b/app/src/components/marketplace/InstalledPane.tsx @@ -0,0 +1,9 @@ +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +export default function InstalledPane({ mp }: { mp: MarketplaceApi }) { + return ( +

+ {mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available. +

+ ); +} 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/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 {};