diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index b164a9cd..9a7e4a04 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -868,7 +868,7 @@ export const SidebarFilesTree: React.FC = () => { const handleOpenFile = React.useCallback(async (node: FileNode) => { if (!root) return; - const openValidation = await validateContextFileOpen(files, node.path); + const openValidation = await validateContextFileOpen(files, node.path, { directory: root }); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index d6951bb6..85cf5093 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -455,7 +455,7 @@ export const CommandPalette: React.FC = () => { const handleOpenFile = React.useCallback( async (filePath: string) => { if (!currentRoot) return; - const validation = await validateContextFileOpen(filesApi, filePath); + const validation = await validateContextFileOpen(filesApi, filePath, { directory: currentRoot }); if (!validation.ok) { toast.error(getContextFileOpenFailureMessage(validation.reason)); return; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 95d83840..31d70c20 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1537,7 +1537,7 @@ export const DiffView: React.FC = ({ : getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified)); const absolutePath = toAbsolutePath(effectiveDirectory, filePath); - const openValidation = await validateContextFileOpen(files, absolutePath); + const openValidation = await validateContextFileOpen(files, absolutePath, { directory: effectiveDirectory }); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/packages/ui/src/lib/contextFileOpenGuard.test.ts b/packages/ui/src/lib/contextFileOpenGuard.test.ts index d2e59ba9..b87b3643 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.test.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.test.ts @@ -3,34 +3,35 @@ 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' }), -}); +const filesApi = (content: string): FilesAPI => + ({ + listDirectory: async () => ({ directory: '/', entries: [] }), + readFile: async () => ({ content, path: '/x' }), + }) as unknown as FilesAPI; describe('validateContextFileOpen', () => { test('allows known binaries through without reading text', async () => { - const files: FilesAPI = { - listDirectory: async () => ({ path: '/', entries: [] }), + const files = { + listDirectory: async () => ({ directory: '/', entries: [] }), readFile: async () => { throw new Error('should not read binary as text'); }, - }; + } as unknown as FilesAPI; - 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 }); + expect(await validateContextFileOpen(files, '/repo/docs/report.pdf')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/docs/report.docx')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/docs/pixel.png')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/bin/archive.zip')).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({ + expect(await validateContextFileOpen(filesApi('%PDF-1.7\nbinary'), '/repo/mystery.bin.bak')).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 }); + expect(await validateContextFileOpen(filesApi('hello\nworld\n'), '/repo/notes.txt')).toEqual({ ok: true }); }); }); diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts index d7bff3cf..b9fd4526 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -2,11 +2,11 @@ import type { FilesAPI } from '@/lib/api/types'; import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits'; import { getCurrentIntlLocale } from '@/lib/i18n'; import { formatMessage, useI18nStore } from '@/lib/i18n/store'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { isBinaryFile, isImageFile, isPdfFile, looksLikeBinaryText } from '@/lib/toolHelpers'; 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' | 'binary'; @@ -14,6 +14,10 @@ export type ContextFileOpenValidationResult = | { ok: true } | { ok: false; reason: ContextFileOpenFailureReason }; +export type ContextFileOpenOptions = { + directory?: string; +}; + const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { const message = error instanceof Error ? error.message : String(error ?? ''); const normalized = message.toLowerCase(); @@ -31,16 +35,27 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { return 'unreadable'; }; -const readFileContent = async (files: FilesAPI, path: string): Promise => { +const readFileContent = async ( + files: FilesAPI, + path: string, + options?: ContextFileOpenOptions, +): Promise => { if (files.readFile) { - const result = await files.readFile(path, { optional: true }); + const result = await files.readFile(path, { + optional: true, + directory: options?.directory, + }); return result.content ?? ''; } const params = new URLSearchParams({ path, optional: 'true' }); + if (options?.directory) { + params.set('directory', options.directory); + } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', + headers: options?.directory ? { 'x-opencode-directory': options.directory } : undefined, }); if (!response.ok) { const errorPayload = await response.json().catch(() => ({ error: response.statusText })); @@ -55,13 +70,17 @@ const readFileContent = async (files: FilesAPI, path: string): Promise = * 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 => { +export const validateContextFileOpen = async ( + files: FilesAPI, + path: string, + options?: ContextFileOpenOptions, +): Promise => { if (isBinaryFile(path) || isPdfFile(path) || isImageFile(path)) { return { ok: true }; } try { - const content = await readFileContent(files, path); + const content = await readFileContent(files, path, options); if (looksLikeBinaryText(content)) { return { ok: false, reason: 'binary' }; }