Release Archive Tools 0.1.0
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user