mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 17:27:42 +02:00
[eric] marketplace: Get on every card and sheet, spinner, then Open; installs remembered by the backend and Open launches the app, workflow or skill
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2afbdc5b05
commit
bc1e7acac9
@@ -0,0 +1,70 @@
|
||||
"""What this machine installed from the marketplace, by listing id.
|
||||
|
||||
The App Store knows what is installed; without a record every package showed Install again after a
|
||||
restart, and Open had nothing to open. The importer creates a fresh entity per commit and returns its
|
||||
id, so the record is written at commit time and maps a listing to the thing it became. A record whose
|
||||
entity has since been deleted is stale by design: the store re-checks the live lists before showing Open.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.paths import DATA_ROOT
|
||||
|
||||
INSTALLS_PATH = os.path.join(DATA_ROOT, "marketplace_installs.json")
|
||||
|
||||
|
||||
class InstallRecord(BaseModel):
|
||||
"""One pointer per importable root, typed, so an id can never point at a kind nobody can resolve."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
listing_id: str
|
||||
root_type: str
|
||||
output_id: Optional[str] = None
|
||||
skill_id: Optional[str] = None
|
||||
workflow_id: Optional[str] = None
|
||||
dashboard_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
version: str = ""
|
||||
installed_at: float = 0.0
|
||||
|
||||
def root_id(self) -> Optional[str]:
|
||||
return self.output_id or self.skill_id or self.workflow_id or self.dashboard_id or self.session_id
|
||||
|
||||
|
||||
@typechecked
|
||||
def load_installs(path: Optional[str] = None) -> Dict[str, InstallRecord]:
|
||||
p = path or INSTALLS_PATH
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
out: Dict[str, InstallRecord] = {}
|
||||
for key, value in (raw or {}).items():
|
||||
try:
|
||||
out[key] = InstallRecord.model_validate(value)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def record_install(rec: InstallRecord, path: Optional[str] = None) -> Dict[str, InstallRecord]:
|
||||
p = path or INSTALLS_PATH
|
||||
installs = load_installs(p)
|
||||
if rec.installed_at == 0.0:
|
||||
rec = rec.model_copy(update={"installed_at": time.time()})
|
||||
installs[rec.listing_id] = rec
|
||||
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump({k: v.model_dump() for k, v in installs.items()}, f, indent=1)
|
||||
os.replace(tmp, p)
|
||||
return installs
|
||||
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.apps.marketplace import catalog
|
||||
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
|
||||
from backend.apps.marketplace.package_download import (
|
||||
DownloadRefused,
|
||||
download_package,
|
||||
@@ -61,3 +62,21 @@ async def install_preflight(body: InstallRequest) -> ImportPreflightResponse:
|
||||
logger.warning("marketplace download refused for %s: %s", listing.id, e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return stage_bundle_for_import(raw, package_filename(listing.id, listing.title))
|
||||
|
||||
|
||||
class InstallsResponse(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
installs: dict[str, InstallRecord]
|
||||
|
||||
|
||||
@marketplace.router.get("/installed")
|
||||
async def get_installed() -> InstallsResponse:
|
||||
return InstallsResponse(installs=load_installs())
|
||||
|
||||
|
||||
@marketplace.router.post("/installed")
|
||||
async def post_installed(body: InstallRecord) -> InstallsResponse:
|
||||
if not body.listing_id or not body.root_type or not body.root_id():
|
||||
raise HTTPException(status_code=400, detail="listing_id, root_type and the id of what was installed are required")
|
||||
return InstallsResponse(installs=record_install(body))
|
||||
|
||||
@@ -32,6 +32,9 @@ class EntityKind(str, Enum):
|
||||
CLOUD_WORKFLOW = "cloud_workflow"
|
||||
# A provider login in 9router's own db, not one of our JSON records.
|
||||
PROVIDER_CONNECTION = "provider_connection"
|
||||
SKILL = "skill"
|
||||
# A row of the published marketplace sheet, resolved from the catalog we last fetched.
|
||||
MARKETPLACE_LISTING = "marketplace_listing"
|
||||
|
||||
|
||||
class EntityStore(BaseModel):
|
||||
@@ -67,9 +70,18 @@ ENTITY_STORES: List[EntityStore] = [
|
||||
# The one referent that does not live on this machine. preflight asks the cloud whether it still has the row; a miss renders as "nothing is running this", never as a silent blank.
|
||||
EntityStore(kind=EntityKind.CLOUD_WORKFLOW, module="backend.apps.workflows.cloud.client", lookup="preflight"),
|
||||
EntityStore(kind=EntityKind.PROVIDER_CONNECTION, module="backend.apps.nine_router.credential_store", lookup="read_credential"),
|
||||
# A skill is a folder under ~/.claude/skills; its only by-id lookup is the path resolver.
|
||||
EntityStore(kind=EntityKind.SKILL, module="backend.apps.skills.skills", lookup="skill_md_path"),
|
||||
EntityStore(kind=EntityKind.MARKETPLACE_LISTING, module="backend.apps.marketplace.catalog", lookup="find_listing"),
|
||||
]
|
||||
|
||||
CROSS_ENTITY_REFERENCES: List[EntityReference] = [
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="listing_id", target=EntityKind.MARKETPLACE_LISTING),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="skill_id", target=EntityKind.SKILL),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="workflow_id", target=EntityKind.WORKFLOW),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="selected_app_output_ids", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="workflow_edit_id", target=EntityKind.WORKFLOW),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The store must remember what it installed across restarts, and survive a bad file."""
|
||||
|
||||
import json
|
||||
|
||||
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
|
||||
|
||||
|
||||
def test_a_recorded_install_round_trips(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
|
||||
record_install(InstallRecord(listing_id="hello", root_type="skill", skill_id="sk-1", version="1.0.0"), p)
|
||||
got = load_installs(p)
|
||||
assert set(got) == {"git-graph", "hello"}
|
||||
assert got["git-graph"].output_id == "out-1" and got["git-graph"].installed_at > 0
|
||||
|
||||
|
||||
def test_reinstalling_replaces_the_record_for_that_listing(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-2", version="1.1.0"), p)
|
||||
got = load_installs(p)
|
||||
assert len(got) == 1 and got["git-graph"].output_id == "out-2" and got["git-graph"].version == "1.1.0"
|
||||
|
||||
|
||||
def test_a_missing_or_corrupt_file_reads_as_nothing_installed(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
assert load_installs(p) == {}
|
||||
(tmp_path / "installs.json").write_text("{not json")
|
||||
assert load_installs(p) == {}
|
||||
(tmp_path / "installs.json").write_text(json.dumps({"x": {"listing_id": "x"}}))
|
||||
assert load_installs(p) == {}
|
||||
@@ -11,6 +11,7 @@ import { fetchMarketplaceListings } from '@/shared/state/marketplaceCatalogSlice
|
||||
import { fetchSkills } from '@/shared/state/skillsSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
|
||||
import { addViewCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
import ImportModal from '@/app/components/share/ImportModal';
|
||||
import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm';
|
||||
import { importCommit } from '@/app/components/share/shareApi';
|
||||
@@ -21,6 +22,7 @@ import PackageDialog from './packages/detail/PackageDialog';
|
||||
import PackageBundleCard from './packages/PackageBundleCard';
|
||||
import PackageBundleDialog from './packages/detail/PackageBundleDialog';
|
||||
import { stagePackageInstall } from './packages/installPackage';
|
||||
import { fetchInstalls, installState, recordFor, recordInstall, type InstallRecord, type PillState } from './packages/installs';
|
||||
import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog';
|
||||
|
||||
type Toast = { message: string; severity: 'success' | 'error' } | null;
|
||||
@@ -28,25 +30,47 @@ type Toast = { message: string; severity: 'success' | 'error' } | null;
|
||||
// The store tab: packages published to the marketplace sheet. Install downloads the .swarm and hands
|
||||
// it to the ordinary bundle import, so what a package can do to this machine is reviewed the same way
|
||||
// a dropped file is.
|
||||
const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void }> = ({ onInstalled }) => {
|
||||
const DirectoryPackagesTab: React.FC<{ onOpenSkill?: (skillId: string) => void }> = ({ onOpenSkill }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog);
|
||||
const outputs = useAppSelector((s) => s.outputs.items);
|
||||
const skills = useAppSelector((s) => s.skills.items);
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
const [query, setQuery] = useState('');
|
||||
const [kinds, setKinds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState('newest');
|
||||
const [openListing, setOpenListing] = useState<Listing | null>(null);
|
||||
const [openBundle, setOpenBundle] = useState<Listing | null>(null);
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
const [installedIds, setInstalledIds] = useState<string[]>([]);
|
||||
const [installs, setInstalls] = useState<Record<string, InstallRecord>>({});
|
||||
const [confirm, setConfirm] = useState<{ preflight: ImportPreflight; listingId: string } | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [toast, setToast] = useState<Toast>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMarketplaceListings(false));
|
||||
// The live lists decide whether Open still has something to open; the record alone only says Get is done.
|
||||
dispatch(fetchOutputs());
|
||||
dispatch(fetchSkills());
|
||||
dispatch(fetchWorkflows(undefined));
|
||||
let alive = true;
|
||||
fetchInstalls().then((m) => { if (alive) setInstalls(m); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [dispatch]);
|
||||
|
||||
const stateFor = (listing: Listing): PillState => (
|
||||
installingId === listing.id ? 'installing' : installState(listing, installs[listing.id], { outputs, skills, workflows })
|
||||
);
|
||||
|
||||
const openInstalled = (listing: Listing) => {
|
||||
const rec = installs[listing.id];
|
||||
if (!rec) return;
|
||||
if (rec.output_id) dispatch(addViewCard({ outputId: rec.output_id }));
|
||||
else if (rec.workflow_id) dispatch(openWorkflowsApp({ workflowId: rec.workflow_id }));
|
||||
else if (rec.skill_id) onOpenSkill?.(rec.skill_id);
|
||||
};
|
||||
|
||||
const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]);
|
||||
const bundles = useMemo(() => listings.filter(isBundle), [listings]);
|
||||
const kindOptions = useMemo(
|
||||
@@ -75,21 +99,26 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
return bundles.filter((b) => matchesQuery(b, q));
|
||||
}, [bundles, query, kinds]);
|
||||
|
||||
const finish = (preflight: ImportPreflight, listingId: string, rootType: string) => {
|
||||
setInstalledIds((prev) => (prev.includes(listingId) ? prev : [...prev, listingId]));
|
||||
setToast({ message: `Added ${preflight.summary.root.name}`, severity: 'success' });
|
||||
// The pill turning into Open is the feedback, the way the App Store does it: no toast, no tab jump.
|
||||
const finish = async (listingId: string, rootType: string, rootId: string) => {
|
||||
const version = listings.find((l) => l.id === listingId)?.version ?? '';
|
||||
// Nothing else refetches these on import, so an installed package would otherwise stay invisible.
|
||||
if (rootType === 'skill') dispatch(fetchSkills());
|
||||
if (rootType === 'app') dispatch(fetchOutputs());
|
||||
if (rootType === 'workflow') dispatch(fetchWorkflows(undefined));
|
||||
onInstalled?.(rootType);
|
||||
try {
|
||||
setInstalls(await recordInstall(recordFor(listingId, rootType, rootId, version)));
|
||||
} catch (e: unknown) {
|
||||
setInstalls((prev) => ({ ...prev, [listingId]: { ...recordFor(listingId, rootType, rootId, version), installed_at: Date.now() / 1000 } }));
|
||||
setToast({ message: e instanceof Error ? e.message : "Installed, but the store couldn't remember it.", severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const commit = async (preflight: ImportPreflight, listingId: string) => {
|
||||
setCommitting(true);
|
||||
try {
|
||||
const res = await importCommit(preflight.staging_token);
|
||||
finish(preflight, listingId, res.root_type);
|
||||
await finish(listingId, res.root_type, res.root_id);
|
||||
setConfirm(null);
|
||||
} catch (e: unknown) {
|
||||
setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' });
|
||||
@@ -183,7 +212,10 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
<PackageCard
|
||||
key={listing.id}
|
||||
listing={listing}
|
||||
state={stateFor(listing)}
|
||||
onOpen={() => setOpenListing(listing)}
|
||||
onGet={() => { void install(listing.id); }}
|
||||
onOpenInstalled={() => openInstalled(listing)}
|
||||
onTag={(tag) => setQuery(tag)}
|
||||
/>
|
||||
))}
|
||||
@@ -224,15 +256,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', pr: 0.5 }}>{body()}</Box>
|
||||
<PackageDialog
|
||||
listing={openListing}
|
||||
installing={!!openListing && installingId === openListing.id}
|
||||
installed={!!openListing && installedIds.includes(openListing.id)}
|
||||
state={openListing ? stateFor(openListing) : 'get'}
|
||||
onInstall={() => { if (openListing) void install(openListing.id); }}
|
||||
onOpen={() => { if (openListing) openInstalled(openListing); }}
|
||||
onClose={() => setOpenListing(null)}
|
||||
/>
|
||||
<PackageBundleDialog
|
||||
bundle={openBundle}
|
||||
members={openBundle ? resolveBundleMembers(openBundle, listings) : []}
|
||||
installedIds={installedIds}
|
||||
stateOf={(id) => { const l = listings.find((x) => x.id === id); return l ? stateFor(l) : 'get'; }}
|
||||
onOpenInstalled={(member) => openInstalled(member)}
|
||||
installing={installingId !== null}
|
||||
onInstallAll={() => { if (openBundle) void installBundle(openBundle); }}
|
||||
onInstallMember={(id) => { void install(id); }}
|
||||
|
||||
@@ -66,7 +66,7 @@ const MarketplaceBody: React.FC = () => {
|
||||
const content = (): React.ReactElement => {
|
||||
switch (view) {
|
||||
case 'packages':
|
||||
return <DirectoryPackagesTab onInstalled={(rootType) => { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />;
|
||||
return <DirectoryPackagesTab onOpenSkill={(id) => { setFocusSkillId(id); setView('my-skills'); }} />;
|
||||
case 'connectors':
|
||||
return <DirectoryConnectorsTab onOpenInstalled={(id) => { setFocusToolId(id); setView('my-connectors'); }} />;
|
||||
case 'my-skills':
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { PillState } from './installs';
|
||||
|
||||
interface Props {
|
||||
state: PillState;
|
||||
disabled?: boolean;
|
||||
onGet: () => void;
|
||||
onOpen: () => void;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
// The one action a package has, the way the App Store draws it: Get, a spinner while it lands, then Open. It swallows the click so a card underneath never opens its sheet.
|
||||
export default function InstallPill({ state, disabled, onGet, onOpen, size = 'md' }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const sm = size === 'sm';
|
||||
const base = {
|
||||
borderRadius: `${c.radius.full}px`,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 650,
|
||||
fontSize: sm ? '0.75rem' : '0.8125rem',
|
||||
px: sm ? 1.75 : 2.25,
|
||||
py: sm ? 0.35 : 0.6,
|
||||
minWidth: sm ? 58 : 72,
|
||||
lineHeight: 1.5,
|
||||
flexShrink: 0,
|
||||
};
|
||||
const stop = (e: React.MouseEvent) => { e.stopPropagation(); };
|
||||
if (state === 'installed') {
|
||||
return (
|
||||
<Button onClick={stop} disabled variant="outlined" startIcon={<CheckRoundedIcon sx={{ fontSize: 15 }} />} sx={{ ...base, '&.Mui-disabled': { color: c.text.muted, borderColor: c.border.subtle } }}>
|
||||
Installed
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (state === 'open') {
|
||||
return (
|
||||
<Button onClick={(e) => { stop(e); onOpen(); }} variant="outlined" sx={{ ...base, color: c.accent.primary, borderColor: c.accent.primary, bgcolor: c.bg.surface, '&:hover': { bgcolor: c.bg.elevated, borderColor: c.accent.hover } }}>
|
||||
Open
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => { stop(e); if (state === 'get') onGet(); }}
|
||||
disabled={disabled || state === 'installing'}
|
||||
variant="contained"
|
||||
disableElevation
|
||||
sx={{ ...base, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.accent.primary, color: c.text.inverse, opacity: state === 'installing' ? 1 : 0.5 } }}
|
||||
>
|
||||
{state === 'installing' ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : 'Get'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,19 @@ import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseTags, KIND_LABELS, type Listing } from './catalog';
|
||||
import PackageTagRow from './detail/PackageTagRow';
|
||||
import InstallPill from './InstallPill';
|
||||
import type { PillState } from './installs';
|
||||
|
||||
interface Props {
|
||||
listing: Listing;
|
||||
state: PillState;
|
||||
onOpen: () => void;
|
||||
onGet: () => void;
|
||||
onOpenInstalled: () => void;
|
||||
onTag: (tag: string) => void;
|
||||
}
|
||||
|
||||
export default function PackageCard({ listing, onOpen, onTag }: Props) {
|
||||
export default function PackageCard({ listing, state, onOpen, onGet, onOpenInstalled, onTag }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const tags = parseTags(listing.tags).slice(0, 3);
|
||||
|
||||
@@ -73,6 +78,7 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
|
||||
{listing.author ? ` · ${listing.author}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<InstallPill state={state} disabled={!listing.download_url} onGet={onGet} onOpen={onOpenInstalled} size="sm" />
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
|
||||
@@ -7,18 +7,20 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2Outlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
|
||||
import PackageTagRow from './PackageTagRow';
|
||||
import InstallPill from '../InstallPill';
|
||||
import type { PillState } from '../installs';
|
||||
|
||||
interface Props {
|
||||
bundle: Listing | null;
|
||||
members: Listing[];
|
||||
installedIds: string[];
|
||||
stateOf: (listingId: string) => PillState;
|
||||
onOpenInstalled: (listing: Listing) => void;
|
||||
onClose: () => void;
|
||||
onOpenMember: (member: Listing) => void;
|
||||
onInstallAll: () => void;
|
||||
@@ -28,11 +30,11 @@ interface Props {
|
||||
|
||||
// A bundle has no package of its own: the sheet lists it, the dialog installs its members. There is no
|
||||
// file to download here on purpose; a store page installs, it does not hand out archives.
|
||||
export default function PackageBundleDialog({ bundle, members, installedIds, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
|
||||
export default function PackageBundleDialog({ bundle, members, stateOf, onOpenInstalled, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
if (!bundle) return null;
|
||||
const installable = members.filter((m) => m.download_url);
|
||||
const allInstalled = installable.length > 0 && installable.every((m) => installedIds.includes(m.id));
|
||||
const allInstalled = installable.length > 0 && installable.every((m) => stateOf(m.id) !== 'get');
|
||||
const pill = { borderRadius: `${c.radius.full}px`, textTransform: 'none' as const, fontWeight: 600, fontSize: '0.8125rem', px: 2, py: 0.6, minWidth: 0, whiteSpace: 'nowrap' as const };
|
||||
|
||||
return (
|
||||
@@ -75,7 +77,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
|
||||
disableElevation
|
||||
sx={{ ...pill, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
|
||||
>
|
||||
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Install all'}
|
||||
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Get all'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -94,7 +96,6 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
|
||||
) : (
|
||||
<Stack spacing={0.75}>
|
||||
{members.map((m) => {
|
||||
const done = installedIds.includes(m.id);
|
||||
return (
|
||||
<Box
|
||||
key={m.id}
|
||||
@@ -115,22 +116,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
|
||||
{KIND_LABELS[m.kind] || m.kind || 'Package'}{m.version ? ` · v${m.version}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
{done ? (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: c.text.muted, fontSize: '0.8125rem', flexShrink: 0 }}>
|
||||
<CheckRoundedIcon sx={{ fontSize: 16 }} />
|
||||
<span>Installed</span>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={(e) => { e.stopPropagation(); onInstallMember(m.id); }}
|
||||
disabled={installing || !m.download_url}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ ...pill, borderColor: c.border.strong, color: c.text.primary, '&:hover': { borderColor: c.text.primary, bgcolor: c.bg.elevated } }}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
<InstallPill state={stateOf(m.id)} disabled={!m.download_url} onGet={() => onInstallMember(m.id)} onOpen={() => onOpenInstalled(m)} size="sm" />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -15,19 +12,21 @@ import { KIND_LABELS, parseTags, type Listing } from '../catalog';
|
||||
import { detailsForListing } from '../notionDetails';
|
||||
import PackageDetails from './PackageDetails';
|
||||
import PackageTagRow from './PackageTagRow';
|
||||
import InstallPill from '../InstallPill';
|
||||
import type { PillState } from '../installs';
|
||||
import PackageVideoSection from './PackageVideoSection';
|
||||
|
||||
interface Props {
|
||||
listing: Listing | null;
|
||||
onClose: () => void;
|
||||
state: PillState;
|
||||
onInstall: () => void;
|
||||
installing: boolean;
|
||||
installed?: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
// The package sheet: one Install action, no file to download. The bundle is fetched and reviewed by the
|
||||
// same import path a dropped .swarm takes, so the raw archive has no job on a store page.
|
||||
export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) {
|
||||
export default function PackageDialog({ listing, onClose, state, onInstall, onOpen }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
if (!listing) return null;
|
||||
const details = detailsForListing(listing);
|
||||
@@ -70,16 +69,7 @@ export default function PackageDialog({ listing, onClose, onInstall, installing,
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.4, fontSize: '0.8125rem', color: c.text.muted }}>{meta}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
onClick={onInstall}
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={installing || !!installed || !listing.download_url}
|
||||
startIcon={installed ? <CheckRoundedIcon sx={{ fontSize: 16 }} /> : undefined}
|
||||
sx={{ borderRadius: `${c.radius.full}px`, textTransform: 'none', fontWeight: 600, fontSize: '0.8125rem', px: 2.25, py: 0.6, minWidth: 0, whiteSpace: 'nowrap', bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
|
||||
>
|
||||
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : installed ? 'Installed' : 'Install'}
|
||||
</Button>
|
||||
<InstallPill state={state} disabled={!listing.download_url} onGet={onInstall} onOpen={onOpen} />
|
||||
</Stack>
|
||||
|
||||
{listing.description && (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installState } from './installs';
|
||||
import type { Listing } from './catalog';
|
||||
|
||||
// The store's one button: Get until the machine remembers an install, Open while the thing it became still exists.
|
||||
const listing = { id: 'git-graph', kind: 'app' } as Listing;
|
||||
const rec = { listing_id: 'git-graph', root_type: 'app', output_id: 'out-1', version: '1.0.0', installed_at: 1 };
|
||||
const none = { outputs: {}, skills: {}, workflows: {} };
|
||||
|
||||
test('no record means Get', () => {
|
||||
assert.equal(installState(listing, undefined, none), 'get');
|
||||
});
|
||||
|
||||
test('a recorded app is Open while its output exists, and Get again once it is gone', () => {
|
||||
assert.equal(installState(listing, rec, { ...none, outputs: { 'out-1': {} } }), 'open');
|
||||
assert.equal(installState(listing, rec, none), 'get');
|
||||
});
|
||||
|
||||
test('skills and workflows open through their own lists; other kinds just read Installed', () => {
|
||||
assert.equal(installState(listing, { listing_id: 'x', root_type: 'skill', skill_id: 'sk', version: '', installed_at: 1 }, { ...none, skills: { sk: {} } }), 'open');
|
||||
assert.equal(installState(listing, { listing_id: 'x', root_type: 'workflow', workflow_id: 'wf', version: '', installed_at: 1 }, { ...none, workflows: { wf: {} } }), 'open');
|
||||
assert.equal(installState(listing, { listing_id: 'x', root_type: 'mode', session_id: 'm', version: '', installed_at: 1 }, none), 'installed');
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { Listing } from './catalog';
|
||||
|
||||
// One typed pointer per importable root, mirroring the backend record.
|
||||
export interface InstallRecord {
|
||||
listing_id: string;
|
||||
root_type: string;
|
||||
output_id?: string | null;
|
||||
skill_id?: string | null;
|
||||
workflow_id?: string | null;
|
||||
dashboard_id?: string | null;
|
||||
session_id?: string | null;
|
||||
version: string;
|
||||
installed_at: number;
|
||||
}
|
||||
|
||||
export function recordFor(listingId: string, rootType: string, rootId: string, version: string): Omit<InstallRecord, 'installed_at'> {
|
||||
const rec: Omit<InstallRecord, 'installed_at'> = { listing_id: listingId, root_type: rootType, version };
|
||||
if (rootType === 'app') rec.output_id = rootId;
|
||||
else if (rootType === 'skill') rec.skill_id = rootId;
|
||||
else if (rootType === 'workflow') rec.workflow_id = rootId;
|
||||
else if (rootType === 'dashboard') rec.dashboard_id = rootId;
|
||||
else if (rootType === 'session' || rootType === 'mode') rec.session_id = rootId;
|
||||
return rec;
|
||||
}
|
||||
|
||||
export type PillState = 'get' | 'installing' | 'open' | 'installed';
|
||||
|
||||
interface LiveEntities {
|
||||
outputs: Record<string, unknown>;
|
||||
skills: Record<string, unknown>;
|
||||
workflows: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Get until the record says otherwise; Open only while the thing it became still exists, so a deleted app offers Get again instead of an Open that goes nowhere.
|
||||
export function installState(listing: Listing, record: InstallRecord | undefined, live: LiveEntities): PillState {
|
||||
if (!record) return 'get';
|
||||
if (record.output_id) return record.output_id in live.outputs ? 'open' : 'get';
|
||||
if (record.skill_id) return record.skill_id in live.skills ? 'open' : 'get';
|
||||
if (record.workflow_id) return record.workflow_id in live.workflows ? 'open' : 'get';
|
||||
return 'installed';
|
||||
}
|
||||
|
||||
export async function fetchInstalls(): Promise<Record<string, InstallRecord>> {
|
||||
const res = await fetch(`${API_BASE}/marketplace/installed`);
|
||||
if (!res.ok) return {};
|
||||
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
|
||||
return body.installs || {};
|
||||
}
|
||||
|
||||
export async function recordInstall(rec: Omit<InstallRecord, 'installed_at'>): Promise<Record<string, InstallRecord>> {
|
||||
const res = await fetch(`${API_BASE}/marketplace/installed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...rec, installed_at: 0 }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Installed, but the store couldn't remember it.");
|
||||
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
|
||||
return body.installs || {};
|
||||
}
|
||||
Reference in New Issue
Block a user