diff --git a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx index fed7e89551..0619ab8426 100644 --- a/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx +++ b/web/packages/studio/src/components/FilesetFilePreviewPanel/FilesetFilePreviewContent/index.tsx @@ -63,11 +63,7 @@ export const FilesetFilePreviewContent: FC = ({ hideHeader = false, enabled = true, }) => { - const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile( - workspace, - filesetName, - filePath - ); + const { isBinary: binary, isLoading: isBinaryLoading } = useIsBinaryFile(filePath); const { data: internalContent, diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts new file mode 100644 index 0000000000..6b283628bd --- /dev/null +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts @@ -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 }); + }); +}); diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts index e699f8c60a..e5c790c0c0 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -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 => { - 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 }; } diff --git a/web/packages/studio/src/constants/constants.ts b/web/packages/studio/src/constants/constants.ts index 3ecfa27f2c..97363e02d8 100644 --- a/web/packages/studio/src/constants/constants.ts +++ b/web/packages/studio/src/constants/constants.ts @@ -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', +]);