From 10949dbf8d63a355ca4483976fdabae9701edc42 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 23 Jun 2026 16:09:32 -0700 Subject: [PATCH 1/6] fix: add known text extension allowlist to file binary detection The backend HEAD endpoint returns application/octet-stream for all files, causing useIsBinaryFile to incorrectly classify .jsonl and other text files as binary. Added KNOWN_TEXT_EXTENSIONS allowlist that short-circuits the HEAD request for recognized text extensions. - Add 50+ known text extensions (json, jsonl, csv, py, yaml, md, etc.) - Restructure hook to check text extensions before binary blocklist - Add unit tests for useIsBinaryFile hook Signed-off-by: mschwab --- .../filesets/hooks/useIsBinaryFile.test.ts | 123 ++++++++++++++++++ .../filesets/hooks/useIsBinaryFile.ts | 86 ++++++++++-- 2 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts 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..814b9f9d90 --- /dev/null +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts @@ -0,0 +1,123 @@ +// 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 { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; + +vi.mock('@tanstack/react-query'); +vi.mock('@nemo/sdk/generated/fetchers/platform'); +vi.mock('@nemo/sdk/generated/platform/api', () => ({ + getFilesDownloadFileQueryKey: vi.fn(() => ['/files/download']), +})); +vi.mock('axios', () => { + const mockInstance = { + head: vi.fn(), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + }; + return { default: mockInstance }; +}); + +const mockUseQuery = vi.mocked(useQuery); + +describe('useIsBinaryFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: false, + isPending: false, + refetch: vi.fn(), + isFetching: false, + isStale: false, + isError: false, + error: null, + failureCount: 0, + failureReason: null, + isFetchNextPageEnabled: undefined, + dataUpdateCount: 0, + dataUpdatedAt: 0, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchStatus: 'idle', + status: 'success', + variables: undefined, + queryKey: [], + queryHash: '', + queryFn: undefined, + queryKeyHashFn: undefined, + meta: undefined, + isLoading: false, + isLoadingError: false, + isRefetchError: false, + isSuccess: true, + isObservingMisorderedIndex: false, + } as unknown as UseQueryResult); + }); + + it('returns isBinary=false for .jsonl files without a HEAD request', () => { + const { result } = renderHook(() => + useIsBinaryFile('default', 'test-dataset', 'data/test.jsonl') + ); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + // Query should be disabled for known text extensions + expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + + it('returns isBinary=false for .json files without a HEAD request', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'config.json')); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=false for .csv files without a HEAD request', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data.csv')); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=false for .py files without a HEAD request', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'script.py')); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=false for .yaml and .yml files', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'config.yaml')); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=false for .md files', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'README.md')); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=false when filePath is undefined', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', undefined)); + + expect(result.current).toEqual({ isBinary: false, isLoading: false }); + }); + + it('returns isBinary=true for .png files (binary blocklist)', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'image.png')); + + expect(result.current).toEqual({ isBinary: true, isLoading: false }); + }); + + it('returns isBinary=true for .zip files (binary blocklist)', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'archive.zip')); + + expect(result.current).toEqual({ isBinary: true, isLoading: false }); + }); + + it('enables the HEAD query for unknown extensions', () => { + renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data.unknown')); + + expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: true })); + }); +}); diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts index e699f8c60a..62e99a0523 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -9,37 +9,104 @@ import { isBinaryExtension } from '@studio/util/binaryFile'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; +/** + * Extensions that are unambiguously text. The backend HEAD endpoint returns + * `application/octet-stream` for all files, so Content-Type-based detection + * fails for these. This allowlist short-circuits the HEAD request. + */ +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', +]); + +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. + * Strategy (four tiers): + * 1. Extension in `KNOWN_TEXT_EXTENSIONS` → text immediately, no network. + * 2. Extension in `BINARY_FILE_EXTENSIONS` → binary immediately, no network. + * 3. Extension not in either list → HEAD request; `Content-Type` decides. * - `text/*` or known text application types → not binary. * - Everything else → binary. - * 3. HEAD fails / no Content-Type → assume text (fail-open for preview). + * 4. HEAD fails / no Content-Type → assume text (fail-open for preview). * * Returns `{ isBinary, isLoading }`. `isLoading` is true only during the HEAD - * request (tier-2 path); tier-1 resolves synchronously. + * request (tier-3 path); tiers 1-2 resolve synchronously. */ export function useIsBinaryFile( workspace: string, filesetName: string, filePath: string | undefined ): { isBinary: boolean; isLoading: boolean } { + const knownText = filePath !== undefined && isKnownTextExtension(filePath); 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) + encodeURIComponent(filePath!) ); const res = await axios.head(fileUrl); const ct = String(res.headers['content-type'] ?? ''); @@ -48,13 +115,14 @@ export function useIsBinaryFile( return false; // fail-open: assume text } }, - enabled: !!filePath && !blocklisted, + enabled: !!filePath && !knownText && !blocklisted, staleTime: Infinity, retry: false, }); - if (blocklisted) return { isBinary: true, isLoading: false }; if (!filePath) return { isBinary: false, isLoading: false }; + if (knownText) return { isBinary: false, isLoading: false }; + if (blocklisted) return { isBinary: true, isLoading: false }; return { isBinary: headBinary ?? false, isLoading: isPending }; } From 80bb15b329ae4954de8a21e90da3c3d84e57d29a Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 26 Jun 2026 09:42:23 -0700 Subject: [PATCH 2/6] refactor(studio): remove HEAD request from useIsBinaryFile since backend always returns application/octet-stream Signed-off-by: mschwab --- .../filesets/hooks/useIsBinaryFile.test.ts | 68 ++-------------- .../filesets/hooks/useIsBinaryFile.ts | 77 +++---------------- 2 files changed, 18 insertions(+), 127 deletions(-) diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts index 814b9f9d90..ad0f371da5 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts @@ -2,84 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 import { useIsBinaryFile } from '@studio/components/filesets/hooks/useIsBinaryFile'; -import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react'; -vi.mock('@tanstack/react-query'); -vi.mock('@nemo/sdk/generated/fetchers/platform'); -vi.mock('@nemo/sdk/generated/platform/api', () => ({ - getFilesDownloadFileQueryKey: vi.fn(() => ['/files/download']), -})); -vi.mock('axios', () => { - const mockInstance = { - head: vi.fn(), - interceptors: { - request: { use: vi.fn() }, - response: { use: vi.fn() }, - }, - }; - return { default: mockInstance }; -}); - -const mockUseQuery = vi.mocked(useQuery); - describe('useIsBinaryFile', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseQuery.mockReturnValue({ - data: false, - isPending: false, - refetch: vi.fn(), - isFetching: false, - isStale: false, - isError: false, - error: null, - failureCount: 0, - failureReason: null, - isFetchNextPageEnabled: undefined, - dataUpdateCount: 0, - dataUpdatedAt: 0, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchStatus: 'idle', - status: 'success', - variables: undefined, - queryKey: [], - queryHash: '', - queryFn: undefined, - queryKeyHashFn: undefined, - meta: undefined, - isLoading: false, - isLoadingError: false, - isRefetchError: false, - isSuccess: true, - isObservingMisorderedIndex: false, - } as unknown as UseQueryResult); - }); - - it('returns isBinary=false for .jsonl files without a HEAD request', () => { + it('returns isBinary=false for .jsonl files', () => { const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data/test.jsonl') ); expect(result.current).toEqual({ isBinary: false, isLoading: false }); - // Query should be disabled for known text extensions - expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); }); - it('returns isBinary=false for .json files without a HEAD request', () => { + it('returns isBinary=false for .json files', () => { const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'config.json')); expect(result.current).toEqual({ isBinary: false, isLoading: false }); }); - it('returns isBinary=false for .csv files without a HEAD request', () => { + it('returns isBinary=false for .csv files', () => { const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data.csv')); expect(result.current).toEqual({ isBinary: false, isLoading: false }); }); - it('returns isBinary=false for .py files without a HEAD request', () => { + it('returns isBinary=false for .py files', () => { const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'script.py')); expect(result.current).toEqual({ isBinary: false, isLoading: false }); @@ -115,9 +61,9 @@ describe('useIsBinaryFile', () => { expect(result.current).toEqual({ isBinary: true, isLoading: false }); }); - it('enables the HEAD query for unknown extensions', () => { - renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data.unknown')); + it('returns isBinary=false for unknown extensions (fail-open)', () => { + const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'data.unknown')); - expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: true })); + 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 62e99a0523..fb316212a4 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -1,13 +1,7 @@ // 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 { isBinaryExtension } from '@studio/util/binaryFile'; -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; /** * Extensions that are unambiguously text. The backend HEAD endpoint returns @@ -78,70 +72,21 @@ function isKnownTextExtension(path: string): boolean { /** * Determine whether a fileset file should be treated as binary (no text preview). * - * Strategy (four tiers): - * 1. Extension in `KNOWN_TEXT_EXTENSIONS` → text immediately, no network. - * 2. Extension in `BINARY_FILE_EXTENSIONS` → binary immediately, no network. - * 3. Extension not in either list → HEAD request; `Content-Type` decides. - * - `text/*` or known text application types → not binary. - * - Everything else → binary. - * 4. 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-3 path); tiers 1-2 resolve synchronously. + * Returns `{ isBinary, isLoading }`. `isLoading` is always `false` since + * detection is synchronous. */ export function useIsBinaryFile( - workspace: string, - filesetName: string, + _workspace: string, + _filesetName: string, filePath: string | undefined ): { isBinary: boolean; isLoading: boolean } { - const knownText = filePath !== undefined && isKnownTextExtension(filePath); - const blocklisted = filePath !== undefined && isBinaryExtension(filePath); - - const { data: headBinary, isPending } = useQuery({ - queryKey: ['file-content-type', workspace, filesetName, filePath], - queryFn: async (): Promise => { - 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 && !knownText && !blocklisted, - staleTime: Infinity, - retry: false, - }); - if (!filePath) return { isBinary: false, isLoading: false }; - if (knownText) return { isBinary: false, isLoading: false }; - if (blocklisted) return { isBinary: true, 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 }; } From e2cfa6649b272d32669988da582e7fbb66e54faa Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 26 Jun 2026 09:45:49 -0700 Subject: [PATCH 3/6] fix(studio): update stale doc comment in useIsBinaryFile Signed-off-by: mschwab --- .../studio/src/components/filesets/hooks/useIsBinaryFile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts index fb316212a4..bfd2472a8a 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -4,9 +4,9 @@ import { isBinaryExtension } from '@studio/util/binaryFile'; /** - * Extensions that are unambiguously text. The backend HEAD endpoint returns + * Extensions that are unambiguously text. The backend always returns * `application/octet-stream` for all files, so Content-Type-based detection - * fails for these. This allowlist short-circuits the HEAD request. + * is unusable — this allowlist is the authority for text classification. */ const KNOWN_TEXT_EXTENSIONS = new Set([ // Data From 34e8618c35019478434a37f01ff2c981271a929e Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 26 Jun 2026 09:49:15 -0700 Subject: [PATCH 4/6] refactor(studio): simplify useIsBinaryFile signature to accept only filePath Signed-off-by: mschwab --- .../FilesetFilePreviewContent/index.tsx | 6 +---- .../filesets/hooks/useIsBinaryFile.test.ts | 22 +++++++++---------- .../filesets/hooks/useIsBinaryFile.ts | 9 ++++---- 3 files changed, 15 insertions(+), 22 deletions(-) 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 index ad0f371da5..cef6ad3e1c 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts @@ -6,63 +6,61 @@ import { renderHook } from '@testing-library/react'; describe('useIsBinaryFile', () => { it('returns isBinary=false for .jsonl files', () => { - const { result } = renderHook(() => - useIsBinaryFile('default', 'test-dataset', 'data/test.jsonl') - ); + 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('default', 'test-dataset', 'config.json')); + 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('default', 'test-dataset', 'data.csv')); + 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('default', 'test-dataset', 'script.py')); + 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 } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'config.yaml')); + const { result } = renderHook(() => useIsBinaryFile('config.yaml')); expect(result.current).toEqual({ isBinary: false, isLoading: false }); }); it('returns isBinary=false for .md files', () => { - const { result } = renderHook(() => useIsBinaryFile('default', 'test-dataset', 'README.md')); + 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('default', 'test-dataset', 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('default', 'test-dataset', 'image.png')); + 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('default', 'test-dataset', 'archive.zip')); + 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('default', 'test-dataset', 'data.unknown')); + 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 bfd2472a8a..aba9f47ad7 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -80,11 +80,10 @@ function isKnownTextExtension(path: string): boolean { * 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 } { +export function useIsBinaryFile(filePath: string | undefined): { + isBinary: boolean; + isLoading: boolean; +} { if (!filePath) return { isBinary: false, isLoading: false }; if (isKnownTextExtension(filePath)) return { isBinary: false, isLoading: false }; if (isBinaryExtension(filePath)) return { isBinary: true, isLoading: false }; From b63528bb1079b10f2e3e775d20876c114dd65386 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 26 Jun 2026 09:58:03 -0700 Subject: [PATCH 5/6] refactor(studio): move KNOWN_TEXT_EXTENSIONS to constants file Signed-off-by: mschwab --- .../filesets/hooks/useIsBinaryFile.ts | 62 +------------------ .../studio/src/constants/constants.ts | 56 +++++++++++++++++ 2 files changed, 57 insertions(+), 61 deletions(-) diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts index aba9f47ad7..e5c790c0c0 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.ts @@ -1,69 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { KNOWN_TEXT_EXTENSIONS } from '@studio/constants/constants'; import { isBinaryExtension } from '@studio/util/binaryFile'; -/** - * Extensions that are unambiguously text. The backend always returns - * `application/octet-stream` for all files, so Content-Type-based detection - * is unusable — this allowlist is the authority for text classification. - */ -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', -]); - function isKnownTextExtension(path: string): boolean { const ext = path.split('.').at(-1)?.toLowerCase(); return ext !== undefined && KNOWN_TEXT_EXTENSIONS.has(ext); 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', +]); From e4254fe162df86b0accd64b88970b258e655bae4 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 26 Jun 2026 10:22:30 -0700 Subject: [PATCH 6/6] test(studio): test .yaml and .yml extensions separately in useIsBinaryFile Signed-off-by: mschwab --- .../src/components/filesets/hooks/useIsBinaryFile.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts index cef6ad3e1c..6b283628bd 100644 --- a/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts +++ b/web/packages/studio/src/components/filesets/hooks/useIsBinaryFile.test.ts @@ -30,9 +30,11 @@ describe('useIsBinaryFile', () => { }); it('returns isBinary=false for .yaml and .yml files', () => { - const { result } = renderHook(() => useIsBinaryFile('config.yaml')); + const { result: yamlResult } = renderHook(() => useIsBinaryFile('config.yaml')); + const { result: ymlResult } = renderHook(() => useIsBinaryFile('config.yml')); - expect(result.current).toEqual({ isBinary: false, isLoading: false }); + expect(yamlResult.current).toEqual({ isBinary: false, isLoading: false }); + expect(ymlResult.current).toEqual({ isBinary: false, isLoading: false }); }); it('returns isBinary=false for .md files', () => {