refactor(vscode-ide-companion/qwenAgentManager): remove saveCheckpointViaCommand method

refactor(vscode-ide-companion/webview): improve message handling during checkpoint saves
feat(vscode-ide-companion/markdown): enhance file path link handling with line numbers support
feat(vscode-ide-companion/message): add enableFileLinks prop to MessageContent component
feat(vscode-ide-companion/user-message): disable file links in user messages
This commit is contained in:
yiliang114
2025-12-10 01:28:41 +08:00
parent 49c032492a
commit 5ef3d32f16
5 changed files with 161 additions and 110 deletions

View File

@@ -14,6 +14,8 @@ import './MarkdownRenderer.css';
interface MarkdownRendererProps {
content: string;
onFileClick?: (filePath: string) => void;
/** When false, do not convert file paths into clickable links. Default: true */
enableFileLinks?: boolean;
}
/**
@@ -32,6 +34,7 @@ const FILE_PATH_WITH_LINES_REGEX =
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onFileClick,
enableFileLinks = true,
}) => {
/**
* Initialize markdown-it with plugins
@@ -59,8 +62,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Process the markdown content
let html = md.render(content);
// Post-process to add file path click handlers
html = processFilePaths(html);
// Post-process to add file path click handlers unless disabled
if (enableFileLinks) {
html = processFilePaths(html);
}
return html;
} catch (error) {
@@ -108,20 +113,41 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
container.innerHTML = html;
const union = new RegExp(
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}`,
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`,
'gi',
);
// Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line)
const normalizePathAndLine = (
raw: string,
): { displayText: string; dataPath: string } => {
const displayText = raw;
let base = raw;
// Extract hash fragment like #12, #L12 or #12-34 and keep only the first number
const hashIndex = raw.indexOf('#');
if (hashIndex >= 0) {
const frag = raw.slice(hashIndex + 1);
// Accept L12, 12 or 12-34
const m = frag.match(/^L?(\d+)(?:-\d+)?$/i);
if (m) {
const line = parseInt(m[1], 10);
base = raw.slice(0, hashIndex);
return { displayText, dataPath: `${base}:${line}` };
}
}
return { displayText, dataPath: base };
};
const makeLink = (text: string) => {
const link = document.createElement('a');
// Pass base path to the handler; keep the full text as label
const filePath = text.split('#')[0];
// Pass base path (with optional :line) to the handler; keep the full text as label
const { dataPath } = normalizePathAndLine(text);
link.className = 'file-path-link';
link.textContent = text;
link.setAttribute('href', '#');
link.setAttribute('title', `Open ${text}`);
// Carry file path via data attribute; click handled by event delegation
link.setAttribute('data-file-path', filePath);
link.setAttribute('data-file-path', dataPath);
return link;
};
@@ -129,29 +155,54 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const href = a.getAttribute('href') || '';
const text = (a.textContent || '').trim();
// Helper function to check if a string looks like a code reference
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
// but DO NOT treat filenames/paths as code refs.
const isCodeReference = (str: string): boolean => {
// Check if it looks like a code reference (e.g., module.property)
// Patterns like "vscode.contribution", "module.submodule.function"
if (BARE_FILE_REGEX.test(str)) {
return false; // looks like a filename
}
if (/[/\\]/.test(str)) {
return false; // contains a path separator
}
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
// If linkify turned a bare filename into http://<filename>, convert it back
// If linkify turned a bare filename (e.g. README.md) into http://<filename>, convert it back
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
// Skip if it looks like a code reference
if (isCodeReference(text)) {
return;
}
if (httpMatch) {
try {
const url = new URL(href);
const host = url.hostname || '';
const pathname = url.pathname || '';
const noPath = pathname === '' || pathname === '/';
// Treat as a file link instead of external URL
const filePath = text; // no leading slash
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text}`);
a.setAttribute('data-file-path', filePath);
return;
// Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md)
if (
noPath &&
BARE_FILE_REGEX.test(text) &&
host.toLowerCase() === text.toLowerCase()
) {
const { dataPath } = normalizePathAndLine(text);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text}`);
a.setAttribute('data-file-path', dataPath);
return;
}
// Case 2: host itself looks like a filename (rare but happens), use it
if (noPath && BARE_FILE_REGEX.test(host)) {
const { dataPath } = normalizePathAndLine(host);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || host}`);
a.setAttribute('data-file-path', dataPath);
return;
}
} catch {
// fall through; unparseable URL
}
}
// Ignore other external protocols
@@ -170,18 +221,33 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
FILE_PATH_NO_G.test(candidate)
) {
const filePath = candidate.split('#')[0];
const { dataPath } = normalizePathAndLine(candidate);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || href}`);
a.setAttribute('data-file-path', filePath);
a.setAttribute('data-file-path', dataPath);
return;
}
// Bare file name or relative path (e.g. README.md or docs/README.md)
if (BARE_FILE_REGEX.test(candidate)) {
const { dataPath } = normalizePathAndLine(candidate);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || href}`);
a.setAttribute('data-file-path', dataPath);
}
};
// Helper function to check if a string looks like a code reference
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
// but DO NOT treat filenames/paths as code refs.
const isCodeReference = (str: string): boolean => {
// Check if it looks like a code reference (e.g., module.property)
// Patterns like "vscode.contribution", "module.submodule.function"
if (BARE_FILE_REGEX.test(str)) {
return false; // looks like a filename
}
if (/[/\\]/.test(str)) {
return false; // contains a path separator
}
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
@@ -194,6 +260,11 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
return; // Don't descend into <a>
}
// Avoid transforming inside code/pre blocks
const tag = el.tagName.toLowerCase();
if (tag === 'code' || tag === 'pre') {
return;
}
}
for (let child = node.firstChild; child; ) {
@@ -252,6 +323,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const handleContainerClick = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
// If file links disabled, do nothing
if (!enableFileLinks) {
return;
}
const target = e.target as HTMLElement | null;
if (!target) {
return;
@@ -260,18 +335,46 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Find nearest anchor with our marker class
const anchor = (target.closest &&
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
if (!anchor) {
if (anchor) {
const filePath = anchor.getAttribute('data-file-path');
if (!filePath) {
return;
}
e.preventDefault();
e.stopPropagation();
onFileClick?.(filePath);
return;
}
const filePath = anchor.getAttribute('data-file-path');
if (!filePath) {
// Fallback: intercept "http://README.md" style links that slipped through
const anyAnchor = (target.closest &&
target.closest('a')) as HTMLAnchorElement | null;
if (!anyAnchor) {
return;
}
e.preventDefault();
e.stopPropagation();
onFileClick?.(filePath);
const href = anyAnchor.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
return;
}
try {
const url = new URL(href);
const host = url.hostname || '';
const path = url.pathname || '';
const noPath = path === '' || path === '/';
// Basic bare filename heuristic on the host part (e.g. README.md)
if (noPath && /\.[a-z0-9]+$/i.test(host)) {
// Prefer the readable text content if it looks like a file
const text = (anyAnchor.textContent || '').trim();
const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host;
e.preventDefault();
e.stopPropagation();
onFileClick?.(candidate);
}
} catch {
// ignore
}
};
return (