Preview a checked file's content directly in the report

Task #41: Let users click a non-binary file in the "Geprüfte Dateien"
tree to view its stored text content, without re-downloading the archive.

Changes:
- lib/api-spec/openapi.yaml: added optional nullable `content` field to the
  ScanFile schema so the scan-detail response can carry stored file text.
- Regenerated API codegen (api-client-react + api-zod via orval) and ran
  the libs typecheck.
- artifacts/api-server/src/routes/scans.ts: serializeFile now returns
  `content` alongside the existing `hasContent`. Only used by the scan
  detail endpoint, so list responses are unaffected.
- artifacts/skillguard/src/pages/scan-report.tsx (FilesTree): non-binary
  file names are now clickable buttons that open a Dialog showing the
  stored content in a monospace, scrollable panel (ScrollArea). Binary
  files (hasContent === false) render as plain text and show a clear
  "Keine Vorschau verfügbar (Binärdatei)." message in the dialog guard.

Verification:
- tsc -b passes for both skillguard and api-server.
- relation + compare route tests pass (8 tests).
- Confirmed via API that the detail endpoint exposes content for a
  text-source scan (hasContent true) and null for binary .skill archives.

No new persistence: content is read from existing scan file data.

Replit-Task-Id: 931befbc-6ca3-4d15-b422-ac8e9f061f9f
This commit is contained in:
amertensreplit 2026-06-11 05:07:29 +00:00
parent 4b51ba482a
commit e54b0528be
6 changed files with 49 additions and 4 deletions

View file

@ -68,6 +68,7 @@ function serializeFile(f: ScanFile) {
size: f.size,
hash: f.hash,
hasContent: f.content !== null,
content: f.content,
};
}

View file

@ -17,6 +17,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
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, ChevronRight, ChevronDown } from "lucide-react";
@ -94,6 +96,7 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const rows = useMemo(() => flattenFileTree(tree, collapsed), [tree, collapsed]);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<ScanReportFile | null>(null);
const toggleFolder = (path: string) => {
setCollapsed((prev) => {
@ -115,6 +118,7 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
};
return (
<>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
@ -159,10 +163,16 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
) : (
<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: `${row.depth * 1.25 + 1}rem` }}>
<button
type="button"
onClick={() => setPreviewFile(row.node.file)}
title={row.node.file.hasContent ? "Inhalt anzeigen" : "Keine Vorschau verfügbar (Binärdatei)"}
className="inline-flex items-center gap-2 text-left text-primary hover:underline"
style={{ paddingLeft: `${row.depth * 1.25 + 1}rem` }}
>
<FileIcon className="w-4 h-4 text-muted-foreground shrink-0" />
{row.node.name}
</span>
</button>
</td>
<td className="p-4">
<Badge variant="outline" className="capitalize">
@ -197,6 +207,25 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
</tbody>
</table>
</div>
<Dialog open={previewFile !== null} onOpenChange={(open) => !open && setPreviewFile(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="font-mono text-sm break-all">{previewFile?.path}</DialogTitle>
<DialogDescription>
{previewFile?.language ? `${previewFile.language} · ` : ""}
{previewFile?.size} B
</DialogDescription>
</DialogHeader>
{previewFile?.hasContent && previewFile.content != null ? (
<ScrollArea className="max-h-[60vh] rounded-md border bg-muted/30">
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words">{previewFile.content}</pre>
</ScrollArea>
) : (
<p className="text-sm text-muted-foreground">Keine Vorschau verfügbar (Binärdatei).</p>
)}
</DialogContent>
</Dialog>
</>
);
}

View file

@ -218,6 +218,11 @@ export interface ScanFile {
hash: string;
/** Whether the text content was stored (false for binary files) */
hasContent: boolean;
/**
* The stored text content of the file, or null for binary files
* @nullable
*/
content?: string | null;
}
export type FindingAxis = typeof FindingAxis[keyof typeof FindingAxis];

View file

@ -607,6 +607,9 @@ components:
hasContent:
type: boolean
description: Whether the text content was stored (false for binary files)
content:
type: ["string", "null"]
description: The stored text content of the file, or null for binary files
Finding:
type: object

View file

@ -232,7 +232,8 @@ export const GetScanResponse = zod.object({
"language": zod.string().nullish(),
"size": zod.number(),
"hash": zod.string().describe('SHA-256 hash of the file content'),
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)')
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)'),
"content": zod.string().nullish().describe('The stored text content of the file, or null for binary files')
})),
"findings": zod.array(zod.object({
"id": zod.number(),
@ -317,7 +318,8 @@ export const GenerateScanDescriptionResponse = zod.object({
"language": zod.string().nullish(),
"size": zod.number(),
"hash": zod.string().describe('SHA-256 hash of the file content'),
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)')
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)'),
"content": zod.string().nullish().describe('The stored text content of the file, or null for binary files')
})),
"findings": zod.array(zod.object({
"id": zod.number(),

View file

@ -17,4 +17,9 @@ export interface ScanFile {
hash: string;
/** Whether the text content was stored (false for binary files) */
hasContent: boolean;
/**
* The stored text content of the file, or null for binary files
* @nullable
*/
content?: string | null;
}