fix: pass workspace directory in Files API requests (#1588)

* fix: pass effective workspace directory in Files API requests

The web Files API used useDirectoryStore.currentDirectory as the
workspace root, but the FilesView's effective directory comes from
useEffectiveDirectory() which can differ (e.g. worktree sessions).
When they diverged the server rejected file reads with 'Path is
outside of active workspace'.

Add directory override to FileReadOptions so callers can pass the
effective directory per-call. The FilesView now passes its root
(from useEffectiveDirectory) through readFile, statFile, image/PDF
URLs, and the desktop image fallback. The server receives the
correct workspace root via x-opencode-directory header or directory
query parameter.

Fixes #1456

* fix: cover files workspace directory regressions

* fix: sync directory store on draft session and forward cache options

The content cache wrapper in RuntimeAPIProvider was dropping the
options parameter (including the per-call directory override) when
making internal statFile and readFreshFile calls during cache
validation and misses. This caused the underlying web API to fall
back to getDirectory() which reads useDirectoryStore.currentDirectory.

Additionally, openNewSessionDraft, setNewSessionDraftTarget, and
overrideNewSessionDraftTarget updated the draft's directory without
ever syncing useDirectoryStore. Since the web API's getDirectory()
reads from that store, it returned the stale previous-project
directory during draft sessions, causing 'Path is outside of active
workspace' errors when opening files.

Forward options through all internal calls in the content cache
wrapper, and sync useDirectoryStore via setDirectory() whenever the
draft session directory changes.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Tom Rochette
2026-06-15 11:00:02 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 5773297ecf
commit 71bae089a7
7 changed files with 123 additions and 29 deletions
+22 -16
View File
@@ -800,8 +800,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
() => ({
allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace,
outsideFileGrant: selectedOutsideFileGrant,
directory: root || undefined,
}),
[mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant],
[mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant, root],
);
// Editor tabs horizontal scroll fades
@@ -1478,10 +1479,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path, options);
return result.content ?? '';
}
if (files.readFile) {
const result = await files.readFile(path, { ...(options ?? {}), directory: root || undefined });
return result.content ?? '';
}
const params = new URLSearchParams({ path });
if (options?.allowOutsideWorkspace) {
@@ -1490,11 +1491,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (options?.outsideFileGrant) {
params.set('outsideFileGrant', options.outsideFileGrant);
}
if (options?.optional) {
params.set('optional', 'true');
}
if (options?.optional) {
params.set('optional', 'true');
}
if (root) {
params.set('directory', root);
}
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
// Avoid conditional requests (304 + empty body).
cache: options?.optional ? 'no-store' : 'default',
});
if (!response.ok) {
@@ -1502,19 +1505,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
}
return response.text();
}, [files, t]);
}, [files, root, t]);
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise<FileStatSnapshot | null> => {
if (files.statFile) {
const result = await files.statFile(path, options);
return {
if (files.statFile) {
const result = await files.statFile(path, { ...(options ?? {}), directory: root || undefined });
return {
path: result.path,
size: result.size,
mtimeMs: result.mtimeMs,
};
}
return null;
}, [files]);
}, [files, root]);
React.useEffect(() => {
if (!root || !files.statFile || openPaths.length === 0) {
@@ -1526,7 +1529,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
void Promise.all(paths.map(async (path) => {
try {
const stat = await files.statFile?.(path);
const stat = await files.statFile?.(path, { directory: root || undefined });
if (!cancelled && stat && !stat.isFile) {
removeOpenPathsByPrefix(root, path);
}
@@ -2948,6 +2951,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
directory: root || undefined,
}) : ''))
: '';
@@ -2956,6 +2960,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
directory: root || undefined,
})
: '';
@@ -2997,6 +3002,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
directory: root || undefined,
},
});
if (!response.ok) {
@@ -3042,7 +3048,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => {
cancelled = true;
};
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
}, [files, isSelectedImage, isSelectedSvg, root, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
React.useEffect(() => {
return () => {