Release Archive Tools 0.1.0

This commit is contained in:
2026-09-01 02:42:42 +02:00
commit a49ec6b17a
77 changed files with 11528 additions and 0 deletions
@@ -0,0 +1,148 @@
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 [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}`,
),
);
const extension = format === "tar.gz" ? "tar.gz" : format;
downloadBlob(blob, `${name.trim() || "archive"}.${extension}`);
setStatus(
`Created deterministic ${format.toLocaleUpperCase("en-US")} locally.`,
);
} 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>
<StatusMessage error={error} status={status} />
<div className="button-row">
<button
className="primary-button"
type="button"
disabled={
!files.length || busy || total > ARCHIVE_LIMITS.maxCreateBytes
}
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>
);
}