Files
maestro/ui/src/components/spaces/SpaceApps.tsx
T
oss-sync b857c33ef6
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

336 lines
14 KiB
TypeScript

import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceFiles,
fetchSpaceFileContent,
getAppShareLink,
createAppShareLink,
revokeAppShareLink,
} from '../../api';
import { deriveAppList, type WorkspaceApp } from './app-bridge';
import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { buildAppShareDisplayUrl } from './appShareUrl';
/**
* SpaceApps — the workspace "アプリ" tab.
*
* Lists the runnable workspace apps (subfolders under `apps/` that contain an
* `index.html`) and launches a selected one in the AppRunner (sandboxed iframe
* + postMessage bridge from Stage 1). Each app may carry an optional
* `apps/{name}/app.json` manifest ({title?, description?, entry?}); when present
* its title/description are shown, otherwise we fall back to the folder name +
* `index.html`.
*
* NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin
* sandbox with `connect-src 'none'` — this tab only discovers + launches them.
*/
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
const { t } = useTranslation('spaces');
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
const appsQuery = useQuery({
queryKey: ['space-apps', spaceId],
queryFn: () => loadWorkspaceApps(spaceId),
staleTime: 10_000,
});
const apps = appsQuery.data ?? [];
return (
<div data-testid="space-apps" className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">
{t('apps.heading')}
</p>
<button
type="button"
data-testid="space-apps-refresh"
onClick={() => void appsQuery.refetch()}
disabled={appsQuery.isFetching}
title={t('apps.refresh')}
aria-label={t('apps.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
<svg className={`h-3.5 w-3.5 ${appsQuery.isFetching ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
<path d="M12 2v3h-3M4 14v-3h3" />
</svg>
</button>
</div>
{appsQuery.isError && (
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
)}
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
<div
data-testid="space-apps-empty"
className="rounded-lg border border-dashed border-hairline bg-surface p-6 text-center text-sm text-slate-500"
>
<p className="font-medium text-slate-600">{t('apps.empty.title')}</p>
<p className="mt-1.5 leading-relaxed">
{t('apps.empty.body')}
</p>
<p className="mt-1.5 text-2xs text-slate-400">
<Trans
t={t}
i18nKey="apps.empty.location"
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
/>
</p>
</div>
)}
{apps.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{apps.map(app => (
<div
key={app.name}
data-testid={`space-app-${app.name}`}
className="flex flex-col gap-2 rounded-lg border border-hairline bg-surface p-3"
>
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[var(--brand-primary)]/10 text-[var(--brand-primary)]" aria-hidden>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="5" height="5" rx="1" />
<rect x="9" y="2" width="5" height="5" rx="1" />
<rect x="2" y="9" width="5" height="5" rx="1" />
<rect x="9" y="9" width="5" height="5" rx="1" />
</svg>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-800" title={app.title}>
{app.title}
</p>
{app.description ? (
<p className="mt-0.5 line-clamp-2 text-2xs text-slate-500">{app.description}</p>
) : (
<p className="mt-0.5 truncate font-mono text-2xs text-slate-400">apps/{app.name}/index.html</p>
)}
</div>
</div>
<button
type="button"
data-testid={`space-app-open-${app.name}`}
onClick={() => setAppToRun(app)}
className="mt-auto inline-flex h-8 items-center justify-center gap-1 rounded-md bg-accent px-3 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" stroke="none">
<path d="M5 3.5v9l7-4.5z" />
</svg>
{t('apps.open')}
</button>
{canManage && <AppShareControls spaceId={spaceId} appName={app.name} />}
</div>
))}
</div>
)}
{appToRun && (
<AppRunner
gateway={createSpaceGateway(spaceId)}
appName={appToRun.name}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
/>
)}
</div>
);
}
/**
* Discover workspace apps for a space: list `apps/`, then for each subfolder
* check for an index.html and read its optional app.json. Folders without an
* index.html are skipped (not runnable). A missing `apps/` directory simply
* yields an empty list (the listing call 404s → caught → []).
*/
async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
// List the workspace root first (never 404s on a fresh workspace) and only
// descend into apps/ when that directory actually exists — listing a missing
// apps/ directly would 404 noisily on every fresh workspace.
let hasAppsDir = false;
try {
const root = await fetchSpaceFiles(spaceId, '');
hasAppsDir = root.entries.some(e => e.kind === 'directory' && e.name === 'apps');
} catch {
return [];
}
if (!hasAppsDir) return [];
let appDirs: string[];
try {
const appsRoot = await fetchSpaceFiles(spaceId, 'apps');
appDirs = appsRoot.entries.filter(e => e.kind === 'directory').map(e => e.name);
} catch {
return [];
}
if (appDirs.length === 0) return [];
// Fetch each app folder's listing in parallel to find index.html + app.json.
const perApp = await Promise.all(
appDirs.map(async name => {
try {
const dir = await fetchSpaceFiles(spaceId, `apps/${name}`);
const names = new Set(dir.entries.filter(e => e.kind !== 'directory').map(e => e.name));
const hasIndex = names.has('index.html');
let manifest: string | null = null;
if (hasIndex && names.has('app.json')) {
try {
manifest = await fetchSpaceFileContent(spaceId, `apps/${name}/app.json`);
} catch {
manifest = null; // tolerant: a bad/unreadable manifest falls back to defaults
}
}
return { name, hasIndex, manifest };
} catch {
return { name, hasIndex: false, manifest: null };
}
}),
);
const indexMap = new Map(perApp.map(p => [p.name, p]));
return deriveAppList(
appDirs,
name => indexMap.get(name)?.hasIndex ?? false,
name => indexMap.get(name)?.manifest ?? null,
);
}
/**
* AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。
*
* 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・
* コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・
* ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。
*/
function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const [copied, setCopied] = useState(false);
const queryKey = ['app-share-link', spaceId, appName];
const linkQuery = useQuery({
queryKey,
queryFn: () => getAppShareLink(spaceId, appName),
staleTime: 10_000,
});
const createMut = useMutation({
mutationFn: () => createAppShareLink(spaceId, appName),
onSuccess: (data) => {
qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null });
},
});
const revokeMut = useMutation({
mutationFn: () => revokeAppShareLink(spaceId, appName),
onSuccess: () => {
qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() });
setCopied(false);
},
});
const link = linkQuery.data;
const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null;
const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null;
const copy = async () => {
if (!displayUrl) return;
try {
await navigator.clipboard.writeText(displayUrl);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
// clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。
}
};
const onRevoke = () => {
if (window.confirm(t('appShare.revokeConfirm', { appName }))) {
revokeMut.mutate();
}
};
const busy = createMut.isPending || revokeMut.isPending;
return (
<div
data-testid={`space-app-share-${appName}`}
className="mt-1 border-t border-hairline pt-2"
>
{linkQuery.isLoading ? (
<p className="text-2xs text-slate-400">{t('appShare.checking')}</p>
) : displayUrl ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-2xs font-semibold text-emerald-600 dark:text-emerald-400"
data-testid={`space-app-share-badge-${appName}`}
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
</svg>
{t('appShare.issued')}
</span>
</div>
<input
type="text"
readOnly
value={displayUrl}
data-testid={`space-app-share-url-${appName}`}
onFocus={(e) => e.currentTarget.select()}
className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600"
/>
<div className="flex items-center gap-1.5">
<button
type="button"
data-testid={`space-app-share-copy-${appName}`}
onClick={() => void copy()}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface"
>
{copied ? t('appShare.copied') : t('appShare.copyUrl')}
</button>
<button
type="button"
data-testid={`space-app-share-revoke-${appName}`}
onClick={onRevoke}
disabled={busy}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950/30"
>
{t('appShare.revoke')}
</button>
</div>
<p className="text-2xs leading-relaxed text-amber-600">
<Trans
t={t}
i18nKey="appShare.warning"
components={{ code: <code className="rounded bg-canvas px-1" />, strong: <strong /> }}
/>
</p>
</div>
) : (
<div className="flex flex-col gap-1">
<button
type="button"
data-testid={`space-app-share-create-${appName}`}
onClick={() => createMut.mutate()}
disabled={busy}
className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
</svg>
{createMut.isPending ? t('appShare.creating') : t('appShare.create')}
</button>
{(createMut.isError || revokeMut.isError) && (
<p className="text-2xs text-red-600">{t('appShare.opError')}</p>
)}
</div>
)}
</div>
);
}