From 4f30a008149b8b05d886a279ab4ec4fc8f852725 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 07:05:34 +0000 Subject: [PATCH] fix(files): let context panel open binaries into FilesView safely Skip UTF-8 pre-read for known binary/image/PDF paths so the shared editor can show preview or cannot-preview instead of failing open or decoding binaries as text. Co-authored-by: Serhii Dziupin --- .../ui/src/lib/contextFileOpenGuard.test.ts | 36 +++++++++++++++++++ packages/ui/src/lib/contextFileOpenGuard.ts | 19 +++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/lib/contextFileOpenGuard.test.ts diff --git a/packages/ui/src/lib/contextFileOpenGuard.test.ts b/packages/ui/src/lib/contextFileOpenGuard.test.ts new file mode 100644 index 00000000..d2e59ba9 --- /dev/null +++ b/packages/ui/src/lib/contextFileOpenGuard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; + +import type { FilesAPI } from '@/lib/api/types'; +import { validateContextFileOpen } from './contextFileOpenGuard'; + +const filesApi = (content: string): FilesAPI => ({ + listDirectory: async () => ({ path: '/', entries: [] }), + readFile: async () => ({ content, path: '/x' }), +}); + +describe('validateContextFileOpen', () => { + test('allows known binaries through without reading text', async () => { + const files: FilesAPI = { + listDirectory: async () => ({ path: '/', entries: [] }), + readFile: async () => { + throw new Error('should not read binary as text'); + }, + }; + + await expect(validateContextFileOpen(files, '/repo/docs/report.pdf')).resolves.toEqual({ ok: true }); + await expect(validateContextFileOpen(files, '/repo/docs/report.docx')).resolves.toEqual({ ok: true }); + await expect(validateContextFileOpen(files, '/repo/docs/pixel.png')).resolves.toEqual({ ok: true }); + await expect(validateContextFileOpen(files, '/repo/bin/archive.zip')).resolves.toEqual({ ok: true }); + }); + + test('rejects text payloads that look binary', async () => { + await expect(validateContextFileOpen(filesApi('%PDF-1.7\nbinary'), '/repo/mystery.bin.bak')).resolves.toEqual({ + ok: false, + reason: 'binary', + }); + }); + + test('allows ordinary text files', async () => { + await expect(validateContextFileOpen(filesApi('hello\nworld\n'), '/repo/notes.txt')).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts index e4339fab..d7bff3cf 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -6,8 +6,9 @@ import { formatMessage, useI18nStore } from '@/lib/i18n/store'; const t = (key: Parameters[1], params?: Parameters[2]) => formatMessage(useI18nStore.getState().dictionary, key, params); import { runtimeFetch } from '@/lib/runtime-fetch'; +import { isBinaryFile, isImageFile, isPdfFile, looksLikeBinaryText } from '@/lib/toolHelpers'; -export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable'; +export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable' | 'binary'; export type ContextFileOpenValidationResult = | { ok: true } @@ -49,9 +50,21 @@ const readFileContent = async (files: FilesAPI, path: string): Promise = return response.text(); }; +/** + * Validate whether a context-panel click may open a path in the shared file editor. + * Previewable/non-text binaries are allowed through so FilesView can show image/PDF + * preview or the cannot-preview empty state — never by decoding them as editable text here. + */ export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise => { + if (isBinaryFile(path) || isPdfFile(path) || isImageFile(path)) { + return { ok: true }; + } + try { const content = await readFileContent(files, path); + if (looksLikeBinaryText(content)) { + return { ok: false, reason: 'binary' }; + } const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES); if (lineCount > MAX_OPEN_FILE_LINES) { return { ok: false, reason: 'too-large' }; @@ -73,5 +86,9 @@ export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureR return t('contextFileOpen.failure.missing'); } + if (reason === 'binary') { + return t('filesView.editor.cannotPreviewBinary'); + } + return t('contextFileOpen.failure.unreadable'); };