Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions web/packages/studio/src/api/datasets/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <img alt={filePath} />,
}));

const baseProps = {
workspace: 'default',
filesetName: 'test-dataset',
Expand Down Expand Up @@ -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(
<TestProviders>
<FilesetFilePreviewContent {...baseProps} filePath="folder/chart.png" />
</TestProviders>
);

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -64,6 +66,7 @@ export const FilesetFilePreviewContent: FC<FilesetFilePreviewContentProps> = ({
enabled = true,
}) => {
const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile(filePath);
const isImage = isImageExtension(filePath);

const {
data: internalContent,
Expand All @@ -73,7 +76,7 @@ export const FilesetFilePreviewContent: FC<FilesetFilePreviewContentProps> = ({
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, {
Expand All @@ -89,7 +92,14 @@ export const FilesetFilePreviewContent: FC<FilesetFilePreviewContentProps> = ({

const body = useMemo(
() =>
binary ? (
isImage ? (
<FilesetImagePreview
workspace={workspace}
filesetName={filesetName}
filePath={filePath}
enabled={enabled}
/>
) : binary ? (
<Flex align="center" justify="center" className="h-full">
<Text kind="body/regular/md" className="text-fg-subdued">
Text preview not available for binary files.
Expand All @@ -103,7 +113,18 @@ export const FilesetFilePreviewContent: FC<FilesetFilePreviewContentProps> = ({
error={error ?? null}
/>
),
[binary, isBinaryLoading, filePath, fileContent, isLoading, error]
[
binary,
enabled,
error,
fileContent,
filePath,
filesetName,
isBinaryLoading,
isImage,
isLoading,
workspace,
]
);

if (hideHeader) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@nemo/sdk/generated/platform/api')>();
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<typeof platformApi.useFilesDownloadFile>);
});

it('downloads an image and renders it from an object URL', async () => {
render(
<TestProviders>
<FilesetImagePreview
workspace="default"
filesetName="images"
filePath="examples/chart.png"
enabled
/>
</TestProviders>
);

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<typeof platformApi.useFilesDownloadFile>);

render(
<TestProviders>
<FilesetImagePreview
workspace="default"
filesetName="images"
filePath="examples/chart.png"
enabled
/>
</TestProviders>
);

expect(screen.getByText('Error: Unable to load image')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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 { useEffect, 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<ImagePreviewProps> = ({ imageUrl, filePath }) => {
const [imageLoadError, setImageLoadError] = useState(false);

const revokeImageUrl = () => URL.revokeObjectURL(imageUrl);

useEffect(() => {
return () => URL.revokeObjectURL(imageUrl);
}, [imageUrl]);

if (imageLoadError) {
return (
<Flex align="center" justify="center" className="h-full">
<Text className="text-danger-base">Error: Image could not be displayed.</Text>
</Flex>
);
}

return (
<Flex className="h-full items-center justify-center overflow-auto rounded-lg border border-base bg-surface-raised p-4">
<img
src={imageUrl}
alt={getFileName(filePath)}
className="max-h-full max-w-full object-contain"
onLoad={revokeImageUrl}
onError={() => {
revokeImageUrl();
setImageLoadError(true);
}}
/>
Comment thread
steramae-nvidia marked this conversation as resolved.
</Flex>
);
};

/** Downloads an image through the authenticated API and displays it in the file viewer. */
export const FilesetImagePreview: FC<FilesetImagePreviewProps> = ({
workspace,
filesetName,
filePath,
enabled,
}) => {
const {
data: imageUrl,
error,
isLoading,
} = useFilesDownloadFile<string>(workspace, filesetName, filePath, {
query: { enabled, select: URL.createObjectURL },
});

if (isLoading) {
return (
<Flex align="center" justify="center" className="h-full">
<Spinner size="medium" aria-label="Loading..." />
</Flex>
);
}

if (error) {
return (
<Flex align="center" justify="center" className="h-full">
<Text className="text-danger-base">Error: {error.message ?? 'Failed to load image'}</Text>
</Flex>
);
}

if (!imageUrl) {
return (
<Flex align="center" justify="center" className="h-full">
<Text>No content available</Text>
</Flex>
);
}

return <ImagePreview key={imageUrl} imageUrl={imageUrl} filePath={filePath} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const FilesetFilePreviewHeader: FC<FilesetFilePreviewHeaderProps> = ({
}) => (
<Flex justify="between" align="center" gap="density-sm" className="shrink-0 w-full">
<Flex gap="density-sm" align="center" className="min-w-0">
<FolderOpen width={16} height={16} />
<FolderOpen className="shrink-0" width={16} height={16} />
<FileBreadcrumbs
filesetName={filesetName}
filePath={filePath}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,12 @@ export function useFilesetFileExplorerRows({
<Flex gap="density-sm" align="center">
{node.type === 'directory' ? (
expandedFolders.has(node.path) ? (
<FolderOpen />
<FolderOpen className="shrink-0" />
) : (
<FolderClosed />
<FolderClosed className="shrink-0" />
)
) : (
<File />
<File className="shrink-0" />
)}
<div>{searchQuery ? node.path : node.path.split('/').pop()}</div>
</Flex>
Expand Down
17 changes: 17 additions & 0 deletions web/packages/studio/src/util/binaryFile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 12 additions & 2 deletions web/packages/studio/src/util/binaryFile.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading