feat: linkify file paths inside fenced code blocks (#1560)
Extends existing file-reference detection (currently only inline code and anchor tags) to also wrap path-like tokens inside <pre><code> blocks, making shell-output paths like 'src/foo.ts:42' clickable just like inline ones. Implementation in MarkdownRendererImpl.tsx: - New regex BLOCK_PATH_TOKEN_RE matches paths with mandatory extension and optional :line[:col] suffix. - New helper wrapBlockCodePathTokens() walks text nodes in each rendered <pre><code>, wraps matches in <span data-openchamber-block-path-token="true">, and marks the block as scanned (data-openchamber-block-paths-scanned) to avoid re-walking. - annotateFileLinks() invokes the wrapper and extends its selector to include the new tokens; the rest of the pipeline (stat check, click handler) is reused unchanged. - Code blocks longer than 200KB are skipped to keep large outputs (git log, build logs) cheap.
This commit is contained in:
@@ -1102,6 +1102,20 @@ interface MarkdownRendererProps {
|
|||||||
|
|
||||||
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 BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
|
||||||
|
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
|
||||||
|
const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
||||||
|
// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file
|
||||||
|
// extension (1-8 alphanumerics) so plain words don't qualify; the path itself
|
||||||
|
// must contain at least one extension-bearing segment.
|
||||||
|
//
|
||||||
|
// Known limitation: backslash-separated Windows paths (e.g.
|
||||||
|
// `C:\Users\test\file.ts:12`) are not matched because the path character class
|
||||||
|
// does not include `\`. Compiler output inside fenced code blocks predominantly
|
||||||
|
// uses forward slashes, so this is a niche gap. The inline-code pipeline is not
|
||||||
|
// affected — it reads full text content rather than matching with a regex.
|
||||||
|
const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g;
|
||||||
|
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
||||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||||
@@ -1304,6 +1318,96 @@ const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
|||||||
return (element.textContent || '').trim();
|
return (element.textContent || '').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walks text nodes inside `<pre><code>` subtrees and wraps any substring that
|
||||||
|
// looks like a `path[:line[:col]]` reference in a span carrying
|
||||||
|
// `data-openchamber-block-path-token`. `annotateFileLinks` then promotes those
|
||||||
|
// spans into clickable file links via the same existing pipeline used for
|
||||||
|
// inline code (parseFileReference → fileReferenceExists → openFileReference).
|
||||||
|
//
|
||||||
|
// Idempotent: each `<code>` node is marked with
|
||||||
|
// `data-openchamber-block-paths-scanned` once processed so the walk is not
|
||||||
|
// repeated on the same element. When the renderer replaces the `<code>` subtree
|
||||||
|
// (e.g. on content change during streaming), the new element lacks the marker and
|
||||||
|
// will be rescanned on the next mutation-observer callback.
|
||||||
|
const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||||
|
const codeBlocks = container.querySelectorAll<HTMLElement>('pre code');
|
||||||
|
if (codeBlocks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = container.ownerDocument;
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const codeBlock of Array.from(codeBlocks)) {
|
||||||
|
if (codeBlock.getAttribute(CODE_BLOCK_PATH_SCANNED_ATTR) === 'true') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip absurdly large code blocks to keep DOM work bounded.
|
||||||
|
if ((codeBlock.textContent ?? '').length > MAX_BLOCK_CODE_SCAN_LENGTH) {
|
||||||
|
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = doc.createTreeWalker(codeBlock, NodeFilter.SHOW_TEXT);
|
||||||
|
const textNodes: Text[] = [];
|
||||||
|
let currentNode = walker.nextNode();
|
||||||
|
while (currentNode) {
|
||||||
|
textNodes.push(currentNode as Text);
|
||||||
|
currentNode = walker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
// Skip nodes already inside one of our token spans.
|
||||||
|
if (textNode.parentElement?.closest(BLOCK_PATH_TOKEN_SELECTOR)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textNode.data;
|
||||||
|
if (!text || !text.includes('.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCK_PATH_TOKEN_RE.lastIndex = 0;
|
||||||
|
const matches: Array<{ start: number; end: number; raw: string }> = [];
|
||||||
|
let match: RegExpExecArray | null = BLOCK_PATH_TOKEN_RE.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const raw = match[0];
|
||||||
|
if (raw && isLikelyFilePath(raw)) {
|
||||||
|
matches.push({ start: match.index, end: match.index + raw.length, raw });
|
||||||
|
}
|
||||||
|
match = BLOCK_PATH_TOKEN_RE.exec(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = doc.createDocumentFragment();
|
||||||
|
let cursor = 0;
|
||||||
|
for (const { start, end, raw } of matches) {
|
||||||
|
if (start > cursor) {
|
||||||
|
fragment.appendChild(doc.createTextNode(text.slice(cursor, start)));
|
||||||
|
}
|
||||||
|
const span = doc.createElement('span');
|
||||||
|
span.setAttribute(BLOCK_PATH_TOKEN_ATTR, 'true');
|
||||||
|
span.textContent = raw;
|
||||||
|
fragment.appendChild(span);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) {
|
||||||
|
fragment.appendChild(doc.createTextNode(text.slice(cursor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
|
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
|
||||||
const parsed = parseFileReference(rawValue);
|
const parsed = parseFileReference(rawValue);
|
||||||
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
|
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
|
||||||
@@ -1424,7 +1528,12 @@ const useFileReferenceInteractions = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const annotateFileLinks = () => {
|
const annotateFileLinks = () => {
|
||||||
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
|
if (enabled) {
|
||||||
|
wrapBlockCodePathTokens(container);
|
||||||
|
}
|
||||||
|
const candidates = container.querySelectorAll<HTMLElement>(
|
||||||
|
`[data-markdown="inline-code"], a, ${BLOCK_PATH_TOKEN_SELECTOR}`,
|
||||||
|
);
|
||||||
let linkedCount = 0;
|
let linkedCount = 0;
|
||||||
|
|
||||||
for (const candidate of Array.from(candidates)) {
|
for (const candidate of Array.from(candidates)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user