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:
parent
4b51ba482a
commit
e54b0528be
6 changed files with 49 additions and 4 deletions
|
|
@ -68,6 +68,7 @@ function serializeFile(f: ScanFile) {
|
|||
size: f.size,
|
||||
hash: f.hash,
|
||||
hasContent: f.content !== null,
|
||||
content: f.content,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue