← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/workspace-publish-button.tsx
18.3 KB · Sep 30, 2026 · 23:01 UTC
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { isWorkspaceExportNameForFormat } from "./viewer-operations";
import type {
PreparedSequenceWorkspaceArtifact,
SequenceWorkspaceArtifactPublisher,
} from "./views/workbench-persistence";
import {
isSafeWorkspaceBrowserChildName,
type SequenceListWorkspaceDirectoryResult,
} from "./workspace-browser-protocol";
export function WorkspacePublishButton({
artifact,
className,
label = "Publish to workspace",
publisher,
}: {
artifact: PreparedSequenceWorkspaceArtifact;
className: string;
label?: string;
publisher: SequenceWorkspaceArtifactPublisher;
}): React.ReactElement {
return (
<WorkspaceSaveAsButton
browser={publisher}
className={className}
item={artifact}
label={label}
successVerb="Published"
onSave={async (relativePath, collisionPolicy, signal) => {
const result = await publisher(
artifact,
relativePath,
collisionPolicy,
signal,
);
if (
result.kind !== "artifact" ||
!("destination" in result) ||
result.destination.kind !== "workspace"
) {
throw new Error("The server did not confirm a workspace publication.");
}
return result.outputWorkspacePath;
}}
/>
);
}
export type WorkspaceSaveAsItem = Pick<
PreparedSequenceWorkspaceArtifact,
"format" | "name"
>;
export type WorkspaceSaveAsBrowser = Pick<
SequenceWorkspaceArtifactPublisher,
"createDirectory" | "listDirectory"
>;
export function WorkspaceSaveAsButton({
browser,
className,
item,
label,
onSave,
successVerb = "Saved",
}: {
browser: WorkspaceSaveAsBrowser;
className: string;
item: WorkspaceSaveAsItem;
label: string;
onSave: (
relativePath: string,
collisionPolicy: "exact" | "next-version",
signal: AbortSignal,
) => Promise<string>;
successVerb?: string;
}): React.ReactElement {
const [message, setMessage] = useState<string>();
const [open, setOpen] = useState(false);
const publishButtonRef = useRef<HTMLButtonElement>(null);
function closeDialog(): void {
setOpen(false);
window.setTimeout(() => publishButtonRef.current?.focus(), 0);
}
return (
<span className="inline-flex items-center gap-1">
<button
className={className}
onClick={() => {
setMessage(undefined);
setOpen(true);
}}
ref={publishButtonRef}
type="button"
>
{label}
</button>
{message == null ? null : (
<span
aria-live="polite"
className="max-w-64 text-[10px] text-token-text-secondary"
>
{message}
</span>
)}
{open
? createPortal(
<WorkspaceSaveAsDialog
browser={browser}
item={item}
onClose={closeDialog}
onPublished={(workspacePath) => {
setMessage(`${successVerb} ${workspacePath}`);
closeDialog();
}}
onSave={onSave}
/>,
document.body,
)
: null}
</span>
);
}
function WorkspaceSaveAsDialog({
browser,
item,
onClose,
onPublished,
onSave,
}: {
browser: WorkspaceSaveAsBrowser;
item: WorkspaceSaveAsItem;
onClose: () => void;
onPublished: (workspacePath: string) => void;
onSave: (
relativePath: string,
collisionPolicy: "exact" | "next-version",
signal: AbortSignal,
) => Promise<string>;
}): React.ReactElement {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const saveAbortController = useRef<AbortController | undefined>(undefined);
const [collisionPolicy, setCollisionPolicy] = useState<
"exact" | "next-version"
>("exact");
const [cursor, setCursor] = useState<string>();
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const [directory, setDirectory] = useState(".");
const [error, setError] = useState<string>();
const [fileName, setFileName] = useState(item.name);
const [listing, setListing] =
useState<SequenceListWorkspaceDirectoryResult>();
const [loading, setLoading] = useState(true);
const [newFolderName, setNewFolderName] = useState("");
const [refreshRevision, setRefreshRevision] = useState(0);
const [saving, setSaving] = useState(false);
const fileNameError = validateWorkspaceFileName(item, fileName);
const newFolderNameError =
newFolderName === "" || isSafeWorkspaceBrowserChildName(newFolderName)
? undefined
: "Enter one safe folder name without path separators.";
useEffect(() => {
const controller = new AbortController();
setLoading(true);
const timeout = window.setTimeout(() => {
void browser
.listDirectory({
candidate:
fileNameError == null
? { format: item.format, name: fileName }
: undefined,
cursor,
directory,
limit: 50,
signal: controller.signal,
})
.then((result) => {
if (controller.signal.aborted) return;
setListing(result);
})
.catch((cause) => {
if (isAbortError(cause)) return;
setListing(undefined);
setError(errorMessage(cause));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, 100);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [
browser,
cursor,
directory,
fileName,
fileNameError,
item.format,
refreshRevision,
]);
const candidate = listing?.candidate;
const selectedWorkspacePath =
collisionPolicy === "exact"
? candidate?.exactWorkspacePath
: candidate?.nextVersionWorkspacePath;
const canSave =
fileNameError == null &&
!loading &&
!saving &&
candidate != null &&
(collisionPolicy === "exact"
? candidate.exactAvailable
: candidate.nextVersionName != null);
function navigate(relativePath: string): void {
setDirectory(relativePath);
setLoading(true);
setCursor(undefined);
setCursorStack([]);
setError(undefined);
}
async function createFolder(): Promise<void> {
if (newFolderName === "" || newFolderNameError != null) return;
setSaving(true);
setError(undefined);
try {
const created = await browser.createDirectory({
name: newFolderName,
parentDirectory: directory,
});
setNewFolderName("");
navigate(created.relativePath);
} catch (cause) {
setError(errorMessage(cause));
} finally {
setSaving(false);
}
}
async function save(): Promise<void> {
if (!canSave || listing == null) return;
const relativePath = joinWorkspacePath(
listing.directory.relativePath,
fileName,
);
setSaving(true);
setError(undefined);
const controller = new AbortController();
saveAbortController.current = controller;
try {
const workspacePath = await onSave(
relativePath,
collisionPolicy,
controller.signal,
);
onPublished(workspacePath);
} catch (cause) {
setError(errorMessage(cause));
setRefreshRevision((revision) => revision + 1);
} finally {
if (saveAbortController.current === controller) {
saveAbortController.current = undefined;
}
setSaving(false);
}
}
function cancelAndClose(): void {
saveAbortController.current?.abort(
new DOMException("Workspace publication was cancelled.", "AbortError"),
);
onClose();
}
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4"
onKeyDown={(event) => {
if (event.key === "Escape") cancelAndClose();
if (event.key !== "Tab") return;
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable == null || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}}
>
<div
aria-labelledby={titleId}
aria-modal="true"
className="flex max-h-[640px] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-token-border bg-token-main-surface-primary shadow-xl"
ref={dialogRef}
role="dialog"
style={{ maxHeight: "min(calc(100vh - 2rem), 640px)" }}
>
<div className="flex items-center justify-between border-b border-token-border px-4 py-3">
<h2 className="text-sm font-semibold" id={titleId}>
Save to workspace
</h2>
<button
aria-label="Close Save As dialog"
className="rounded px-2 py-1 text-token-text-secondary hover:bg-token-bg-tertiary"
onClick={cancelAndClose}
type="button"
>
Close
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-4">
{listing == null ? null : (
<nav aria-label="Workspace location" className="flex flex-wrap gap-1">
{listing.directory.breadcrumbs.map((breadcrumb, index) => (
<span className="inline-flex items-center gap-1" key={breadcrumb.workspacePath}>
{index === 0 ? null : <span aria-hidden="true">/</span>}
<button
className="rounded px-1.5 py-1 text-xs text-token-text-secondary hover:bg-token-bg-tertiary"
onClick={() => navigate(breadcrumb.relativePath)}
type="button"
>
{breadcrumb.label}
</button>
</span>
))}
</nav>
)}
<div className="min-h-40 rounded-lg border border-token-border">
{loading ? (
<p className="p-4 text-sm text-token-text-secondary">Loading workspace…</p>
) : listing == null ? (
<p className="p-4 text-sm text-token-text-secondary">
Workspace browsing is unavailable.
</p>
) : listing.entries.length === 0 ? (
<p className="p-4 text-sm text-token-text-secondary">
This folder is empty.
</p>
) : (
<ul className="divide-y divide-token-border" aria-label="Workspace folder contents">
{listing.entries.map((entry) => (
<li className="flex items-center justify-between gap-3 px-3 py-2" key={`${entry.kind}:${entry.name}`}>
{entry.kind === "directory" ? (
<button
className="min-w-0 truncate text-left text-sm font-medium hover:underline"
onClick={() => navigate(entry.relativePath)}
type="button"
>
{entry.name}/
</button>
) : (
<span className="min-w-0 truncate text-sm">{entry.name}</span>
)}
{entry.size == null ? null : (
<span className="shrink-0 text-xs text-token-text-secondary">
{formatFileSize(entry.size)}
</span>
)}
</li>
))}
</ul>
)}
</div>
<div className="flex items-center justify-between gap-2">
<button
className="rounded border border-token-border px-2 py-1 text-xs disabled:opacity-50"
disabled={cursorStack.length === 0 || loading}
onClick={() => {
setError(undefined);
setLoading(true);
const previous = cursorStack.at(-1);
setCursor(previous);
setCursorStack((stack) => stack.slice(0, -1));
}}
type="button"
>
Previous page
</button>
<button
className="rounded border border-token-border px-2 py-1 text-xs disabled:opacity-50"
disabled={listing?.nextCursor == null || loading}
onClick={() => {
setError(undefined);
setLoading(true);
setCursorStack((stack) => [...stack, cursor]);
setCursor(listing?.nextCursor);
}}
type="button"
>
Next page
</button>
</div>
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-token-border p-3">
<label className="min-w-48 flex-1 text-xs font-medium">
New folder
<input
className="mt-1 w-full rounded border border-token-border bg-transparent px-2 py-1.5 text-sm"
onChange={(event) => {
setError(undefined);
setNewFolderName(event.target.value);
}}
placeholder="One folder name"
value={newFolderName}
/>
</label>
<button
className="rounded border border-token-border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
disabled={saving || newFolderName === "" || newFolderNameError != null}
onClick={() => void createFolder()}
type="button"
>
Create folder
</button>
{newFolderNameError == null ? null : (
<p className="w-full text-xs text-red-600" role="alert">
{newFolderNameError}
</p>
)}
</div>
<label className="text-xs font-medium">
File name
<input
autoFocus
className="mt-1 w-full rounded border border-token-border bg-transparent px-2 py-1.5 text-sm"
onChange={(event) => {
setError(undefined);
setLoading(true);
setFileName(event.target.value);
}}
value={fileName}
/>
</label>
{fileNameError == null ? null : (
<p className="text-xs text-red-600" role="alert">
{fileNameError}
</p>
)}
<fieldset className="flex flex-wrap gap-4 text-xs">
<legend className="mb-1 font-medium">If the name exists</legend>
<label className="inline-flex items-center gap-1.5">
<input
checked={collisionPolicy === "exact"}
name={`${titleId}-collision-policy`}
onChange={() => {
setError(undefined);
setCollisionPolicy("exact");
}}
type="radio"
/>
Report collision
</label>
<label className="inline-flex items-center gap-1.5">
<input
checked={collisionPolicy === "next-version"}
name={`${titleId}-collision-policy`}
onChange={() => {
setError(undefined);
setCollisionPolicy("next-version");
}}
type="radio"
/>
Save next version
</label>
</fieldset>
{candidate == null ? null : (
<div className="rounded-lg bg-token-bg-secondary p-3 text-xs">
<div>
Destination: <strong>{selectedWorkspacePath ?? "Unavailable"}</strong>
</div>
{collisionPolicy === "exact" && !candidate.exactAvailable ? (
<div className="mt-1 text-amber-700" role="status">
The artifact or provenance sidecar already exists.
</div>
) : null}
</div>
)}
{listing?.omittedEntries ? (
<p className="text-xs text-token-text-secondary">
{listing.omittedEntries} unsupported or linked entries were omitted.
</p>
) : null}
{error == null ? null : (
<p aria-live="polite" className="text-xs text-red-600">
{error}
</p>
)}
</div>
<div className="flex justify-end gap-2 border-t border-token-border px-4 py-3">
<button
className="rounded border border-token-border px-3 py-1.5 text-sm"
onClick={cancelAndClose}
type="button"
>
Cancel
</button>
<button
className="rounded bg-token-text-primary px-3 py-1.5 text-sm font-medium text-token-main-surface-primary disabled:opacity-50"
disabled={!canSave}
onClick={() => void save()}
type="button"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}
function validateWorkspaceFileName(
item: WorkspaceSaveAsItem,
name: string,
): string | undefined {
if (
!isSafeWorkspaceBrowserChildName(name)
) {
return "Enter one safe file name without path separators.";
}
if (!isWorkspaceExportNameForFormat(item.format, name)) {
return `The file extension does not match ${item.format}.`;
}
if (new TextEncoder().encode(`${name}.provenance.json`).byteLength > 255) {
return "The file name is too long for its provenance sidecar.";
}
return undefined;
}
function joinWorkspacePath(directory: string, name: string): string {
return directory === "." ? name : `${directory.replace(/\/$/u, "")}/${name}`;
}
function formatFileSize(bytes: number): string {
if (bytes < 1_024) return `${bytes} B`;
if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)} KiB`;
return `${(bytes / (1_024 * 1_024)).toFixed(1)} MiB`;
}
function errorMessage(error: unknown): string {
return error instanceof Error
? error.message
: "The workspace export operation failed.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
SHA-256: 02e8bc550f8e137b7ca783218557ae527eed1ceff3d29ded8f2fae758315e9fd