sync: update from private repo (dfadcd5f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+184
View File
@@ -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/&#123;&#125;/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,
);
}