perf: optimize markdown file link detection
Only mark existing files as openable links Avoid file-link behavior while messages stream Limit file existence checks to 4 concurrent requests
This commit is contained in:
@@ -992,10 +992,15 @@ interface MarkdownRendererProps {
|
|||||||
disableStreamAnimation?: boolean;
|
disableStreamAnimation?: boolean;
|
||||||
variant?: MarkdownVariant;
|
variant?: MarkdownVariant;
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
enableFileReferences?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
||||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||||
|
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||||
|
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
||||||
|
let activeFileReferenceStatCount = 0;
|
||||||
|
const pendingFileReferenceStats: Array<() => void> = [];
|
||||||
|
|
||||||
type ParsedFileReference = {
|
type ParsedFileReference = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -1262,6 +1267,44 @@ 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) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new Promise<boolean>((resolve) => {
|
||||||
|
const run = () => {
|
||||||
|
activeFileReferenceStatCount += 1;
|
||||||
|
void fetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
.then((response) => resolve(response.ok))
|
||||||
|
.catch(() => resolve(false))
|
||||||
|
.finally(() => {
|
||||||
|
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
||||||
|
pendingFileReferenceStats.shift()?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
||||||
|
run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFileReferenceStats.push(run);
|
||||||
|
});
|
||||||
|
|
||||||
|
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||||
const normalizedDirectory = normalizePath(effectiveDirectory);
|
const normalizedDirectory = normalizePath(effectiveDirectory);
|
||||||
if (normalizedDirectory) {
|
if (normalizedDirectory) {
|
||||||
@@ -1278,11 +1321,13 @@ const useFileReferenceInteractions = ({
|
|||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor,
|
preferRuntimeEditor,
|
||||||
|
enabled,
|
||||||
}: {
|
}: {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
effectiveDirectory: string;
|
effectiveDirectory: string;
|
||||||
editor?: EditorAPI;
|
editor?: EditorAPI;
|
||||||
preferRuntimeEditor?: boolean;
|
preferRuntimeEditor?: boolean;
|
||||||
|
enabled: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const annotationDebounceRef = React.useRef<number | null>(null);
|
const annotationDebounceRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -1291,6 +1336,20 @@ const useFileReferenceInteractions = ({
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const clearFileLinkAttributes = (candidate: HTMLElement) => {
|
||||||
|
candidate.removeAttribute('data-openchamber-file-link');
|
||||||
|
candidate.removeAttribute('data-openchamber-file-ref');
|
||||||
|
candidate.removeAttribute('data-openchamber-file-path');
|
||||||
|
if (candidate.getAttribute('title') === 'Open file') {
|
||||||
|
candidate.removeAttribute('title');
|
||||||
|
}
|
||||||
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
||||||
|
candidate.removeAttribute('role');
|
||||||
|
candidate.removeAttribute('tabindex');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const annotateFileLinks = () => {
|
const annotateFileLinks = () => {
|
||||||
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
|
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
|
||||||
@@ -1298,28 +1357,32 @@ const useFileReferenceInteractions = ({
|
|||||||
for (const candidate of Array.from(candidates)) {
|
for (const candidate of Array.from(candidates)) {
|
||||||
const rawCandidate = extractPathCandidateFromElement(candidate);
|
const rawCandidate = extractPathCandidateFromElement(candidate);
|
||||||
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
||||||
if (!resolved) {
|
clearFileLinkAttributes(candidate);
|
||||||
candidate.removeAttribute('data-openchamber-file-link');
|
|
||||||
candidate.removeAttribute('data-openchamber-file-ref');
|
if (!enabled || !resolved) {
|
||||||
candidate.removeAttribute('data-openchamber-file-path');
|
|
||||||
if (candidate.getAttribute('title') === 'Open file') {
|
|
||||||
candidate.removeAttribute('title');
|
|
||||||
}
|
|
||||||
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
||||||
candidate.removeAttribute('role');
|
|
||||||
candidate.removeAttribute('tabindex');
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate.setAttribute('data-openchamber-file-link', 'true');
|
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
|
||||||
candidate.setAttribute('data-openchamber-file-ref', rawCandidate);
|
if (cancelled || !exists || !container.contains(candidate)) {
|
||||||
candidate.setAttribute('data-openchamber-file-path', resolved.resolvedPath);
|
return;
|
||||||
candidate.setAttribute('title', 'Open file');
|
}
|
||||||
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
||||||
candidate.setAttribute('role', 'button');
|
const latestRawCandidate = extractPathCandidateFromElement(candidate);
|
||||||
candidate.setAttribute('tabindex', '0');
|
const latestResolved = getResolvedReference(latestRawCandidate, effectiveDirectory);
|
||||||
}
|
if (!latestResolved || latestResolved.resolvedPath !== resolved.resolvedPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.setAttribute('data-openchamber-file-link', 'true');
|
||||||
|
candidate.setAttribute('data-openchamber-file-ref', latestRawCandidate);
|
||||||
|
candidate.setAttribute('data-openchamber-file-path', latestResolved.resolvedPath);
|
||||||
|
candidate.setAttribute('title', 'Open file');
|
||||||
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
||||||
|
candidate.setAttribute('role', 'button');
|
||||||
|
candidate.setAttribute('tabindex', '0');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1416,6 +1479,7 @@ const useFileReferenceInteractions = ({
|
|||||||
container.addEventListener('keydown', handleKeyDown);
|
container.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(annotationDebounceRef.current);
|
window.clearTimeout(annotationDebounceRef.current);
|
||||||
}
|
}
|
||||||
@@ -1424,7 +1488,7 @@ const useFileReferenceInteractions = ({
|
|||||||
container.removeEventListener('click', handleClick);
|
container.removeEventListener('click', handleClick);
|
||||||
container.removeEventListener('keydown', handleKeyDown);
|
container.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor]);
|
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, enabled]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useMermaidInlineInteractions = ({
|
const useMermaidInlineInteractions = ({
|
||||||
@@ -1531,6 +1595,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
disableStreamAnimation = false,
|
disableStreamAnimation = false,
|
||||||
variant = 'assistant',
|
variant = 'assistant',
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
|
enableFileReferences = true,
|
||||||
}) => {
|
}) => {
|
||||||
const currentTheme = useCurrentMermaidTheme();
|
const currentTheme = useCurrentMermaidTheme();
|
||||||
const { editor, runtime } = useRuntimeAPIs();
|
const { editor, runtime } = useRuntimeAPIs();
|
||||||
@@ -1543,6 +1608,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
|
enabled: enableFileReferences && !isStreaming,
|
||||||
});
|
});
|
||||||
useExternalLinkInteractions({ containerRef });
|
useExternalLinkInteractions({ containerRef });
|
||||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||||
@@ -1615,6 +1681,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
|||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
mermaidControls?: MermaidControlOptions;
|
mermaidControls?: MermaidControlOptions;
|
||||||
allowMermaidWheelZoom?: boolean;
|
allowMermaidWheelZoom?: boolean;
|
||||||
|
enableFileReferences?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
content,
|
content,
|
||||||
className,
|
className,
|
||||||
@@ -1623,6 +1690,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
|||||||
stripFrontmatter = false,
|
stripFrontmatter = false,
|
||||||
onShowPopup,
|
onShowPopup,
|
||||||
allowMermaidWheelZoom = false,
|
allowMermaidWheelZoom = false,
|
||||||
|
enableFileReferences = true,
|
||||||
}) => {
|
}) => {
|
||||||
const { editor, runtime } = useRuntimeAPIs();
|
const { editor, runtime } = useRuntimeAPIs();
|
||||||
const renderedContent = React.useMemo(
|
const renderedContent = React.useMemo(
|
||||||
@@ -1644,6 +1712,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
|||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
editor,
|
editor,
|
||||||
preferRuntimeEditor: runtime.isVSCode,
|
preferRuntimeEditor: runtime.isVSCode,
|
||||||
|
enabled: enableFileReferences,
|
||||||
});
|
});
|
||||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
@@ -1674,5 +1743,6 @@ export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (pr
|
|||||||
&& prev.disableLinkSafety === next.disableLinkSafety
|
&& prev.disableLinkSafety === next.disableLinkSafety
|
||||||
&& prev.stripFrontmatter === next.stripFrontmatter
|
&& prev.stripFrontmatter === next.stripFrontmatter
|
||||||
&& prev.onShowPopup === next.onShowPopup
|
&& prev.onShowPopup === next.onShowPopup
|
||||||
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom;
|
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
|
||||||
|
&& prev.enableFileReferences === next.enableFileReferences;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
disableStreamAnimation={chatRenderMode === 'sorted'}
|
disableStreamAnimation={chatRenderMode === 'sorted'}
|
||||||
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
||||||
|
enableFileReferences={isFinalized}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -976,6 +976,8 @@ html:not(.dark) .chat-scroll {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content [data-openchamber-file-link="true"]:hover {
|
.markdown-content [data-openchamber-file-link="true"]:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user