Add collapse/expand to checked-files folder tree (Task #40)

Make folder nodes in the "Geprüfte Dateien" tree on the scan report
collapsible/expandable (artifacts/skillguard/src/pages/scan-report.tsx).

Changes to the FilesTree component:
- Track collapsed folders in a Set<string> keyed by full folder path.
- flattenFileTree now takes the collapsed set, computes each node's full
  path and depth, and skips children of collapsed folders. Folder rows
  carry a fileCount (via new countFiles helper) and a collapsed flag.
- FlatFileRow became a discriminated union (file | dir) so folder rows
  expose path/fileCount/collapsed.
- Folder rows are now <button> elements with aria-expanded; clicking
  toggles collapse. They show a chevron (ChevronDown expanded /
  ChevronRight collapsed), the folder icon/name, and a localized file
  count "(N Datei/Dateien)".
- Folders start expanded by default (empty collapsed set).
- Added ChevronRight/ChevronDown to the lucide-react import.

Verification:
- Type-check passes for scan-report.tsx after rebuilding the
  api-client-react lib declarations (known stale-codegen issue in
  isolated task envs; pre-existing errors in other pages remain).
- Interactive Playwright test on a temporary nested-folder scan
  confirmed default-expanded tree, file counts, collapsing hides nested
  files/folders, chevron state flips, and re-expanding restores them.
  Temporary demo scan was deleted afterward.

No backend or API changes.

Replit-Task-Id: 7afa9ec3-c857-4581-b0d2-da12dbcac46e
This commit is contained in:
amertensreplit 2026-06-11 05:04:53 +00:00
parent e90a51af50
commit 4b51ba482a
2 changed files with 66 additions and 22 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check } from "lucide-react"; import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check, ChevronRight, ChevronDown } from "lucide-react";
import type { ScanDetail } from "@workspace/api-client-react"; import type { ScanDetail } from "@workspace/api-client-react";
type ScanReportFile = ScanDetail["files"][number]; type ScanReportFile = ScanDetail["files"][number];
@ -60,20 +60,50 @@ function sortFileTree(nodes: FileTreeNode[]) {
} }
} }
type FlatFileRow = { depth: number; node: FileTreeNode }; function countFiles(node: FileTreeNode): number {
if (node.type === "file") return 1;
return node.children.reduce((sum, c) => sum + countFiles(c), 0);
}
function flattenFileTree(nodes: FileTreeNode[], depth = 0, out: FlatFileRow[] = []): FlatFileRow[] { type FlatFileRow =
| { type: "file"; depth: number; path: string; node: Extract<FileTreeNode, { type: "file" }> }
| { type: "dir"; depth: number; path: string; node: Extract<FileTreeNode, { type: "dir" }>; fileCount: number; collapsed: boolean };
function flattenFileTree(
nodes: FileTreeNode[],
collapsed: Set<string>,
depth = 0,
parentPath = "",
out: FlatFileRow[] = [],
): FlatFileRow[] {
for (const node of nodes) { for (const node of nodes) {
out.push({ depth, node }); const path = parentPath ? `${parentPath}/${node.name}` : node.name;
if (node.type === "dir") flattenFileTree(node.children, depth + 1, out); if (node.type === "dir") {
const isCollapsed = collapsed.has(path);
out.push({ type: "dir", depth, path, node, fileCount: countFiles(node), collapsed: isCollapsed });
if (!isCollapsed) flattenFileTree(node.children, collapsed, depth + 1, path, out);
} else {
out.push({ type: "file", depth, path, node });
}
} }
return out; return out;
} }
function FilesTree({ files }: { files: ScanReportFile[] }) { function FilesTree({ files }: { files: ScanReportFile[] }) {
const rows = useMemo(() => flattenFileTree(buildFileTree(files)), [files]); const tree = useMemo(() => buildFileTree(files), [files]);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const rows = useMemo(() => flattenFileTree(tree, collapsed), [tree, collapsed]);
const [copiedHash, setCopiedHash] = useState<string | null>(null); const [copiedHash, setCopiedHash] = useState<string | null>(null);
const toggleFolder = (path: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
const copyHash = async (hash: string) => { const copyHash = async (hash: string) => {
try { try {
await navigator.clipboard.writeText(hash); await navigator.clipboard.writeText(hash);
@ -102,50 +132,64 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td> <td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
</tr> </tr>
) : ( ) : (
rows.map(({ depth, node }, i) => rows.map((row, i) =>
node.type === "dir" ? ( row.type === "dir" ? (
<tr key={i} className="border-b last:border-0 bg-muted/20"> <tr key={i} className="border-b last:border-0 bg-muted/20">
<td colSpan={5} className="p-2 px-4"> <td colSpan={5} className="p-0">
<span className="inline-flex items-center gap-2 font-medium" style={{ paddingLeft: `${depth * 1.25}rem` }}> <button
type="button"
onClick={() => toggleFolder(row.path)}
aria-expanded={!row.collapsed}
className="flex w-full items-center gap-2 p-2 px-4 text-left font-medium hover:bg-muted/40 transition-colors"
style={{ paddingLeft: `${row.depth * 1.25 + 1}rem` }}
>
{row.collapsed ? (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<Folder className="w-4 h-4 text-amber-500 shrink-0" /> <Folder className="w-4 h-4 text-amber-500 shrink-0" />
{node.name} {row.node.name}
</span> <span className="text-xs font-normal text-muted-foreground">
({row.fileCount} {row.fileCount === 1 ? "Datei" : "Dateien"})
</span>
</button>
</td> </td>
</tr> </tr>
) : ( ) : (
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors"> <tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="p-4 font-mono"> <td className="p-4 font-mono">
<span className="inline-flex items-center gap-2" style={{ paddingLeft: `${depth * 1.25}rem` }}> <span className="inline-flex items-center gap-2" style={{ paddingLeft: `${row.depth * 1.25 + 1}rem` }}>
<FileIcon className="w-4 h-4 text-muted-foreground shrink-0" /> <FileIcon className="w-4 h-4 text-muted-foreground shrink-0" />
{node.name} {row.node.name}
</span> </span>
</td> </td>
<td className="p-4"> <td className="p-4">
<Badge variant="outline" className="capitalize"> <Badge variant="outline" className="capitalize">
{node.file.kind === "instruction" ? "Anweisung" : node.file.kind === "script" ? "Skript" : "Ressource"} {row.node.file.kind === "instruction" ? "Anweisung" : row.node.file.kind === "script" ? "Skript" : "Ressource"}
</Badge> </Badge>
</td> </td>
<td className="p-4 text-muted-foreground capitalize">{node.file.language || "-"}</td> <td className="p-4 text-muted-foreground capitalize">{row.node.file.language || "-"}</td>
<td className="p-4"> <td className="p-4">
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
<span title={node.file.hash}>{node.file.hash ? node.file.hash.slice(0, 12) : "-"}</span> <span title={row.node.file.hash}>{row.node.file.hash ? row.node.file.hash.slice(0, 12) : "-"}</span>
{node.file.hash && ( {row.node.file.hash && (
<button <button
type="button" type="button"
onClick={() => copyHash(node.file.hash)} onClick={() => copyHash(row.node.file.hash)}
title="Vollständigen SHA-256 kopieren" title="Vollständigen SHA-256 kopieren"
aria-label="Vollständigen SHA-256 kopieren" aria-label="Vollständigen SHA-256 kopieren"
className="inline-flex items-center justify-center rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center justify-center rounded p-1 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
> >
{copiedHash === node.file.hash ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />} {copiedHash === row.node.file.hash ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button> </button>
)} )}
{!node.file.hasContent && ( {!row.node.file.hasContent && (
<Badge variant="outline" className="text-[10px]">binär</Badge> <Badge variant="outline" className="text-[10px]">binär</Badge>
)} )}
</span> </span>
</td> </td>
<td className="p-4 text-right font-mono">{node.file.size} B</td> <td className="p-4 text-right font-mono">{row.node.file.size} B</td>
</tr> </tr>
), ),
) )