← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/msa/use-alignment-workbench-commands.ts

17.9 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import {
  useCallback,
  useEffect,
  useRef,
  type Dispatch,
  type MutableRefObject,
} from "react";

import { parseSequenceTrack } from "../sequence/tracks";
import type {
  QueuedSequenceViewerCommand,
  SequenceViewerCommandResult,
} from "../viewer-commands";
import { queryAlignmentViewer } from "../viewer-query";
import { exportAlignmentWorkbench } from "../workbench-exports";
import {
  parseWorkbenchSession,
  serializeWorkbenchSession,
  settleRestoredJobs,
  summarizeWorkbenchArtifact,
  type AlignmentWorkbenchAction,
  type AlignmentWorkbenchState,
  type WorkbenchArtifact,
  type WorkbenchJob,
  type WorkbenchSession,
  type WorkbenchViewState,
} from "../workbench-state";
import { exportAlignedFasta } from "./alignment-editing";
import { createAlignmentExportParameters } from "./alignment-export-provenance";
import type { MsaDerivedAnalysis } from "./analysis";
import { isGuideTreeResult, type GuideTreeResult } from "./phylogenetic-tree";
import type { MsaMotifSearchHit } from "./search";
import type { MsaColumnRange, MsaDocument, MsaSequenceRow } from "./types";
import {
  applyAlignmentEditRequest,
  realignRows,
  runAlignmentAnalysis,
} from "./workbench-controller";

export type AlignmentWorkbenchView = NonNullable<
  WorkbenchViewState["alignment"]
>;

export function useAlignmentWorkbenchCommands({
  analysis,
  cancelledJobsRef,
  command,
  dispatch,
  documentFileName,
  handledCommandIdRef,
  hits,
  onCommandResult,
  onRestoreView,
  onSetTree,
  selectedColumns,
  sourceStateKey,
  state,
  tree,
  view,
  visibleRows,
}: {
  analysis: MsaDerivedAnalysis | null;
  cancelledJobsRef: MutableRefObject<Set<string>>;
  command?: QueuedSequenceViewerCommand;
  dispatch: Dispatch<AlignmentWorkbenchAction>;
  documentFileName?: string;
  handledCommandIdRef: MutableRefObject<string | undefined>;
  hits: Array<MsaMotifSearchHit>;
  onCommandResult?: (
    command: QueuedSequenceViewerCommand,
    result: SequenceViewerCommandResult,
  ) => void | Promise<SequenceViewerCommandResult>;
  onRestoreView: (view: AlignmentWorkbenchView) => void;
  onSetTree: (
    tree: GuideTreeResult | null,
    sourceDocument?: MsaDocument,
  ) => void;
  selectedColumns: MsaColumnRange | null;
  sourceStateKey: string;
  state: AlignmentWorkbenchState;
  tree: GuideTreeResult | null;
  view: AlignmentWorkbenchView;
  visibleRows: Array<MsaSequenceRow>;
}): void {
  const stateRef = useRef(state);
  stateRef.current = state;
  const complete = useCallback(
    (
      result: SequenceViewerCommandResult,
    ): void | Promise<SequenceViewerCommandResult> => {
      if (command == null) return;
      handledCommandIdRef.current = command.commandId;
      return onCommandResult?.(command, result);
    },
    [command, handledCommandIdRef, onCommandResult],
  );
  useEffect(() => {
    if (
      command == null ||
      !isAlignmentOperation(command) ||
      handledCommandIdRef.current === command.commandId
    ) {
      return;
    }
    try {
      switch (command.action) {
        case "query_viewer": {
          const query = queryAlignmentViewer({
            analysis,
            artifacts: state.artifacts,
            document: state.document,
            hits,
            jobs: state.jobs,
            request: command.request,
            tracks: state.tracks,
            tree,
          });
          complete({
            applied: true,
            message: `Returned ${command.request.target} from the live Alignment viewer.`,
            state: { query },
          });
          return;
        }
        case "run_analysis": {
          const sourceDocument = state.document;
          const job = createJob(
            command.jobId,
            command.request.analysis === "build-tree"
              ? "guide-tree"
              : "distance-matrix",
            command.request,
          );
          cancelledJobsRef.current.delete(job.id);
          dispatch({ job, type: "start-job" });
          window.setTimeout(() => {
            if (cancelledJobsRef.current.has(job.id)) return;
            if (stateRef.current.document !== sourceDocument) {
              failStaleJob(dispatch, job.id);
              return;
            }
            try {
              const result = runAlignmentAnalysis({
                document: sourceDocument,
                request: command.request,
              });
              if (cancelledJobsRef.current.has(job.id)) return;
              if (stateRef.current.document !== sourceDocument) {
                failStaleJob(dispatch, job.id);
                return;
              }
              if (command.request.analysis === "build-tree") {
                onSetTree(result.tree as GuideTreeResult, sourceDocument);
              }
              dispatch({
                completedAt: Date.now(),
                id: job.id,
                message: `${job.kind} completed.`,
                result,
                type: "complete-job",
              });
            } catch (error) {
              dispatch({
                completedAt: Date.now(),
                error:
                  error instanceof Error ? error.message : "Analysis failed.",
                id: job.id,
                type: "fail-job",
              });
            }
          }, 0);
          complete({
            applied: true,
            message: `Started ${command.request.analysis} as job ${job.id}.`,
            state: { job },
          });
          return;
        }
        case "align_sequences": {
          const sourceDocument = state.document;
          const job = createJob(command.jobId, "align", {
            algorithm: command.algorithm ?? null,
            rowIds: command.rowIds ?? null,
          });
          cancelledJobsRef.current.delete(job.id);
          dispatch({ job, type: "start-job" });
          window.setTimeout(() => {
            if (cancelledJobsRef.current.has(job.id)) return;
            if (stateRef.current.document !== sourceDocument) {
              failStaleJob(dispatch, job.id);
              return;
            }
            try {
              const result = realignRows({
                algorithm: command.algorithm,
                document: sourceDocument,
                rowIds: command.rowIds,
              });
              if (cancelledJobsRef.current.has(job.id)) return;
              if (stateRef.current.document !== sourceDocument) {
                failStaleJob(dispatch, job.id);
                return;
              }
              const artifact = createArtifact({
                content: exportAlignedFasta(result.document.rows),
                engine: result.engine,
                format: "aligned-fasta",
                mediaType: "text/x-fasta",
                name: `${baseName(documentFileName)}.realigned.fasta`,
                parameters: result.parameters,
                sourceRevision: state.revision,
              });
              dispatch({
                description: "Realigned selected rows in an editable copy.",
                document: result.document,
                type: "apply-alignment-document",
              });
              dispatch({ artifact, type: "add-artifact" });
              dispatch({
                completedAt: Date.now(),
                id: job.id,
                message: "Alignment completed.",
                result: {
                  alignedLength: result.document.alignedLength,
                  artifact: summarizeWorkbenchArtifact(artifact),
                  rowCount: result.document.rows.length,
                  warning: result.warning,
                },
                type: "complete-job",
              });
            } catch (error) {
              dispatch({
                completedAt: Date.now(),
                error:
                  error instanceof Error ? error.message : "Alignment failed.",
                id: job.id,
                type: "fail-job",
              });
            }
          }, 0);
          complete({
            applied: true,
            message: `Started exploratory realignment as job ${job.id}.`,
            state: { job },
          });
          return;
        }
        case "edit_copy": {
          const change = applyAlignmentEditRequest({
            document: state.document,
            request: command.request,
            tree,
          });
          if ("historyOperation" in change) {
            dispatch({
              type:
                change.historyOperation === "undo"
                  ? "undo-alignment-document"
                  : "redo-alignment-document",
            });
          } else {
            dispatch({
              description: change.description,
              document: change.document,
              type: "apply-alignment-document",
            });
          }
          complete({
            applied: true,
            message:
              "historyOperation" in change
                ? `${change.historyOperation === "undo" ? "Undid" : "Redid"} the most recent alignment-copy edit.`
                : change.description,
            state: { dirty: true, sourceFileOverwritten: false },
          });
          return;
        }
        case "load_track": {
          const content =
            command.encoding === "utf8"
              ? command.content
              : Uint8Array.from(atob(command.content), (symbol) =>
                  symbol.charCodeAt(0),
                );
          const track = parseSequenceTrack({
            content,
            displayName: command.displayName,
            format: command.format,
            id: command.trackId,
            requestedReference: command.reference,
            sourceItemCount: command.sourceItemCount,
            sourceTruncated: command.sourceTruncated,
          });
          dispatch({ track, type: "add-track" });
          complete({
            applied: true,
            message: `Loaded ${track.name} with ${track.summary.itemCount.toLocaleString()} items.`,
            state: { track },
          });
          return;
        }
        case "export_artifact": {
          if (
            command.format !== "a3m" &&
            command.format !== "aligned-fasta" &&
            command.format !== "clustal" &&
            command.format !== "json" &&
            command.format !== "newick" &&
            command.format !== "pdf" &&
            command.format !== "stockholm" &&
            command.format !== "svg" &&
            command.format !== "tsv"
          ) {
            throw new Error(
              `${command.format} is available only while the viewer is in Sequence mode.`,
            );
          }
          const output = exportAlignmentWorkbench({
            document: state.document,
            format: command.format,
            name: command.name,
            newick: tree?.newick,
            scope: command.scope,
            selectedColumns,
            selectedRows: state.selectedRows,
            visibleRows,
          });
          const artifact = createArtifact({
            ...output,
            engine: "sequence-viewer-alignment-export-v1",
            parameters: createAlignmentExportParameters({
              format: command.format,
              scope: command.scope,
              tree,
            }),
            sourceRevision: state.revision,
          });
          const completion = complete({
            applied: true,
            message: `Prepared ${output.name} for download.`,
            state: { artifact: output, provenance: artifact.provenance },
          });
          if (completion == null) {
            dispatch({ artifact, type: "add-artifact" });
          } else {
            void completion.then(
              ({ applied }) => {
                if (applied) dispatch({ artifact, type: "add-artifact" });
              },
              () => undefined,
            );
          }
          return;
        }
        case "save_session": {
          complete({
            applied: true,
            message: "Serialized the current Alignment workbench session.",
            state: {
              session: serializeWorkbenchSession(
                createAlignmentWorkbenchSession(
                  state,
                  view,
                  documentFileName,
                  sourceStateKey,
                ),
              ),
            },
          });
          return;
        }
        case "restore_session": {
          const session = parseWorkbenchSession(command.session);
          if (
            session.view.mode !== "alignment" ||
            session.view.alignment == null
          ) {
            throw new Error(
              "The saved session is not an Alignment-mode workbench session.",
            );
          }
          if (
            session.source.fileName !== (documentFileName ?? null) ||
            session.source.format !== state.document.format ||
            session.source.stateKey !== sourceStateKey
          ) {
            throw new Error(
              "The saved session belongs to a different source artifact and was not applied.",
            );
          }
          if (session.snapshot?.alignmentDocument != null) {
            dispatch({
              description: "Restored saved alignment-copy state.",
              document: session.snapshot.alignmentDocument,
              type: "apply-alignment-document",
            });
          }
          dispatch({
            state: {
              artifacts: session.artifacts,
              dirty: session.dirty,
              jobs: settleRestoredJobs(session.jobs),
              revision: state.revision,
              tracks: session.tracks,
            },
            type: "restore-shared",
          });
          onRestoreView(session.view.alignment);
          const restoredTree = [...session.jobs]
            .reverse()
            .find(
              ({ kind, status }) =>
                kind === "guide-tree" && status === "completed",
            )?.result?.tree;
          if (isGuideTreeResult(restoredTree)) {
            onSetTree(restoredTree, session.snapshot?.alignmentDocument);
          }
          complete({
            applied: true,
            message: "Restored the saved Alignment workbench session.",
            state: { restoredRevision: session.revision },
          });
          return;
        }
        case "cancel_job": {
          const job = state.jobs.find(({ id }) => id === command.jobId);
          if (job == null)
            throw new Error(`No workbench job matched ${command.jobId}.`);
          if (job.status !== "running") {
            complete({
              applied: false,
              message: `Job ${job.id} is already ${job.status}.`,
              state: { job },
            });
            return;
          }
          cancelledJobsRef.current.add(job.id);
          dispatch({ id: job.id, type: "cancel-job" });
          complete({
            applied: true,
            message: `Cancelled ${job.kind} job ${job.id}.`,
            state: { jobId: job.id, status: "cancelled" },
          });
          return;
        }
        case "manage_annotations":
          throw new Error(
            "Sequence feature annotation editing is available in Sequence mode; alignment column tracks remain preserved as alignment metadata.",
          );
      }
    } catch (error) {
      complete({
        applied: false,
        message:
          error instanceof Error
            ? error.message
            : "The Alignment workbench command could not be completed.",
      });
    }
  }, [
    analysis,
    cancelledJobsRef,
    command,
    complete,
    dispatch,
    documentFileName,
    handledCommandIdRef,
    hits,
    onRestoreView,
    onSetTree,
    selectedColumns,
    sourceStateKey,
    state,
    tree,
    view,
    visibleRows,
  ]);
}

export function isAlignmentOperation(
  command: QueuedSequenceViewerCommand | undefined,
): boolean {
  return (
    command?.action === "query_viewer" ||
    command?.action === "run_analysis" ||
    command?.action === "align_sequences" ||
    command?.action === "edit_copy" ||
    command?.action === "manage_annotations" ||
    command?.action === "load_track" ||
    command?.action === "export_artifact" ||
    command?.action === "save_session" ||
    command?.action === "restore_session" ||
    command?.action === "cancel_job"
  );
}

function createJob(
  id: string,
  kind: WorkbenchJob["kind"],
  parameters: Record<string, unknown>,
): WorkbenchJob {
  return {
    id,
    kind,
    message: "Queued in the viewer",
    parameters,
    progress: 0,
    startedAt: Date.now(),
    status: "running",
  };
}

function failStaleJob(
  dispatch: Dispatch<AlignmentWorkbenchAction>,
  id: string,
): void {
  dispatch({
    completedAt: Date.now(),
    error:
      "The source alignment changed before this job completed. Run it again on the current copy.",
    id,
    type: "fail-job",
  });
}

export function createAlignmentWorkbenchSession(
  state: AlignmentWorkbenchState,
  view: AlignmentWorkbenchView,
  fileName: string | undefined,
  sourceStateKey: string,
): WorkbenchSession {
  return {
    artifacts: state.artifacts.filter(
      ({ format }) => format !== "sequence-viewer-session",
    ),
    createdAt: Date.now(),
    dirty: state.dirty,
    jobs: state.jobs,
    revision: state.revision,
    schemaVersion: 1,
    snapshot: { alignmentDocument: state.document },
    source: {
      fileName: fileName ?? null,
      format: state.document.format,
      stateKey: sourceStateKey,
    },
    tracks: state.tracks,
    view: { alignment: view, mode: "alignment" },
  };
}

function createArtifact({
  content,
  engine,
  format,
  mediaType,
  name,
  parameters,
  sourceRevision,
}: {
  content: string;
  engine: string;
  format: string;
  mediaType: string;
  name: string;
  parameters: Record<string, unknown>;
  sourceRevision: number;
}): WorkbenchArtifact {
  return {
    content,
    createdAt: Date.now(),
    format,
    id: crypto.randomUUID(),
    mediaType,
    name,
    provenance: { engine, parameters, sourceRevision },
  };
}

function baseName(fileName: string | undefined): string {
  const leaf = fileName?.split(/[\\/]/u).at(-1) ?? "alignment";
  return leaf.replace(/\.[^.]+$/u, "") || "alignment";
}

SHA-256: 581d875d272255de0642a076a4244b0a29463d7ebdbbc3fea229c78bb2a35812