Files
archive-tools/src/components/workspaces/InspectWorkspace.tsx
T
2026-09-01 02:42:42 +02:00

358 lines
12 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { downloadBlob, outputStem } from "../../archive/downloads";
import { errorMessage } from "../../archive/errors";
import { ARCHIVE_LIMITS, formatBytes } from "../../archive/limits";
import { previewEntry } from "../../archive/preview";
import {
createSafeSelectionZip,
inspectArchive,
reportJson,
} from "../../archive/service";
import type {
ArchiveDocument,
ArchiveEntryRecord,
PreviewResult,
ProgressUpdate,
} from "../../archive/types";
import { ArchiveSummaryView } from "./ArchiveSummaryView";
import { PreviewPane } from "./PreviewPane";
import { StatusMessage } from "./StatusMessage";
type EntryFilter = "all" | "safe" | "blocked";
export function InspectWorkspace() {
const [document, setDocument] = useState<ArchiveDocument>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<EntryFilter>("all");
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string>();
const [error, setError] = useState<string>();
const [activeEntry, setActiveEntry] = useState<ArchiveEntryRecord>();
const [preview, setPreview] = useState<PreviewResult>();
const [previewBusy, setPreviewBusy] = useState(false);
const [previewError, setPreviewError] = useState<string>();
const operation = useRef<AbortController | undefined>(undefined);
const generation = useRef(0);
const visible = useMemo(() => {
if (!document) return [];
const normalizedQuery = query.trim().toLocaleLowerCase("en-US");
return document.entries
.filter(
(entry) =>
!normalizedQuery ||
entry.path.toLocaleLowerCase("en-US").includes(normalizedQuery),
)
.filter(
(entry) =>
filter === "all" ||
(filter === "safe"
? entry.extractable
: !entry.extractable && entry.kind !== "directory"),
)
.slice(0, ARCHIVE_LIMITS.maxDisplayedEntries);
}, [document, filter, query]);
async function openFile(file?: File) {
if (!file) return;
operation.current?.abort();
const controller = new AbortController();
operation.current = controller;
const current = ++generation.current;
setBusy(true);
setPreviewBusy(false);
setError(undefined);
setStatus(`Inspecting ${file.name}…`);
try {
const next = await inspectArchive(file, controller.signal);
if (current !== generation.current) return;
setDocument(next);
setSelected(new Set());
setActiveEntry(undefined);
setPreview(undefined);
setPreviewError(undefined);
setStatus(
`Inspected ${next.entries.length.toLocaleString()} entries locally.`,
);
} catch (reason) {
if (controller.signal.aborted) return;
if (current === generation.current) setError(errorMessage(reason));
} finally {
if (current === generation.current) setBusy(false);
}
}
async function showPreview(entry: ArchiveEntryRecord) {
if (!document) return;
operation.current?.abort();
const controller = new AbortController();
operation.current = controller;
const current = ++generation.current;
setActiveEntry(entry);
setPreview(undefined);
setPreviewBusy(true);
setPreviewError(undefined);
try {
const next = await previewEntry(document, entry, controller.signal);
if (current === generation.current) setPreview(next);
} catch (reason) {
if (!controller.signal.aborted && current === generation.current)
setPreviewError(errorMessage(reason));
} finally {
if (current === generation.current) setPreviewBusy(false);
}
}
async function extractSelection() {
if (!document) return;
operation.current?.abort();
const controller = new AbortController();
operation.current = controller;
const current = ++generation.current;
setBusy(true);
setPreviewBusy(false);
setError(undefined);
try {
const blob = await createSafeSelectionZip(
document,
[...selected],
controller.signal,
progressStatus(setStatus),
);
if (current !== generation.current) return;
downloadBlob(blob, `${outputStem(document.name)}-safe-selection.zip`);
setStatus(
`Verified and repackaged ${selected.size} safe files into a new ZIP.`,
);
} catch (reason) {
if (!controller.signal.aborted && current === generation.current)
setError(errorMessage(reason));
} finally {
if (current === generation.current) setBusy(false);
}
}
function toggle(entry: ArchiveEntryRecord) {
setSelected((current) => {
const next = new Set(current);
if (next.has(entry.id)) next.delete(entry.id);
else next.add(entry.id);
return next;
});
}
function cancel() {
generation.current += 1;
operation.current?.abort(
new DOMException("Operation cancelled.", "AbortError"),
);
setBusy(false);
setPreviewBusy(false);
setStatus("Operation cancelled.");
}
const safeVisible = visible.filter((entry) => entry.extractable);
return (
<section
className="panel workspace-panel"
aria-labelledby="inspect-heading"
>
<div className="panel-heading">
<div>
<p className="eyebrow">ZIP · TAR · gzip</p>
<h2 id="inspect-heading">Inspect and safely repackage</h2>
</div>
<div className="button-row">
<label className="button primary-button">
Choose archive
<input
className="visually-hidden"
type="file"
accept=".zip,.tar,.tgz,.gz,application/zip,application/gzip,application/x-tar"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void openFile(file);
}}
/>
</label>
{busy || previewBusy ? (
<button type="button" onClick={cancel}>
Cancel
</button>
) : null}
</div>
</div>
<p className="policy-note">
Encrypted and multipart ZIPs, nested expansion, symlinks, hardlinks,
devices and FIFOs are intentionally unsupported.
</p>
<StatusMessage error={error} status={status} />
{document ? (
<ArchiveSummaryView document={document} />
) : (
<div className="drop-prompt">
<strong>Open a local archive</strong>
<span>
It is inspected in this browser under fixed path, count, size and
ratio limits.
</span>
</div>
)}
{document ? (
<div className="inspect-layout">
<div className="inventory-pane">
<div className="inventory-toolbar">
<label className="field">
<span>Find path</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search entries"
/>
</label>
<label className="field">
<span>Show</span>
<select
value={filter}
onChange={(event) =>
setFilter(event.target.value as EntryFilter)
}
>
<option value="all">All entries</option>
<option value="safe">Safe files</option>
<option value="blocked">Blocked</option>
</select>
</label>
</div>
<div className="selection-toolbar">
<button
type="button"
onClick={() =>
setSelected(new Set(safeVisible.map((entry) => entry.id)))
}
disabled={!safeVisible.length}
>
Select visible safe
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
disabled={!selected.size}
>
Clear selection
</button>
<span>{selected.size} selected</span>
</div>
<div className="table-scroll">
<table className="entry-table">
<thead>
<tr>
<th scope="col">
<span className="visually-hidden">Select</span>
</th>
<th scope="col">Path</th>
<th scope="col">Kind</th>
<th scope="col">Size</th>
<th scope="col">State</th>
</tr>
</thead>
<tbody>
{visible.map((entry) => (
<tr
key={entry.id}
className={
!entry.extractable && entry.kind !== "directory"
? "blocked-row"
: undefined
}
>
<td>
<input
type="checkbox"
aria-label={`Select ${entry.path}`}
checked={selected.has(entry.id)}
disabled={!entry.extractable || busy}
onChange={() => toggle(entry)}
/>
</td>
<th scope="row">
<button
className="path-button"
type="button"
disabled={!entry.extractable}
onClick={() => void showPreview(entry)}
>
{entry.path}
</button>
</th>
<td>{entry.kind}</td>
<td>{formatBytes(entry.size)}</td>
<td
title={entry.issues
.map((issue) => issue.message)
.join(" ")}
>
{entry.extractable
? "Safe file"
: entry.kind === "directory"
? "Directory"
: (entry.issues[0]?.message ?? "Blocked")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{document.entries.length > ARCHIVE_LIMITS.maxDisplayedEntries ? (
<p className="policy-note">
Display capped at{" "}
{ARCHIVE_LIMITS.maxDisplayedEntries.toLocaleString()} entries;
use search to narrow the inventory.
</p>
) : null}
<div className="button-row action-row">
<button
className="primary-button"
type="button"
onClick={() => void extractSelection()}
disabled={!selected.size || busy}
>
Verify & download safe ZIP
</button>
<button
type="button"
onClick={() =>
downloadBlob(
new Blob([reportJson(document)], {
type: "application/json",
}),
`${outputStem(document.name)}-archive-report.json`,
)
}
>
Download report
</button>
</div>
</div>
<PreviewPane
entry={activeEntry}
preview={preview}
busy={previewBusy}
error={previewError}
/>
</div>
) : null}
</section>
);
}
function progressStatus(setStatus: (value: string) => void) {
return (update: ProgressUpdate) =>
setStatus(
`${update.stage}: ${update.completed.toLocaleString()} / ${update.total.toLocaleString()}`,
);
}