feat(fs): add stat API for markdown file validation (#774)
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
0e70422835
commit
14fc741cc9
@@ -1022,14 +1022,14 @@ const getContextDirectory = (effectiveDirectory: string, resolvedPath: string):
|
|||||||
const useFileReferenceInteractions = ({
|
const useFileReferenceInteractions = ({
|
||||||
containerRef,
|
containerRef,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
readFile,
|
statFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor,
|
preferRuntimeEditor,
|
||||||
deferValidationUntilIdle = false,
|
deferValidationUntilIdle = false,
|
||||||
}: {
|
}: {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
effectiveDirectory: string;
|
effectiveDirectory: string;
|
||||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
statFile?: (path: string) => Promise<{ path: string; isFile: boolean; size: number }>;
|
||||||
editor?: EditorAPI;
|
editor?: EditorAPI;
|
||||||
preferRuntimeEditor?: boolean;
|
preferRuntimeEditor?: boolean;
|
||||||
deferValidationUntilIdle?: boolean;
|
deferValidationUntilIdle?: boolean;
|
||||||
@@ -1061,10 +1061,14 @@ const useFileReferenceInteractions = ({
|
|||||||
|
|
||||||
const checkPromise = (async () => {
|
const checkPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!readFile) {
|
if (!statFile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stat = await statFile(resolvedPath);
|
||||||
|
if (!stat.isFile) {
|
||||||
|
cache.set(resolvedPath, false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await readFile(resolvedPath);
|
|
||||||
cache.set(resolvedPath, true);
|
cache.set(resolvedPath, true);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1289,7 +1293,7 @@ const useFileReferenceInteractions = ({
|
|||||||
container.removeEventListener('click', handleClick);
|
container.removeEventListener('click', handleClick);
|
||||||
container.removeEventListener('keydown', handleKeyDown);
|
container.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
}, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, statFile]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useMermaidInlineInteractions = ({
|
const useMermaidInlineInteractions = ({
|
||||||
@@ -1405,7 +1409,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
useFileReferenceInteractions({
|
useFileReferenceInteractions({
|
||||||
containerRef: streamdownContainerRef,
|
containerRef: streamdownContainerRef,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
readFile: files.readFile,
|
statFile: files.statFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
deferValidationUntilIdle: isStreaming,
|
deferValidationUntilIdle: isStreaming,
|
||||||
@@ -1491,7 +1495,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
useFileReferenceInteractions({
|
useFileReferenceInteractions({
|
||||||
containerRef: streamdownContainerRef,
|
containerRef: streamdownContainerRef,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
readFile: files.readFile,
|
statFile: files.statFile,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ export interface FilesAPI {
|
|||||||
listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult>;
|
listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult>;
|
||||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
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 }>;
|
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||||
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
|
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
|
||||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||||
|
|||||||
@@ -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': {
|
case 'api:fs:write': {
|
||||||
const { path: targetPath, content } = (payload as { path: string; content: string }) || {};
|
const { path: targetPath, content } = (payload as { path: string; content: string }) || {};
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as fs from 'fs';
|
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 = {
|
type ApiProxyResponsePayload = {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -48,7 +48,7 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
|||||||
return buildProxyJsonError(400, 'Invalid request path');
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +68,21 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
|||||||
return buildProxyJsonError(400, 'Specified path is not a file');
|
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') {
|
if (parsed.pathname === '/api/fs/read') {
|
||||||
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
|
const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8');
|
||||||
return {
|
return {
|
||||||
@@ -94,6 +109,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
|||||||
if (err?.code === 'ENOENT') {
|
if (err?.code === 'ENOENT') {
|
||||||
return buildProxyJsonError(404, 'File not found');
|
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');
|
return buildProxyJsonError(500, 'Unable to read file');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }> {
|
async delete(path: string): Promise<{ success: boolean }> {
|
||||||
const target = normalizePath(path);
|
const target = normalizePath(path);
|
||||||
const data = await sendBridgeMessage<{ success: boolean }>('api:fs:delete', { path: target });
|
const data = await sendBridgeMessage<{ success: boolean }>('api:fs:delete', { path: target });
|
||||||
|
|||||||
@@ -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) => {
|
app.get('/api/fs/read', async (req, res) => {
|
||||||
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
|
|||||||
@@ -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 }> {
|
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||||
const target = normalizePath(path);
|
const target = normalizePath(path);
|
||||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
|
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user