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.
This commit is contained in:
@@ -4,7 +4,6 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
@@ -151,19 +151,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
||||
// output. The regex is defined in `./fileReferenceParser`; the inline-code
|
||||
// pipeline reads full text content rather than using this regex.
|
||||
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||
const FILE_REFERENCE_LINK_LIMIT = 80;
|
||||
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
||||
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
|
||||
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
|
||||
);
|
||||
|
||||
const getFileReferenceLinkLimit = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
||||
@@ -361,61 +351,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
|
||||
};
|
||||
};
|
||||
|
||||
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
||||
const normalizedPath = normalizePath(resolvedPath);
|
||||
if (!normalizedPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
|
||||
if (cached) {
|
||||
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
|
||||
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, 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',
|
||||
})
|
||||
.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(normalizedPath, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||
};
|
||||
@@ -521,7 +456,7 @@ const useFileReferenceInteractions = ({
|
||||
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
|
||||
const existsPromise = canGrantOutsideFile
|
||||
? Promise.resolve(true)
|
||||
: fileReferenceExists(resolved.resolvedPath);
|
||||
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
|
||||
|
||||
void existsPromise.then((exists) => {
|
||||
if (cancelled || !exists || !container.contains(candidate)) {
|
||||
|
||||
Reference in New Issue
Block a user