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
+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?.();
};
}, []);
}