fix: improve external file and path handling

Open external context files read-only
Preserve leading-dot paths in UI
Keep workspace write operations guarded
This commit is contained in:
Bohdan Triapitsyn
2026-04-28 14:06:25 +03:00
parent 15338be265
commit 4f51abddf4
11 changed files with 98 additions and 43 deletions
@@ -37,7 +37,7 @@ export const ChangedFilesList: React.FC<ChangedFilesListProps> = ({ files, curre
<>
<span
className="min-w-0 truncate text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
>
{dirPart}
</span>
@@ -271,6 +271,7 @@ const renderReadFilePath = (displayPath: string) => {
color: 'var(--tools-description)',
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
}}
>
{displayDir}
@@ -1299,7 +1299,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
return (
<span
className={cn('min-w-0 truncate typography-ui-label text-foreground', grow && 'flex-1')}
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
title={path}
>
{path}
@@ -1315,7 +1315,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
return (
<span className={cn('min-w-0 flex items-baseline overflow-hidden typography-ui-label', grow && 'flex-1')} title={path}>
{hasAbsoluteRoot ? <span className="flex-shrink-0 text-muted-foreground">/</span> : null}
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left' }}>
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}>
{displayDir}
</span>
<span className="flex-shrink-0">
@@ -1360,6 +1360,7 @@ const renderAnimatedPathWithIcon = (path: string, _animate = true, grow = true,
color: 'var(--tools-description)',
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
}}
>
{displayDir}
@@ -362,7 +362,7 @@ const FileList = React.memo<FileListProps>(({
</span>
<span
className="min-w-0 flex-1 truncate typography-meta"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
title={file.path}
>
{file.path}
@@ -813,7 +813,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
return (
<span
className="block min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
>
{file.path}
</span>
@@ -827,7 +827,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
<span className="flex min-w-0 items-baseline overflow-hidden">
<span
className="min-w-0 truncate typography-ui-label text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
>
{dir}
</span>
+31 -15
View File
@@ -597,6 +597,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
const selectedFilePath = selectedFile?.path ?? '';
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
const selectedFileReadOptions = React.useMemo(
() => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }),
[mode, selectedFileIsOutsideWorkspace],
);
// Editor tabs horizontal scroll fades
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
@@ -1217,13 +1223,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string): Promise<string> => {
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path);
const result = await files.readFile(path, options);
return result.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
const params = new URLSearchParams({ path });
if (options?.allowOutsideWorkspace) {
params.set('allowOutsideWorkspace', 'true');
}
const response = await fetch(`/api/fs/read?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
@@ -1231,9 +1241,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return response.text();
}, [files, t]);
const readFileStat = React.useCallback(async (path: string): Promise<FileStatSnapshot | null> => {
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<FileStatSnapshot | null> => {
if (files.statFile) {
const result = await files.statFile(path);
const result = await files.statFile(path, options);
return {
path: result.path,
size: result.size,
@@ -1402,14 +1412,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setFileLoading(true);
await readFile(node.path)
const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) };
await readFile(node.path, readOptions)
.then((content) => {
setFileContent(content);
setDraftContent(content.length > MAX_VIEW_CHARS
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: content);
setLoadedFilePath(node.path);
void readFileStat(node.path)
void readFileStat(node.path, readOptions)
.then((stat) => {
if (stat) {
lastLoadedFileStatRef.current = stat;
@@ -1455,7 +1467,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.finally(() => {
setFileLoading(false);
});
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
}, [expandPaths, isMobile, loadDirectory, mode, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
if (!root) {
@@ -1549,7 +1561,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
void readFileStat(selectedFile.path)
void readFileStat(selectedFile.path, selectedFileReadOptions)
.then((latestStat) => {
if (cancelled || !latestStat) {
return;
@@ -1585,7 +1597,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
cancelled = true;
window.clearInterval(interval);
};
}, [loadedFilePath, readFileStat, selectedFile?.path]);
}, [loadedFilePath, readFileStat, selectedFile?.path, selectedFileReadOptions]);
const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current;
@@ -1820,7 +1832,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const selectedFilePath = selectedFile?.path ?? '';
const pendingNavigationTargetPath = React.useMemo(
() => normalizePath(pendingFileNavigation?.path ?? ''),
[pendingFileNavigation?.path],
@@ -1841,7 +1852,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
@@ -2301,7 +2312,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
: desktopImageSrc)
: (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`))
: `/api/fs/raw?${new URLSearchParams({
path: selectedFile.path,
...(selectedFileReadOptions.allowOutsideWorkspace ? { allowOutsideWorkspace: 'true' } : {}),
}).toString()}`))
: '';
@@ -2319,7 +2333,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setFileError(null);
const srcPromise = files.readFileBinary
? files.readFileBinary(selectedFile.path).then((result) => result.dataUrl)
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
: Promise.resolve(convertFileSrc(selectedFile.path, 'asset'));
await srcPromise
@@ -2348,7 +2362,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => {
cancelled = true;
};
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, t]);
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
const renderDialogs = () => (
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
@@ -3004,6 +3018,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
readOnly={!canEdit}
extensions={editorExtensions}
className="h-full"
blockWidgets={blockWidgets}
@@ -3268,6 +3283,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
readOnly={!canEdit}
extensions={editorExtensions}
className="h-full"
onViewReady={(view) => {
@@ -124,7 +124,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
return (
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
title={file.path}
>
{file.path}
@@ -137,7 +137,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
<span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
<span
className="min-w-0 truncate typography-ui-label text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
>
{dir}
</span>
@@ -47,14 +47,14 @@ function withContentCache(files: FilesAPI): FilesAPI {
return result;
};
const readFreshFile = async (path: string): Promise<{ content: string; path: string }> => {
const readFreshFile = async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]): Promise<{ content: string; path: string }> => {
// stat → read → stat to avoid TOCTOU:
// if the file changes between read and either stat, metadata won't match and we retry.
const statBefore = await files.statFile?.(path).catch(() => null);
const statBefore = await files.statFile?.(path, options).catch(() => null);
const result = await files.readFile!(path);
const result = await files.readFile!(path, options);
const statAfter = await files.statFile?.(path).catch(() => null);
const statAfter = await files.statFile?.(path, options).catch(() => null);
// If both stats are available and agree, the read was atomic with respect to file changes.
if (statBefore && statAfter && statBefore.isFile && statAfter.isFile) {
@@ -62,9 +62,9 @@ function withContentCache(files: FilesAPI): FilesAPI {
return syncCacheEntry(path, result, statAfter);
}
// File changed during read — discard and re-read once.
const retryStatBefore = await files.statFile?.(path).catch(() => null);
const retry = await files.readFile!(path);
const retryStat = await files.statFile?.(path).catch(() => null);
const retryStatBefore = await files.statFile?.(path, options).catch(() => null);
const retry = await files.readFile!(path, options);
const retryStat = await files.statFile?.(path, options).catch(() => null);
// Accept retry only if file was stable across the read.
if (retryStatBefore && retryStat && retryStatBefore.isFile && retryStat.isFile
&& retryStatBefore.size === retryStat.size && retryStatBefore.mtimeMs === retryStat.mtimeMs) {
@@ -78,7 +78,10 @@ function withContentCache(files: FilesAPI): FilesAPI {
};
const cachedReadFile: FilesAPI['readFile'] = files.readFile
? async (path: string) => {
? async (path: string, options) => {
if (options?.allowOutsideWorkspace) {
return readFreshFile(path, options);
}
const hit = cache.get(path);
if (hit) {
// Validate cached entry is still fresh
+7 -3
View File
@@ -509,13 +509,17 @@ export interface ListDirectoryOptions {
respectGitignore?: boolean;
}
export interface FileReadOptions {
allowOutsideWorkspace?: boolean;
}
export interface FilesAPI {
listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult>;
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
statFile?(path: string): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }>;
readFile?(path: string): Promise<{ content: string; path: string }>;
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
statFile?(path: string, options?: FileReadOptions): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }>;
readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>;
readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>;
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
delete?(path: string): Promise<{ success: boolean }>;
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
+3 -2
View File
@@ -26,11 +26,12 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path);
const result = await files.readFile(path, { allowOutsideWorkspace: true });
return result.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true' });
const response = await fetch(`/api/fs/read?${params.toString()}`);
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
+24 -3
View File
@@ -95,6 +95,27 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
});
};
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
if (req.query?.allowOutsideWorkspace === 'true') {
const normalized = normalizeDirectoryPath(targetPath);
if (!normalized || typeof normalized !== 'string') {
return { ok: false, error: 'Path is required' };
}
const resolved = path.resolve(normalized);
return { ok: true, base: path.dirname(resolved), resolved };
}
return resolveWorkspacePathFromContext({
req,
targetPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
};
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
return new Promise((resolve) => {
let stdout = '';
@@ -290,7 +311,7 @@ export const registerFsRoutes = (app, dependencies) => {
}
try {
const resolved = await resolveWorkspacePathFromContext({
const resolved = await resolveReadPathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
@@ -338,7 +359,7 @@ export const registerFsRoutes = (app, dependencies) => {
}
try {
const resolved = await resolveWorkspacePathFromContext({
const resolved = await resolveReadPathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
@@ -387,7 +408,7 @@ export const registerFsRoutes = (app, dependencies) => {
}
try {
const resolved = await resolveWorkspacePathFromContext({
const resolved = await resolveReadPathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
+12 -4
View File
@@ -110,9 +110,13 @@ export const createWebFilesAPI = (): FilesAPI => ({
};
},
async statFile(path: string): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }> {
async statFile(path: string, options): Promise<{ path: string; isFile: boolean; size: number; mtimeMs?: number }> {
const target = normalizePath(path);
const response = await fetch(`/api/fs/stat?path=${encodeURIComponent(target)}`);
const params = new URLSearchParams({ path: target });
if (options?.allowOutsideWorkspace) {
params.set('allowOutsideWorkspace', 'true');
}
const response = await fetch(`/api/fs/stat?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
@@ -128,9 +132,13 @@ export const createWebFilesAPI = (): FilesAPI => ({
};
},
async readFile(path: string): Promise<{ content: string; path: string }> {
async readFile(path: string, options): Promise<{ content: string; path: string }> {
const target = normalizePath(path);
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
const params = new URLSearchParams({ path: target });
if (options?.allowOutsideWorkspace) {
params.set('allowOutsideWorkspace', 'true');
}
const response = await fetch(`/api/fs/read?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));