From 6a548353ca9e47e3ed6a40cdb4e6c83406283858 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Fri, 17 Jul 2026 11:24:13 -0700 Subject: [PATCH 1/2] feat(studio): Support image renders in fileset preview Signed-off-by: Sean Teramae --- .../studio/src/api/datasets/constants.ts | 20 ++-- .../FilesetFilePreviewContent/index.test.tsx | 15 +++ .../FilesetFilePreviewContent/index.tsx | 27 +++++- .../FilesetImagePreview.test.tsx | 75 +++++++++++++++ .../FilesetImagePreview.tsx | 91 +++++++++++++++++++ .../FilesetFilePreviewHeader/index.tsx | 2 +- .../useFilesetFileExplorerRows.tsx | 6 +- .../studio/src/util/binaryFile.test.ts | 17 ++++ web/packages/studio/src/util/binaryFile.ts | 14 ++- 9 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.test.tsx create mode 100644 web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx create mode 100644 web/packages/studio/src/util/binaryFile.test.ts diff --git a/web/packages/studio/src/api/datasets/constants.ts b/web/packages/studio/src/api/datasets/constants.ts index 0a6ab57775..b1017a7eb7 100644 --- a/web/packages/studio/src/api/datasets/constants.ts +++ b/web/packages/studio/src/api/datasets/constants.ts @@ -3,18 +3,26 @@ export const ALLOWED_CONTENT_FILE_TYPES = new Set(['csv', 'json', 'jsonl', 'parquet']); // File types that the platform parses as structured data. +/** Image formats that browsers can render directly in the fileset preview. */ +export const IMAGE_FILE_EXTENSIONS = new Set([ + 'avif', + 'bmp', + 'gif', + 'ico', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]); + // Fast-path blocklist for extensions that are unambiguously binary. Files // matching these are rejected immediately without a HEAD request. Unknown // extensions fall through to Content-Type detection (see useIsBinaryFile). // Keep this list short — it's a hint, not an authoritative registry. export const BINARY_FILE_EXTENSIONS = new Set([ // Images - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'ico', + ...IMAGE_FILE_EXTENSIONS, // Archives 'zip', 'tar', diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.test.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.test.tsx index f83ffc1124..7853c8171c 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.test.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.test.tsx @@ -13,6 +13,10 @@ vi.mock('@studio/components/filesets/hooks/useIsBinaryFile', () => ({ useIsBinaryFile: () => ({ isBinary: false, isLoading: false }), })); +vi.mock('@studio/components/FilesetFilePreviewPanel/FilesetImagePreview', () => ({ + FilesetImagePreview: ({ filePath }: { filePath: string }) => {filePath}, +})); + const baseProps = { workspace: 'default', filesetName: 'test-dataset', @@ -75,6 +79,17 @@ describe('FilesetFilePreviewContent', () => { expect(screen.getByText('Error: boom')).toBeInTheDocument(); }); + it('uses the image preview instead of the text editor for image files', () => { + render( + + + + ); + + expect(screen.getByRole('img', { name: 'folder/chart.png' })).toBeInTheDocument(); + expect(screen.queryByTestId('nv-code-editor-root')).not.toBeInTheDocument(); + }); + it('invokes onFolderClick with the cumulative folder path', () => { const onFolderClick = vi.fn(); render( diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx index 0619ab8426..ea1b0ce548 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx @@ -6,8 +6,10 @@ import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; import { FilesetFilePreviewHeader } from '@studio/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader'; +import { FilesetImagePreview } from '@studio/components/FilesetFilePreviewPanel/FilesetImagePreview'; import { useIsBinaryFile } from '@studio/components/filesets/hooks/useIsBinaryFile'; import type { FileSystemFile } from '@studio/components/FilesTable/utils'; +import { isImageExtension } from '@studio/util/binaryFile'; import { useMemo, type FC } from 'react'; export interface FilesetFilePreviewContentProps { @@ -64,6 +66,7 @@ export const FilesetFilePreviewContent: FC = ({ enabled = true, }) => { const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile(filePath); + const isImage = isImageExtension(filePath); const { data: internalContent, @@ -73,7 +76,7 @@ export const FilesetFilePreviewContent: FC = ({ workspace, name: filesetName, path: filePath, - enabled: externalContent === undefined && enabled && !binary && !isBinaryLoading, + enabled: externalContent === undefined && enabled && !binary && !isImage && !isBinaryLoading, }); const { data: allFilesResponse } = useFilesListFilesetFiles(workspace, filesetName, undefined, { @@ -89,7 +92,14 @@ export const FilesetFilePreviewContent: FC = ({ const body = useMemo( () => - binary ? ( + isImage ? ( + + ) : binary ? ( Text preview not available for binary files. @@ -103,7 +113,18 @@ export const FilesetFilePreviewContent: FC = ({ error={error ?? null} /> ), - [binary, isBinaryLoading, filePath, fileContent, isLoading, error] + [ + binary, + enabled, + error, + fileContent, + filePath, + filesetName, + isBinaryLoading, + isImage, + isLoading, + workspace, + ] ); if (hideHeader) { diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.test.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.test.tsx new file mode 100644 index 0000000000..8b3925a26d --- /dev/null +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.test.tsx @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as platformApi from '@nemo/sdk/generated/platform/api'; +import { FilesetImagePreview } from '@studio/components/FilesetFilePreviewPanel/FilesetImagePreview'; +import { TestProviders } from '@studio/tests/util/TestProviders'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useFilesDownloadFile: vi.fn() }; +}); + +const mockCreateObjectURL = vi.fn(); +const mockRevokeObjectURL = vi.fn(); + +describe('FilesetImagePreview', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateObjectURL.mockReturnValue('blob:preview-image'); + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; + vi.mocked(platformApi.useFilesDownloadFile).mockReturnValue({ + data: 'blob:preview-image', + error: null, + isLoading: false, + } as ReturnType); + }); + + it('downloads an image and renders it from an object URL', async () => { + render( + + + + ); + + const image = await screen.findByRole('img', { name: 'chart.png' }); + expect(image).toHaveAttribute('src', 'blob:preview-image'); + expect(platformApi.useFilesDownloadFile).toHaveBeenCalledWith( + 'default', + 'images', + 'examples/chart.png', + { query: { enabled: true, select: URL.createObjectURL } } + ); + + fireEvent.load(image); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:preview-image'); + }); + + it('shows the download error', () => { + vi.mocked(platformApi.useFilesDownloadFile).mockReturnValue({ + data: undefined, + error: new Error('Unable to load image'), + isLoading: false, + } as ReturnType); + + render( + + + + ); + + expect(screen.getByText('Error: Unable to load image')).toBeInTheDocument(); + }); +}); diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx new file mode 100644 index 0000000000..1298932564 --- /dev/null +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useFilesDownloadFile } from '@nemo/sdk/generated/platform/api'; +import { Flex, Spinner, Text } from '@nvidia/foundations-react-core'; +import { useState, type FC } from 'react'; + +interface FilesetImagePreviewProps { + workspace: string; + filesetName: string; + filePath: string; + enabled: boolean; +} + +const getFileName = (filePath: string): string => filePath.split('/').at(-1) ?? filePath; + +interface ImagePreviewProps { + imageUrl: string; + filePath: string; +} + +const ImagePreview: FC = ({ imageUrl, filePath }) => { + const [imageLoadError, setImageLoadError] = useState(false); + + const revokeImageUrl = () => URL.revokeObjectURL(imageUrl); + + if (imageLoadError) { + return ( + + Error: Image could not be displayed. + + ); + } + + return ( + + {getFileName(filePath)} { + revokeImageUrl(); + setImageLoadError(true); + }} + /> + + ); +}; + +/** Downloads an image through the authenticated API and displays it in the file viewer. */ +export const FilesetImagePreview: FC = ({ + workspace, + filesetName, + filePath, + enabled, +}) => { + const { + data: imageUrl, + error, + isLoading, + } = useFilesDownloadFile(workspace, filesetName, filePath, { + query: { enabled, select: URL.createObjectURL }, + }); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error: {error.message ?? 'Failed to load image'} + + ); + } + + if (!imageUrl) { + return ( + + No content available + + ); + } + + return ; +}; diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx index 3f6f256ece..15fd54d247 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/components/FilesetFilePreviewHeader/index.tsx @@ -32,7 +32,7 @@ export const FilesetFilePreviewHeader: FC = ({ }) => ( - + {node.type === 'directory' ? ( expandedFolders.has(node.path) ? ( - + ) : ( - + ) ) : ( - + )}
{searchQuery ? node.path : node.path.split('/').pop()}
diff --git a/web/packages/studio/src/util/binaryFile.test.ts b/web/packages/studio/src/util/binaryFile.test.ts new file mode 100644 index 0000000000..b68d3024d3 --- /dev/null +++ b/web/packages/studio/src/util/binaryFile.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isImageExtension } from '@studio/util/binaryFile'; + +describe('isImageExtension', () => { + it.each(['preview.png', 'preview.JPG', 'preview.svg', 'preview.webp'])( + 'recognizes browser-supported image format %s', + (path) => { + expect(isImageExtension(path)).toBe(true); + } + ); + + it('does not classify other binary files as images', () => { + expect(isImageExtension('archive.zip')).toBe(false); + }); +}); diff --git a/web/packages/studio/src/util/binaryFile.ts b/web/packages/studio/src/util/binaryFile.ts index 7ce0d2e7fc..4061e7a6fc 100644 --- a/web/packages/studio/src/util/binaryFile.ts +++ b/web/packages/studio/src/util/binaryFile.ts @@ -1,10 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { BINARY_FILE_EXTENSIONS } from '@studio/api/datasets/constants'; +import { BINARY_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS } from '@studio/api/datasets/constants'; + +function getExtension(path: string): string | undefined { + return path.split('.').at(-1)?.toLowerCase(); +} /** True when the file path has an extension in the known-binary blocklist. */ export function isBinaryExtension(path: string): boolean { - const ext = path.split('.').at(-1)?.toLowerCase(); + const ext = getExtension(path); return ext !== undefined && BINARY_FILE_EXTENSIONS.has(ext); } + +/** True when the file path has an extension supported by the image preview. */ +export function isImageExtension(path: string): boolean { + const ext = getExtension(path); + return ext !== undefined && IMAGE_FILE_EXTENSIONS.has(ext); +} From 3dabb5acd4ff552f2fe0a02c893c27bcde8f5f79 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Fri, 24 Jul 2026 16:34:13 -0700 Subject: [PATCH 2/2] fix revoke url on unmount Signed-off-by: Sean Teramae --- .../FilesetFilePreviewPanel/FilesetImagePreview.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx index 1298932564..b9338e9da2 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetImagePreview.tsx @@ -3,7 +3,7 @@ import { useFilesDownloadFile } from '@nemo/sdk/generated/platform/api'; import { Flex, Spinner, Text } from '@nvidia/foundations-react-core'; -import { useState, type FC } from 'react'; +import { useEffect, useState, type FC } from 'react'; interface FilesetImagePreviewProps { workspace: string; @@ -24,6 +24,10 @@ const ImagePreview: FC = ({ imageUrl, filePath }) => { const revokeImageUrl = () => URL.revokeObjectURL(imageUrl); + useEffect(() => { + return () => URL.revokeObjectURL(imageUrl); + }, [imageUrl]); + if (imageLoadError) { return (