Files
openchamber/packages/ui/src/components/chat/fileReferenceStat.ts
T
Tom Rochette a79ae57ae5 fix(ui): scope file-reference stat probes to the session directory (#3022)
* test: add regression test for stat directory scoping (#3019)

* fix: scope file-reference stat probes to the session directory (#3019)

The /api/fs/stat probe sent by the markdown renderer carried no
directory hint, so the server resolved the workspace from
settings.lastDirectory. When the browsed directory differs from the
session directory the stat returns 400 and the renderer treats it as
file-does-not-exist, silently disabling file-reference links.

Send effectiveDirectory as x-opencode-directory on the probe and
qualify the stat cache key by directory so a rejection under one
directory cannot leak into another. The probe logic moves from
MarkdownRendererImpl into fileReferenceStat with unit coverage.
2026-08-21 16:48:32 +03:00

81 lines
2.9 KiB
TypeScript

import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};