diff --git a/frontend/src/__tests__/unitTests/fileTypes.test.ts b/frontend/src/__tests__/unitTests/fileTypes.test.ts new file mode 100644 index 00000000..9b95d8a7 --- /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'); + }); +}); diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index 3bf6760a..cab32248 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 '@/utils/fileTypes'; 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,19 @@ export default function FileBrowser({ }, shouldShow: !showPropertiesDrawer }, + { + name: 'View', + action: () => { + const result = handleView(); + if (!result.success) { + toast.error(`Error viewing file: ${result.error}`); + } + }, + shouldShow: + !propertiesTarget.is_dir && + !propertiesTarget.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 00000000..0868bb7c --- /dev/null +++ b/frontend/src/hooks/useHandleView.ts @@ -0,0 +1,29 @@ +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +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 = (): Result => { + if ( + !fileQuery.data?.currentFileSharePath || + !fileBrowserState.propertiesTarget + ) { + 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); + } + }; + + return { handleView }; +} diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts new file mode 100644 index 00000000..922565fe --- /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; +}