← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/workspace-session-controls.tsx
11.6 KB · Sep 30, 2026 · 23:01 UTC
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type {
SequenceListWorkspaceSessionsResult,
SequenceRestoreWorkspaceSessionResult,
} from "./workspace-session-protocol";
import { WorkspaceSaveAsButton } from "./workspace-publish-button";
import type { SequenceWorkspaceSessionClient } from "./views/workspace-sessions";
type WorkspaceSessionCandidate =
SequenceListWorkspaceSessionsResult["candidates"][number];
export function WorkspaceSessionSaveButton({
className,
client,
defaultName,
prepare,
}: {
className: string;
client: SequenceWorkspaceSessionClient;
defaultName: string;
prepare: () => string;
}): React.ReactElement {
return (
<WorkspaceSaveAsButton
browser={client}
className={className}
item={{ format: "json", name: defaultName }}
label="Save project"
onSave={async (relativePath, collisionPolicy, signal) => {
const result = await client(
{ content: prepare(), name: defaultName },
relativePath,
collisionPolicy,
signal,
);
if (
result.kind !== "session" ||
!("destination" in result) ||
result.destination.kind !== "workspace"
) {
throw new Error("The server did not confirm a workspace project.");
}
window.dispatchEvent(new Event("sequence-workspace-session-saved"));
return result.outputWorkspacePath;
}}
/>
);
}
export function WorkspaceSessionDiscovery({
client,
}: {
client?: SequenceWorkspaceSessionClient;
}): React.ReactElement | null {
const [result, setResult] =
useState<SequenceListWorkspaceSessionsResult>();
const [open, setOpen] = useState(false);
const [notice, setNotice] = useState<string>();
const [refreshRevision, setRefreshRevision] = useState(0);
const reviewButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setResult(undefined);
setOpen(false);
if (client == null) return;
const controller = new AbortController();
void client
.discover(controller.signal)
.then((value) => {
if (!controller.signal.aborted) setResult(value);
})
.catch(() => {
// Discovery is an optional installed-host capability. Keep the viewer
// fully usable when an older host does not expose it.
});
return () => controller.abort();
}, [client, refreshRevision]);
useEffect(() => setNotice(undefined), [client]);
useEffect(() => {
const refresh = () => setRefreshRevision((revision) => revision + 1);
window.addEventListener("sequence-workspace-session-saved", refresh);
return () =>
window.removeEventListener("sequence-workspace-session-saved", refresh);
}, []);
if (client == null || result == null || result.candidates.length === 0) {
return null;
}
function close(): void {
setOpen(false);
window.setTimeout(() => reviewButtonRef.current?.focus(), 0);
}
return (
<>
<div
className="flex flex-wrap items-center justify-between gap-2 border-b border-token-border bg-blue-500/5 px-3 py-2 text-xs"
role="status"
>
<span>
{notice ?? (
<>
{result.candidates.length} saved workspace project
{result.candidates.length === 1 ? "" : "s"} found for this source.
</>
)}
</span>
<button
className="rounded border border-token-border px-2 py-1 font-medium"
onClick={() => setOpen(true)}
ref={reviewButtonRef}
type="button"
>
Review saved projects
</button>
</div>
{open
? createPortal(
<WorkspaceSessionRestoreDialog
candidates={result.candidates}
client={client}
omittedCandidates={result.omittedCandidates}
onClose={close}
onRestored={(restored) => {
const unavailable = restored.dependencies.filter(
({ required, status }) => !required && status !== "matched",
);
setNotice(
unavailable.length === 0
? `Restored ${restored.name}.`
: `Restored ${restored.name}; ${unavailable.length} optional dependency${unavailable.length === 1 ? " is" : "ies are"} unavailable.`,
);
}}
/>,
document.body,
)
: null}
</>
);
}
function WorkspaceSessionRestoreDialog({
candidates,
client,
omittedCandidates,
onClose,
onRestored,
}: {
candidates: Array<WorkspaceSessionCandidate>;
client: SequenceWorkspaceSessionClient;
omittedCandidates: number;
onClose: () => void;
onRestored: (result: SequenceRestoreWorkspaceSessionResult) => void;
}): React.ReactElement {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const restoreController = useRef<AbortController | undefined>(undefined);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string>();
const [restoring, setRestoring] = useState<string>();
useEffect(() => {
dialogRef.current
?.querySelector<HTMLElement>('button, input, [tabindex]:not([tabindex="-1"])')
?.focus();
return () => restoreController.current?.abort();
}, []);
function cancelAndClose(): void {
restoreController.current?.abort(
new DOMException("Workspace restore was cancelled.", "AbortError"),
);
onClose();
}
async function restore(candidate: WorkspaceSessionCandidate): Promise<void> {
if (!confirmed || restoring != null) return;
const controller = new AbortController();
restoreController.current = controller;
setError(undefined);
setRestoring(candidate.candidateId);
try {
const result = await client.restore(
candidate.candidateId,
controller.signal,
);
onRestored(result);
onClose();
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) {
setError(
cause instanceof Error
? cause.message
: "The workspace project could not be restored.",
);
}
} finally {
if (restoreController.current === controller) {
restoreController.current = undefined;
}
setRestoring(undefined);
}
}
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="w-full max-w-2xl rounded-xl border border-token-border bg-token-main-surface-primary shadow-xl"
ref={dialogRef}
role="dialog"
>
<div className="flex items-center justify-between border-b border-token-border px-4 py-3">
<h2 className="text-sm font-semibold" id={titleId}>
Restore workspace project
</h2>
<button
aria-label="Close workspace project dialog"
className="rounded px-2 py-1 text-token-text-secondary"
onClick={cancelAndClose}
type="button"
>
Close
</button>
</div>
<div className="space-y-3 p-4">
<p className="text-xs text-token-text-secondary">
Saved projects are never applied automatically. The source and
required dependencies are verified again when you restore.
</p>
<ul className="space-y-2" aria-label="Compatible saved projects">
{candidates.map((candidate) => (
<li
className="rounded-lg border border-token-border p-3 text-xs"
key={candidate.candidateId}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{candidate.name}</div>
<div className="mt-1 text-token-text-secondary">
{candidate.mode} · {candidate.workspacePath} ·{" "}
{new Date(candidate.createdAt).toLocaleString()}
</div>
{candidate.dependencies.length === 0 ? null : (
<ul className="mt-2 space-y-0.5" aria-label={`${candidate.name} dependencies`}>
{candidate.dependencies.map((dependency) => (
<li key={`${dependency.kind}:${dependency.workspacePath}`}>
{dependency.name}: {dependency.status}
{dependency.required ? " (required)" : " (optional)"}
</li>
))}
</ul>
)}
<div className="mt-1 text-token-text-secondary">
Source: {candidate.sourceStatus}
{candidate.dependencies.length === 0
? " · No external dependencies"
: ` · ${candidate.dependencies.length} dependencies`}
</div>
</div>
<button
className="rounded bg-token-text-primary px-3 py-1.5 font-medium text-token-main-surface-primary disabled:opacity-50"
disabled={!confirmed || restoring != null}
onClick={() => void restore(candidate)}
type="button"
>
{restoring === candidate.candidateId ? "Restoring…" : "Restore"}
</button>
</div>
</li>
))}
</ul>
{omittedCandidates === 0 ? null : (
<p className="text-xs text-token-text-secondary">
{omittedCandidates} incompatible or invalid project
{omittedCandidates === 1 ? " was" : "s were"} omitted.
</p>
)}
<label className="flex items-start gap-2 text-xs font-medium">
<input
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
type="checkbox"
/>
I want to replace the current workbench state with the selected
saved project.
</label>
{error == null ? null : (
<p aria-live="polite" className="text-xs text-red-600">
{error}
</p>
)}
</div>
</div>
</div>
);
}
export function workspaceSessionDefaultName(
sourceName: string | undefined,
fallback: string,
): string {
const basename = (sourceName ?? fallback).split(/[\\/]/u).at(-1) ?? fallback;
const withoutCompression = basename.replace(/\.gz$/iu, "");
const dot = withoutCompression.lastIndexOf(".");
const stem = dot > 0 ? withoutCompression.slice(0, dot) : withoutCompression;
return `${stem || fallback}.sequence-viewer.session.json`;
}
SHA-256: 1f7e2e415aeea5bc1eb8a93018bf767e059f3a4afe708a32bcd18b22f8cdfd10