+ );
+}
diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx
index 62436412..8a042b7c 100644
--- a/frontend/src/components/ui/Dialogs/DataLink.tsx
+++ b/frontend/src/components/ui/Dialogs/DataLink.tsx
@@ -21,6 +21,7 @@ import {
} from '@/utils/pathHandling';
import type { FileSharePath } from '@/shared.types';
import type { PendingToolKey } from '@/hooks/useZarrMetadata';
+import { DependentViewsError } from '@/queries/proxiedPathQueries';
import FgDialog from './FgDialog';
import TextWithFilePath from './TextWithFilePath';
import DataLinkOptions, {
@@ -54,7 +55,10 @@ interface DeleteLinkDialogProps extends CommonDataLinkDialogProps {
action: 'delete';
pending: boolean;
proxiedPath: ProxiedPath;
- handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise;
+ handleDeleteDataLink: (
+ proxiedPath: ProxiedPath,
+ confirm?: boolean
+ ) => Promise;
}
type DataLinkDialogProps =
@@ -190,6 +194,9 @@ export default function DataLinkDialog(props: DataLinkDialogProps) {
const [openAdvancedSections, setOpenAdvancedSections] = useState(
[]
);
+ const [dependentViews, setDependentViews] = useState<
+ { short_key: string; name: string }[] | null
+ >(null);
const customSubpathError = useMemo(
() =>
@@ -356,18 +363,53 @@ export default function DataLinkDialog(props: DataLinkDialogProps) {
be able to use it to view these data. You can create a new data
link at any time.
+ {dependentViews && dependentViews.length > 0 ? (
+
+
+ These Neuroglancer Views you own use this data link and will
+ be marked broken:
+
+
+ {dependentViews.map(v => (
+
+ {v.name || v.short_key}
+
+ ))}
+
+
+ ) : null}
{
- await props.handleDeleteDataLink(props.proxiedPath);
- props.setShowDataLinkDialog(false);
+ if (dependentViews) {
+ // Second click: user confirmed despite dependent Views.
+ try {
+ await props.handleDeleteDataLink(props.proxiedPath, true);
+ props.setShowDataLinkDialog(false);
+ } catch (error) {
+ if (error instanceof DependentViewsError) {
+ setDependentViews(error.views);
+ }
+ // other errors are already toasted in handleDeleteDataLink
+ }
+ return;
+ }
+ try {
+ await props.handleDeleteDataLink(props.proxiedPath, false);
+ props.setShowDataLinkDialog(false);
+ } catch (error) {
+ if (error instanceof DependentViewsError) {
+ setDependentViews(error.views);
+ }
+ // other errors are already toasted in handleDeleteDataLink
+ }
}}
>
- Delete
+ {dependentViews ? 'Delete anyway' : 'Delete'}
diff --git a/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx b/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx
new file mode 100644
index 00000000..24e0039b
--- /dev/null
+++ b/frontend/src/components/ui/PropertiesDrawer/AppearsInViews.tsx
@@ -0,0 +1,39 @@
+import { Typography } from '@material-tailwind/react';
+
+import { useViewsForDataLinkQuery } from '@/queries/viewQueries';
+
+interface AppearsInViewsProps {
+ readonly sharingKey: string;
+}
+
+export default function AppearsInViews({ sharingKey }: AppearsInViewsProps) {
+ const viewsQuery = useViewsForDataLinkQuery(sharingKey);
+
+ // Stay quiet while loading / on error / when unused — this is a
+ // supplementary read-only hint, not a primary control.
+ if (viewsQuery.isPending || viewsQuery.isError) {
+ return null;
+ }
+ const views = viewsQuery.data ?? [];
+ if (views.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ Appears in {views.length} View{views.length === 1 ? '' : 's'}
+
+ {/* ponytail: names only, not links — PR 6 makes View names
+ navigable to the embedded viewer; a link to nowhere now is worse
+ than plain text. */}
+
+ {views.map(v => (
+
+ {v.name || v.short_key}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx
index 3125a026..78e50518 100644
--- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx
+++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx
@@ -3,10 +3,12 @@ import { Card, IconButton, Typography, Tabs } from '@material-tailwind/react';
import toast from 'react-hot-toast';
import { HiOutlineDocument, HiOutlineDuplicate, HiX } from 'react-icons/hi';
import { HiFolder } from 'react-icons/hi2';
-import { useLocation } from 'react-router';
+import { useLocation, useNavigate } from 'react-router';
import FgIcon from '@/components/designSystem/atoms/FgIcon';
+import CartList from '@/components/ui/Views/CartList';
+import AppearsInViews from '@/components/ui/PropertiesDrawer/AppearsInViews';
import PermissionsTable from '@/components/ui/PropertiesDrawer/PermissionsTable';
import OverviewTable from '@/components/ui/PropertiesDrawer/OverviewTable';
import TicketDetails from '@/components/ui/PropertiesDrawer/TicketDetails';
@@ -35,6 +37,7 @@ type PropertiesDrawerProps = {
readonly setShowConvertFileDialog: React.Dispatch<
React.SetStateAction
>;
+ readonly mode?: 'properties' | 'cart';
};
function CopyPathButton({
@@ -86,9 +89,11 @@ function CopyPathButton({
export default function PropertiesDrawer({
togglePropertiesDrawer,
setShowPermissionsDialog,
- setShowConvertFileDialog
+ setShowConvertFileDialog,
+ mode = 'properties'
}: PropertiesDrawerProps) {
const location = useLocation();
+ const navigate = useNavigate();
const [showDataLinkDialog, setShowDataLinkDialog] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
@@ -131,7 +136,9 @@ export default function PropertiesDrawer({
- {fileBrowserState.propertiesTarget.is_symlink ? (
- <>
- {fileBrowserState.propertiesTarget.symlink_target_fsp ? (
-
+ {mode === 'cart' ? (
+ // ponytail: cart body reuses ; the "dot on the ⓘ icon
+ // when a file is selected behind the open cart" (design §7) is
+ // deferred - it needs cross-panel selection wiring for no
+ // functional gain.
+
+
+ navigate('/ngviews?tab=cart')}
+ variant="outline"
+ >
+ Open full Layer Cart
+
+
) : (
- <>
- {fileBrowserState.propertiesTarget.is_dir ? (
-
- ) : (
-
- )}
-
-
- {fileBrowserState.propertiesTarget?.name}
-
-
- >
+
+ Click on a file or folder to view its properties
+
)}
-
- ) : (
-
- Click on a file or folder to view its properties
-
- )}
- {fileBrowserState.propertiesTarget ? (
-
-
-
- Overview
-
-
-
- Permissions
-
+
+
+ Overview
+
- {tasksEnabled && !fileBrowserState.propertiesTarget.is_symlink ? (
-
- Convert
-
- ) : null}
-
-
+
+ Permissions
+
- {/*Overview panel*/}
-
-
-
- {/* Show data link controls for any path (directories, files, and symlinks) */}
- {proxiedPathByFspAndPathQuery.isPending ||
- externalDataUrlQuery.isPending ? (
-
- Loading data link information...
-
- ) : proxiedPathByFspAndPathQuery.isError ? (
- <>
-
- Error loading data link information
-
-
- {proxiedPathByFspAndPathQuery.error.message ||
- 'An unknown error occurred'}
-
- >
- ) : externalDataUrlQuery.isError ? (
- <>
-
- Error loading external data link information
-
-
- {externalDataUrlQuery.error.message ||
- 'An unknown error occurred'}
-
- >
- ) : (
- <>
-
- {
- if (
- areDataLinksAutomatic &&
- dataLinkSubpathMode !== 'custom' &&
- !proxiedPathByFspAndPathQuery.data
- ) {
- await handleCreateDataLink();
- } else {
- setShowDataLinkDialog(true);
- }
- }}
- />
-
- {externalDataUrlQuery.data
- ? 'Public data link already exists since this data is on s3.janelia.org.'
- : proxiedPathByFspAndPathQuery.data
- ? 'Deleting the data link will remove data access for collaborators with the link.'
- : 'Creating a data link allows you to share the data at this path with internal collaborators or use tools to view the data.'}
+ Convert
+
+ ) : null}
+
+
+
+ {/*Overview panel*/}
+
+
+
+ {/* Show data link controls for any path (directories, files, and symlinks) */}
+ {proxiedPathByFspAndPathQuery.isPending ||
+ externalDataUrlQuery.isPending ? (
+
+ Loading data link information...
- {!externalDataUrlQuery.data &&
- !proxiedPathByFspAndPathQuery.data ? (
-
- Learn more about data links
-
- ) : null}
-
+ {
+ if (
+ areDataLinksAutomatic &&
+ dataLinkSubpathMode !== 'custom' &&
+ !proxiedPathByFspAndPathQuery.data
+ ) {
+ await handleCreateDataLink();
+ } else {
+ setShowDataLinkDialog(true);
}
- fspName={
- fileQuery.data?.currentFileSharePath?.name ?? ''
+ }}
+ />
+
+ {externalDataUrlQuery.data
+ ? 'Public data link already exists since this data is on s3.janelia.org.'
+ : proxiedPathByFspAndPathQuery.data
+ ? 'Deleting the data link will remove data access for collaborators with the link.'
+ : 'Creating a data link allows you to share the data at this path with internal collaborators or use tools to view the data.'}
+
+ {!externalDataUrlQuery.data &&
+ !proxiedPathByFspAndPathQuery.data ? (
+
+ Learn more about data links
+
+ ) : null}
+
+ {(externalDataUrlQuery.data ??
+ proxiedPathByFspAndPathQuery.data?.url) ? (
+ <>
+
- )}
-
+
+ {closeDialog => (
+
+ )}
+
+ >
+ ) : null}
>
+ )}
+ {proxiedPathByFspAndPathQuery.data?.sharing_key ? (
+
) : null}
- >
- )}
-
+
- {/*Permissions panel*/}
-
-
- {
- setShowPermissionsDialog(true);
- }}
- variant="outline"
- >
- Change Permissions
-
-
+ {/*Permissions panel*/}
+
+
+ {
+ setShowPermissionsDialog(true);
+ }}
+ variant="outline"
+ >
+ Change Permissions
+
+
- {/*Task panel*/}
- {tasksEnabled && !fileBrowserState.propertiesTarget.is_symlink ? (
-
- {ticketByPathQuery.isPending ? (
-
- Loading ticket information...
-
- ) : ticketByPathQuery.isError ? (
- <>
-
- Error loading ticket information
-
-
- {ticketByPathQuery.error.message ||
- 'An unknown error occurred'}
-
- >
- ) : ticketByPathQuery.data ? (
-
- ) : (
- <>
-
- Scientific Computing can help you convert images to
- OME-Zarr format, suitable for viewing in external viewers
- like Neuroglancer.
-
- {
- setShowConvertFileDialog(true);
- }}
- variant="outline"
- >
- Open conversion request
-
- >
- )}
-
+ {/*Task panel*/}
+ {tasksEnabled &&
+ !fileBrowserState.propertiesTarget.is_symlink ? (
+
+ {ticketByPathQuery.isPending ? (
+
+ Loading ticket information...
+
+ ) : ticketByPathQuery.isError ? (
+ <>
+
+ Error loading ticket information
+
+
+ {ticketByPathQuery.error.message ||
+ 'An unknown error occurred'}
+
+ >
+ ) : ticketByPathQuery.data ? (
+
+ ) : (
+ <>
+
+ Scientific Computing can help you convert images to
+ OME-Zarr format, suitable for viewing in external
+ viewers like Neuroglancer.
+
+ {
+ setShowConvertFileDialog(true);
+ }}
+ variant="outline"
+ >
+ Open conversion request
+
+ >
+ )}
+
+ ) : null}
+
) : null}
-
- ) : null}
+ >
+ )}
{showDataLinkDialog &&
!proxiedPathByFspAndPathQuery.data &&
diff --git a/frontend/src/components/ui/Views/CartList.tsx b/frontend/src/components/ui/Views/CartList.tsx
new file mode 100644
index 00000000..76d76e96
--- /dev/null
+++ b/frontend/src/components/ui/Views/CartList.tsx
@@ -0,0 +1,101 @@
+import { useMemo } from 'react';
+import { Typography } from '@material-tailwind/react';
+import toast from 'react-hot-toast';
+
+import CartDatasetRow from '@/components/ui/Views/CartDatasetRow';
+import CreateViewButton from '@/components/ui/Views/CreateViewButton';
+import FgButton from '@/components/designSystem/atoms/FgButton';
+import { useCartContext } from '@/contexts/CartContext';
+import { useAllProxiedPathsQuery } from '@/queries/proxiedPathQueries';
+import { datasetKey } from '@/utils/pathHandling';
+import type { CartItem } from '@/contexts/CartContext';
+
+type CartGroup = {
+ fsp_name: string;
+ path: string;
+ label: string;
+ items: CartItem[];
+};
+
+function groupCartByDataset(cart: CartItem[]): CartGroup[] {
+ const groups = new Map();
+ for (const item of cart) {
+ const key = datasetKey(item.fsp_name, item.path);
+ const existing = groups.get(key);
+ if (existing) {
+ existing.items.push(item);
+ // Prefer the base (no-channel) entry's label for the dataset row.
+ if (!item.channel) {
+ existing.label = item.label;
+ }
+ } else {
+ // A channel-only entry's label is the channel name (e.g. "DAPI"), not
+ // the dataset name - fall back to the path until/unless a base entry
+ // shows up, rather than letting a channel string become the header.
+ groups.set(key, {
+ fsp_name: item.fsp_name,
+ path: item.path,
+ label: item.channel ? item.path : item.label,
+ items: [item]
+ });
+ }
+ }
+ return Array.from(groups.values());
+}
+
+export default function CartList() {
+ const { cart, clearCart } = useCartContext();
+ const allProxiedPathsQuery = useAllProxiedPathsQuery();
+
+ const cartGroups = useMemo(() => groupCartByDataset(cart), [cart]);
+ const dataLinkUrlByDataset = useMemo(() => {
+ const map = new Map();
+ for (const p of allProxiedPathsQuery.data ?? []) {
+ map.set(datasetKey(p.fsp_name, p.path), p.url);
+ }
+ return map;
+ }, [allProxiedPathsQuery.data]);
+
+ const handleClearCart = async () => {
+ try {
+ await clearCart();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Clear failed');
+ }
+ };
+
+ if (cart.length === 0) {
+ return (
+
+ Your Layer Cart is empty. Add datasets from the file browser.
+
+ );
+ }
+
+ return (
+