Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12675-installed-apps-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/i18n': minor
---

Seven `marketplace.*` keys are removed from all ten language packs: `installedTitle`, `installedSubtitle`, `installedEmpty`, `installedAt`, `installedBy`, `installedPackageId` and `cachedAs` (objectui#12675). Only the Installed Apps list read them. That list's body is retired: Setup → Packages is the one list of what is installed, and its rows do not show an install date, an installer or the cached-manifest path. A host that read one of these keys gets the key back instead of a sentence.

Fix for a host that still reads one of them: carry the sentence in the host's own bundle, or drop the read.

Clause-②: yes (narrowing)
13 changes: 13 additions & 0 deletions .changeset/12675-packages-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
---

Setup → Packages is the one list of what is installed: every package carries a Source, and marketplace installs carry their actions on their own row (objectui#12675, ruling B on objectstack#23892).

- **Source column.** Each row reads `Code`, `Marketplace` or `Authored`, a closed set. `Authored` is a row whose served `writable` verdict is `true` (a database-authored base). `Marketplace` is a non-writable row whose id the marketplace lists as installed: the control plane's list in a cloud-connected environment, this runtime's install-local list otherwise. Every other non-writable row is `Code`. A row with no boolean `writable`, or a writable row the marketplace also lists, is refused by its id, and the list says so instead of drawing a blank or a guessed Source. When the install-local capability is on and its installed list cannot be read, the list is refused the same way, so a marketplace install is never shown as `Code`.
- **Marketplace row actions.** When the runtime reports the install-local capability (`features.installLocal`), the rows whose Source is `Marketplace` carry the actions the Installed Apps list had, for a workspace admin: **Details** opens the package's marketplace page, where Update and the sample-data Re-seed / Purge menu are. **Uninstall** is offered for this runtime's own installs, behind an in-app confirm. An install the runtime refused to load still gets its row, with its Not loaded reason and Uninstall. With the capability off, rows still show a Source and no row carries a marketplace action. With `features.installLocal` off no row reads `Marketplace`, so in such an environment control-plane installs read `Code`; the declared key objectstack#23972 adds removes that case.
- **Source filter.** `?source=code`, `?source=marketplace` or `?source=authored` on the Packages route (`/apps/APP/component/developer/packages`) filters the list. A Setup nav item gets the same URL with `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`. An unknown, empty or repeated value is refused by name and no rows are drawn.
- **Installed Apps opens Packages filtered to Marketplace.** The `marketplace:installed-list` widget, which the install-local plugin's Installed Apps page renders, and the legacy `system/marketplace/installed` route both redirect to `/apps/APP/component/developer/packages?source=marketplace`. The widget registration, the route and the `MarketplaceInstalledPage` export are kept until the plugin's entry points at that URL (objectstack#23933).
- **Copy.** The new Packages copy is in the metadata-admin designer's own string tables (en and zh). The Packages rows do not show the Installed Apps list's install date, installer or cached-manifest path; the language-pack keys that only that list read are removed (the `@object-ui/i18n` changeset beside this one).

Clause-②: yes (narrowing)
261 changes: 22 additions & 239 deletions packages/app-shell/src/console/marketplace/InstalledListWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,252 +1,35 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Installed-packages list — registered as the SDUI widget
* `marketplace:installed-list` (cloud ADR-0009 P2a).
* `marketplace:installed-list` — the SDUI widget the install-local plugin's
* Installed Apps page renders (cloud ADR-0009 P2a; objectstack's
* `MarketplaceInstalledPage` in `@objectstack/cloud-connection`).
*
* The Installed Apps page ships as METADATA with
* `@objectstack/cloud-connection`'s install-local plugin; this widget is
* the interactive body: load (control-plane list when cloud-connected,
* local install-local cache otherwise), refresh, uninstall. The legacy
* React route (`MarketplaceInstalledPage`) renders the SAME component
* during the ADR-0009 migration window — single implementation, two
* entries.
* Setup → Packages is now the one list of what is installed (objectui#12675,
* ruling B on objectstack#23892): every package carries its Source, and the
* rows whose Source is `marketplace` carry the actions this list used to
* (Details, Uninstall — `MarketplaceRowActions`). So this widget no longer
* draws a second list: it opens Packages filtered to Source = marketplace,
* `/apps/APP/component/developer/packages?source=marketplace`.
*
* It stays registered only so the published `marketplace_installed` page keeps
* landing somewhere until objectstack#23933 retargets the plugin's Installed
* Apps entry at that URL; the registration, this file, the legacy
* `system/marketplace/installed` routes and the `MarketplaceInstalledPage`
* export retire in the follow-up after that lands.
*/

import { useEffect, useId, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
Button,
Skeleton,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@object-ui/components';
import { RefreshCcw, Trash2, AlertCircle, ExternalLink } from 'lucide-react';
import { useWorkspaceAdminStatus } from '@object-ui/auth';
import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
import { Navigate, useParams } from 'react-router-dom';
import { ComponentRegistry } from '@object-ui/core';
import {
listLocalInstalls,
listInstalledPackages,
uninstallLocal,
type LocalInstallEntry,
type LocalInstallNotLoaded,
} from './marketplaceApi.js';
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js';
import { packagesHref } from '../../views/metadata-admin/packageSource.js';

export function InstalledList() {
const navigate = useNavigate();
const { appName } = useParams<{ appName?: string }>();
const { isAdmin } = useWorkspaceAdminStatus();
const { t } = useObjectTranslation();
// The install date reads the DISPLAY locale, never the UI language: a
// regional locale (`de-CH` under an English UI) must reach it, and an empty
// language must not fall through to the machine's locale (objectui#10331).
const displayLocale = useDisplayLocale();
const basePath = appName ? `/apps/${appName}` : '';
// One description node serves every loaded row's Uninstall: the kernel note
// is the same sentence for each of them (objectui#12610).
const kernelNoteId = useId();

const [items, setItems] = useState<LocalInstallEntry[]>([]);
// 'cloud' = list comes from the control plane (CLI/marketplace/REST installs,
// ADR-0007 step ①); 'local' = self-hosted install-local cache.
const [source, setSource] = useState<'cloud' | 'local'>('local');
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState<string | null>(null);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);

const load = async () => {
setLoading(true);
try {
// Cloud-connected env → authoritative installed list is the control
// plane's. Self-hosted (not bound) → fall back to the local cache.
const cloud = await listInstalledPackages();
if (cloud.connected) {
setSource('cloud');
setItems(cloud.items);
} else {
setSource('local');
setItems(await listLocalInstalls());
}
} finally {
setLoading(false);
}
};

useEffect(() => { void load(); }, []);

// objectui#11645 — the reason a `notLoaded` entry was refused, in plain words.
// A code the console has no sentence for still reads as not loaded, naming
// the code, rather than hiding the row the operator needs to uninstall.
const notLoadedReason = (marker: LocalInstallNotLoaded): string =>
marker.code === 'OS_PROTOCOL_INCOMPATIBLE'
? t('marketplace.notLoaded.protocolIncompatible', { requiredRange: marker.requiredRange })
: t('marketplace.notLoaded.otherReason', { code: marker.code });

const doUninstall = async (entry: LocalInstallEntry) => {
// A package the runtime did not load leaves nothing in the running kernel,
// so its confirm and its result drop the "stays loaded until restart"
// caveat that a loaded package's texts carry, which would contradict the
// row's own "Not loaded" (objectui#11645).
const question = entry.notLoaded
? t('marketplace.uninstall.confirmNotLoaded', { manifestId: entry.manifestId, version: entry.version })
: t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version });
if (!confirm(question)) {
return;
}
setWorking(entry.manifestId);
setResult(null);
try {
await uninstallLocal(entry.manifestId);
setResult({
ok: true,
message: entry.notLoaded
? t('marketplace.uninstall.successNotLoaded', { manifestId: entry.manifestId })
: t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }),
});
await load();
} catch (e: any) {
setResult({ ok: false, message: e?.message ?? String(e) });
} finally {
setWorking(null);
}
};

// The kernel note is about a LOADED package: the running kernel keeps it
// registered until restart. It rides the Uninstall it qualifies, as a
// tooltip, instead of a paragraph under the list (objectui#12610). A
// not-loaded entry left nothing in the kernel (objectui#11645), so its
// Uninstall carries no note.
const uninstallAction = (entry: LocalInstallEntry) => {
const button = (
<Button
variant="outline"
size="sm"
onClick={() => void doUninstall(entry)}
disabled={working === entry.manifestId}
aria-describedby={entry.notLoaded ? undefined : kernelNoteId}
>
<Trash2 className="h-4 w-4 mr-1.5" aria-hidden="true" />
{working === entry.manifestId ? t('marketplace.action.uninstalling') : t('marketplace.action.uninstall')}
</Button>
);
if (entry.notLoaded) return button;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{t('marketplace.installedAdditiveNote')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

if (!isAdmin) return <MarketplaceAccessDenied />;

return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCcw className="h-4 w-4 mr-1.5" aria-hidden="true" />
{t('marketplace.refresh')}
</Button>
</div>

{result && (
<div className={`rounded-md border p-3 text-sm ${result.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />
<div>{result.message}</div>
</div>
</div>
)}

{loading && items.length === 0 ? (
<div className="grid gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground border rounded-md">
<p>{t('marketplace.installedEmpty')}</p>
<Button
variant="link"
className="mt-2"
onClick={() => navigate(`${basePath}/system/marketplace`)}
>
{t('marketplace.browseLink')}
</Button>
</div>
) : (
<div className="grid gap-3">
{items.map((entry) => (
<Card key={entry.manifestId}>
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-2">
<div className="min-w-0">
<CardTitle className="text-base truncate flex items-center gap-2">
{entry.manifestId}
<Badge variant="outline">{t('marketplace.versionBadge', { version: entry.version })}</Badge>
{entry.notLoaded && (
<Badge variant="destructive">{t('marketplace.notLoaded.badge')}</Badge>
)}
</CardTitle>
<div className="text-xs text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1">
<span>{t('marketplace.installedAt', { when: new Date(entry.installedAt).toLocaleString(displayLocale) })}</span>
{entry.installedBy && <span>{t('marketplace.installedBy', { user: entry.installedBy })}</span>}
<span>{t('marketplace.installedPackageId')} <code className="font-mono">{entry.packageId}</code></span>
</div>
{entry.notLoaded && (
<p className="text-xs text-destructive mt-1.5 flex items-start gap-1.5">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" aria-hidden="true" />
<span>{notLoadedReason(entry.notLoaded)}</span>
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`${basePath}/system/marketplace/${entry.packageId}`)}
>
<ExternalLink className="h-4 w-4 mr-1.5" aria-hidden="true" />
{t('marketplace.action.details')}
</Button>
{source === 'local' && uninstallAction(entry)}
</div>
</CardHeader>
{source === 'local' && (
<CardContent
className="pt-0 text-xs text-muted-foreground"
dangerouslySetInnerHTML={{
__html: t('marketplace.cachedAs', {
path: `.objectstack/installed-packages/${entry.manifestId.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
}),
}}
/>
)}
</Card>
))}
</div>
)}

{/* The Uninstall tooltip only shows on hover or focus; this is what a
screen reader announces as the button's description. */}
{source === 'local' && items.some((entry) => !entry.notLoaded) && (
<span id={kernelNoteId} className="sr-only">{t('marketplace.installedAdditiveNote')}</span>
)}
</div>
);
// Packages is a Setup page; outside an app route there is no app segment to
// keep, so the Setup app's own (`/apps/setup`, the spelling the console's
// no-app empty state uses) is where it lives.
const basePath = appName ? `/apps/${appName}` : '/apps/setup';
return <Navigate to={packagesHref(basePath, 'marketplace')} replace />;
}

// SDUI registration — the metadata page shipped by the install-local plugin
Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,17 @@
/**
* Marketplace Installed Apps Page (legacy React route).
* Marketplace Installed Apps Page (legacy React route,
* `system/marketplace/installed`).
*
* The Installed Apps surface is migrating to plugin-carried metadata
* (cloud ADR-0009 P2a): the page shell ships with
* @objectstack/cloud-connection's install-local plugin and renders the
* `marketplace:installed-list` widget. This route remains during the
* migration window (the marketplace page's "Installed (N)" button links
* here) and renders the SAME widget — single implementation, two entries.
* Setup → Packages is the one installed list now (objectui#12675): this route
* opens it filtered to Source = marketplace, through the same
* `marketplace:installed-list` body the plugin-carried page renders, so the
* marketplace page's "Installed (N)" button and the plugin's entry land on the
* same view. The route and this export retire in the follow-up after
* objectstack#23933 retargets the plugin's entry.
*/

import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@object-ui/components';
import { ArrowLeft, Store } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { InstalledList } from './InstalledListWidget.js';

export function MarketplaceInstalledPage() {
const navigate = useNavigate();
const { appName } = useParams<{ appName?: string }>();
const { t } = useObjectTranslation();
const basePath = appName ? `/apps/${appName}` : '';

return (
<div className="flex flex-col gap-6 p-4 sm:p-6 max-w-5xl">
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => navigate(`${basePath}/system/marketplace`)}
>
<ArrowLeft className="h-4 w-4 mr-1.5" aria-hidden="true" />
{t('marketplace.back')}
</Button>

<div>
<div className="flex items-center gap-2">
<Store className="h-6 w-6 text-primary" aria-hidden="true" />
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">{t('marketplace.installedTitle')}</h1>
</div>
<p
className="text-sm text-muted-foreground mt-1"
// Subtitle contains an inline <code> path; render translated HTML from our own bundle.
dangerouslySetInnerHTML={{ __html: t('marketplace.installedSubtitle') }}
/>
</div>

<InstalledList />
</div>
);
return <InstalledList />;
}
Loading
Loading