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:
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 |
|
|
@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Progress } from "@/components/ui/progress";
|
||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
||||
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";
|
||||
|
||||
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) {
|
||||
out.push({ depth, node });
|
||||
if (node.type === "dir") flattenFileTree(node.children, depth + 1, out);
|
||||
const path = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
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;
|
||||
}
|
||||
|
||||
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 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) => {
|
||||
try {
|
||||
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>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(({ depth, node }, i) =>
|
||||
node.type === "dir" ? (
|
||||
rows.map((row, i) =>
|
||||
row.type === "dir" ? (
|
||||
<tr key={i} className="border-b last:border-0 bg-muted/20">
|
||||
<td colSpan={5} className="p-2 px-4">
|
||||
<span className="inline-flex items-center gap-2 font-medium" style={{ paddingLeft: `${depth * 1.25}rem` }}>
|
||||
<td colSpan={5} className="p-0">
|
||||
<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" />
|
||||
{node.name}
|
||||
{row.node.name}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
({row.fileCount} {row.fileCount === 1 ? "Datei" : "Dateien"})
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<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" />
|
||||
{node.name}
|
||||
{row.node.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
{node.file.hash && (
|
||||
<span title={row.node.file.hash}>{row.node.file.hash ? row.node.file.hash.slice(0, 12) : "-"}</span>
|
||||
{row.node.file.hash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyHash(node.file.hash)}
|
||||
onClick={() => copyHash(row.node.file.hash)}
|
||||
title="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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{!node.file.hasContent && (
|
||||
{!row.node.file.hasContent && (
|
||||
<Badge variant="outline" className="text-[10px]">binär</Badge>
|
||||
)}
|
||||
</span>
|
||||
</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>
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue