Marketplace UI plumbing: wrappers, singleton tab, settings section, view shell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 08:48:06 -07:00
co-authored by Claude Opus 5.5
parent d419d0a6b4
commit d23d0a44c5
21 changed files with 964 additions and 9 deletions
+9 -1
View File
@@ -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() {
/>
</PaneVisibilityProvider>
))}
{tabOrder.includes(MARKETPLACE_TAB_KEY) && (
<PaneVisibilityProvider visible={activeTabKey === MARKETPLACE_TAB_KEY}>
<MarketplaceView active={activeTabKey === MARKETPLACE_TAB_KEY} />
</PaneVisibilityProvider>
)}
</div>
)}
</main>
+18 -1
View File
@@ -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(<MainTabs />);
expect(screen.getByRole("tab", { name: /marketplace/i })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("button", { name: "Close Marketplace tab" }));
expect(useAppState.getState().tabOrder).toEqual([HOME]);
});
});
+40 -2
View File
@@ -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<PermissionMode, { text: string; className: string }> =
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<HTMLDivElement>) => {
@@ -314,6 +317,41 @@ export default function MainTabs() {
const renderTab = (key: string, index: number) => {
const active = activeTabKey === key;
if (isMarketplaceTab(key)) {
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
data-tab-index={index}
onClick={() => activateTab(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
{...pointerProps(key, false)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]">◈</span>
<span className="truncate max-w-[160px]">Marketplace</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeMarketplaceTab();
}}
aria-label="Close Marketplace tab"
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
+1 -1
View File
@@ -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);
@@ -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 (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{count} account{count === 1 ? "" : "s"}.
</p>
);
}
@@ -0,0 +1,9 @@
import type { MarketplaceApi } from "../../hooks/useMarketplace";
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{mp.snapshots.length} marketplace{mp.snapshots.length === 1 ? "" : "s"} configured.
</p>
);
}
@@ -0,0 +1,9 @@
import type { MarketplaceApi } from "../../hooks/useMarketplace";
export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">
{mp.updates.length} update{mp.updates.length === 1 ? "" : "s"} available.
</p>
);
}
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const load = vi.fn(async () => {});
vi.mock("../../hooks/useMarketplace", () => ({
useMarketplace: () => ({
snapshots: [],
updates: [],
loading: false,
refreshing: [],
load,
refresh: vi.fn(),
reloadState: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
setDisabled: vi.fn(),
update: vi.fn(),
forget: vi.fn(),
remove: vi.fn(),
}),
}));
vi.mock("./BrowsePane", () => ({ default: () => <div>browse pane</div> }));
vi.mock("./InstalledPane", () => ({ default: () => <div>installed pane</div> }));
vi.mock("./AccountsPane", () => ({ default: () => <div>accounts pane</div> }));
import MarketplaceView from "./MarketplaceView";
describe("MarketplaceView", () => {
beforeEach(() => vi.clearAllMocks());
it("loads with stale refresh when first shown and switches sub-tabs", async () => {
render(<MarketplaceView active />);
await waitFor(() => expect(load).toHaveBeenCalledWith({ refreshStale: true }));
expect(screen.getByText("browse pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("installed pane")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Accounts" }));
expect(screen.getByText("accounts pane")).toBeInTheDocument();
});
it("does not load while hidden", () => {
render(<MarketplaceView active={false} />);
expect(load).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { useMarketplace } from "../../hooks/useMarketplace";
import BrowsePane from "./BrowsePane";
import InstalledPane from "./InstalledPane";
import AccountsPane from "./AccountsPane";
const SUB_TABS = [
{ id: "browse", label: "Browse" },
{ id: "installed", label: "Installed" },
{ id: "accounts", label: "Accounts" },
] as const;
export type MarketplaceSubTab = (typeof SUB_TABS)[number]["id"];
interface Props {
active: boolean;
}
export default function MarketplaceView({ active }: Props) {
const mp = useMarketplace();
const [tab, setTab] = useState<MarketplaceSubTab>("browse");
const { load } = mp;
const wasActive = useRef(false);
// Load (and refresh stale marketplaces) each time the tab comes to the front.
useEffect(() => {
if (active && !wasActive.current) void load({ refreshStale: true });
wasActive.current = active;
}, [active, load]);
return (
<div className={`w-full h-full flex flex-col min-h-0 ${active ? "" : "hidden"}`}>
<div
role="tablist"
aria-label="Marketplace sections"
className="flex gap-1 px-3 pt-3 border-b border-[var(--border-color)]"
>
{SUB_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 text-xs rounded-t-[var(--radius-control)] ${
tab === t.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
{t.id === "installed" && mp.updates.length > 0 && (
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
{mp.updates.length}
</span>
)}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{tab === "browse" && <BrowsePane mp={mp} />}
{tab === "installed" && <InstalledPane mp={mp} />}
{tab === "accounts" && <AccountsPane mp={mp} />}
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import MarketplaceSettings from "./MarketplaceSettings";
import { useAppState, MARKETPLACE_TAB_KEY } from "../../store/appState";
import type { AppSettings } from "../../lib/types";
const listMarketplaceUpdates = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
listMarketplaceUpdates: () => listMarketplaceUpdates(),
}));
describe("MarketplaceSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
tabOrder: [],
activeTabKey: null,
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
global_marketplace_installs: [
{ marketplace_id: "m1", kind: "agent", key: "a", commit: "a".repeat(40) },
{ marketplace_id: "m1", kind: "hook", key: "h", commit: "a".repeat(40) },
],
marketplace_accounts: [],
} as unknown as AppSettings,
});
listMarketplaceUpdates.mockResolvedValue([
{ item: { marketplace_id: "m1", kind: "agent", key: "a" }, pinned: "a".repeat(40), head: "b".repeat(40) },
]);
});
it("summarises and opens the Marketplace tab", async () => {
render(<MarketplaceSettings />);
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 marketplace");
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("2 installed for all projects");
await waitFor(() =>
expect(screen.getByTestId("marketplace-summary")).toHaveTextContent("1 update available"),
);
fireEvent.click(screen.getByRole("button", { name: "Open Marketplace" }));
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
});
});
@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { useAppState } from "../../store/appState";
import { listMarketplaceUpdates } from "../../lib/tauri-commands";
import Button from "../ui/Button";
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export default function MarketplaceSettings() {
const appSettings = useAppState((s) => s.appSettings);
const openMarketplace = useAppState((s) => s.openMarketplace);
const [updateCount, setUpdateCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
listMarketplaceUpdates()
.then((u) => {
if (!cancelled) setUpdateCount(u.length);
})
.catch(() => {
if (!cancelled) setUpdateCount(null);
});
return () => {
cancelled = true;
};
}, [appSettings?.marketplaces.length]);
const marketplaces = appSettings?.marketplaces.length ?? 0;
const globalInstalls = appSettings?.global_marketplace_installs.length ?? 0;
return (
<div className="space-y-2">
<p data-testid="marketplace-summary" className="text-xs text-[var(--text-secondary)] leading-snug">
{plural(marketplaces, "marketplace", "marketplaces")} ·{" "}
{globalInstalls} installed for all projects
{updateCount !== null && updateCount > 0 && (
<> · {plural(updateCount, "update available", "updates available")}</>
)}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Agents, skills, commands, hooks and plugins from git repositories, installed for all
projects or per project. Changes apply to new Claude sessions.
</p>
<Button size="md" variant="secondary" onClick={() => openMarketplace()}>
Open Marketplace
</Button>
</div>
);
}
@@ -20,6 +20,7 @@ import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import WebTerminalSettings from "./WebTerminalSettings";
import 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() {
<SharedAuthSettings />
</AccordionSection>
<AccordionSection id="marketplace" title="Marketplace" defaultOpen={false}>
<MarketplaceSettings />
</AccordionSection>
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
<AwsSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
+14 -1
View File
@@ -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]);
});
});
+3 -1
View File
@@ -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));
}
+120
View File
@@ -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");
});
});
+208
View File
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
import { isStale } from "../lib/marketplace";
import type {
InstallScope,
ItemUpdate,
MarketplaceItemRef,
MarketplaceSnapshot,
SyncReport,
} from "../lib/types";
export interface MarketplaceApi {
snapshots: MarketplaceSnapshot[];
updates: ItemUpdate[];
loading: boolean;
/** Ids of marketplaces currently being fetched. */
refreshing: string[];
load: (opts?: { refreshStale?: boolean }) => Promise<void>;
refresh: (marketplaceId?: string) => Promise<void>;
/** Reload settings, projects and the update list after a mutation. */
reloadState: () => Promise<void>;
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
forget: (marketplaceId: string) => Promise<boolean>;
remove: (marketplaceId: string) => Promise<boolean>;
}
function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
export function useMarketplace(): MarketplaceApi {
const setAppSettings = useAppState((s) => s.setAppSettings);
const setProjects = useAppState((s) => s.setProjects);
const pushToast = useAppState((s) => s.pushToast);
const [snapshots, setSnapshots] = useState<MarketplaceSnapshot[]>([]);
const [updates, setUpdates] = useState<ItemUpdate[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState<string[]>([]);
const merge = useCallback((fresh: MarketplaceSnapshot[]) => {
setSnapshots((prev) => {
const byId = new Map(prev.map((s) => [s.marketplace_id, s]));
for (const s of fresh) byId.set(s.marketplace_id, s);
return [...byId.values()];
});
}, []);
const loadUpdates = useCallback(async () => {
try {
setUpdates(await commands.listMarketplaceUpdates());
} catch (e) {
console.error("Failed to list marketplace updates:", e);
}
}, []);
const refresh = useCallback(
async (marketplaceId?: string) => {
const ids = marketplaceId ? [marketplaceId] : snapshots.map((s) => s.marketplace_id);
setRefreshing((r) => [...new Set([...r, ...ids])]);
try {
merge(await commands.refreshMarketplaces(marketplaceId));
await loadUpdates();
} catch (e) {
pushToast({ kind: "error", message: "Could not refresh the marketplace", detail: errorText(e) });
} finally {
setRefreshing((r) => r.filter((id) => !ids.includes(id)));
}
},
[snapshots, merge, loadUpdates, pushToast],
);
const load = useCallback(
async (opts: { refreshStale?: boolean } = {}) => {
setLoading(true);
try {
const list = await commands.listMarketplaceSnapshots();
setSnapshots(list);
await loadUpdates();
if (opts.refreshStale) {
const now = Date.now();
const stale = list.filter((s) => isStale(s, now)).map((s) => s.marketplace_id);
if (stale.length > 0) {
setRefreshing(stale);
try {
// One call per marketplace so one slow or failing repo does not hold up the rest.
await Promise.all(
stale.map(async (id) => {
try {
merge(await commands.refreshMarketplaces(id));
} finally {
setRefreshing((r) => r.filter((x) => x !== id));
}
}),
);
} finally {
await loadUpdates();
}
}
}
} catch (e) {
pushToast({ kind: "error", message: "Could not load marketplaces", detail: errorText(e) });
} finally {
setLoading(false);
}
},
[merge, loadUpdates, pushToast],
);
const reloadState = useCallback(async () => {
const [settings, projects] = await Promise.all([commands.getSettings(), commands.listProjects()]);
setAppSettings(settings);
setProjects(projects);
await loadUpdates();
}, [setAppSettings, setProjects, loadUpdates]);
/** Run a mutation; on failure toast it. Always resync local state afterwards. */
const mutate = useCallback(
async (label: string, run: () => Promise<unknown>): Promise<boolean> => {
let ok = true;
try {
await run();
} catch (e) {
ok = false;
pushToast({ kind: "error", message: label, detail: errorText(e) });
}
try {
await reloadState();
} catch (e) {
console.error("Failed to reload after marketplace change:", e);
}
return ok;
},
[reloadState, pushToast],
);
return {
snapshots,
updates,
loading,
refreshing,
load,
refresh,
reloadState,
install: (item, scope) =>
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
uninstall: (item, scope) =>
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
setDisabled: (projectId, item, disabled) =>
mutate(`Could not change ${item.key} for this project`, () =>
commands.setGlobalItemDisabled(projectId, item, disabled),
),
update: (item, scope) =>
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
forget: (marketplaceId) =>
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
remove: async (marketplaceId) => {
const ok = await mutate("Could not remove the marketplace", () =>
commands.removeMarketplace(marketplaceId),
);
if (ok) setSnapshots((prev) => prev.filter((s) => s.marketplace_id !== marketplaceId));
return ok;
},
};
}
interface SyncFinishedEvent {
project_id: string;
report: SyncReport;
}
/**
* App-wide: toast when a marketplace sync (container start or "Apply now")
* reports errors or skipped items. A clean sync is silent.
*/
export function useMarketplaceSyncToasts() {
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | null = null;
void listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
const { project_id, report } = event.payload;
if (report.errors.length === 0 && report.skipped.length === 0) return;
const state = useAppState.getState();
const name = state.projects.find((p) => p.id === project_id)?.name ?? project_id;
const lines = [
...report.errors,
...report.skipped.map((s) => `${s.item}: ${s.reason}`),
];
state.pushToast({
kind: report.errors.length > 0 ? "error" : "info",
message: `Marketplace sync for “${name}” ${report.errors.length > 0 ? "had errors" : "skipped items"}`,
detail: lines.join("\n"),
dedupeKey: `marketplace-sync-${project_id}`,
});
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import {
effectiveInstalls,
formatItemRef,
isStale,
itemRefKey,
projectItemState,
STALE_AFTER_MS,
} from "./marketplace";
import type { MarketplaceInstall, MarketplaceSnapshot, Project } from "./types";
const A = "a".repeat(40);
const B = "b".repeat(40);
const inst = (key: string, commit = A, kind: MarketplaceInstall["kind"] = "agent"): MarketplaceInstall => ({
marketplace_id: "m1",
kind,
key,
commit,
});
const project = (patch: Partial<Project> = {}): Project =>
({
id: "p1",
name: "api",
marketplace_installs: [],
marketplace_disabled: [],
...patch,
}) as unknown as Project;
describe("itemRefKey / formatItemRef", () => {
it("keys and formats a ref", () => {
const r = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
expect(itemRefKey(r)).toBe("m1/hook/notify");
expect(formatItemRef(r)).toBe("hook:notify");
});
});
describe("projectItemState", () => {
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
it("is none when nothing installs it", () => {
expect(projectItemState(ref, [], project())).toBe("none");
});
it("is inherited from a global install", () => {
expect(projectItemState(ref, [inst("rev")], project())).toBe("inherited");
});
it("is opted_out when the project disabled the global install", () => {
const p = project({ marketplace_disabled: [ref] });
expect(projectItemState(ref, [inst("rev")], p)).toBe("opted_out");
});
it("is project for a project-only install", () => {
const p = project({ marketplace_installs: [inst("rev")] });
expect(projectItemState(ref, [], p)).toBe("project");
});
it("is project when project and global share the pin", () => {
const p = project({ marketplace_installs: [inst("rev", A)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project");
});
it("flags a project pin that differs from the global pin", () => {
const p = project({ marketplace_installs: [inst("rev", B)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project_pinned_differently");
});
it("does not confuse kinds with the same key", () => {
expect(projectItemState(ref, [inst("rev", A, "skill")], project())).toBe("none");
});
});
describe("effectiveInstalls", () => {
it("merges global minus disabled plus project, project winning", () => {
const disabledRef = { marketplace_id: "m1", kind: "agent" as const, key: "off" };
const p = project({
marketplace_disabled: [disabledRef],
marketplace_installs: [inst("both", B), inst("mine")],
});
const out = effectiveInstalls([inst("glob"), inst("off"), inst("both", A)], p);
expect(out.map((i) => [i.key, i.commit, i.source])).toEqual([
["both", B, "project"],
["glob", A, "global"],
["mine", A, "project"],
]);
});
});
describe("isStale", () => {
const snap = (fetched_at: string | null): MarketplaceSnapshot => ({
marketplace_id: "m1",
head_commit: null,
fetched_at,
fetch_error: null,
items: [],
});
const now = Date.parse("2026-09-27T12:00:00Z");
it("treats a never-fetched snapshot as stale", () => {
expect(isStale(snap(null), now)).toBe(true);
});
it("is fresh within 15 minutes and stale after", () => {
expect(isStale(snap(new Date(now - STALE_AFTER_MS + 1000).toISOString()), now)).toBe(false);
expect(isStale(snap(new Date(now - STALE_AFTER_MS - 1000).toISOString()), now)).toBe(true);
});
it("treats an unparsable timestamp as stale", () => {
expect(isStale(snap("not a date"), now)).toBe(true);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type {
ItemKind,
MarketplaceInstall,
MarketplaceItemRef,
MarketplaceSnapshot,
Project,
} from "./types";
/** How a project relates to one marketplace item. */
export type ProjectItemState =
| "none"
| "inherited"
| "opted_out"
| "project"
| "project_pinned_differently";
export const KIND_ORDER: ItemKind[] = ["agent", "skill", "command", "hook", "plugin"];
export const KIND_LABELS: Record<ItemKind, string> = {
agent: "Agents",
skill: "Skills",
command: "Commands",
hook: "Hooks",
plugin: "Plugins",
};
/** A marketplace is refreshed when its tab opens if the last fetch is older than this. */
export const STALE_AFTER_MS = 15 * 60 * 1000;
export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`;
/** Same shape as the item strings in a `SyncReport`. */
export const formatItemRef = (r: MarketplaceItemRef) => `${r.kind}:${r.key}`;
const sameItem = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
export function projectItemState(
item: MarketplaceItemRef,
globalInstalls: MarketplaceInstall[],
project: Project,
): ProjectItemState {
const own = project.marketplace_installs.find((i) => sameItem(i, item));
const global = globalInstalls.find((i) => sameItem(i, item));
if (own) {
return global && global.commit !== own.commit ? "project_pinned_differently" : "project";
}
if (!global) return "none";
return project.marketplace_disabled.some((d) => sameItem(d, item)) ? "opted_out" : "inherited";
}
/** Mirror of the backend's `effective_installs`, tagged with where each install comes from. */
export function effectiveInstalls(
globalInstalls: MarketplaceInstall[],
project: Project,
): (MarketplaceInstall & { source: "global" | "project" })[] {
const byKey = new Map<string, MarketplaceInstall & { source: "global" | "project" }>();
for (const g of globalInstalls) {
if (project.marketplace_disabled.some((d) => sameItem(d, g))) continue;
byKey.set(itemRefKey(g), { ...g, source: "global" });
}
for (const p of project.marketplace_installs) {
byKey.set(itemRefKey(p), { ...p, source: "project" });
}
return [...byKey.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([, v]) => v);
}
export function isStale(snapshot: MarketplaceSnapshot, now: number): boolean {
if (!snapshot.fetched_at) return true;
const at = Date.parse(snapshot.fetched_at);
if (Number.isNaN(at)) return true;
return now - at > STALE_AFTER_MS;
}
+53 -1
View File
@@ -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<boolean>("check_docker");
@@ -432,3 +432,55 @@ export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
export const viewerChooseFile = (index: number) =>
invoke<ViewerState>("viewer_choose_file", { index });
// ---- Marketplace ----
export const listMarketplaceSnapshots = () =>
invoke<MarketplaceSnapshot[]>("list_marketplace_snapshots");
export const refreshMarketplaces = (marketplaceId?: string) =>
invoke<MarketplaceSnapshot[]>("refresh_marketplaces", { marketplaceId: marketplaceId ?? null });
export const addMarketplace = (
name: string,
url: string,
branch: string | null,
accountId: string | null,
) => invoke<MarketplaceSnapshot>("add_marketplace", { name, url, branch, accountId });
export const updateMarketplace = (marketplace: Marketplace) =>
invoke<AppSettings>("update_marketplace", { marketplace });
export const removeMarketplace = (marketplaceId: string) =>
invoke<AppSettings>("remove_marketplace", { marketplaceId });
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<AppSettings>("install_marketplace_item", { item, scope });
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("uninstall_marketplace_item", { item, scope });
export const setGlobalItemDisabled = (
projectId: string,
item: MarketplaceItemRef,
disabled: boolean,
) => invoke<Project>("set_global_item_disabled", { projectId, item, disabled });
export const forgetMarketplaceInstalls = (marketplaceId: string) =>
invoke<void>("forget_marketplace_installs", { marketplaceId });
export const listMarketplaceUpdates = () => invoke<ItemUpdate[]>("list_marketplace_updates");
export const marketplaceItemDiff = (
item: MarketplaceItemRef,
fromCommit: string,
toCommit: string,
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("update_marketplace_item", { item, scope });
export const applyMarketplaceNow = (projectId?: string) =>
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
export const getMarketplaceSyncReport = (projectId: string) =>
invoke<SyncReport | null>("get_marketplace_sync_report", { projectId });
export const addMarketplaceTokenAccount = (label: string, host: string, token: string) =>
invoke<MarketplaceAccount>("add_marketplace_token_account", { label, host, token });
export const addMarketplaceGhHostAccount = (label: string, host: string) =>
invoke<MarketplaceAccount>("add_marketplace_gh_host_account", { label, host });
export const startMarketplaceGhContainerLogin = (label: string, host: string, projectId: string) =>
invoke<MarketplaceAccount>("start_marketplace_gh_container_login", { label, host, projectId });
export const cancelMarketplaceGhLogin = () => invoke<void>("cancel_marketplace_gh_login");
export const testMarketplaceAccount = (accountId: string) =>
invoke<string>("test_marketplace_account", { accountId });
export const removeMarketplaceAccount = (accountId: string) =>
invoke<AppSettings>("remove_marketplace_account", { accountId });
export const marketplaceGhHostAvailable = () => invoke<boolean>("marketplace_gh_host_available");
+43 -1
View File
@@ -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]);
});
});
+31
View File
@@ -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<AppState>((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 {};