feat(chat): recognize file:start-end as a clickable reference (#2000)
File references in chat messages can now use the 'path:start-end' form (e.g. 'src/foo.ts:120-145'). The reference becomes clickable in the renderer and, on click, the file opens at the start line. Range selection is intentionally not done at this layer — the 'path:start-end' form is parsed only so the link resolves to the correct path; navigation jumps to the start line, matching the behavior of the existing 'path:line' form. - Extract the file-reference parser to a dedicated module so it can be unit-tested without pulling in the markdown renderer's worker dependencies. - Add a range branch to 'parseFileReference' and update the block-code path regex to recognize the new form. - Switch the colon-form regex to a non-greedy path match so 'path:line:col' is no longer mis-parsed as 'path:line' with the first numeric suffix dropped into the path. - Add a unit test covering the new and existing parser forms.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
|
||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
||||
|
||||
describe('parseFileReference', () => {
|
||||
test('returns null for empty or whitespace input', () => {
|
||||
expect(parse('')).toBeNull();
|
||||
expect(parse(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('parses bare path', () => {
|
||||
expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' });
|
||||
});
|
||||
|
||||
test('parses path with single line', () => {
|
||||
expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 });
|
||||
});
|
||||
|
||||
test('parses path with line and column', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||
});
|
||||
|
||||
test('parses path with line range', () => {
|
||||
expect(parse('src/foo.ts:42-58')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
endLine: 58,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses path with single-line range (start equals end)', () => {
|
||||
expect(parse('src/foo.ts:10-10')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
endLine: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects range with end before start', () => {
|
||||
expect(parse('src/foo.ts:20-10')).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to path-only when range endpoint is non-numeric', () => {
|
||||
// `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the
|
||||
// line info is discarded and only the path is returned (the trailing
|
||||
// `:`-suffix is stripped).
|
||||
expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' });
|
||||
expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' });
|
||||
});
|
||||
|
||||
test('strips backtick and quote wrapping from range forms', () => {
|
||||
expect(parse('`src/foo.ts:10-20`')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
endLine: 20,
|
||||
});
|
||||
expect(parse('"src/foo.ts:1-3"')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
endLine: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses absolute Windows path with line range', () => {
|
||||
expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({
|
||||
path: 'C:/repo/src/foo.ts',
|
||||
line: 5,
|
||||
endLine: 9,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves line:col form (does not interpret as range)', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves hash form', () => {
|
||||
expect(parse('src/foo.ts#L42C8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
expect(parse('src/foo.ts#L42')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => {
|
||||
const result = parse('src/foo.ts:42-58');
|
||||
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
||||
import {
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
type DecorateLabels,
|
||||
type MermaidRender,
|
||||
} from './markdown/decorate';
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
isAbsoluteReferencePath,
|
||||
normalizeReferencePath,
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -148,16 +155,9 @@ 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;
|
||||
// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
|
||||
// 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;
|
||||
@@ -177,12 +177,6 @@ const getFileReferenceLinkLimit = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
||||
);
|
||||
|
||||
type ParsedFileReference = {
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
};
|
||||
|
||||
const KNOWN_FILE_BASENAMES = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
@@ -192,126 +186,19 @@ const KNOWN_FILE_BASENAMES = new Set([
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
]);
|
||||
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
return normalizeFilePath(value);
|
||||
return normalizeReferencePath(value);
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string): boolean => {
|
||||
return isAbsoluteFilePath(value);
|
||||
return isAbsoluteReferencePath(value);
|
||||
};
|
||||
|
||||
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
||||
return toAbsoluteFilePath(basePath, targetPath);
|
||||
};
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
||||
next = next.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
next = next.replace(/[.,;!?]+$/g, '');
|
||||
|
||||
if (next.endsWith(')') && !next.includes('(')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
if (next.endsWith(']') && !next.includes('[')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripTrailingReference = (value: string): string => {
|
||||
let next = trimPathCandidate(value);
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const semicolonIndex = next.indexOf(';');
|
||||
if (semicolonIndex >= 0) {
|
||||
next = next.slice(0, semicolonIndex);
|
||||
}
|
||||
|
||||
next = next.replace(/#.*$/, '');
|
||||
|
||||
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
||||
if (extensionSuffixMatch) {
|
||||
next = extensionSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
||||
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
||||
: null;
|
||||
if (basenameSuffixMatch) {
|
||||
next = basenameSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
return trimPathCandidate(next);
|
||||
};
|
||||
|
||||
const parseFileReference = (value: string): ParsedFileReference | null => {
|
||||
const trimmed = trimPathCandidate(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const semicolonIndex = trimmed.indexOf(';');
|
||||
const withoutSemicolonSuffix = semicolonIndex >= 0
|
||||
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
||||
: trimmed;
|
||||
if (!withoutSemicolonSuffix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
||||
if (hashMatch) {
|
||||
const path = stripTrailingReference(hashMatch[1] ?? '');
|
||||
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
||||
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
|
||||
if (colonMatch) {
|
||||
const path = stripTrailingReference(colonMatch[1] ?? '');
|
||||
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
||||
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
||||
if (!pathOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathOnly };
|
||||
};
|
||||
|
||||
const hasFileExtension = (path: string): boolean => {
|
||||
const base = path.split('/').filter(Boolean).pop() ?? '';
|
||||
if (!base || base.endsWith('.')) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils';
|
||||
|
||||
export type ParsedFileReference = {
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
|
||||
const KNOWN_FILE_BASENAMES = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'readme',
|
||||
'license',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
]);
|
||||
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
|
||||
export const normalizeReferencePath = (value: string): string => normalizeFilePath(value);
|
||||
|
||||
export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
||||
next = next.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
next = next.replace(/[.,;!?]+$/g, '');
|
||||
|
||||
if (next.endsWith(')') && !next.includes('(')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
if (next.endsWith(']') && !next.includes('[')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripTrailingReference = (value: string): string => {
|
||||
let next = trimPathCandidate(value);
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const semicolonIndex = next.indexOf(';');
|
||||
if (semicolonIndex >= 0) {
|
||||
next = next.slice(0, semicolonIndex);
|
||||
}
|
||||
|
||||
next = next.replace(/#.*$/, '');
|
||||
|
||||
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
||||
if (extensionSuffixMatch) {
|
||||
next = extensionSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
||||
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
||||
: null;
|
||||
if (basenameSuffixMatch) {
|
||||
next = basenameSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
return trimPathCandidate(next);
|
||||
};
|
||||
|
||||
export const parseFileReference = (value: string): ParsedFileReference | null => {
|
||||
const trimmed = trimPathCandidate(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const semicolonIndex = trimmed.indexOf(';');
|
||||
const withoutSemicolonSuffix = semicolonIndex >= 0
|
||||
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
||||
: trimmed;
|
||||
if (!withoutSemicolonSuffix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Range form: `path:start-end`. Tried before the colon form so a suffix
|
||||
// like `:10-20` is consumed as a range rather than truncated to a line
|
||||
// number. Range and col (`:line:col`) are mutually exclusive.
|
||||
const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const path = stripTrailingReference(rangeMatch[1] ?? '');
|
||||
const line = Number.parseInt(rangeMatch[2] ?? '', 10);
|
||||
const endLine = Number.parseInt(rangeMatch[3] ?? '', 10);
|
||||
if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path, line, endLine };
|
||||
}
|
||||
|
||||
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
||||
if (hashMatch) {
|
||||
const path = stripTrailingReference(hashMatch[1] ?? '');
|
||||
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
||||
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/);
|
||||
if (colonMatch) {
|
||||
const path = stripTrailingReference(colonMatch[1] ?? '');
|
||||
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
||||
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
||||
if (!pathOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathOnly };
|
||||
};
|
||||
|
||||
// Matches `path[:line[:col]]` or `path:start-end` 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. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and
|
||||
// range are mutually exclusive.
|
||||
//
|
||||
// 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.
|
||||
export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g;
|
||||
Reference in New Issue
Block a user