Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 141 additions & 19 deletions src/core/mentions/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ import * as git from "../../../utils/git"
import { getWorkspacePath } from "../../../utils/path"
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")

jest.mock("fs/promises", () => ({
stat: jest.fn(),
readdir: jest.fn(),
}))
import fs from "fs/promises"
import * as path from "path"

jest.mock("../../../integrations/misc/open-file", () => ({
openFile: jest.fn(),
}))
import { openFile } from "../../../integrations/misc/open-file"

jest.mock("../../../integrations/misc/extract-text", () => ({
extractTextFromFile: jest.fn(),
}))

import * as vscode from "vscode"

describe("mentions", () => {
const mockCwd = "/test/workspace"
let mockUrlContentFetcher: UrlContentFetcher
Expand All @@ -112,6 +130,16 @@ describe("mentions", () => {
})

describe("parseMentions", () => {
let mockUrlFetcher: UrlContentFetcher

beforeEach(() => {
mockUrlFetcher = new (UrlContentFetcher as jest.Mock<UrlContentFetcher>)()
;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => true, isDirectory: () => false })
;(require("../../../integrations/misc/extract-text").extractTextFromFile as jest.Mock).mockResolvedValue(
"Mock file content",
)
})

it("should parse git commit mentions", async () => {
const commitHash = "abc1234"
const commitInfo = `abc1234 Fix bug in parser
Expand Down Expand Up @@ -144,35 +172,72 @@ Detailed commit message with multiple lines
expect(result).toContain(`<git_commit hash="${commitHash}">`)
expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
})
})

describe("openMention", () => {
it("should handle file paths and problems", async () => {
// Mock stat to simulate file not existing
mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))
it("should correctly parse mentions with escaped spaces and fetch content", async () => {
const text = "Please check the file @/path/to/file\\ with\\ spaces.txt"
const expectedUnescaped = "path/to/file with spaces.txt" // Note: leading '/' removed by slice(1) in parseMentions
const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)

// Call openMention and wait for it to complete
await openMention("/path/to/file")
const result = await parseMentions(text, mockCwd, mockUrlFetcher)

// Verify error handling
expect(mockExecuteCommand).not.toHaveBeenCalled()
expect(mockOpenExternal).not.toHaveBeenCalled()
expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
// Check if fs.stat was called with the unescaped path
expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
// Check if extractTextFromFile was called with the unescaped path
expect(require("../../../integrations/misc/extract-text").extractTextFromFile).toHaveBeenCalledWith(
expectedAbsPath,
)

// Reset mocks for next test
jest.clearAllMocks()
// Check the output format
expect(result).toContain(`'path/to/file\\ with\\ spaces.txt' (see below for file content)`)
expect(result).toContain(
`<file_content path="path/to/file\\ with\\ spaces.txt">\nMock file content\n</file_content>`,
)
})

// Test problems command
await openMention("problems")
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
it("should handle folder mentions with escaped spaces", async () => {
const text = "Look in @/my\\ documents/folder\\ name/"
const expectedUnescaped = "my documents/folder name/"
const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => false, isDirectory: () => true })
;(fs.readdir as jest.Mock).mockResolvedValue([]) // Empty directory

const result = await parseMentions(text, mockCwd, mockUrlFetcher)

expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
expect(fs.readdir).toHaveBeenCalledWith(expectedAbsPath, { withFileTypes: true })
expect(result).toContain(`'my\\ documents/folder\\ name/' (see below for folder content)`)
expect(result).toContain(`<folder_content path="my\\ documents/folder\\ name/">`) // Content check might be more complex
})

it("should handle errors when accessing paths with escaped spaces", async () => {
const text = "Check @/nonexistent\\ file.txt"
const expectedUnescaped = "nonexistent file.txt"
const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
const mockError = new Error("ENOENT: no such file or directory")
;(fs.stat as jest.Mock).mockRejectedValue(mockError)

const result = await parseMentions(text, mockCwd, mockUrlFetcher)

expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
expect(result).toContain(
`<file_content path="nonexistent\\ file.txt">\nError fetching content: Failed to access path "nonexistent\\ file.txt": ${mockError.message}\n</file_content>`,
)
})

// Add more tests for parseMentions if needed (URLs, other mentions combined with escaped paths etc.)
})

describe("openMention", () => {
beforeEach(() => {
;(getWorkspacePath as jest.Mock).mockReturnValue(mockCwd)
})

it("should handle URLs", async () => {
const url = "https://example.com"
await openMention(url)
const mockUri = mockVscode.Uri.parse(url)
expect(mockVscode.env.openExternal).toHaveBeenCalled()
const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
const mockUri = vscode.Uri.parse(url)
expect(vscode.env.openExternal).toHaveBeenCalled()
const calledArg = (vscode.env.openExternal as jest.Mock).mock.calls[0][0]
expect(calledArg).toEqual(
expect.objectContaining({
scheme: mockUri.scheme,
Expand All @@ -183,5 +248,62 @@ Detailed commit message with multiple lines
}),
)
})

it("should unescape file path before opening", async () => {
const mention = "/file\\ with\\ spaces.txt"
const expectedUnescaped = "file with spaces.txt"
const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)

await openMention(mention)

expect(openFile).toHaveBeenCalledWith(expectedAbsPath)
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
})

it("should unescape folder path before revealing", async () => {
const mention = "/folder\\ with\\ spaces/"
const expectedUnescaped = "folder with spaces/"
const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
const expectedUri = { fsPath: expectedAbsPath } // From mock
;(vscode.Uri.file as jest.Mock).mockReturnValue(expectedUri)

await openMention(mention)

expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealInExplorer", expectedUri)
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedAbsPath)
expect(openFile).not.toHaveBeenCalled()
})

it("should handle mentions without paths correctly", async () => {
await openMention("problems")
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.actions.view.problems")

await openMention("terminal")
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.focus")

await openMention("http://example.com")
expect(vscode.env.openExternal).toHaveBeenCalled() // Check if called, specific URI mock might be needed for detailed check

await openMention("git-changes") // Assuming no specific action for this yet
// Add expectations if an action is defined for git-changes

await openMention("a1b2c3d") // Assuming no specific action for commit hashes yet
// Add expectations if an action is defined for commit hashes
})

it("should do nothing if mention is undefined or empty", async () => {
await openMention(undefined)
await openMention("")
expect(openFile).not.toHaveBeenCalled()
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
expect(vscode.env.openExternal).not.toHaveBeenCalled()
})

it("should do nothing if cwd is not available", async () => {
;(getWorkspacePath as jest.Mock).mockReturnValue(undefined)
await openMention("/some\\ path.txt")
expect(openFile).not.toHaveBeenCalled()
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
})
})
})
9 changes: 6 additions & 3 deletions src/core/mentions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isBinaryFile } from "isbinaryfile"

import { openFile } from "../../integrations/misc/open-file"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { mentionRegexGlobal } from "../../shared/context-mentions"
import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"

import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
Expand All @@ -25,7 +25,8 @@ export async function openMention(mention?: string): Promise<void> {
}

if (mention.startsWith("/")) {
const relPath = mention.slice(1)
// Slice off the leading slash and unescape any spaces in the path
const relPath = unescapeSpaces(mention.slice(1))
const absPath = path.resolve(cwd, relPath)
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
Expand Down Expand Up @@ -158,7 +159,9 @@ export async function parseMentions(
}

async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
const absPath = path.resolve(cwd, mentionPath)
// Unescape spaces in the path before resolving it
const unescapedPath = unescapeSpaces(mentionPath)
const absPath = path.resolve(cwd, unescapedPath)

try {
const stats = await fs.stat(absPath)
Expand Down
79 changes: 79 additions & 0 deletions src/shared/__tests__/context-mentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"

describe("mentionRegex and mentionRegexGlobal", () => {
// Test cases for various mention types
const testCases = [
// Basic file paths
{ input: "@/path/to/file.txt", expected: ["@/path/to/file.txt"] },
{ input: "@/file.js", expected: ["@/file.js"] },
{ input: "@/folder/", expected: ["@/folder/"] },

// File paths with escaped spaces
{ input: "@/path/to/file\\ with\\ spaces.txt", expected: ["@/path/to/file\\ with\\ spaces.txt"] },
{ input: "@/users/my\\ project/report\\ final.pdf", expected: ["@/users/my\\ project/report\\ final.pdf"] },
{ input: "@/folder\\ with\\ spaces/", expected: ["@/folder\\ with\\ spaces/"] },
{ input: "@/a\\ b\\ c.txt", expected: ["@/a\\ b\\ c.txt"] },

// URLs
{ input: "@http://example.com", expected: ["@http://example.com"] },
{ input: "@https://example.com/path?query=1", expected: ["@https://example.com/path?query=1"] },

// Other mentions
{ input: "@problems", expected: ["@problems"] },
{ input: "@git-changes", expected: ["@git-changes"] },
{ input: "@terminal", expected: ["@terminal"] },
{ input: "@a1b2c3d", expected: ["@a1b2c3d"] }, // Git commit hash (short)
{ input: "@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", expected: ["@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"] }, // Git commit hash (long)

// Mentions within text
{
input: "Check file @/path/to/file\\ with\\ spaces.txt for details.",
expected: ["@/path/to/file\\ with\\ spaces.txt"],
},
{ input: "See @problems and @terminal output.", expected: ["@problems", "@terminal"] },
{ input: "URL: @https://example.com.", expected: ["@https://example.com"] }, // Trailing punctuation
{ input: "Commit @a1b2c3d, then check @/file.txt", expected: ["@a1b2c3d", "@/file.txt"] },

// Negative cases (should not match or match partially)
{ input: "@/path/with unescaped space.txt", expected: ["@/path/with"] }, // Unescaped space
{ input: "@ /path/leading-space.txt", expected: null }, // Space after @
{ input: "email@example.com", expected: null }, // Email address
{ input: "mention@", expected: null }, // Trailing @
{ input: "@/path/trailing\\", expected: null }, // Trailing backslash (invalid escape)
{ input: "@/path/to/file\\not-a-space", expected: null }, // Backslash not followed by space
]

testCases.forEach(({ input, expected }) => {
it(`should handle input: "${input}"`, () => {
// Test mentionRegex (first match)
const match = input.match(mentionRegex)
const firstExpected = expected ? expected[0] : null
if (firstExpected) {
expect(match).not.toBeNull()
// Check the full match (group 0)
expect(match?.[0]).toBe(firstExpected)
// Check the captured group (group 1) - remove leading '@'
expect(match?.[1]).toBe(firstExpected.slice(1))
} else {
expect(match).toBeNull()
}

// Test mentionRegexGlobal (all matches)
const globalMatches = Array.from(input.matchAll(mentionRegexGlobal)).map((m) => m[0])
if (expected) {
expect(globalMatches).toEqual(expected)
} else {
expect(globalMatches).toEqual([])
}
})
})

it("should correctly capture the mention part (group 1)", () => {
const input = "Mention @/path/to/escaped\\ file.txt and @problems"
const matches = Array.from(input.matchAll(mentionRegexGlobal))

expect(matches.length).toBe(2)
expect(matches[0][1]).toBe("/path/to/escaped\\ file.txt") // Group 1 should not include '@'
expect(matches[1][1]).toBe("problems")
})
})
19 changes: 14 additions & 5 deletions src/shared/context-mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ Mention regex:
- `\/`:
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
- `|`: Logical OR.
- `\w+:\/\/`:
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
- `[^\s]+?`:
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
- `\w+:\/\/`:
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
- `(?:[^\s\\]|\\ )+?`:
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them.
- **Non-Whitespace and Non-Backslash (`[^\s\\]`)**: Matches any character that is not whitespace or a backslash.
- **OR (`|`)**: Logical OR.
- **Escaped Space (`\\ `)**: Matches a backslash followed by a space (an escaped space).
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
- `|`: Logical OR.
- `problems\b`:
Expand All @@ -39,6 +42,7 @@ Mention regex:
- **Summary**:
- The regex effectively matches:
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
- File paths can include spaces if they are escaped with a backslash (e.g., `@/path/to/file\ with\ spaces.txt`).
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
- The exact word 'problems'.
- The exact word 'git-changes'.
Expand All @@ -50,7 +54,7 @@ Mention regex:

*/
export const mentionRegex =
/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
/@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")

export interface MentionSuggestion {
Expand Down Expand Up @@ -90,3 +94,8 @@ export function formatGitSuggestion(commit: {
date: commit.date,
}
}

// Helper function to unescape paths with backslash-escaped spaces
export function unescapeSpaces(path: string): string {
return path.replace(/\\ /g, " ")
}
4 changes: 2 additions & 2 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us
import { useEvent } from "react-use"
import DynamicTextArea from "react-textarea-autosize"

import { mentionRegex, mentionRegexGlobal } from "@roo/shared/context-mentions"
import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions"
import { WebviewMessage } from "@roo/shared/WebviewMessage"
import { Mode, getAllModes } from "@roo/shared/modes"
import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
Expand Down Expand Up @@ -470,7 +470,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Send message to extension to search files
vscode.postMessage({
type: "searchFiles",
query: query,
query: unescapeSpaces(query),
requestId: reqId,
})
}, 200) // 200ms debounce
Expand Down
Loading