Skip to content
Draft
58 changes: 49 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"redux": "4.0.x",
"redux-logic": "3.x",
"reselect": "4.0.x",
"string-natural-compare": "3.0.x"
"string-natural-compare": "3.0.x",
"uuid": "^13.0.0"
Copy link
Author

@frasercl frasercl Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already present somewhere further down the dependency tree, so depending on it explicitly doesn't change much.

}
}
111 changes: 109 additions & 2 deletions packages/core/hooks/useOpenWithMenuItems/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ContextualMenuItemType, DefaultButton, Icon, IContextualMenuItem } from
import { isEmpty } from "lodash";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";

import AnnotationName from "../../entity/Annotation/AnnotationName";
import FileDetail from "../../entity/FileDetail";
Expand Down Expand Up @@ -38,6 +39,7 @@ interface Apps {

type AppOptions = {
openInCfe: () => void;
openInVole: () => void;
};

const SUPPORTED_APPS_HEADER = {
Expand Down Expand Up @@ -160,7 +162,7 @@ const APPS = (
key: AppKeys.VOLE,
text: "Vol-E",
title: `Open files with Vol-E`,
href: `https://volumeviewer.allencell.org/viewer?url=${fileDetails?.path}/`,
onClick: options?.openInVole,
disabled: !fileDetails?.path,
target: "_blank",
onRenderContent(props, defaultRenders) {
Expand Down Expand Up @@ -205,6 +207,12 @@ const APPS = (
} as IContextualMenuItem,
});

type VoleMessage = {
scenes?: string[];
meta: Record<string, Record<string, unknown>>;
sceneIndex?: number;
};

function getSupportedApps(
apps: Apps,
isSmallFile: boolean,
Expand Down Expand Up @@ -267,6 +275,44 @@ function getFileExtension(fileDetails: FileDetail): string {
return fileDetails.path.slice(fileDetails.path.lastIndexOf(".") + 1).toLowerCase();
}

/**
* Opens a window at `openUrl`, then attempts to send the data in `entry` to it.
*
* This requires a bit of protocol to accomplish:
* 1. We add some query params to `openUrl` before opening: `msgorigin` is this site's origin for
* validation, and `storageid` uniquely identifies the message we want to send.
* 2. *The opened window must check if these params are present* and post the value of `storageid`
* back to us (via `window.opener`, validated using `msgorigin`) once it's loaded and ready.
* 3. Once we receive that message, we send over `entry`.
*
* This is currently only used by `openInVole`, but is broken out into a separate function to
* emphasize that this protocol is both message- and receiver-agnostic, and could be used to send
* large bundles of data to other apps as well.
*/
function openWindowWithMessage(openUrl: URL, message: any): void {
if (message === undefined || message === null) {
window.open(openUrl);
return;
}

const storageid = uuidv4();
openUrl.searchParams.append("msgorigin", window.location.origin);
openUrl.searchParams.append("storageid", storageid);

const handle = window.open(openUrl);
const loadHandler = (event: MessageEvent): void => {
if (event.origin !== openUrl.origin || event.data !== storageid) {
return;
}
handle?.postMessage(message, openUrl.origin);
window.removeEventListener("message", loadHandler);
};

window.addEventListener("message", loadHandler);
// ensure handlers can't build up with repeated failed requests
window.setTimeout(() => window.removeEventListener("message", loadHandler), 60000);
}

export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMenuItem[] => {
const path = fileDetails?.path;
const size = fileDetails?.size;
Expand Down Expand Up @@ -299,6 +345,67 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe
fileService
);

// custom hook this, like `useOpenInCfe`?
const openInVole = React.useCallback(async (): Promise<void> => {
Comment on lines +348 to +349
Copy link
Author

@frasercl frasercl Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't end up breaking out a custom hook for this because:

  1. It uses the tiny utility function getFileExtension and I didn't feel like copying it over.
  2. More importantly, it uses openWindowWithMessage. Like I said in that function's docs, it may be useful for opening large collections of data in other apps, and I didn't want it isolated in another file just yet.
  3. The implementation below is just on the edge of feeling okay to include inline, if openInCfe was long enough to break out.

Any of the above could fall the other way for someone else or change in the future though, so I also kept the comment.

const VOLE_BASE_URL = "https://vole.allencell.org/viewer";

const allDetails = await fileSelection.fetchAllDetails();
const details = allDetails.filter((detail) => {
const fileExt = getFileExtension(detail);
return fileExt === "zarr" || fileExt === "";
});

const scenes: string[] = [];
const message: VoleMessage = { meta: {} };

for (const detail of details) {
const sceneMeta: Record<string, unknown> = {};
for (const annotation of detail.annotations) {
const isSingleValue = annotation.values.length === 1;
const value = isSingleValue ? annotation.values[0] : annotation.values;
sceneMeta[annotation.name] = value;
}
scenes.push(detail.path);
if (Object.keys(sceneMeta).length > 0) {
message.meta[detail.path] = sceneMeta;
}
}

const openUrl = new URL(VOLE_BASE_URL);

// Start on the focused scene
const sceneIndex = details.findIndex((detail) => detail.path === fileDetails?.path);

// Prefer putting the image URLs directly in the query string for easy sharing, if the
// length of the URL would be reasonable
const includeUrls =
details.length < 5 ||
details.reduce((acc, detail) => acc + detail.path.length + 1, 0) <= 250;

if (includeUrls) {
// We can fit all the URLs we want!
openUrl.searchParams.append("url", details.map(({ path }) => path).join("+"));
if (sceneIndex > 0) {
openUrl.searchParams.append("scene", sceneIndex.toString());
}
} else {
// There are more scene URLs than we want to put in the full URL. We need to send them over as a message.
// Include only the URL of the focused scene, so the link is usable even if the message fails.
const initialImageUrl = details[Math.max(sceneIndex, 0)].path;
openUrl.searchParams.append("url", initialImageUrl);
message.scenes = scenes;
if (sceneIndex > 0) {
message.sceneIndex = sceneIndex;
}
}

if (includeUrls && Object.keys(message.meta).length === 0) {
window.open(openUrl);
} else {
openWindowWithMessage(openUrl, message);
}
}, [fileDetails, fileSelection]);

const plateLink = fileDetails?.getLinkToPlateUI(loadBalancerBaseUrl);
const annotationNameToLinkMap = React.useMemo(
() =>
Expand Down Expand Up @@ -365,7 +472,7 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe
})
.sort((a, b) => (a.text || "").localeCompare(b.text || ""));

const apps = APPS(fileDetails, { openInCfe });
const apps = APPS(fileDetails, { openInCfe, openInVole });

// Determine is the file is small or not asynchronously
React.useEffect(() => {
Expand Down