This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
|
||||
import { deriveAppList, type WorkspaceApp } from './app-bridge';
|
||||
import { AppRunner } from './AppRunner';
|
||||
|
||||
/**
|
||||
* 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 }: { spaceId: string }) {
|
||||
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">
|
||||
ワークスペース・アプリ
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-apps-refresh"
|
||||
onClick={() => void appsQuery.refetch()}
|
||||
disabled={appsQuery.isFetching}
|
||||
title="再読み込み"
|
||||
aria-label="再読み込み"
|
||||
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">アプリ一覧の取得に失敗しました。</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">まだアプリがありません</p>
|
||||
<p className="mt-1.5 leading-relaxed">
|
||||
エージェントに「ワークスペース・アプリを作って」と頼むと、ここに表示されます。
|
||||
</p>
|
||||
<p className="mt-1.5 text-2xs text-slate-400">
|
||||
アプリはワークスペースの <code className="rounded bg-canvas px-1 py-0.5">apps/</code> フォルダ(<code className="rounded bg-canvas px-1 py-0.5">apps/{名前}/index.html</code>)に置かれます。
|
||||
</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>
|
||||
開く
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user