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 } 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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]" />
|
||||
|
||||
Reference in New Issue
Block a user