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,
|
size: f.size,
|
||||||
hash: f.hash,
|
hash: f.hash,
|
||||||
hasContent: f.content !== null,
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
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 { 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, ChevronRight, ChevronDown } 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";
|
||||||
|
|
@ -94,6 +96,7 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||||
const rows = useMemo(() => flattenFileTree(tree, collapsed), [tree, collapsed]);
|
const rows = useMemo(() => flattenFileTree(tree, collapsed), [tree, collapsed]);
|
||||||
const [copiedHash, setCopiedHash] = useState<string | null>(null);
|
const [copiedHash, setCopiedHash] = useState<string | null>(null);
|
||||||
|
const [previewFile, setPreviewFile] = useState<ScanReportFile | null>(null);
|
||||||
|
|
||||||
const toggleFolder = (path: string) => {
|
const toggleFolder = (path: string) => {
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
|
|
@ -115,6 +118,7 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<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">
|
<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: `${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" />
|
<FileIcon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
{row.node.name}
|
{row.node.name}
|
||||||
</span>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<Badge variant="outline" className="capitalize">
|
<Badge variant="outline" className="capitalize">
|
||||||
|
|
@ -197,6 +207,25 @@ function FilesTree({ files }: { files: ScanReportFile[] }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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;
|
hash: string;
|
||||||
/** Whether the text content was stored (false for binary files) */
|
/** Whether the text content was stored (false for binary files) */
|
||||||
hasContent: boolean;
|
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];
|
export type FindingAxis = typeof FindingAxis[keyof typeof FindingAxis];
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,9 @@ components:
|
||||||
hasContent:
|
hasContent:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the text content was stored (false for binary files)
|
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:
|
Finding:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,8 @@ export const GetScanResponse = zod.object({
|
||||||
"language": zod.string().nullish(),
|
"language": zod.string().nullish(),
|
||||||
"size": zod.number(),
|
"size": zod.number(),
|
||||||
"hash": zod.string().describe('SHA-256 hash of the file content'),
|
"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({
|
"findings": zod.array(zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.number(),
|
||||||
|
|
@ -317,7 +318,8 @@ export const GenerateScanDescriptionResponse = zod.object({
|
||||||
"language": zod.string().nullish(),
|
"language": zod.string().nullish(),
|
||||||
"size": zod.number(),
|
"size": zod.number(),
|
||||||
"hash": zod.string().describe('SHA-256 hash of the file content'),
|
"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({
|
"findings": zod.array(zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.number(),
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,9 @@ export interface ScanFile {
|
||||||
hash: string;
|
hash: string;
|
||||||
/** Whether the text content was stored (false for binary files) */
|
/** Whether the text content was stored (false for binary files) */
|
||||||
hasContent: boolean;
|
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