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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { AppSettings, MarketplaceSnapshot } from "../lib/types";
|
||||
|
||||
const listMarketplaceSnapshots = vi.fn();
|
||||
const refreshMarketplaces = vi.fn();
|
||||
const listMarketplaceUpdates = vi.fn();
|
||||
const getSettings = vi.fn();
|
||||
const listProjects = vi.fn();
|
||||
const installMarketplaceItem = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listMarketplaceSnapshots: () => listMarketplaceSnapshots(),
|
||||
refreshMarketplaces: (id?: string) => refreshMarketplaces(id),
|
||||
listMarketplaceUpdates: () => listMarketplaceUpdates(),
|
||||
getSettings: () => getSettings(),
|
||||
listProjects: () => listProjects(),
|
||||
installMarketplaceItem: (...a: unknown[]) => installMarketplaceItem(...a),
|
||||
}));
|
||||
|
||||
let syncHandler: ((e: { payload: unknown }) => void) | null = null;
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, cb: (e: { payload: unknown }) => void) => {
|
||||
syncHandler = cb;
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useMarketplace, useMarketplaceSyncToasts } from "./useMarketplace";
|
||||
|
||||
const snap = (id: string, fetched_at: string | null): MarketplaceSnapshot => ({
|
||||
marketplace_id: id,
|
||||
head_commit: null,
|
||||
fetched_at,
|
||||
fetch_error: null,
|
||||
items: [],
|
||||
});
|
||||
|
||||
describe("useMarketplace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({ toasts: [], appSettings: { marketplaces: [] } as unknown as AppSettings });
|
||||
listMarketplaceUpdates.mockResolvedValue([]);
|
||||
getSettings.mockResolvedValue({ marketplaces: [] });
|
||||
listProjects.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("loads snapshots and refreshes only stale ones", async () => {
|
||||
const fresh = snap("m1", new Date().toISOString());
|
||||
const stale = snap("m2", null);
|
||||
listMarketplaceSnapshots.mockResolvedValue([fresh, stale]);
|
||||
refreshMarketplaces.mockResolvedValue([{ ...stale, fetched_at: new Date().toISOString() }]);
|
||||
|
||||
const { result } = renderHook(() => useMarketplace());
|
||||
await act(() => result.current.load({ refreshStale: true }));
|
||||
|
||||
expect(refreshMarketplaces).toHaveBeenCalledTimes(1);
|
||||
expect(refreshMarketplaces).toHaveBeenCalledWith("m2");
|
||||
expect(result.current.snapshots.map((s) => s.marketplace_id)).toEqual(["m1", "m2"]);
|
||||
expect(result.current.snapshots[1].fetched_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toasts and reloads after a failed mutation", async () => {
|
||||
listMarketplaceSnapshots.mockResolvedValue([]);
|
||||
installMarketplaceItem.mockRejectedValue("boom");
|
||||
const { result } = renderHook(() => useMarketplace());
|
||||
const ok = await act(() =>
|
||||
result.current.install({ marketplace_id: "m1", kind: "agent", key: "a" }, { type: "global" }),
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "error", detail: "boom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMarketplaceSyncToasts", () => {
|
||||
beforeEach(() => {
|
||||
syncHandler = null;
|
||||
useAppState.setState({
|
||||
toasts: [],
|
||||
projects: [{ id: "p1", name: "api" }] as never,
|
||||
});
|
||||
});
|
||||
|
||||
it("toasts a sync with errors and stays quiet on a clean one", async () => {
|
||||
renderHook(() => useMarketplaceSyncToasts());
|
||||
await waitFor(() => expect(syncHandler).not.toBeNull());
|
||||
|
||||
act(() =>
|
||||
syncHandler!({
|
||||
payload: {
|
||||
project_id: "p1",
|
||||
report: { installed: ["agent:a"], updated: [], removed: [], skipped: [], errors: [], finished_at: "" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(useAppState.getState().toasts).toHaveLength(0);
|
||||
|
||||
act(() =>
|
||||
syncHandler!({
|
||||
payload: {
|
||||
project_id: "p1",
|
||||
report: {
|
||||
installed: [],
|
||||
updated: [],
|
||||
removed: [],
|
||||
skipped: [{ item: "agent:a", reason: "a file you created has the same name" }],
|
||||
errors: ["claude plugin install failed"],
|
||||
finished_at: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const toast = useAppState.getState().toasts[0];
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.message).toContain("api");
|
||||
expect(toast.detail).toContain("claude plugin install failed");
|
||||
expect(toast.detail).toContain("agent:a");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { isStale } from "../lib/marketplace";
|
||||
import type {
|
||||
InstallScope,
|
||||
ItemUpdate,
|
||||
MarketplaceItemRef,
|
||||
MarketplaceSnapshot,
|
||||
SyncReport,
|
||||
} from "../lib/types";
|
||||
|
||||
export interface MarketplaceApi {
|
||||
snapshots: MarketplaceSnapshot[];
|
||||
updates: ItemUpdate[];
|
||||
loading: boolean;
|
||||
/** Ids of marketplaces currently being fetched. */
|
||||
refreshing: string[];
|
||||
load: (opts?: { refreshStale?: boolean }) => Promise<void>;
|
||||
refresh: (marketplaceId?: string) => Promise<void>;
|
||||
/** Reload settings, projects and the update list after a mutation. */
|
||||
reloadState: () => Promise<void>;
|
||||
install: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||
uninstall: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||
setDisabled: (projectId: string, item: MarketplaceItemRef, disabled: boolean) => Promise<boolean>;
|
||||
update: (item: MarketplaceItemRef, scope: InstallScope) => Promise<boolean>;
|
||||
forget: (marketplaceId: string) => Promise<boolean>;
|
||||
remove: (marketplaceId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export function useMarketplace(): MarketplaceApi {
|
||||
const setAppSettings = useAppState((s) => s.setAppSettings);
|
||||
const setProjects = useAppState((s) => s.setProjects);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const [snapshots, setSnapshots] = useState<MarketplaceSnapshot[]>([]);
|
||||
const [updates, setUpdates] = useState<ItemUpdate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState<string[]>([]);
|
||||
|
||||
const merge = useCallback((fresh: MarketplaceSnapshot[]) => {
|
||||
setSnapshots((prev) => {
|
||||
const byId = new Map(prev.map((s) => [s.marketplace_id, s]));
|
||||
for (const s of fresh) byId.set(s.marketplace_id, s);
|
||||
return [...byId.values()];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadUpdates = useCallback(async () => {
|
||||
try {
|
||||
setUpdates(await commands.listMarketplaceUpdates());
|
||||
} catch (e) {
|
||||
console.error("Failed to list marketplace updates:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (marketplaceId?: string) => {
|
||||
const ids = marketplaceId ? [marketplaceId] : snapshots.map((s) => s.marketplace_id);
|
||||
setRefreshing((r) => [...new Set([...r, ...ids])]);
|
||||
try {
|
||||
merge(await commands.refreshMarketplaces(marketplaceId));
|
||||
await loadUpdates();
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: "Could not refresh the marketplace", detail: errorText(e) });
|
||||
} finally {
|
||||
setRefreshing((r) => r.filter((id) => !ids.includes(id)));
|
||||
}
|
||||
},
|
||||
[snapshots, merge, loadUpdates, pushToast],
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
async (opts: { refreshStale?: boolean } = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await commands.listMarketplaceSnapshots();
|
||||
setSnapshots(list);
|
||||
await loadUpdates();
|
||||
if (opts.refreshStale) {
|
||||
const now = Date.now();
|
||||
const stale = list.filter((s) => isStale(s, now)).map((s) => s.marketplace_id);
|
||||
if (stale.length > 0) {
|
||||
setRefreshing(stale);
|
||||
try {
|
||||
// One call per marketplace so one slow or failing repo does not hold up the rest.
|
||||
await Promise.all(
|
||||
stale.map(async (id) => {
|
||||
try {
|
||||
merge(await commands.refreshMarketplaces(id));
|
||||
} finally {
|
||||
setRefreshing((r) => r.filter((x) => x !== id));
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await loadUpdates();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({ kind: "error", message: "Could not load marketplaces", detail: errorText(e) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[merge, loadUpdates, pushToast],
|
||||
);
|
||||
|
||||
const reloadState = useCallback(async () => {
|
||||
const [settings, projects] = await Promise.all([commands.getSettings(), commands.listProjects()]);
|
||||
setAppSettings(settings);
|
||||
setProjects(projects);
|
||||
await loadUpdates();
|
||||
}, [setAppSettings, setProjects, loadUpdates]);
|
||||
|
||||
/** Run a mutation; on failure toast it. Always resync local state afterwards. */
|
||||
const mutate = useCallback(
|
||||
async (label: string, run: () => Promise<unknown>): Promise<boolean> => {
|
||||
let ok = true;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
pushToast({ kind: "error", message: label, detail: errorText(e) });
|
||||
}
|
||||
try {
|
||||
await reloadState();
|
||||
} catch (e) {
|
||||
console.error("Failed to reload after marketplace change:", e);
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
[reloadState, pushToast],
|
||||
);
|
||||
|
||||
return {
|
||||
snapshots,
|
||||
updates,
|
||||
loading,
|
||||
refreshing,
|
||||
load,
|
||||
refresh,
|
||||
reloadState,
|
||||
install: (item, scope) =>
|
||||
mutate(`Could not install ${item.key}`, () => commands.installMarketplaceItem(item, scope)),
|
||||
uninstall: (item, scope) =>
|
||||
mutate(`Could not remove ${item.key}`, () => commands.uninstallMarketplaceItem(item, scope)),
|
||||
setDisabled: (projectId, item, disabled) =>
|
||||
mutate(`Could not change ${item.key} for this project`, () =>
|
||||
commands.setGlobalItemDisabled(projectId, item, disabled),
|
||||
),
|
||||
update: (item, scope) =>
|
||||
mutate(`Could not update ${item.key}`, () => commands.updateMarketplaceItem(item, scope)),
|
||||
forget: (marketplaceId) =>
|
||||
mutate("Could not forget those installs", () => commands.forgetMarketplaceInstalls(marketplaceId)),
|
||||
remove: async (marketplaceId) => {
|
||||
const ok = await mutate("Could not remove the marketplace", () =>
|
||||
commands.removeMarketplace(marketplaceId),
|
||||
);
|
||||
if (ok) setSnapshots((prev) => prev.filter((s) => s.marketplace_id !== marketplaceId));
|
||||
return ok;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncFinishedEvent {
|
||||
project_id: string;
|
||||
report: SyncReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide: toast when a marketplace sync (container start or "Apply now")
|
||||
* reports errors or skipped items. A clean sync is silent.
|
||||
*/
|
||||
export function useMarketplaceSyncToasts() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
void listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
|
||||
const { project_id, report } = event.payload;
|
||||
if (report.errors.length === 0 && report.skipped.length === 0) return;
|
||||
const state = useAppState.getState();
|
||||
const name = state.projects.find((p) => p.id === project_id)?.name ?? project_id;
|
||||
const lines = [
|
||||
...report.errors,
|
||||
...report.skipped.map((s) => `${s.item}: ${s.reason}`),
|
||||
];
|
||||
state.pushToast({
|
||||
kind: report.errors.length > 0 ? "error" : "info",
|
||||
message: `Marketplace sync for “${name}” ${report.errors.length > 0 ? "had errors" : "skipped items"}`,
|
||||
detail: lines.join("\n"),
|
||||
dedupeKey: `marketplace-sync-${project_id}`,
|
||||
});
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user