From 8c119f6b78272d0dd64e5508ee96b464bc9e7c57 Mon Sep 17 00:00:00 2001 From: Mark Kittisopikul Date: Wed, 8 Apr 2026 12:49:20 -0400 Subject: [PATCH 1/5] feat: add View option to context menu for browser-renderable files Adds a "View" item to the "..." context menu that opens images, video, audio, and PDF files directly in a new browser tab via the /api/content/ endpoint. The item is only shown for non-directory, non-symlink files with a browser-renderable extension. Introduces getBrowserRenderableType() in fileContentQueries.ts (exported for future inline FileViewer preview use) and a useHandleView hook mirroring the existing useHandleDownload pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../components/ui/BrowsePage/FileBrowser.tsx | 13 +++++++ frontend/src/hooks/useHandleView.ts | 22 +++++++++++ frontend/src/queries/fileContentQueries.ts | 39 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 frontend/src/hooks/useHandleView.ts diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 4e320eac3..1302ffcfc 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -20,7 +20,9 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useHideDotFiles from '@/hooks/useHideDotFiles'; import { useHandleDownload } from '@/hooks/useHandleDownload'; +import { useHandleView } from '@/hooks/useHandleView'; import { detectZarrVersions } from '@/queries/zarrQueries'; +import { getBrowserRenderableType } from '@/queries/fileContentQueries'; import { detectN5, getN5DetectionSignals } from '@/queries/n5Queries'; import { makeMapKey } from '@/utils'; import type { FileOrFolder } from '@/shared.types'; @@ -61,6 +63,7 @@ export default function FileBrowser({ usePreferencesContext(); const { displayFiles } = useHideDotFiles(); const { handleDownload } = useHandleDownload(); + const { handleView } = useHandleView(); const { contextMenuCoords, @@ -138,6 +141,16 @@ export default function FileBrowser({ }, shouldShow: !showPropertiesDrawer }, + { + name: 'View', + action: () => { + handleView(); + }, + shouldShow: + !fileBrowserState.selectedFiles[0]?.is_dir && + !fileBrowserState.selectedFiles[0]?.is_symlink && + getBrowserRenderableType(propertiesTarget.name) !== null + }, { name: 'Download', action: () => { diff --git a/frontend/src/hooks/useHandleView.ts b/frontend/src/hooks/useHandleView.ts new file mode 100644 index 000000000..71d5f4ccd --- /dev/null +++ b/frontend/src/hooks/useHandleView.ts @@ -0,0 +1,22 @@ +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { getFileURL } from '@/utils'; + +export function useHandleView() { + const { fileBrowserState, fileQuery } = useFileBrowserContext(); + + const handleView = () => { + if ( + !fileQuery.data?.currentFileSharePath || + !fileBrowserState.propertiesTarget + ) { + return; + } + const url = getFileURL( + fileQuery.data.currentFileSharePath.name, + fileBrowserState.propertiesTarget.path + ); + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + return { handleView }; +} diff --git a/frontend/src/queries/fileContentQueries.ts b/frontend/src/queries/fileContentQueries.ts index 82a9ab530..3e398ce87 100644 --- a/frontend/src/queries/fileContentQueries.ts +++ b/frontend/src/queries/fileContentQueries.ts @@ -8,6 +8,45 @@ import { buildUrl, sendFetchRequest } from '@/utils'; import { fetchFileContent } from './queryUtils'; import type { FetchRequestOptions } from '@/shared.types'; +const BROWSER_RENDERABLE_EXTENSIONS: Record< + string, + 'image' | 'video' | 'audio' | 'pdf' +> = { + // Images + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + webp: 'image', + svg: 'image', + ico: 'image', + bmp: 'image', + // Video + mp4: 'video', + webm: 'video', + ogv: 'video', + // Audio + mp3: 'audio', + wav: 'audio', + ogg: 'audio', + aac: 'audio', + flac: 'audio', + // Documents + pdf: 'pdf' +}; + +/** + * Returns the browser-renderable media type for a filename, or null if the + * browser cannot render it natively. Used to gate the "View" menu item and + * (in future) to select the appropriate inline preview renderer. + */ +export function getBrowserRenderableType( + filename: string +): 'image' | 'video' | 'audio' | 'pdf' | null { + const ext = filename.toLowerCase().split('.').pop() ?? ''; + return BROWSER_RENDERABLE_EXTENSIONS[ext] ?? null; +} + // Query keys for file content and metadata export const fileContentQueryKeys = { detail: (fspName: string, filePath: string) => From 5f10aea2ef0c4816b9a6482969f8efb6bbf08e18 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 21:25:55 +0000 Subject: [PATCH 2/5] refactor: move getBrowserRenderableType to utils/fileTypes --- .../components/ui/BrowsePage/FileBrowser.tsx | 2 +- frontend/src/queries/fileContentQueries.ts | 38 ------------------- frontend/src/utils/fileTypes.ts | 38 +++++++++++++++++++ 3 files changed, 39 insertions(+), 39 deletions(-) create mode 100644 frontend/src/utils/fileTypes.ts diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 034673f6a..f238b795a 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -22,7 +22,7 @@ import useHideDotFiles from '@/hooks/useHideDotFiles'; import { useHandleDownload } from '@/hooks/useHandleDownload'; import { useHandleView } from '@/hooks/useHandleView'; import { detectZarrVersions } from '@/queries/zarrQueries'; -import { getBrowserRenderableType } from '@/queries/fileContentQueries'; +import { getBrowserRenderableType } from '@/utils/fileTypes'; import { detectN5, getN5DetectionSignals } from '@/queries/n5Queries'; import { makeMapKey } from '@/utils'; import type { FileOrFolder } from '@/shared.types'; diff --git a/frontend/src/queries/fileContentQueries.ts b/frontend/src/queries/fileContentQueries.ts index b103abd8e..0eeb91282 100644 --- a/frontend/src/queries/fileContentQueries.ts +++ b/frontend/src/queries/fileContentQueries.ts @@ -8,44 +8,6 @@ import { buildUrl, sendFetchRequest } from '@/utils'; import { fetchFileContent } from './queryUtils'; import type { FetchRequestOptions } from '@/shared.types'; -const BROWSER_RENDERABLE_EXTENSIONS: Record< - string, - 'image' | 'video' | 'audio' | 'pdf' -> = { - // Images - png: 'image', - jpg: 'image', - jpeg: 'image', - gif: 'image', - webp: 'image', - svg: 'image', - ico: 'image', - bmp: 'image', - // Video - mp4: 'video', - webm: 'video', - ogv: 'video', - // Audio - mp3: 'audio', - wav: 'audio', - ogg: 'audio', - aac: 'audio', - flac: 'audio', - // Documents - pdf: 'pdf' -}; - -/** - * Returns the browser-renderable media type for a filename, or null if the - * browser cannot render it natively. Used to gate the "View" menu item and - * (in future) to select the appropriate inline preview renderer. - */ -export function getBrowserRenderableType( - filename: string -): 'image' | 'video' | 'audio' | 'pdf' | null { - const ext = filename.toLowerCase().split('.').pop() ?? ''; - return BROWSER_RENDERABLE_EXTENSIONS[ext] ?? null; -} // Number of bytes to fetch for binary hex preview const BINARY_PREVIEW_BYTES = 512; diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts new file mode 100644 index 000000000..922565fe7 --- /dev/null +++ b/frontend/src/utils/fileTypes.ts @@ -0,0 +1,38 @@ +const BROWSER_RENDERABLE_EXTENSIONS: Record< + string, + 'image' | 'video' | 'audio' | 'pdf' +> = { + // Images + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + webp: 'image', + svg: 'image', + ico: 'image', + bmp: 'image', + // Video + mp4: 'video', + webm: 'video', + ogv: 'video', + // Audio + mp3: 'audio', + wav: 'audio', + ogg: 'audio', + aac: 'audio', + flac: 'audio', + // Documents + pdf: 'pdf' +}; + +/** + * Returns the browser-renderable media type for a filename, or null if the + * browser cannot render it natively. Used to gate the "View" menu item and + * (in future) to select the appropriate inline preview renderer. + */ +export function getBrowserRenderableType( + filename: string +): 'image' | 'video' | 'audio' | 'pdf' | null { + const ext = filename.toLowerCase().split('.').pop() ?? ''; + return BROWSER_RENDERABLE_EXTENSIONS[ext] ?? null; +} From 07cf128aa10d30cbe53fac63c526f130a21dce41 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 21:26:06 +0000 Subject: [PATCH 3/5] fix: use propertiesTarget consistently in View shouldShow check --- frontend/src/components/ui/BrowsePage/FileBrowser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index f238b795a..0c897961f 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -147,8 +147,8 @@ export default function FileBrowser({ handleView(); }, shouldShow: - !fileBrowserState.selectedFiles[0]?.is_dir && - !fileBrowserState.selectedFiles[0]?.is_symlink && + !propertiesTarget.is_dir && + !propertiesTarget.is_symlink && getBrowserRenderableType(propertiesTarget.name) !== null }, { From 8a1bdb6ade7b662018960b8f914f244df1ebb8a8 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 21:26:10 +0000 Subject: [PATCH 4/5] refactor: return Result from useHandleView for consistent error handling --- .../components/ui/BrowsePage/FileBrowser.tsx | 5 +++- frontend/src/hooks/useHandleView.ts | 23 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 0c897961f..cab322484 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -144,7 +144,10 @@ export default function FileBrowser({ { name: 'View', action: () => { - handleView(); + const result = handleView(); + if (!result.success) { + toast.error(`Error viewing file: ${result.error}`); + } }, shouldShow: !propertiesTarget.is_dir && diff --git a/frontend/src/hooks/useHandleView.ts b/frontend/src/hooks/useHandleView.ts index 71d5f4ccd..0868bb7c8 100644 --- a/frontend/src/hooks/useHandleView.ts +++ b/frontend/src/hooks/useHandleView.ts @@ -1,21 +1,28 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; -import { getFileURL } from '@/utils'; +import type { Result } from '@/shared.types'; +import { createSuccess, handleError } from '@/utils/errorHandling'; +import { getFileURL } from '@/utils/index'; export function useHandleView() { const { fileBrowserState, fileQuery } = useFileBrowserContext(); - const handleView = () => { + const handleView = (): Result => { if ( !fileQuery.data?.currentFileSharePath || !fileBrowserState.propertiesTarget ) { - return; + return handleError(new Error('No file selected for viewing')); + } + try { + const url = getFileURL( + fileQuery.data.currentFileSharePath.name, + fileBrowserState.propertiesTarget.path + ); + window.open(url, '_blank', 'noopener,noreferrer'); + return createSuccess(undefined); + } catch (error) { + return handleError(error); } - const url = getFileURL( - fileQuery.data.currentFileSharePath.name, - fileBrowserState.propertiesTarget.path - ); - window.open(url, '_blank', 'noopener,noreferrer'); }; return { handleView }; From 88d3610c2207e4a592a9a32960281721b868e8a0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 21:26:13 +0000 Subject: [PATCH 5/5] test: add unit tests for getBrowserRenderableType --- .../src/__tests__/unitTests/fileTypes.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/fileTypes.test.ts diff --git a/frontend/src/__tests__/unitTests/fileTypes.test.ts b/frontend/src/__tests__/unitTests/fileTypes.test.ts new file mode 100644 index 000000000..9b95d8a7a --- /dev/null +++ b/frontend/src/__tests__/unitTests/fileTypes.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'vitest'; +import { getBrowserRenderableType } from '@/utils/fileTypes'; + +describe('getBrowserRenderableType', () => { + test('returns "image" for image extensions', () => { + expect(getBrowserRenderableType('photo.png')).toBe('image'); + expect(getBrowserRenderableType('photo.jpg')).toBe('image'); + expect(getBrowserRenderableType('photo.jpeg')).toBe('image'); + expect(getBrowserRenderableType('photo.gif')).toBe('image'); + expect(getBrowserRenderableType('photo.webp')).toBe('image'); + expect(getBrowserRenderableType('icon.svg')).toBe('image'); + expect(getBrowserRenderableType('icon.ico')).toBe('image'); + expect(getBrowserRenderableType('photo.bmp')).toBe('image'); + }); + + test('returns "video" for video extensions', () => { + expect(getBrowserRenderableType('clip.mp4')).toBe('video'); + expect(getBrowserRenderableType('clip.webm')).toBe('video'); + expect(getBrowserRenderableType('clip.ogv')).toBe('video'); + }); + + test('returns "audio" for audio extensions', () => { + expect(getBrowserRenderableType('track.mp3')).toBe('audio'); + expect(getBrowserRenderableType('track.wav')).toBe('audio'); + expect(getBrowserRenderableType('track.ogg')).toBe('audio'); + expect(getBrowserRenderableType('track.aac')).toBe('audio'); + expect(getBrowserRenderableType('track.flac')).toBe('audio'); + }); + + test('returns "pdf" for PDF files', () => { + expect(getBrowserRenderableType('doc.pdf')).toBe('pdf'); + }); + + test('returns null for non-renderable extensions', () => { + expect(getBrowserRenderableType('file.txt')).toBeNull(); + expect(getBrowserRenderableType('data.csv')).toBeNull(); + expect(getBrowserRenderableType('archive.zip')).toBeNull(); + expect(getBrowserRenderableType('script.py')).toBeNull(); + }); + + test('is case-insensitive', () => { + expect(getBrowserRenderableType('FILE.PNG')).toBe('image'); + expect(getBrowserRenderableType('video.MP4')).toBe('video'); + expect(getBrowserRenderableType('Doc.PDF')).toBe('pdf'); + }); + + test('returns null for files without extensions', () => { + expect(getBrowserRenderableType('Makefile')).toBeNull(); + expect(getBrowserRenderableType('README')).toBeNull(); + }); + + test('returns null for files ending with a dot', () => { + expect(getBrowserRenderableType('file.')).toBeNull(); + }); + + test('uses the last extension for multi-dot filenames', () => { + expect(getBrowserRenderableType('archive.tar.gz')).toBeNull(); + expect(getBrowserRenderableType('image.backup.png')).toBe('image'); + }); +});