feat: open chat file references directly in VS Code editor with line targeting
Markdown file links now open in VS Code editor tabs in VS Code runtime Tool entries for edit/apply_patch now open the file at the first changed line
This commit is contained in:
@@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
|
||||
const withStableStringId = <T extends object>(value: T, id: string): T => {
|
||||
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
|
||||
@@ -966,10 +967,14 @@ const useFileReferenceInteractions = ({
|
||||
containerRef,
|
||||
effectiveDirectory,
|
||||
readFile,
|
||||
editor,
|
||||
preferRuntimeEditor,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
effectiveDirectory: string;
|
||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||
editor?: EditorAPI;
|
||||
preferRuntimeEditor?: boolean;
|
||||
}) => {
|
||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
@@ -1126,6 +1131,19 @@ const useFileReferenceInteractions = ({
|
||||
}
|
||||
|
||||
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
|
||||
if (preferRuntimeEditor && editor) {
|
||||
await editor.openFile(
|
||||
resolved.resolvedPath,
|
||||
Number.isFinite(resolved.line ?? Number.NaN)
|
||||
? Math.max(1, Math.trunc(resolved.line as number))
|
||||
: undefined,
|
||||
Number.isFinite(resolved.column ?? Number.NaN)
|
||||
? Math.max(1, Math.trunc(resolved.column as number))
|
||||
: undefined,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const uiStore = useUIStore.getState();
|
||||
if (Number.isFinite(resolved.line ?? Number.NaN)) {
|
||||
uiStore.openContextFileAtLine(
|
||||
@@ -1206,7 +1224,7 @@ const useFileReferenceInteractions = ({
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, effectiveDirectory, readFile]);
|
||||
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
||||
};
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
@@ -1312,12 +1330,18 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { files } = useRuntimeAPIs();
|
||||
const { files, editor, runtime } = useRuntimeAPIs();
|
||||
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
|
||||
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
|
||||
useFileReferenceInteractions({
|
||||
containerRef: streamdownContainerRef,
|
||||
effectiveDirectory,
|
||||
readFile: files.readFile,
|
||||
editor,
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
});
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||
@@ -1373,7 +1397,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
onShowPopup,
|
||||
allowMermaidWheelZoom = false,
|
||||
}) => {
|
||||
const { files } = useRuntimeAPIs();
|
||||
const { files, editor, runtime } = useRuntimeAPIs();
|
||||
const renderedContent = React.useMemo(
|
||||
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
||||
[content, stripFrontmatter],
|
||||
@@ -1387,7 +1411,13 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
onShowPopup,
|
||||
allowWheelZoom: allowMermaidWheelZoom,
|
||||
});
|
||||
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
|
||||
useFileReferenceInteractions({
|
||||
containerRef: streamdownContainerRef,
|
||||
effectiveDirectory,
|
||||
readFile: files.readFile,
|
||||
editor,
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
});
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||
|
||||
@@ -157,6 +157,78 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
|
||||
return { added, removed };
|
||||
};
|
||||
|
||||
const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
|
||||
if (!diffText || typeof diffText !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = diffText.split('\n');
|
||||
let currentNewLine: number | undefined;
|
||||
let firstHunkStart: number | undefined;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
||||
if (hunkMatch) {
|
||||
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
currentNewLine = Math.max(1, parsed);
|
||||
if (!Number.isFinite(firstHunkStart)) {
|
||||
firstHunkStart = currentNewLine;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
return currentNewLine;
|
||||
}
|
||||
|
||||
if (line.startsWith(' ')) {
|
||||
currentNewLine += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-') || line.startsWith('\\')) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return firstHunkStart;
|
||||
};
|
||||
|
||||
const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): number | undefined => {
|
||||
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof metadata.diff === 'string') {
|
||||
const line = extractFirstChangedLineFromDiff(metadata.diff);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
const firstFile = files[0] as { diff?: unknown } | undefined;
|
||||
if (typeof firstFile?.diff === 'string') {
|
||||
const line = extractFirstChangedLineFromDiff(firstFile.diff);
|
||||
if (Number.isFinite(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
|
||||
if (absolutePath.startsWith(currentDirectory)) {
|
||||
const relativePath = absolutePath.substring(currentDirectory.length);
|
||||
@@ -1637,12 +1709,15 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
}
|
||||
|
||||
let filePath: unknown;
|
||||
let targetLine: number | undefined;
|
||||
if (part.tool === 'edit' || part.tool === 'multiedit') {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
||||
} else if (part.tool === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
||||
filePath = firstFile?.relativePath || firstFile?.filePath;
|
||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
||||
} else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
}
|
||||
@@ -1653,7 +1728,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
if (!filePath.startsWith('/')) {
|
||||
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
|
||||
}
|
||||
runtime.editor.openFile(absolutePath);
|
||||
runtime.editor.openFile(absolutePath, targetLine);
|
||||
} else {
|
||||
onToggle(part.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user