Files
nextcraft/apps/web/components/learner/file-tree.tsx
T
CIAgent ed243594d2 feat(P06): real build + defense surfaces, E2E credential flow (Waves 3-5)
Wave 3 (Task 6-3-01): /build/[competencyId] rewritten as a real build surface —
variant statement + starter files in a live namespace sandbox, workspace file tree +
editor with save, Run/Test buttons executing bounded commands in-sandbox with
read-only TerminalFrame output (CUT-2), live TelemetryStatus pulse, Lab feedback over
the live trace, honest 503-busy/403-429-denied states with retry.
Wave 4 (Task 6-4-01): /defend/[competencyId] rewritten — DefenseSession: start ->
examiner question -> typed answers (mic capture w/ MediaRecorder consent + denied
fallback; browser-SR first-class per CUT-1) -> finish -> verdict + integrity signals
-> Grade My Work renders real rubric bars from the trace digest. Dead mock components
disposed (oral-defense-interface, assessor-results-panel, proctor-banner — G-5 class).
Wave 5 (Task 6-5-01): test_e2e_credential_flow — REAL uvicorn + REAL namespaces:
variant -> sandbox -> in-sandbox exec -> trace -> grade (seed stamped) -> coaching ->
defense -> verdict -> proctor; corpus-fixture scan of all payloads. E2E caught a real
bug: the sandboxes API dropped task_id (every HTTP-created sandbox was capture-less)
— fixed. README E2E + manual browser pass documented.

pnpm build 4/4; typecheck FULL TURBO; backend suite 397 green; ruff clean.

---ci---
phase: 6
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-007, REQ-3-008], partial: []}
---/ci---
2026-09-12 17:54:30 +00:00

78 lines
2.6 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { FileText, Folder } from 'lucide-react';
/**
* Real workspace file tree (REQ-3-008): files come from the sandbox workdir
* via the engine client; selecting a file loads its content into the editor.
*/
export function FileTree({
files,
activePath,
onSelect,
disabled,
}: {
files: string[];
activePath: string | null;
onSelect: (path: string) => void;
disabled?: boolean;
}) {
const [dirs, setDirs] = useState<Record<string, string[]>>({});
useEffect(() => {
// Group by top-level segment; the flat workspace is v0.3's shape.
const grouped: Record<string, string[]> = {};
for (const f of files) {
const seg = f.includes('/') ? f.split('/')[0] + '/' : '';
(grouped[seg] ??= []).push(f);
}
setDirs(grouped);
}, [files]);
return (
<ul
role="tree"
aria-label="Workspace files"
className="space-y-0.5 text-xs text-slate-700 dark:text-slate-300"
>
{Object.entries(dirs).map(([group, paths]) => (
<li key={group || '__root__'} role="treeitem">
{group ? (
<span className="flex items-center gap-1 py-0.5 font-medium text-slate-600 dark:text-slate-400">
<Folder className="h-3.5 w-3.5" aria-hidden />
{group}
</span>
) : null}
<ul className={group ? 'ml-4 space-y-0.5' : 'space-y-0.5'}>
{paths.map((path) => {
const name = path.split('/').pop() ?? path;
const active = activePath === path;
return (
<li key={path}>
<button
type="button"
onClick={() => onSelect(path)}
disabled={disabled}
aria-current={active ? 'true' : undefined}
className={`flex w-full items-center gap-1 rounded px-1.5 py-1 text-left transition-colors disabled:opacity-50 ${
active
? 'bg-primary-100 text-primary-900 dark:bg-primary-900/40 dark:text-primary-100'
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate">{name}</span>
</button>
</li>
);
})}
</ul>
</li>
))}
{files.length === 0 && (
<li className="px-1 py-2 text-slate-500 dark:text-slate-400">No files yet.</li>
)}
</ul>
);
}