From 3bb7362ab08f79abff781bb5f2a85b59c687cf34 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 17:59:47 +0000 Subject: [PATCH 1/6] feat: add Neuroglancer URL source preference and resolver Introduce a per-user viewerUrlSources preference (configured/manifest/custom, keyed by viewer) and a resolveViewerTemplate helper that maps a viewer plus the chosen source to an effective URL template. Expose the manifest-default template on ValidViewer so the manifest ('external') option has a URL to resolve to, and persist the new preference through the existing preference API. No consumers yet; wired up in subsequent commits. --- .../src/__tests__/unitTests/viewerUrl.test.ts | 48 +++++++++++++++++++ frontend/src/contexts/PreferencesContext.tsx | 38 +++++++++++++++ frontend/src/contexts/ViewersContext.tsx | 21 ++++++-- frontend/src/queries/preferencesQueries.ts | 6 ++- frontend/src/utils/viewerUrl.ts | 24 ++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/unitTests/viewerUrl.test.ts create mode 100644 frontend/src/utils/viewerUrl.ts diff --git a/frontend/src/__tests__/unitTests/viewerUrl.test.ts b/frontend/src/__tests__/unitTests/viewerUrl.test.ts new file mode 100644 index 00000000..e566e8ff --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewerUrl.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; + +import { resolveViewerTemplate } from '@/utils/viewerUrl'; +import type { ValidViewer } from '@/contexts/ViewersContext'; + +const viewer = { + key: 'neuroglancer', + displayName: 'Neuroglancer', + urlTemplate: 'https://ng.internal.example.org/#!', + manifestTemplateUrl: 'https://neuroglancer-demo.appspot.com/#!', + logoPath: '', + label: 'View in Neuroglancer', + manifest: {} as ValidViewer['manifest'] +} satisfies ValidViewer; + +describe('resolveViewerTemplate', () => { + test('defaults to the configured template when no source is set', () => { + expect(resolveViewerTemplate(viewer, undefined)).toBe(viewer.urlTemplate); + expect(resolveViewerTemplate(viewer, 'configured')).toBe( + viewer.urlTemplate + ); + }); + + test('uses the manifest default when source is "manifest"', () => { + expect(resolveViewerTemplate(viewer, 'manifest')).toBe( + viewer.manifestTemplateUrl + ); + }); + + test('falls back to the configured template when manifest default is empty', () => { + const noManifest = { ...viewer, manifestTemplateUrl: '' }; + expect(resolveViewerTemplate(noManifest, 'manifest')).toBe( + noManifest.urlTemplate + ); + }); + + test('uses the custom URL when provided', () => { + expect( + resolveViewerTemplate(viewer, { custom: 'https://custom.example/#!' }) + ).toBe('https://custom.example/#!'); + }); + + test('falls back to the configured template for an empty custom URL', () => { + expect(resolveViewerTemplate(viewer, { custom: '' })).toBe( + viewer.urlTemplate + ); + }); +}); diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index d08c680b..b868a7f6 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -39,6 +39,18 @@ export type FolderPreference = { fspName: string; }; +/** + * Which URL to use for a given viewer. Defaults to 'configured' when a viewer + * is absent from the map. + * - 'configured': the deployment default (viewers.config instance_template_url + * override, else the manifest default) + * - 'manifest': the capability-manifest library's default (e.g. the external + * Neuroglancer at appspot) + * - { custom }: a user-supplied template URL (reserved for future UI) + */ +export type ViewerUrlSource = 'configured' | 'manifest' | { custom: string }; +export type ViewerUrlSources = Record; + type PreferencesContextType = { //Full query for accessing loading/error states preferenceQuery: ReturnType; @@ -52,6 +64,7 @@ type PreferencesContextType = { disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; + viewerUrlSources: ViewerUrlSources; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -84,6 +97,10 @@ type PreferencesContextType = { toggleDisableNeuroglancerStateGeneration: () => Promise>; toggleDisableHeuristicalLayerTypeDetection: () => Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; + setViewerUrlSource: ( + viewerKey: string, + source: ViewerUrlSource + ) => Promise>; toggleFilterByGroups: () => Promise>; toggleShowTutorial: () => Promise>; updateDefaultExtraArgs: (args: string) => Promise>; @@ -257,6 +274,25 @@ export const PreferencesProvider = ({ ); }; + const setViewerUrlSource = async ( + viewerKey: string, + source: ViewerUrlSource + ): Promise> => { + try { + const updated: ViewerUrlSources = { + ...(preferencesQuery.data?.viewerUrlSources || {}), + [viewerKey]: source + }; + await updatePreferenceMutation.mutateAsync({ + key: 'viewerUrlSources', + value: updated + }); + return createSuccess(undefined); + } catch (error) { + return handleError(error); + } + }; + const toggleShowTutorial = async (): Promise> => { return togglePreference( 'showTutorial', @@ -550,6 +586,7 @@ export const PreferencesProvider = ({ preferencesQuery.data?.disableHeuristicalLayerTypeDetection || false, useLegacyMultichannelApproach: preferencesQuery.data?.useLegacyMultichannelApproach || false, + viewerUrlSources: preferencesQuery.data?.viewerUrlSources || {}, isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true, showTutorial: preferencesQuery.data?.showTutorial ?? true, defaultExtraArgs: preferencesQuery.data?.defaultExtraArgs || '', @@ -579,6 +616,7 @@ export const PreferencesProvider = ({ toggleDisableNeuroglancerStateGeneration, toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, + setViewerUrlSource, toggleFilterByGroups, toggleShowTutorial, updateDefaultExtraArgs, diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index bff4bc00..4b018b67 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -26,6 +26,13 @@ export interface ValidViewer { displayName: string; /** URL template (may contain {dataLink} placeholder) */ urlTemplate: string; + /** + * The capability-manifest library's default template, before any instance + * override from viewers.config. Empty string if the manifest has no + * template_url. Used to let users opt back to the manifest default (e.g. the + * external Neuroglancer at appspot) when a deployment overrides the URL. + */ + manifestTemplateUrl: string; /** Logo path */ logoPath: string; /** Tooltip/alt text label */ @@ -124,10 +131,15 @@ export function ViewersProvider({ } // Replace {DATA_URL} with {dataLink} for consistency with existing code - const normalizedUrlTemplate = urlTemplate.replace( - /{DATA_URL}/g, - '{dataLink}' - ); + const normalizeTemplate = (template: string) => + template.replace(/{DATA_URL}/g, '{dataLink}'); + const normalizedUrlTemplate = normalizeTemplate(urlTemplate); + + // The manifest's own default template (before any instance override), + // normalized the same way. Empty string if the manifest has none. + const manifestTemplateUrl = manifest.viewer.template_url + ? normalizeTemplate(manifest.viewer.template_url) + : ''; // Create valid viewer entry const key = normalizeViewerName(manifest.viewer.name); @@ -139,6 +151,7 @@ export function ViewersProvider({ key, displayName, urlTemplate: normalizedUrlTemplate, + manifestTemplateUrl, logoPath, label, manifest diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 4e43dbec..e1372c75 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -17,7 +17,8 @@ import type { ZonePreference, FileSharePathPreference, FolderPreference, - FolderFavorite + FolderFavorite, + ViewerUrlSources } from '@/contexts/PreferencesContext'; import { getResponseJsonOrError, @@ -37,6 +38,7 @@ type PreferencesApiResponse = { disableNeuroglancerStateGeneration?: { value: boolean }; disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; + viewerUrlSources?: { value: ViewerUrlSources }; isFilteredByGroups?: { value: boolean }; showTutorial?: { value: boolean }; defaultExtraArgs?: { value: string }; @@ -70,6 +72,7 @@ export type PreferencesQueryData = { disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; + viewerUrlSources: ViewerUrlSources; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -242,6 +245,7 @@ const createTransformPreferences = ( rawData.disableHeuristicalLayerTypeDetection?.value || false, useLegacyMultichannelApproach: rawData.useLegacyMultichannelApproach?.value || false, + viewerUrlSources: rawData.viewerUrlSources?.value || {}, isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true, showTutorial: rawData.showTutorial?.value ?? true, defaultExtraArgs: rawData.defaultExtraArgs?.value || '', diff --git a/frontend/src/utils/viewerUrl.ts b/frontend/src/utils/viewerUrl.ts new file mode 100644 index 00000000..3dc4d05f --- /dev/null +++ b/frontend/src/utils/viewerUrl.ts @@ -0,0 +1,24 @@ +import type { ValidViewer } from '@/contexts/ViewersContext'; +import type { ViewerUrlSource } from '@/contexts/PreferencesContext'; + +/** + * Resolve the URL template to use for a viewer given the user's chosen source. + * + * Falls back to the deployment-configured template for an absent/unknown source + * or when the requested template is empty, so callers always get a usable value. + */ +export function resolveViewerTemplate( + viewer: ValidViewer, + source: ViewerUrlSource | undefined +): string { + if (!source || source === 'configured') { + return viewer.urlTemplate; + } + if (source === 'manifest') { + return viewer.manifestTemplateUrl || viewer.urlTemplate; + } + if (typeof source === 'object' && source.custom) { + return source.custom; + } + return viewer.urlTemplate; +} From b7cdb1fd0c464282d35391a203d9097fe5cfa732 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 17:59:53 +0000 Subject: [PATCH 2/6] feat: add Preferences UI to choose the Neuroglancer URL Add a radio group under a new 'Neuroglancer' preferences section letting users switch their Neuroglancer links between the deployment-configured URL and the manifest default (external appspot). The control only appears when the deployment actually overrides the URL. Includes the accompanying layout adjustments to give Neuroglancer options their own section. --- frontend/src/components/Preferences.tsx | 10 +- .../ui/PreferencesPage/DataLinkOptions.tsx | 4 +- .../PreferencesPage/NeuroglancerOptions.tsx | 175 ++++++++++++------ 3 files changed, 129 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/Preferences.tsx b/frontend/src/components/Preferences.tsx index 4ebf3f83..4b2220c3 100644 --- a/frontend/src/components/Preferences.tsx +++ b/frontend/src/components/Preferences.tsx @@ -29,8 +29,16 @@ export default function Preferences() { Data links -
+
+
+
+ +
+ + Neuroglancer links + +
diff --git a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx index d188b443..fba60ddd 100644 --- a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx @@ -74,7 +74,7 @@ export default function DataLinkOptions({ }; return ( - <> +
{hideSubpathMode ? null : } - +
); } diff --git a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx index 903ad75b..cd182d5f 100644 --- a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx @@ -1,8 +1,20 @@ +import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; +import FgRadio from '@/components/designSystem/atoms/formElements/FgRadio'; import FgFieldSet from '@/components/designSystem/molecules/FgFieldSet'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; + +/** Best-effort hostname for display; falls back to the raw template. */ +function hostLabel(template: string): string { + try { + return new URL(template).hostname; + } catch { + return template; + } +} export default function NeuroglancerOptions() { const { @@ -11,65 +23,114 @@ export default function NeuroglancerOptions() { disableNeuroglancerStateGeneration, toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - toggleDisableHeuristicalLayerTypeDetection + toggleDisableHeuristicalLayerTypeDetection, + viewerUrlSources, + setViewerUrlSource } = usePreferencesContext(); + const { validViewers } = useViewersContext(); + + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + + // Only offer the URL choice when the deployment overrides the Neuroglancer URL + // (i.e. the configured template differs from the manifest default). Otherwise + // both options resolve to the same URL and the control is meaningless. + const showUrlSourceChoice = + !!neuroglancer && + !!neuroglancer.manifestTemplateUrl && + neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate; + + const currentSource = + viewerUrlSources['neuroglancer'] === 'manifest' ? 'manifest' : 'configured'; + + const handleUrlSourceChange = async (source: 'configured' | 'manifest') => { + const result = await setViewerUrlSource('neuroglancer', source); + if (result.success) { + toast.success('Neuroglancer URL preference updated'); + } else { + toast.error(result.error); + } + }; return ( - - { - const result = await toggleUseLegacyMultichannelApproach(); - if (result.success) { - toast.success( - useLegacyMultichannelApproach - ? 'Disabled multichannel state generation for Neuroglancer' - : 'Enabled multichannel state generation for Neuroglancer' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - { - const result = await toggleDisableNeuroglancerStateGeneration(); - if (result.success) { - toast.success( - disableNeuroglancerStateGeneration - ? 'Neuroglancer state generation is now enabled' - : 'Neuroglancer state generation is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - { - const result = await toggleDisableHeuristicalLayerTypeDetection(); - if (result.success) { - toast.success( - disableHeuristicalLayerTypeDetection - ? 'Heuristical layer type determination is now enabled' - : 'Heuristical layer type determination is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - +
+ {showUrlSourceChoice ? ( + + handleUrlSourceChange('configured')} + value="configured" + /> + handleUrlSourceChange('manifest')} + value="manifest" + /> + + ) : null} + + { + const result = await toggleUseLegacyMultichannelApproach(); + if (result.success) { + toast.success( + useLegacyMultichannelApproach + ? 'Disabled multichannel state generation for Neuroglancer' + : 'Enabled multichannel state generation for Neuroglancer' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableNeuroglancerStateGeneration(); + if (result.success) { + toast.success( + disableNeuroglancerStateGeneration + ? 'Neuroglancer state generation is now enabled' + : 'Neuroglancer state generation is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableHeuristicalLayerTypeDetection(); + if (result.success) { + toast.success( + disableHeuristicalLayerTypeDetection + ? 'Heuristical layer type determination is now enabled' + : 'Heuristical layer type determination is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + +
); } From da745ae6eb7ea6ac3fac8d1d9673b62b69711304 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 17:59:58 +0000 Subject: [PATCH 3/6] feat: apply Neuroglancer URL preference to data tool links Resolve the effective Neuroglancer template via resolveViewerTemplate when building the 'Open with' tool links, so OME-Zarr, Zarr, and N5 datasets all honor the user's chosen URL source instead of the deployment default. --- frontend/src/hooks/useN5Metadata.ts | 23 +++++++++++++++++++++-- frontend/src/hooks/useZarrMetadata.ts | 23 ++++++++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts index aa665368..a1f61875 100644 --- a/frontend/src/hooks/useN5Metadata.ts +++ b/frontend/src/hooks/useN5Metadata.ts @@ -2,11 +2,18 @@ import { useMemo } from 'react'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; import { useN5MetadataQuery } from '@/queries/n5Queries'; import type { N5Metadata, N5OpenWithToolUrls } from '@/queries/n5Queries'; export type { N5Metadata, N5OpenWithToolUrls }; +// Fallback used only when no Neuroglancer viewer is configured. +const FALLBACK_NEUROGLANCER_BASE_URL = + 'https://neuroglancer-demo.appspot.com/#!'; + /** * Get the Neuroglancer source URL for N5 format */ @@ -51,6 +58,8 @@ export default function useN5Metadata() { const { fileQuery } = useFileBrowserContext(); const { currentDirProxiedPathQuery } = useProxiedPathContext(); const { externalDataUrlQuery } = useExternalBucketContext(); + const { viewerUrlSources } = usePreferencesContext(); + const { validViewers } = useViewersContext(); // Fetch N5 metadata const n5MetadataQuery = useN5MetadataQuery({ @@ -66,7 +75,15 @@ export default function useN5Metadata() { return null; } - const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; + // Resolve the Neuroglancer base URL from the deployment config and the + // user's per-viewer URL preference, matching the OME-Zarr/Zarr path. + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + const neuroglancerBaseUrl = neuroglancer + ? resolveViewerTemplate( + neuroglancer, + viewerUrlSources['neuroglancer'] + ).split('#!')[0] + '#!' + : FALLBACK_NEUROGLANCER_BASE_URL; const url = externalDataUrlQuery.data || currentDirProxiedPathQuery.data?.url; @@ -86,7 +103,9 @@ export default function useN5Metadata() { }, [ metadata, currentDirProxiedPathQuery.data?.url, - externalDataUrlQuery.data + externalDataUrlQuery.data, + validViewers, + viewerUrlSources ]); return { diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 86190f92..9e1d3b7c 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -18,6 +18,7 @@ import { determineLayerType } from '@/omezarr-helper'; import { buildUrl } from '@/utils'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; import * as zarr from 'zarrita'; export type { OpenWithToolUrls, ZarrMetadata }; @@ -31,7 +32,8 @@ export default function useZarrMetadata() { const { disableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - useLegacyMultichannelApproach + useLegacyMultichannelApproach, + viewerUrlSources } = usePreferencesContext(); const { validViewers, @@ -135,13 +137,19 @@ export default function useZarrMetadata() { continue; } + // Resolve the template based on the user's per-viewer URL preference + const effectiveTemplate = resolveViewerTemplate( + viewer, + viewerUrlSources[viewer.key] + ); + // Generate the viewer URL - let viewerUrl = viewer.urlTemplate; + let viewerUrl = effectiveTemplate; // Special handling for Neuroglancer to maintain existing state generation logic if (viewer.key === 'neuroglancer') { // Extract base URL from template (everything before #!) - const neuroglancerBaseUrl = viewer.urlTemplate.split('#!')[0] + '#!'; + const neuroglancerBaseUrl = effectiveTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { viewerUrl = neuroglancerBaseUrl + @@ -194,9 +202,13 @@ export default function useZarrMetadata() { } else { // Neuroglancer if (url) { + // Resolve the template based on the user's per-viewer URL preference + const effectiveTemplate = resolveViewerTemplate( + viewer, + viewerUrlSources[viewer.key] + ); // Extract base URL from template (everything before #!) - const neuroglancerBaseUrl = - viewer.urlTemplate.split('#!')[0] + '#!'; + const neuroglancerBaseUrl = effectiveTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { openWithToolUrls.neuroglancer = neuroglancerBaseUrl + @@ -229,6 +241,7 @@ export default function useZarrMetadata() { externalDataUrlQuery.data, disableNeuroglancerStateGeneration, useLegacyMultichannelApproach, + viewerUrlSources, layerType, effectiveZarrVersion, validViewers, From a537b0fec0bd5977933218564874b91cc828ec81 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 18:00:03 +0000 Subject: [PATCH 4/6] feat: apply Neuroglancer URL preference to short-link dialog Default the 'Create Neuroglancer Short Link' dialog's base URL and placeholders to the preference-resolved URL rather than a hardcoded appspot value, keeping the create-mode default in sync as viewers load. Existing links keep their stored base URL. --- .../components/ui/Dialogs/NGLinkDialog.tsx | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx index 63936d8c..22f523d2 100644 --- a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import type { ChangeEvent } from 'react'; import { Typography } from '@material-tailwind/react'; @@ -13,6 +13,9 @@ import { validateJsonState, constructNeuroglancerUrl } from '@/utils'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgFormField from '@/components/designSystem/molecules/FgFormField'; import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; @@ -29,6 +32,7 @@ type NGLinkDialogProps = { readonly editItem?: NGLink; }; +// Fallback used only when no Neuroglancer viewer is configured. const DEFAULT_BASE_URL = 'https://neuroglancer-demo.appspot.com/'; export default function NGLinkDialog({ @@ -43,10 +47,33 @@ export default function NGLinkDialog({ const urlInputRef = useRef(null); const shouldAutoSelectUrl = useRef(false); + const { validViewers } = useViewersContext(); + const { viewerUrlSources } = usePreferencesContext(); + + // The base URL a new short link defaults to, honoring the deployment's + // configured Neuroglancer URL and the user's per-viewer URL preference. + const defaultBaseUrl = useMemo(() => { + const source = viewerUrlSources['neuroglancer']; + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + if (neuroglancer) { + const template = resolveViewerTemplate(neuroglancer, source); + // Strip the '#!' state fragment to get the bare base URL. + return template.split('#!')[0] || DEFAULT_BASE_URL; + } + // No Neuroglancer viewer is configured, so 'configured'/'manifest' have no + // template to resolve. A user-supplied custom URL is the only preference + // value that carries a URL on its own; otherwise fall back to the external + // default. + if (typeof source === 'object' && source.custom) { + return source.custom.split('#!')[0] || DEFAULT_BASE_URL; + } + return DEFAULT_BASE_URL; + }, [validViewers, viewerUrlSources]); + const [inputMode, setInputMode] = useState<'url' | 'state'>('url'); const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); const [stateJson, setStateJson] = useState(''); - const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); + const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); const [shortName, setShortName] = useState(''); const [title, setTitle] = useState(''); const [error, setError] = useState(null); @@ -78,12 +105,22 @@ export default function NGLinkDialog({ setShortName(''); setTitle(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); setUrlValidationError(null); setStateValidationError(null); } }, [editItem]); + // When creating a new link, keep the base URL aligned with the resolved + // default as it becomes available (viewers finishing loading) or changes + // (preference update). Owns the create-mode base URL so the reset above + // stays keyed on editItem alone and never clears a half-filled form. Does + // not run in edit mode, so existing links keep their stored base URL. + useEffect(() => { + if (!editItem) { + setBaseUrl(defaultBaseUrl); + } + }, [defaultBaseUrl, editItem]); + // Auto-select the URL text once after it's populated in edit mode useEffect(() => { if (shouldAutoSelectUrl.current && urlInputRef.current) { @@ -131,7 +168,7 @@ export default function NGLinkDialog({ if (!isEditMode) { setNeuroglancerUrl(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); + setBaseUrl(defaultBaseUrl); } setUrlValidationError(null); setStateValidationError(null); @@ -172,7 +209,7 @@ export default function NGLinkDialog({ setInputMode('url'); setNeuroglancerUrl(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); + setBaseUrl(defaultBaseUrl); setShortName(''); setTitle(''); onClose(); @@ -294,7 +331,7 @@ export default function NGLinkDialog({ autoFocus id="neuroglancer-url" onChange={handleUrlChange} - placeholder="https://neuroglancer-demo.appspot.com/#!{...}" + placeholder={`${defaultBaseUrl}#!{...}`} ref={urlInputRef} size="lg" type="text" @@ -328,7 +365,7 @@ export default function NGLinkDialog({ onChange={(e: ChangeEvent) => setBaseUrl(e.target.value) } - placeholder="https://neuroglancer-demo.appspot.com/" + placeholder={defaultBaseUrl} size="lg" type="text" value={baseUrl} From 1158ac379985b0562245080b351b28333217eb5f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 18:00:12 +0000 Subject: [PATCH 5/6] docs: document per-user Neuroglancer URL override --- docs/ViewersConfiguration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 52542957..714b15e0 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -114,6 +114,12 @@ viewers: ``` +#### Per-user override of the Neuroglancer URL + +When a deployment overrides Neuroglancer's URL with `instance_template_url` (for example, to point at an internal Neuroglancer instance), individual users can still switch their own links back to the manifest default (the external Neuroglancer at `neuroglancer-demo.appspot.com`) from **Preferences → Neuroglancer**. + +This choice is a per-user preference (`viewerUrlSources`); it does not change the deployment configuration and only affects the user who sets it. The option only appears when the deployment actually overrides the Neuroglancer URL — if the configured URL already equals the manifest default, there is nothing to switch between and no control is shown. + ### Add a custom viewer To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: From 40d22f22c499bdb8874a8f106f079deb433c40c7 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 22 Jul 2026 19:00:41 +0000 Subject: [PATCH 6/6] refactor: resolve NG short-link base URL in container Move the default Neuroglancer base URL resolution out of NGLinkDialog into a useDefaultNeuroglancerBaseUrl hook used by the NGLinks container, which passes the result down as a prop. Keeps the dialog a pure presentational component (no direct context access), so it renders without the viewers/preferences provider tree and its unit tests pass without extra wrappers. No behavior change. --- frontend/src/components/NGLinks.tsx | 3 ++ .../components/ui/Dialogs/NGLinkDialog.tsx | 41 +++++-------------- .../hooks/useDefaultNeuroglancerBaseUrl.ts | 37 +++++++++++++++++ 3 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts diff --git a/frontend/src/components/NGLinks.tsx b/frontend/src/components/NGLinks.tsx index e9080244..cdf2dbf4 100644 --- a/frontend/src/components/NGLinks.tsx +++ b/frontend/src/components/NGLinks.tsx @@ -10,6 +10,7 @@ import FgDialog from '@/components/ui/Dialogs/FgDialog'; import { useNGLinkContext } from '@/contexts/NGLinkContext'; import type { CreateNGLinkPayload, NGLink } from '@/queries/ngLinkQueries'; import { copyToClipboard } from '@/utils/copyText'; +import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; import FgButton from './designSystem/atoms/FgButton'; export default function NGLinks() { @@ -22,6 +23,7 @@ export default function NGLinks() { const [showDialog, setShowDialog] = useState(false); const [editItem, setEditItem] = useState(undefined); const [deleteItem, setDeleteItem] = useState(undefined); + const defaultBaseUrl = useDefaultNeuroglancerBaseUrl(); const handleOpenCreate = () => { setEditItem(undefined); @@ -134,6 +136,7 @@ export default function NGLinks() { {showDialog ? ( Promise; readonly onUpdate?: (payload: UpdateNGLinkPayload) => Promise; readonly editItem?: NGLink; + /** + * Base URL a new short link defaults to, resolved from the deployment config + * and the user's Neuroglancer URL preference by the parent. Falls back to the + * external default when not provided. + */ + readonly defaultBaseUrl?: string; }; -// Fallback used only when no Neuroglancer viewer is configured. -const DEFAULT_BASE_URL = 'https://neuroglancer-demo.appspot.com/'; - export default function NGLinkDialog({ open, pending, onClose, onCreate, onUpdate, - editItem + editItem, + defaultBaseUrl = DEFAULT_NEUROGLANCER_BASE_URL }: NGLinkDialogProps) { const isEditMode = !!editItem; const urlInputRef = useRef(null); const shouldAutoSelectUrl = useRef(false); - const { validViewers } = useViewersContext(); - const { viewerUrlSources } = usePreferencesContext(); - - // The base URL a new short link defaults to, honoring the deployment's - // configured Neuroglancer URL and the user's per-viewer URL preference. - const defaultBaseUrl = useMemo(() => { - const source = viewerUrlSources['neuroglancer']; - const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); - if (neuroglancer) { - const template = resolveViewerTemplate(neuroglancer, source); - // Strip the '#!' state fragment to get the bare base URL. - return template.split('#!')[0] || DEFAULT_BASE_URL; - } - // No Neuroglancer viewer is configured, so 'configured'/'manifest' have no - // template to resolve. A user-supplied custom URL is the only preference - // value that carries a URL on its own; otherwise fall back to the external - // default. - if (typeof source === 'object' && source.custom) { - return source.custom.split('#!')[0] || DEFAULT_BASE_URL; - } - return DEFAULT_BASE_URL; - }, [validViewers, viewerUrlSources]); - const [inputMode, setInputMode] = useState<'url' | 'state'>('url'); const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); const [stateJson, setStateJson] = useState(''); diff --git a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts new file mode 100644 index 00000000..efeb1055 --- /dev/null +++ b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; + +// Used only when no Neuroglancer viewer is configured and no custom URL is set. +export const DEFAULT_NEUROGLANCER_BASE_URL = + 'https://neuroglancer-demo.appspot.com/'; + +/** + * The Neuroglancer base URL (the part before the '#!' state fragment) that new + * short links should default to, honoring the deployment's configured URL and + * the user's per-viewer URL preference. + */ +export function useDefaultNeuroglancerBaseUrl(): string { + const { validViewers } = useViewersContext(); + const { viewerUrlSources } = usePreferencesContext(); + + return useMemo(() => { + const source = viewerUrlSources['neuroglancer']; + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + if (neuroglancer) { + const template = resolveViewerTemplate(neuroglancer, source); + // Strip the '#!' state fragment to get the bare base URL. + return template.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL; + } + // No Neuroglancer viewer is configured, so 'configured'/'manifest' have no + // template to resolve. A user-supplied custom URL is the only preference + // value that carries a URL on its own; otherwise fall back to the external + // default. + if (typeof source === 'object' && source.custom) { + return source.custom.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL; + } + return DEFAULT_NEUROGLANCER_BASE_URL; + }, [validViewers, viewerUrlSources]); +}