diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 2a87a108..01380763 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1022,14 +1022,14 @@ const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): const useFileReferenceInteractions = ({ containerRef, effectiveDirectory, - readFile, + statFile, editor, preferRuntimeEditor, deferValidationUntilIdle = false, }: { containerRef: React.RefObject; effectiveDirectory: string; - readFile?: (path: string) => Promise<{ content: string; path: string }>; + statFile?: (path: string) => Promise<{ path: string; isFile: boolean; size: number }>; editor?: EditorAPI; preferRuntimeEditor?: boolean; deferValidationUntilIdle?: boolean; @@ -1061,10 +1061,14 @@ const useFileReferenceInteractions = ({ const checkPromise = (async () => { try { - if (!readFile) { + if (!statFile) { + return false; + } + const stat = await statFile(resolvedPath); + if (!stat.isFile) { + cache.set(resolvedPath, false); return false; } - await readFile(resolvedPath); cache.set(resolvedPath, true); return true; } catch { @@ -1289,7 +1293,7 @@ const useFileReferenceInteractions = ({ container.removeEventListener('click', handleClick); container.removeEventListener('keydown', handleKeyDown); }; - }, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]); + }, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, statFile]); }; const useMermaidInlineInteractions = ({ @@ -1405,7 +1409,7 @@ export const MarkdownRenderer: React.FC = ({ useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, - readFile: files.readFile, + statFile: files.statFile, editor, preferRuntimeEditor: runtime.isVSCode, deferValidationUntilIdle: isStreaming, @@ -1491,7 +1495,7 @@ export const SimpleMarkdownRenderer: React.FC<{ useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, - readFile: files.readFile, + statFile: files.statFile, editor, preferRuntimeEditor: runtime.isVSCode, }); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index a67b2496..24a6f473 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -490,6 +490,7 @@ export interface FilesAPI { listDirectory(path: string, options?: ListDirectoryOptions): Promise; search(payload: FileSearchQuery): Promise; createDirectory(path: string): Promise<{ success: boolean; path: string }>; + statFile?(path: string): Promise<{ path: string; isFile: boolean; size: number }>; readFile?(path: string): Promise<{ content: string; path: string }>; readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>; writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>; diff --git a/packages/vscode/src/bridge-fs-runtime.ts b/packages/vscode/src/bridge-fs-runtime.ts index fece387c..d2827f35 100644 --- a/packages/vscode/src/bridge-fs-runtime.ts +++ b/packages/vscode/src/bridge-fs-runtime.ts @@ -180,6 +180,39 @@ export async function handleFsBridgeMessage( } } + case 'api:fs:stat': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + + const resolution = await deps.resolveFileReadPath(target); + if (!resolution.ok) { + return { id, type, success: false, error: resolution.error }; + } + + try { + const stats = await fs.promises.stat(resolution.resolvedPath); + if (!stats.isFile()) { + return { id, type, success: false, error: 'Specified path is not a file' }; + } + + return { + id, + type, + success: true, + data: { + path: deps.normalizeFsPath(resolution.resolvedPath), + isFile: true, + size: stats.size, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to stat file'; + return { id, type, success: false, error: message }; + } + } + case 'api:fs:write': { const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; if (!targetPath) { diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts index 66618ffb..b7e90aea 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { getFsMimeType, resolveFileReadPath, type FsReadPathResolution } from './bridge-fs-helpers-runtime'; +import { getFsMimeType, normalizeFsPath, resolveFileReadPath, type FsReadPathResolution } from './bridge-fs-helpers-runtime'; type ApiProxyResponsePayload = { status: number; @@ -48,7 +48,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) return buildProxyJsonError(400, 'Invalid request path'); } - if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { + if (parsed.pathname !== '/api/fs/stat' && parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { return null; } @@ -68,6 +68,21 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) return buildProxyJsonError(400, 'Specified path is not a file'); } + if (parsed.pathname === '/api/fs/stat') { + return { + status: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + bodyBase64: base64EncodeUtf8(JSON.stringify({ + path: normalizeFsPath(resolution.resolvedPath), + isFile: true, + size: stats.size, + })), + }; + } + if (parsed.pathname === '/api/fs/read') { const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); return { @@ -94,6 +109,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) if (err?.code === 'ENOENT') { return buildProxyJsonError(404, 'File not found'); } + if (parsed.pathname === '/api/fs/stat') { + return buildProxyJsonError(500, 'Unable to stat file'); + } return buildProxyJsonError(500, 'Unable to read file'); } }; diff --git a/packages/vscode/webview/api/files.ts b/packages/vscode/webview/api/files.ts index bcc73d96..4232bd2a 100644 --- a/packages/vscode/webview/api/files.ts +++ b/packages/vscode/webview/api/files.ts @@ -71,6 +71,16 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({ }; }, + async statFile(path: string): Promise<{ path: string; isFile: boolean; size: number }> { + const target = normalizePath(path); + const data = await sendBridgeMessage<{ path?: string; isFile?: boolean; size?: number }>('api:fs:stat', { path: target }); + return { + path: typeof data?.path === 'string' ? normalizePath(data.path) : target, + isFile: Boolean(data?.isFile), + size: typeof data?.size === 'number' ? data.size : 0, + }; + }, + async delete(path: string): Promise<{ success: boolean }> { const target = normalizePath(path); const data = await sendBridgeMessage<{ success: boolean }>('api:fs:delete', { path: target }); diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 0eaaeeed..c7c3276f 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -283,6 +283,54 @@ export const registerFsRoutes = (app, dependencies) => { } }); + app.get('/api/fs/stat', async (req, res) => { + const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); + if (!stats.isFile()) { + return res.status(400).json({ error: 'Specified path is not a file' }); + } + + return res.json({ path: canonicalPath, isFile: true, size: stats.size }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to stat file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' }); + } + }); + app.get('/api/fs/read', async (req, res) => { const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; if (!filePath) { diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index a1ba6f9f..349f5180 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -110,6 +110,23 @@ export const createWebFilesAPI = (): FilesAPI => ({ }; }, + async statFile(path: string): Promise<{ path: string; isFile: boolean; size: number }> { + const target = normalizePath(path); + const response = await fetch(`/api/fs/stat?path=${encodeURIComponent(target)}`); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Failed to stat file'); + } + + const result = await response.json().catch(() => ({})); + return { + path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : target, + isFile: Boolean((result as { isFile?: boolean }).isFile), + size: typeof (result as { size?: number }).size === 'number' ? (result as { size: number }).size : 0, + }; + }, + async readFile(path: string): Promise<{ content: string; path: string }> { const target = normalizePath(path); const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);