diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 25d4bdcf..8dc39d0a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -800,8 +800,9 @@ export const FilesView: React.FC = ({ 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 = ({ mode = 'full' }) => { }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise => { - 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 = ({ 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 = ({ 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 => { - 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 () => { diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index c78f8ecf..5cb76743 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -100,20 +100,20 @@ function withContentCache(files: FilesAPI): FilesAPI { if (hit) { // Validate cached entry is still fresh if (files.statFile) { - const latest = await files.statFile(path).catch(() => { + const latest = await files.statFile(path, options).catch(() => { removeCacheEntry(path); return null; }); if (!latest || !statMatches(hit, latest)) { removeCacheEntry(path); - return readFreshFile(path); + return readFreshFile(path, options); } } touchContentLru(path); return { content: hit.content, path: hit.path }; } - return readFreshFile(path); + return readFreshFile(path, options); } : undefined; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7a648776..294c2bbe 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -597,6 +597,7 @@ export interface FileReadOptions { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean; + directory?: string; } export interface FilesAPI { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f4960e01..3cf91067 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -668,6 +668,10 @@ export const useSessionUIStore = create()((set, get) => ({ void activateConfigForDirectory(configDirectory).then(() => { useConfigStore.getState().applyDefaultModelAgentSelection() }) + + if (directory && directory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(directory) + } }, // --------------------------------------------------------------------------- @@ -706,6 +710,10 @@ export const useSessionUIStore = create()((set, get) => ({ } }) void activateConfigForDirectory(nextDirectory) + + if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(nextDirectory) + } }, setDraftPreserveDirectoryOverride: (value) => @@ -824,6 +832,10 @@ export const useSessionUIStore = create()((set, get) => ({ return { newSessionDraft: nextDraft } }) void activateConfigForDirectory(nextDirectory) + + if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(nextDirectory) + } }, resolvePendingDraftWorktreeTarget: (requestId, directory, options) => diff --git a/packages/web/src/api/files.test.ts b/packages/web/src/api/files.test.ts new file mode 100644 index 00000000..f1b963c2 --- /dev/null +++ b/packages/web/src/api/files.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; + +const runtimeFetchMock = vi.fn(); + +vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +const toUrl = (path: string, query?: RuntimeUrlQuery): string => { + const params = query instanceof URLSearchParams ? query : new URLSearchParams(); + const queryString = params.toString(); + return queryString ? `${path}?${queryString}` : path; +}; + +const urls: RuntimeUrlResolver = { + api: toUrl, + authenticatedAsset: toUrl, + auth: toUrl, + health: (query?: RuntimeUrlQuery) => toUrl('/health', query), + rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })), + sse: toUrl, + websocket: toUrl, +}; + +describe('createWebFilesAPI', () => { + it('uses per-call workspace directory for stat and read requests', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/stale-workspace' }); + + runtimeFetchMock.mockResolvedValueOnce(Response.json({ path: '/worktree-b/file.txt', isFile: true, size: 12 })); + await api.statFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/stat?path=%2Fworktree-b%2Ffile.txt', { + headers: { 'x-opencode-directory': '/worktree-a' }, + }); + + runtimeFetchMock.mockResolvedValueOnce(new Response('content')); + await api.readFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/read?path=%2Fworktree-b%2Ffile.txt', { + cache: 'default', + headers: { 'x-opencode-directory': '/worktree-a' }, + }); + }); + + it('sends the workspace directory header for downloads', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/current-workspace' }); + + runtimeFetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + await expect(api.downloadFile?.('/current-workspace/file.txt')).rejects.toThrow('Download failed'); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/raw', { + query: { path: '/current-workspace/file.txt', download: true }, + headers: { 'x-opencode-directory': '/current-workspace' }, + }); + }); +}); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 3cb034e5..7e8f07e7 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -11,6 +11,7 @@ const normalizePath = (path: string): string => path.replace(/\\/g, '/'); interface WebFilesAPIOptions { urls: RuntimeUrlResolver; + getDirectory?: () => string | undefined; } type WebDirectoryEntry = { @@ -45,7 +46,12 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL }; }; -export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ +const directoryHeaders = (getDirectory?: () => string | undefined, override?: string): Record | undefined => { + const directory = override || getDirectory?.(); + return directory ? { 'x-opencode-directory': directory } : undefined; +}; + +export const createWebFilesAPI = ({ urls, getDirectory }: WebFilesAPIOptions): FilesAPI => ({ async listDirectory(path: string, options): Promise { const target = normalizePath(path); const params = new URLSearchParams(); @@ -56,7 +62,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ params.set('respectGitignore', 'true'); } - const response = await runtimeFetch(urls.api('/api/fs/list', params)); + const response = await runtimeFetch(urls.api('/api/fs/list', params), { + headers: directoryHeaders(getDirectory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -83,7 +91,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ params.set('limit', String(payload.maxResults)); } - const response = await runtimeFetch(urls.api('/api/find/file', params)); + const response = await runtimeFetch(urls.api('/api/find/file', params), { + headers: directoryHeaders(getDirectory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -103,7 +113,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/mkdir'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target }), }); @@ -128,7 +138,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ if (options?.outsideFileGrant) { params.set('outsideFileGrant', options.outsideFileGrant); } - const response = await runtimeFetch(urls.api('/api/fs/stat', params)); + const response = await runtimeFetch(urls.api('/api/fs/stat', params), { + headers: directoryHeaders(getDirectory, options?.directory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -158,6 +170,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ } const response = await runtimeFetch(urls.api('/api/fs/read', params), { cache: options?.optional ? 'no-store' : 'default', + headers: directoryHeaders(getDirectory, options?.directory), }); if (!response.ok) { @@ -173,7 +186,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/write'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target, content }), }); @@ -193,7 +206,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/delete'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target }), }); @@ -209,7 +222,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> { const response = await runtimeFetch(urls.api('/api/fs/rename'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ oldPath, newPath }), }); @@ -228,7 +241,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ async revealPath(targetPath: string): Promise<{ success: boolean }> { const response = await runtimeFetch(urls.api('/api/fs/reveal'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: normalizePath(targetPath) }), }); @@ -245,6 +258,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch('/api/fs/raw', { query: { path: target, download: true }, + headers: directoryHeaders(getDirectory), }); if (!response.ok) { throw new Error(`Download failed (${response.status})`); diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 20398594..12831517 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -5,6 +5,7 @@ import { setRuntimeUrlResolver, type RuntimeUrlResolver, } from '@openchamber/ui/lib/runtime-url'; +import { useDirectoryStore } from '@openchamber/ui/stores/useDirectoryStore'; import { createWebTerminalAPI } from './terminal'; import { createWebGitAPI } from './git'; import { createWebFilesAPI } from './files'; @@ -39,7 +40,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' }, terminal: createWebTerminalAPI(), git: createWebGitAPI(), - files: createWebFilesAPI({ urls: activeUrls }), + files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }), settings: createWebSettingsAPI(), permissions: createWebPermissionsAPI(), notifications: createWebNotificationsAPI(),