fix(webview): share path-segment rule to stop truncating @file refs with spaced filenames

Extract looksLikePathSegment into webview/src/utils/pathSegment.ts so the
display-side scanner (CollapsibleTextBlock) and the input-side fallback
scanner (useFileTags) share one rule: a word after a space continues the
path when it contains a path separator or ends with a file extension,
with any trailing #L line marker stripped first.

PR #1576 applied this rule only to message display; the chat input box
still truncated absolute paths whose filenames contain spaces (e.g.
"第六章 框架开发实践.md"), and its comment promised extension support the
code did not have. Reuse the shared helper there, sync the comments, and
move the misplaced convertAtFileRefsToLinks docstring back to its
function.

Tests: new pathSegment.test.ts unit tests; useFileTags.test.ts gains
fallback cases for spaced absolute paths with and without line markers.
This commit is contained in:
zhukunpenglinyutong
2026-08-22 13:50:22 +08:00
parent 254c9421b3
commit 349bf3a767
5 changed files with 93 additions and 28 deletions
@@ -241,4 +241,42 @@ describe('useFileTags', () => {
expect(editable.querySelectorAll('.file-tag').length).toBe(1);
});
it('renders absolute paths with spaces in the filename (not in path mapping)', () => {
const editable = createEditable();
editable.textContent = '@D:\\workspace\\docs\\chapter6\\第六章 框架开发实践.md ';
mockSelection();
const { result } = setupHook(editable);
// No pathMappingRef entry: falls back to absolute-path pattern matching.
// The space before "框架开发实践.md" must not truncate the path.
result.current.renderFileTags();
expect(editable.querySelectorAll('.file-tag').length).toBe(1);
expect(result.current.extractFileTags()).toEqual([
{
displayPath: 'D:\\workspace\\docs\\chapter6\\第六章 框架开发实践.md',
absolutePath: 'D:\\workspace\\docs\\chapter6\\第六章 框架开发实践.md',
},
]);
});
it('renders absolute paths with spaces and a line marker (not in path mapping)', () => {
const editable = createEditable();
editable.textContent = '@D:\\docs\\第六章 框架开发实践.md#L10-20 ';
mockSelection();
const { result } = setupHook(editable);
result.current.renderFileTags();
expect(editable.querySelectorAll('.file-tag').length).toBe(1);
expect(result.current.extractFileTags()).toEqual([
{
displayPath: 'D:\\docs\\第六章 框架开发实践.md#L10-20',
absolutePath: 'D:\\docs\\第六章 框架开发实践.md#L10-20',
},
]);
});
});
@@ -11,6 +11,7 @@ import {
getVirtualCursorPosition,
setVirtualCursorPosition,
} from '../utils/virtualCursorUtils.js';
import { looksLikePathSegment } from '../../../utils/pathSegment.js';
import type { FileTagInfo } from '../types.js';
interface FileMatch {
@@ -172,7 +173,8 @@ export function useFileTags({
// Fall back to simple pattern matching for paths not in pathMappingRef.
// This handles absolute paths and paths with line numbers.
// The helper scans forward allowing spaces when the next segment
// looks like a path continuation (contains a path separator: \ or /).
// looks like a path continuation (contains a path separator: \ or /,
// or ends with a file extension — e.g. a filename with spaces).
const remainingText = text.substring(i);
const afterAt = remainingText.slice(1); // text after '@'
let endPos = 0;
@@ -186,7 +188,7 @@ export function useFileTags({
if (peekRemainder.length > 0 && peekRemainder[0] !== ' '
&& peekRemainder[0] !== '\t' && peekRemainder[0] !== '\n'
&& peekRemainder[0] !== '\r' && peekRemainder[0] !== '@'
&& /[\\/]/.test(peekRemainder.split(/\s/)[0])
&& looksLikePathSegment(peekRemainder.split(/\s/)[0])
) {
endPos++; // include the space, continue scanning
continue;
+13 -26
View File
@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import DOMPurify from 'dompurify';
import { openFile } from '../utils/bridge';
import { looksLikePathSegment } from '../utils/pathSegment';
interface CollapsibleTextBlockProps {
content: string;
@@ -24,31 +25,6 @@ function escapeHtml(text: string): string {
.replace(/'/g, ''');
}
/**
* Convert @path references into compact clickable `<a>` links at display time.
*
* Before: "@C:\Users\Bob\proj\src\app.ts#L10-20"
* After: "<a href=... data-linkify=file>@app.ts#L10-20</a>"
*
* The href preserves the full absolute path (with :line format for navigation).
* Protocol layer text sent to the AI is unchanged — this transformation is
* display-only.
*/
/**
* Whether a word following a space could be a continuation of a file path.
* Accepts segments that contain a path separator (`\` or `/`), or that end
* with a file extension — the latter covers filenames containing spaces
* such as "第六章 框架开发实践.md". A trailing `#L10-20` line marker is
* stripped first so the extension is still visible.
*/
function looksLikePathSegment(segment: string): boolean {
const withoutLineMarker = segment.replace(/#L\d+(?:-\d+)?$/, '');
return (
/[\\/]/.test(withoutLineMarker) || // contains a path separator (dir / absolute segment)
/\.[A-Za-z0-9]{1,10}$/.test(withoutLineMarker) // ends with a file extension (filename with spaces)
);
}
/**
* Extract a file path starting after `@` at position `start` in `text`.
* Returns `[fullMatch, filePath, lineStart?, lineEnd?]` or null.
@@ -112,7 +88,18 @@ function extractAtFilePath(
return { rawPath, lineStart, lineEnd };
}
/** @visibleForTesting */
/**
* Convert @path references into compact clickable `<a>` links at display time.
*
* Before: "@C:\Users\Bob\proj\src\app.ts#L10-20"
* After: "<a href=... data-linkify=file>@app.ts#L10-20</a>"
*
* The href preserves the full absolute path (with :line format for navigation).
* Protocol layer text sent to the AI is unchanged — this transformation is
* display-only.
*
* @visibleForTesting
*/
export function convertAtFileRefsToLinks(text: string): string {
if (!text || !text.includes('@')) {
return escapeHtml(text);
+24
View File
@@ -0,0 +1,24 @@
import { looksLikePathSegment } from './pathSegment.js';
describe('looksLikePathSegment', () => {
it('accepts segments containing a path separator', () => {
expect(looksLikePathSegment('docs\\chapter6')).toBe(true);
expect(looksLikePathSegment('docs/chapter6')).toBe(true);
});
it('accepts segments ending with a file extension (filename with spaces)', () => {
expect(looksLikePathSegment('框架开发实践.md')).toBe(true);
expect(looksLikePathSegment('my file.ts')).toBe(true);
});
it('strips a trailing #L line marker before checking the extension', () => {
expect(looksLikePathSegment('框架开发实践.md#L10-20')).toBe(true);
expect(looksLikePathSegment('框架开发实践.md#L10')).toBe(true);
});
it('rejects plain words that are not path-like', () => {
expect(looksLikePathSegment('and')).toBe(false);
expect(looksLikePathSegment('中查看')).toBe(false);
expect(looksLikePathSegment('')).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Whether a word following a space could be a continuation of a file path.
* Accepts segments that contain a path separator (`\` or `/`), or that end
* with a file extension — the latter covers filenames containing spaces
* such as "第六章 框架开发实践.md". A trailing `#L10-20` line marker is
* stripped first so the extension is still visible.
*/
export function looksLikePathSegment(segment: string): boolean {
const withoutLineMarker = segment.replace(/#L\d+(?:-\d+)?$/, '');
return (
/[\\/]/.test(withoutLineMarker) || // contains a path separator (dir / absolute segment)
/\.[A-Za-z0-9]{1,10}$/.test(withoutLineMarker) // ends with a file extension (filename with spaces)
);
}