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
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,7 @@ export const FilesetFilePreviewContent: FC<FilesetFilePreviewContentProps> = ({
hideHeader = false,
enabled = true,
}) => {
const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile(
workspace,
filesetName,
filePath
);
const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile(filePath);

const {
data: internalContent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useIsBinaryFile } from '@studio/components/filesets/hooks/useIsBinaryFile';
import { renderHook } from '@testing-library/react';

describe('useIsBinaryFile', () => {
it('returns isBinary=false for .jsonl files', () => {
const { result } = renderHook(() => useIsBinaryFile('data/test.jsonl'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false for .json files', () => {
const { result } = renderHook(() => useIsBinaryFile('config.json'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false for .csv files', () => {
const { result } = renderHook(() => useIsBinaryFile('data.csv'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false for .py files', () => {
const { result } = renderHook(() => useIsBinaryFile('script.py'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false for .yaml and .yml files', () => {
const { result: yamlResult } = renderHook(() => useIsBinaryFile('config.yaml'));
const { result: ymlResult } = renderHook(() => useIsBinaryFile('config.yml'));

expect(yamlResult.current).toEqual({ isBinary: false, isLoading: false });
expect(ymlResult.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false for .md files', () => {
const { result } = renderHook(() => useIsBinaryFile('README.md'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=false when filePath is undefined', () => {
const { result } = renderHook(() => useIsBinaryFile(undefined));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});

it('returns isBinary=true for .png files (binary blocklist)', () => {
const { result } = renderHook(() => useIsBinaryFile('image.png'));

expect(result.current).toEqual({ isBinary: true, isLoading: false });
});

it('returns isBinary=true for .zip files (binary blocklist)', () => {
const { result } = renderHook(() => useIsBinaryFile('archive.zip'));

expect(result.current).toEqual({ isBinary: true, isLoading: false });
});

it('returns isBinary=false for unknown extensions (fail-open)', () => {
const { result } = renderHook(() => useIsBinaryFile('data.unknown'));

expect(result.current).toEqual({ isBinary: false, isLoading: false });
});
});
Original file line number Diff line number Diff line change
@@ -1,79 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Importing from the SDK fetchers module activates the Axios interceptor that
// injects the OIDC Bearer token, so axios.head() calls below are auth-aware.
import '@nemo/sdk/generated/fetchers/platform';
import { getFilesDownloadFileQueryKey } from '@nemo/sdk/generated/platform/api';
import { KNOWN_TEXT_EXTENSIONS } from '@studio/constants/constants';
import { isBinaryExtension } from '@studio/util/binaryFile';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

function isKnownTextExtension(path: string): boolean {
const ext = path.split('.').at(-1)?.toLowerCase();
return ext !== undefined && KNOWN_TEXT_EXTENSIONS.has(ext);
}

/**
* Determine whether a fileset file should be treated as binary (no text preview).
*
* Strategy (three tiers):
* 1. Extension in `BINARY_FILE_EXTENSIONS` → binary immediately, no network.
* 2. Extension not in blocklist → HEAD request; `Content-Type` header decides.
* - `text/*` or known text application types → not binary.
* - Everything else → binary.
* 3. HEAD fails / no Content-Type → assume text (fail-open for preview).
* Strategy:
* - Extension in `KNOWN_TEXT_EXTENSIONS` → text immediately.
* - Extension in `BINARY_FILE_EXTENSIONS` → binary immediately.
* - Unknown extension → assume text (fail-open for preview).
*
* Returns `{ isBinary, isLoading }`. `isLoading` is true only during the HEAD
* request (tier-2 path); tier-1 resolves synchronously.
* Returns `{ isBinary, isLoading }`. `isLoading` is always `false` since
* detection is synchronous.
*/
export function useIsBinaryFile(
workspace: string,
filesetName: string,
filePath: string | undefined
): { isBinary: boolean; isLoading: boolean } {
const blocklisted = filePath !== undefined && isBinaryExtension(filePath);

const { data: headBinary, isPending } = useQuery({
queryKey: ['file-content-type', workspace, filesetName, filePath],
queryFn: async (): Promise<boolean> => {
if (!filePath) return false;
try {
// axios.head() is auth-aware via the interceptor registered when
// '@nemo/sdk/generated/fetchers/platform' is imported above.
const [fileUrl] = getFilesDownloadFileQueryKey(
encodeURIComponent(workspace),
encodeURIComponent(filesetName),
encodeURIComponent(filePath)
);
const res = await axios.head(fileUrl);
const ct = String(res.headers['content-type'] ?? '');
return !isTextContentType(ct);
} catch {
return false; // fail-open: assume text
}
},
enabled: !!filePath && !blocklisted,
staleTime: Infinity,
retry: false,
});

if (blocklisted) return { isBinary: true, isLoading: false };
export function useIsBinaryFile(filePath: string | undefined): {
isBinary: boolean;
isLoading: boolean;
} {
if (!filePath) return { isBinary: false, isLoading: false };
return { isBinary: headBinary ?? false, isLoading: isPending };
}

const TEXT_CONTENT_TYPES = [
'text/',
'application/json',
'application/xml',
'application/javascript',
'application/typescript',
'application/yaml',
'application/x-yaml',
'application/toml',
'application/csv',
'application/x-sh',
];

function isTextContentType(ct: string): boolean {
// Extract the MIME type token only (strip "; charset=..." parameters) before
// matching, so parameter values can't accidentally trigger a false positive.
const mimeToken = ct.split(';')[0].trim().toLowerCase();
return TEXT_CONTENT_TYPES.some((prefix) => mimeToken.startsWith(prefix));
if (isKnownTextExtension(filePath)) return { isBinary: false, isLoading: false };
if (isBinaryExtension(filePath)) return { isBinary: true, isLoading: false };
return { isBinary: false, isLoading: false };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
56 changes: 56 additions & 0 deletions web/packages/studio/src/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,59 @@ export const DEFAULT_TOOLS_FILE_NAME = 'tools.json';
export const EMPTY_FIELD_VALUE = '-';
export const EMPTY_FIELD_EMDASH_VALUE = '—';
export const DEFAULT_BUILD_MODEL_NAME = 'nvidia-llama-3-3-nemotron-super-49b-v1';

export const KNOWN_TEXT_EXTENSIONS = new Set([
// Data
'json',
'jsonl',
'csv',
'tsv',
// Code
'py',
'js',
'jsx',
'ts',
'tsx',
'java',
'c',
'cpp',
'h',
'go',
'rs',
'rb',
'php',
'swift',
'kt',
'scala',
'r',
'm',
'sh',
'bash',
'zsh',
'fish',
// Markup / Config
'html',
'htm',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'conf',
'jsonc',
'env',
// Text
'txt',
'md',
'rst',
'log',
'diff',
'patch',
// Other
'sql',
'graphql',
'proto',
'dockerfile',
'makefile',
]);