Files
archive-tools/src/components/workspaces/CreateWorkspace.tsx
T
zemion fe578f46bd
Verify / verify (push) Canceled after 0s
Release Archive Tools 0.2.0
2026-09-02 09:34:59 +02:00

202 lines
7.1 KiB
TypeScript

import { useRef, useState } from "react";
import { downloadBlob } from "../../archive/downloads";
import { errorMessage } from "../../archive/errors";
import { ARCHIVE_LIMITS, formatBytes } from "../../archive/limits";
import { createArchive } from "../../archive/service";
import type { CreateFormat } from "../../archive/types";
import { StatusMessage } from "./StatusMessage";
export function CreateWorkspace() {
const [files, setFiles] = useState<File[]>([]);
const [format, setFormat] = useState<CreateFormat>("zip");
const [name, setName] = useState("archive");
const [encryption, setEncryption] = useState<
"none" | "aes-256" | "zipcrypto"
>("none");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string>();
const [error, setError] = useState<string>();
const controller = useRef<AbortController | undefined>(undefined);
const total = files.reduce((sum, file) => sum + file.size, 0);
async function build() {
controller.current?.abort();
const next = new AbortController();
controller.current = next;
setBusy(true);
setError(undefined);
try {
const blob = await createArchive(
files,
format,
next.signal,
(progress) =>
setStatus(
`${progress.stage}: ${progress.completed} / ${progress.total}`,
),
format === "zip" && encryption !== "none"
? { password, method: encryption }
: undefined,
);
const extension = format === "tar.gz" ? "tar.gz" : format;
downloadBlob(blob, `${name.trim() || "archive"}.${extension}`);
setStatus(
encryption === "none" || format !== "zip"
? `Created deterministic ${format.toLocaleUpperCase("en-US")} locally.`
: `Created encrypted ${format.toLocaleUpperCase("en-US")} locally; random salt makes encrypted bytes intentionally non-deterministic.`,
);
if (encryption !== "none") setPassword("");
} catch (reason) {
if (!next.signal.aborted) setError(errorMessage(reason));
} finally {
if (controller.current === next) setBusy(false);
}
}
return (
<section className="panel workspace-panel" aria-labelledby="create-heading">
<div className="panel-heading">
<div>
<p className="eyebrow">Reproducible output</p>
<h2 id="create-heading">Create an archive</h2>
</div>
</div>
<div className="create-grid">
<div className="form-stack">
<label className="field">
<span>Files</span>
<input
type="file"
multiple
onChange={(event) => {
setFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = "";
}}
/>
</label>
<div className="field-grid">
<label className="field">
<span>Output name</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label className="field">
<span>Format</span>
<select
value={format}
onChange={(event) =>
setFormat(event.target.value as CreateFormat)
}
>
<option value="zip">ZIP (DEFLATE)</option>
<option value="tar">TAR / PAX</option>
<option value="tar.gz">tar.gz</option>
</select>
</label>
</div>
<p className="policy-note">
Files are sorted by normalized path. Timestamps, owner IDs and
permissions are normalized; ZIP compression uses a fixed
implementation and level.
</p>
{format === "zip" ? (
<div className="field-grid">
<label className="field">
<span>Encryption</span>
<select
value={encryption}
onChange={(event) =>
setEncryption(
event.target.value as "none" | "aes-256" | "zipcrypto",
)
}
>
<option value="none">None</option>
<option value="aes-256">AES-256 (recommended)</option>
<option value="zipcrypto">ZipCrypto (legacy, weak)</option>
</select>
</label>
{encryption !== "none" ? (
<label className="field">
<span>Password (memory only)</span>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
) : null}
</div>
) : null}
{format === "zip" && encryption === "zipcrypto" ? (
<p className="policy-note" role="note">
ZipCrypto is offered only for compatibility and is not secure
against modern password attacks. Prefer AES-256.
</p>
) : null}
<StatusMessage error={error} status={status} />
<div className="button-row">
<button
className="primary-button"
type="button"
disabled={
!files.length ||
busy ||
total > ARCHIVE_LIMITS.maxCreateBytes ||
(format === "zip" && encryption !== "none" && !password)
}
onClick={() => void build()}
>
Create & download
</button>
{busy ? (
<button
type="button"
onClick={() => {
controller.current?.abort();
setStatus("Operation cancelled.");
}}
>
Cancel
</button>
) : null}
<button
type="button"
disabled={!files.length || busy}
onClick={() => setFiles([])}
>
Clear
</button>
</div>
</div>
<aside className="file-selection-summary">
<h3>Selection</h3>
<p>
<strong>{files.length.toLocaleString()}</strong> files ·{" "}
<strong>{formatBytes(total)}</strong>
</p>
<p className="policy-note">
Limits: {ARCHIVE_LIMITS.maxCreateFiles.toLocaleString()} files and{" "}
{formatBytes(ARCHIVE_LIMITS.maxCreateBytes)} total input.
</p>
<ul>
{files.slice(0, 30).map((file, index) => (
<li key={`${file.name}-${file.size}-${index}`}>
<span>{file.webkitRelativePath || file.name}</span>
<span>{formatBytes(file.size)}</span>
</li>
))}
</ul>
{files.length > 30 ? (
<p className="policy-note">and {files.length - 30} more</p>
) : null}
</aside>
</div>
</section>
);
}