Skip to content

Commit 718fb82

Browse files
committed
Add mock for react-i18next and enhance mentions parsing
- Introduced a mock implementation for `react-i18next` to simplify testing. - Enhanced the `parseMentionsFromText` and `extractFilePath` functions to improve handling of paths with escaped spaces and special characters. - Added comprehensive unit tests for mentions parsing, covering various scenarios including escaped spaces, international characters, and multiple mentions. - Updated the `highlightMentions` function to utilize the new parsing logic, ensuring accurate highlighting of mentions in the chat interface. - Improved debugging information in the context menu logic to assist in identifying valid mentions.
1 parent 244db19 commit 718fb82

File tree

8 files changed

+383
-97
lines changed

8 files changed

+383
-97
lines changed

src/__mocks__/react-i18next.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
useTranslation: () => ({
3+
t: (key) => key, // Return key itself for simplicity
4+
i18n: {
5+
changeLanguage: () => new Promise(() => {}),
6+
// Add other i18n properties/methods if needed by the component
7+
},
8+
}),
9+
// Mock other exports from react-i18next if necessary
10+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { parseMentionsFromText, extractFilePath } from "../context-mentions"
2+
3+
describe("extractFilePath", () => {
4+
it("should extract fullMatch for path with escaped spaces", () => {
5+
const text = "@/foo\\ bar/abc.txt"
6+
const result = extractFilePath(text)
7+
expect(result).not.toBeNull()
8+
expect(result!.fullMatch).toBe("@/foo\\ bar/abc.txt")
9+
expect(result!.value).toBe("/foo bar/abc.txt")
10+
})
11+
12+
it("should extract only up to first space for path with unescaped space", () => {
13+
const text = "@/foo bar/abc.txt"
14+
const result = extractFilePath(text)
15+
expect(result).not.toBeNull()
16+
expect(result!.fullMatch).toBe("@/foo")
17+
expect(result!.value).toBe("/foo")
18+
})
19+
20+
it("should extract fullMatch for path with multiple escaped spaces", () => {
21+
const text = "@/foo\\ bar/baz\\ qux/abc.txt"
22+
const result = extractFilePath(text)
23+
expect(result).not.toBeNull()
24+
expect(result!.fullMatch).toBe("@/foo\\ bar/baz\\ qux/abc.txt")
25+
expect(result!.value).toBe("/foo bar/baz qux/abc.txt")
26+
})
27+
28+
it("should extract fullMatch for path with special characters", () => {
29+
const text = "@/foo\\ bar/abc-123_测试.txt"
30+
const result = extractFilePath(text)
31+
expect(result).not.toBeNull()
32+
expect(result!.fullMatch).toBe("@/foo\\ bar/abc-123_测试.txt")
33+
expect(result!.value).toBe("/foo bar/abc-123_测试.txt")
34+
})
35+
})
36+
37+
describe("parseMentionsFromText", () => {
38+
it("should parse mention with escaped spaces", () => {
39+
const text = "请看这个文件 @/foo\\ bar/abc.txt 很重要"
40+
const result = parseMentionsFromText(text)
41+
expect(result.length).toBe(1)
42+
expect(result[0].fullMatch).toBe("@/foo\\ bar/abc.txt")
43+
expect(result[0].value).toBe("/foo bar/abc.txt")
44+
})
45+
46+
it("should only parse up to first space for path with unescaped space", () => {
47+
const text = "请看这个文件 @/foo bar/abc.txt 很重要"
48+
const result = parseMentionsFromText(text)
49+
expect(result.length).toBe(1)
50+
expect(result[0].fullMatch).toBe("@/foo")
51+
expect(result[0].value).toBe("/foo")
52+
})
53+
54+
it("should parse multiple mentions with escaped spaces", () => {
55+
const text = "A @/foo\\ bar/abc.txt and B @/baz\\ qux/def.txt"
56+
const result = parseMentionsFromText(text)
57+
expect(result.length).toBe(2)
58+
expect(result[0].fullMatch).toBe("@/foo\\ bar/abc.txt")
59+
expect(result[0].value).toBe("/foo bar/abc.txt")
60+
expect(result[1].fullMatch).toBe("@/baz\\ qux/def.txt")
61+
expect(result[1].value).toBe("/baz qux/def.txt")
62+
})
63+
})

src/shared/context-mentions.ts

Lines changed: 44 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ export function parseMentionsFromText(text: string): Array<{ fullMatch: string;
5151
// Handle different mention types
5252
if (nextChar === "/") {
5353
// File or folder path
54-
const pathInfo = extractFilePath(text.substring(atPos))
54+
const subText = text.substring(atPos)
55+
console.log("[DEBUG][parseMentionsFromText] atPos:", atPos, "subText:", subText)
56+
const pathInfo = extractFilePath(subText)
57+
console.log("[DEBUG][parseMentionsFromText] extractFilePath result:", pathInfo)
5558
if (pathInfo) {
5659
results.push({
5760
fullMatch: pathInfo.fullMatch,
@@ -115,10 +118,19 @@ export function parseMentionsFromText(text: string): Array<{ fullMatch: string;
115118
}
116119

117120
// If we get here, this @ wasn't part of a valid mention, or extract failed
121+
console.log(
122+
"[DEBUG][parseMentionsFromText] invalid or failed mention atPos:",
123+
atPos,
124+
"char after @:",
125+
nextChar,
126+
"text:",
127+
text,
128+
)
118129
// Advance position by one to avoid infinite loop on invalid char after @
119130
currentPos = atPos + 1
120131
}
121132

133+
console.log("[DEBUG][parseMentionsFromText] final results:", results)
122134
return results
123135
}
124136

@@ -137,63 +149,33 @@ export function parseMentionsFromText(text: string): Array<{ fullMatch: string;
137149
export function extractFilePath(text: string): { fullMatch: string; value: string } | null {
138150
if (!text || !text.startsWith("@/")) return null
139151

140-
const result = { fullMatch: "@/", value: "/" } // Revert value initialization back to '/'
152+
const result = { fullMatch: "@/", value: "/" }
141153
let pos = 2 // Start after @/
154+
let inPath = true // Track if we're still in a valid path
142155

143-
while (pos < text.length) {
156+
while (pos < text.length && inPath) {
144157
const char = text.charAt(pos)
145158

146159
if (char === "\\" && pos + 1 < text.length) {
147160
// Handle escape sequences
148161
const nextChar = text.charAt(pos + 1)
149162

150-
// Handle the special case of "with\ \spaces" - consecutive backslashes with a space between
151-
if (
152-
nextChar === " " &&
153-
pos + 3 < text.length &&
154-
text.charAt(pos + 2) === "\\" &&
155-
pos + 8 < text.length &&
156-
text.substring(pos + 3, pos + 9) === "spaces"
157-
) {
158-
result.fullMatch += "\\ \\spaces"
159-
result.value += " spaces" // Treat as a space followed by "spaces"
160-
pos += 9 // Skip over "\ \spaces"
161-
}
162-
// Handle standard escaped space
163-
else if (nextChar === " ") {
163+
// Handle escaped space
164+
if (nextChar === " ") {
164165
result.fullMatch += "\\ "
165166
result.value += " "
166167
pos += 2
167168
}
168-
// Handle Windows-style path with double backslash representing a path separator
169-
else if (nextChar === "\\" && pos + 6 < text.length) {
170-
// Check if this is a pattern like "with\\spaces"
171-
const followingText = text.substring(pos + 2)
172-
if (followingText.startsWith("spaces")) {
173-
result.fullMatch += "\\\\spaces"
174-
result.value += "/spaces" // Use forward slash for path
175-
pos += 8 // Skip over "\\spaces"
176-
} else if (followingText.startsWith("style")) {
177-
result.fullMatch += "\\\\style"
178-
result.value += "/style" // Use forward slash for path
179-
pos += 7 // Skip over "\\style"
180-
} else {
181-
// Normal escaped backslash
182-
result.fullMatch += "\\\\"
183-
result.value += "\\"
184-
pos += 2
185-
}
186-
}
187-
// Handle standard escaped backslash
169+
// Handle Windows-style path with backslash
188170
else if (nextChar === "\\") {
189171
result.fullMatch += "\\\\"
190-
result.value += "\\"
172+
result.value += "/" // Convert to forward slash for consistency
191173
pos += 2
192174
} else {
193-
// Backslash followed by something else
194-
result.fullMatch += "\\"
195-
result.value += char
196-
pos += 1
175+
// Keep other escaped characters
176+
result.fullMatch += "\\" + nextChar
177+
result.value += nextChar
178+
pos += 2
197179
}
198180
} else if (char === "%" && pos + 2 < text.length) {
199181
// Handle percent encoding
@@ -205,20 +187,25 @@ export function extractFilePath(text: string): { fullMatch: string; value: strin
205187
result.value += decodedChar
206188
pos += 3
207189
} catch (e) {
208-
// Invalid encoding, treat literally
209190
result.fullMatch += char
210191
result.value += char
211192
pos++
212193
}
213194
} else {
214-
// Not a valid hex sequence
215195
result.fullMatch += char
216196
result.value += char
217197
pos++
218198
}
219-
} else if ((/[\s,;!?'"]/.test(char) || char === "@") && char !== ":") {
220-
// Terminator character (allow colon)
221-
break
199+
} else if ((/[\s,;!?'"]/.test(char) || char === "@") && !result.value.endsWith("\\")) {
200+
// Stop at whitespace or punctuation, but only if not escaped
201+
// If we haven't parsed any path segment, just break (keep @/)
202+
if (result.value.length === 1) {
203+
// only "/"
204+
inPath = false
205+
} else {
206+
// Already parsed some segment, just break and keep what we have
207+
inPath = false
208+
}
222209
} else {
223210
// Regular character
224211
result.fullMatch += char
@@ -228,12 +215,19 @@ export function extractFilePath(text: string): { fullMatch: string; value: strin
228215
}
229216

230217
// After loop, check if we actually captured a path
218+
// If the path contains any unescaped space, treat as invalid and return minimal mention
219+
if (/(^|[^\\]) /.test(result.fullMatch)) {
220+
return { fullMatch: "@/", value: "/" }
221+
}
231222
if (result.value.length <= 1) {
232-
// Only got '/'
233-
return null
223+
// Always return minimal mention for invalid or empty path
224+
return { fullMatch: "@/", value: "/" }
234225
}
235226

236-
// Trim trailing slash ONLY from the value if it wasn't explicitly escaped
227+
// Normalize slashes in the value
228+
result.value = result.value.replace(/\\/g, "/")
229+
230+
// Remove trailing slash if not escaped
237231
if (result.value.endsWith("/") && !result.fullMatch.endsWith("\\/")) {
238232
result.value = result.value.slice(0, -1)
239233
}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
22
import { useEvent } from "react-use"
33
import DynamicTextArea from "react-textarea-autosize"
4-
5-
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
4+
import { mentionRegex, parseMentionsFromText } from "../../../../src/shared/context-mentions"
65
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
76
import { Mode, getAllModes } from "../../../../src/shared/modes"
87
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
@@ -579,10 +578,29 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
579578

580579
const text = textAreaRef.current.value
581580

582-
highlightLayerRef.current.innerHTML = text
583-
.replace(/\n$/, "\n\n")
584-
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
585-
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
581+
// First perform HTML escaping
582+
const escapeHtml = (str: string) =>
583+
str.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
584+
585+
const mentions = parseMentionsFromText(text)
586+
if (!mentions.length) {
587+
highlightLayerRef.current.innerHTML = escapeHtml(text).replace(/\n$/, "\n\n")
588+
} else {
589+
let html = ""
590+
let lastIndex = 0
591+
mentions.forEach((mention) => {
592+
const start = text.indexOf(mention.fullMatch, lastIndex)
593+
if (start > lastIndex) {
594+
html += escapeHtml(text.slice(lastIndex, start))
595+
}
596+
html += `<mark class="mention-context-textarea-highlight">${escapeHtml(mention.fullMatch)}</mark>`
597+
lastIndex = start + mention.fullMatch.length
598+
})
599+
if (lastIndex < text.length) {
600+
html += escapeHtml(text.slice(lastIndex))
601+
}
602+
highlightLayerRef.current.innerHTML = html.replace(/\n$/, "\n\n")
603+
}
586604

587605
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
588606
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft

webview-ui/src/components/chat/TaskHeader.tsx

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { calculateTokenDistribution, getMaxTokensForModel } from "@/utils/model-
1010
import { Button } from "@/components/ui"
1111

1212
import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
13-
import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
1413
import { HistoryItem } from "../../../../src/shared/HistoryItem"
14+
import { parseMentionsFromText } from "../../../../src/shared/context-mentions"
1515

1616
import { useExtensionState } from "../../context/ExtensionStateContext"
1717
import Thumbnails from "../common/Thumbnails"
@@ -357,24 +357,41 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
357357

358358
export const highlightMentions = (text?: string, withShadow = true) => {
359359
if (!text) return text
360-
const parts = text.split(mentionRegexGlobal)
361-
return parts.map((part, index) => {
362-
if (index % 2 === 0) {
363-
// This is regular text
364-
return part
365-
} else {
366-
// This is a mention
367-
return (
368-
<span
369-
key={index}
370-
className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
371-
style={{ cursor: "pointer" }}
372-
onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
373-
@{part}
374-
</span>
375-
)
360+
361+
const mentions = parseMentionsFromText(text)
362+
if (!mentions.length) return text
363+
364+
const elements: React.ReactNode[] = []
365+
let lastIndex = 0
366+
367+
/**
368+
* Use parseMentionsFromText to find all valid mention ranges, and highlight only those.
369+
* This implementation ensures that only allowed mention types (path, URL, problems, git-changes, terminal, git-hash) are highlighted,
370+
* and that repeated mentions or overlapping mentions are handled correctly.
371+
*/
372+
mentions.forEach((mention, idx) => {
373+
const start = text.indexOf(mention.fullMatch, lastIndex)
374+
if (start === -1) {
375+
// Should not happen, but skip if not found
376+
return
376377
}
378+
if (start > lastIndex) {
379+
elements.push(text.slice(lastIndex, start))
380+
}
381+
elements.push(
382+
<span
383+
key={start}
384+
className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
385+
style={{ cursor: "pointer" }}>
386+
{mention.fullMatch}
387+
</span>,
388+
)
389+
lastIndex = start + mention.fullMatch.length
377390
})
391+
if (lastIndex < text.length) {
392+
elements.push(text.slice(lastIndex))
393+
}
394+
return elements
378395
}
379396

380397
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {

0 commit comments

Comments
 (0)