Revert "feat @-mention window path style & file with space (#1924)" (#2162)

* Revert "feat @-mention window path style & file with space (#1924)"

This reverts commit c62e8f2ee2.

* Add changeset
This commit is contained in:
Matt Rubens 2025-03-31 15:56:47 -05:00 committed by cte
parent 372e27d9c8
commit 05c00573a8
22 changed files with 232 additions and 794 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Revert mention changes in case they're causing performance issues/crashes

View file

@ -2049,8 +2049,6 @@ export class Cline extends EventEmitter<ClineEvents> {
// 2. ToolResultBlockParam's content/context text arrays if it contains "<feedback>" (see formatToolDeniedFeedback, attemptCompletion, executeCommand, and consecutiveMistakeCount >= 3) or "<answer>" (see askFollowupQuestion), we place all user generated content in these tags so they can effectively be used as markers for when we should parse mentions)
Promise.all(
userContent.map(async (block) => {
const { osInfo } = (await this.providerRef.deref()?.getState()) || { osInfo: "unix" }
const shouldProcessMentions = (text: string) =>
text.includes("<task>") || text.includes("<feedback>")
@ -2058,7 +2056,7 @@ export class Cline extends EventEmitter<ClineEvents> {
if (shouldProcessMentions(block.text)) {
return {
...block,
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher, osInfo),
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
}
}
return block
@ -2067,12 +2065,7 @@ export class Cline extends EventEmitter<ClineEvents> {
if (shouldProcessMentions(block.content)) {
return {
...block,
content: await parseMentions(
block.content,
this.cwd,
this.urlContentFetcher,
osInfo,
),
content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
}
}
return block
@ -2086,7 +2079,6 @@ export class Cline extends EventEmitter<ClineEvents> {
contentBlock.text,
this.cwd,
this.urlContentFetcher,
osInfo,
),
}
}

View file

@ -941,7 +941,6 @@ describe("Cline", () => {
"<task>Text with @/some/path in task tags</task>",
expect.any(String),
expect.any(Object),
expect.any(String),
)
// Feedback tag content should be processed
@ -952,7 +951,6 @@ describe("Cline", () => {
"<feedback>Check @/some/path</feedback>",
expect.any(String),
expect.any(Object),
expect.any(String),
)
// Regular tool result should not be processed

View file

@ -1,18 +1,17 @@
import * as vscode from "vscode"
import * as path from "path"
import fs from "fs/promises"
import { openFile } from "../../integrations/misc/open-file"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { mentionRegexGlobal } from "../../shared/context-mentions"
import { getWorkspacePath } from "../../utils/path"
import { HandlerConfig, MentionContext, XmlTag } from "./types"
import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
import fs from "fs/promises"
import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { isBinaryFile } from "isbinaryfile"
import { getWorkingState, getCommitInfo } from "../../utils/git"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import { getCommitInfo, getWorkingState } from "../../utils/git"
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
import { getWorkspacePath } from "../../utils/path"
export async function openMention(mention?: string, osInfo?: string): Promise<void> {
export async function openMention(mention?: string): Promise<void> {
if (!mention) {
return
}
@ -22,17 +21,10 @@ export async function openMention(mention?: string, osInfo?: string): Promise<vo
return
}
if ((osInfo !== "win32" && mention.startsWith("/")) || (osInfo === "win32" && mention.startsWith("\\"))) {
if (mention.startsWith("/")) {
const relPath = mention.slice(1)
let absPath = path.resolve(cwd, relPath)
if (absPath.includes(" ")) {
let escapedSpace = osInfo === "win32" ? "/ " : "\\ "
absPath = absPath.replaceAll(escapedSpace, " ")
}
if (
((osInfo === "unix" || osInfo === undefined) && mention.endsWith("/")) ||
(osInfo === "win32" && mention.endsWith("\\"))
) {
const absPath = path.resolve(cwd, relPath)
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
} else {
openFile(absPath)
@ -45,29 +37,115 @@ export async function openMention(mention?: string, osInfo?: string): Promise<vo
vscode.env.openExternal(vscode.Uri.parse(mention))
}
}
// Utility functions
export const createXmlTag = (name: string, attrs: Record<string, string> = {}): XmlTag => {
const attrString = Object.entries(attrs)
.map(([key, value]) => `${key}="${value}"`)
.join(" ")
return {
start: `\n\n<${name}${attrString ? " " + attrString : ""}>`,
end: `</${name}>`,
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
const mentions: Set<string> = new Set()
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
if (mention.startsWith("http")) {
return `'${mention}' (see below for site content)`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
return mentionPath.endsWith("/")
? `'${mentionPath}' (see below for folder content)`
: `'${mentionPath}' (see below for file content)`
} else if (mention === "problems") {
return `Workspace Problems (see below for diagnostics)`
} else if (mention === "git-changes") {
return `Working directory changes (see below for details)`
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
return `Git commit '${mention}' (see below for commit info)`
} else if (mention === "terminal") {
return `Terminal Output (see below for output)`
}
return match
})
const urlMention = Array.from(mentions).find((mention) => mention.startsWith("http"))
let launchBrowserError: Error | undefined
if (urlMention) {
try {
await urlContentFetcher.launchBrowser()
} catch (error) {
launchBrowserError = error
vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${error.message}`)
}
}
for (const mention of mentions) {
if (mention.startsWith("http")) {
let result: string
if (launchBrowserError) {
result = `Error fetching content: ${launchBrowserError.message}`
} else {
try {
const markdown = await urlContentFetcher.urlToMarkdown(mention)
result = markdown
} catch (error) {
vscode.window.showErrorMessage(`Error fetching content for ${mention}: ${error.message}`)
result = `Error fetching content: ${error.message}`
}
}
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
try {
const content = await getFileOrFolderContent(mentionPath, cwd)
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
}
} catch (error) {
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\nError fetching content: ${error.message}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\nError fetching content: ${error.message}\n</file_content>`
}
}
} else if (mention === "problems") {
try {
const problems = await getWorkspaceProblems(cwd)
parsedText += `\n\n<workspace_diagnostics>\n${problems}\n</workspace_diagnostics>`
} catch (error) {
parsedText += `\n\n<workspace_diagnostics>\nError fetching diagnostics: ${error.message}\n</workspace_diagnostics>`
}
} else if (mention === "git-changes") {
try {
const workingState = await getWorkingState(cwd)
parsedText += `\n\n<git_working_state>\n${workingState}\n</git_working_state>`
} catch (error) {
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
}
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
try {
const commitInfo = await getCommitInfo(mention, cwd)
parsedText += `\n\n<git_commit hash="${mention}">\n${commitInfo}\n</git_commit>`
} catch (error) {
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
}
} else if (mention === "terminal") {
try {
const terminalOutput = await getLatestTerminalOutput()
parsedText += `\n\n<terminal_output>\n${terminalOutput}\n</terminal_output>`
} catch (error) {
parsedText += `\n\n<terminal_output>\nError fetching terminal output: ${error.message}\n</terminal_output>`
}
}
}
if (urlMention) {
try {
await urlContentFetcher.closeBrowser()
} catch (error) {
console.error(`Error closing browser: ${error.message}`)
}
}
return parsedText
}
export const wrapContent = (content: string, tag: XmlTag): string => `${tag.start}\n${content}\n${tag.end}`
export const handleError = (error: Error, message: string): string => {
const errorMsg = `Error ${message}: ${error.message}`
if (error instanceof Error) {
vscode.window.showErrorMessage(errorMsg)
}
return errorMsg
}
// File utilities
export async function getFileOrFolderContent(mentionPath: string, cwd: string, osInfo: string): Promise<string> {
async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
const absPath = path.resolve(cwd, mentionPath)
try {
@ -99,7 +177,7 @@ export async function getFileOrFolderContent(mentionPath: string, cwd: string, o
return undefined
}
const content = await extractTextFromFile(absoluteFilePath)
return `<file_content path="${filePath}">\n${content}\n</file_content>`
return `<file_content path="${filePath.toPosix()}">\n${content}\n</file_content>`
} catch (error) {
return undefined
}
@ -121,8 +199,7 @@ export async function getFileOrFolderContent(mentionPath: string, cwd: string, o
}
}
// Workspace utilities
export async function getWorkspaceProblems(cwd: string): Promise<string> {
async function getWorkspaceProblems(cwd: string): Promise<string> {
const diagnostics = vscode.languages.getDiagnostics()
const result = await diagnosticsToProblemsString(
diagnostics,
@ -134,193 +211,3 @@ export async function getWorkspaceProblems(cwd: string): Promise<string> {
}
return result
}
// Handler implementations
const urlHandler: HandlerConfig = {
name: "url",
test: (mention: string) => mention.startsWith("http"),
handler: async (mention, { urlContentFetcher, launchBrowserError }) => {
const tag = createXmlTag("url_content", { url: mention })
let content: string
if (launchBrowserError) {
content = handleError(launchBrowserError, "fetching content")
} else {
try {
content = await urlContentFetcher.urlToMarkdown(mention)
} catch (error) {
content = handleError(error, `fetching content for ${mention}`)
}
}
return wrapContent(content, tag)
},
}
const fileHandler: HandlerConfig = {
name: "file",
test: (mention: string, { osInfo }) => (osInfo !== "win32" ? mention.startsWith("/") : mention.startsWith("\\")),
handler: async (mention, { cwd, osInfo }) => {
let mentionPath = mention.slice(1)
const isFolder = osInfo === "win32" ? mention.endsWith("\\") : mention.endsWith("/")
const tag = createXmlTag(isFolder ? "folder_content" : "file_content", { path: mentionPath })
if (mentionPath.includes(" ")) {
let escapedSpace = osInfo === "win32" ? "/ " : "\\ "
mentionPath = mentionPath.replaceAll(escapedSpace, " ")
}
try {
const content = await getFileOrFolderContent(mentionPath, cwd, osInfo)
return wrapContent(content, tag)
} catch (error) {
return wrapContent(handleError(error, "fetching content"), tag)
}
},
}
const problemsHandler: HandlerConfig = {
name: "problems",
test: (mention: string) => mention === "problems",
handler: async (mention, { cwd }) => {
const tag = createXmlTag("workspace_diagnostics")
try {
const problems = await getWorkspaceProblems(cwd)
return wrapContent(problems, tag)
} catch (error) {
return wrapContent(handleError(error, "fetching diagnostics"), tag)
}
},
}
const gitChangesHandler: HandlerConfig = {
name: "git-changes",
test: (mention: string) => mention === "git-changes",
handler: async (mention, { cwd }) => {
const tag = createXmlTag("git_working_state")
try {
const workingState = await getWorkingState(cwd)
return wrapContent(workingState, tag)
} catch (error) {
return wrapContent(handleError(error, "fetching working state"), tag)
}
},
}
const commitHandler: HandlerConfig = {
name: "commit",
test: (mention: string) => /^[a-f0-9]{7,40}$/.test(mention),
handler: async (mention, { cwd }) => {
const tag = createXmlTag("git_commit", { hash: mention })
try {
const commitInfo = await getCommitInfo(mention, cwd)
return wrapContent(commitInfo, tag)
} catch (error) {
return wrapContent(handleError(error, "fetching commit info"), tag)
}
},
}
const terminalHandler: HandlerConfig = {
name: "terminal",
test: (mention: string) => mention === "terminal",
handler: async (mention) => {
const tag = createXmlTag("terminal_output")
try {
const terminalOutput = await getLatestTerminalOutput()
return wrapContent(terminalOutput, tag)
} catch (error) {
return wrapContent(handleError(error, "fetching terminal output"), tag)
}
},
}
// Define handlers array
const handlers: HandlerConfig[] = [
urlHandler,
fileHandler,
problemsHandler,
gitChangesHandler,
commitHandler,
terminalHandler,
]
export async function parseMentions(
text: string,
cwd: string,
urlContentFetcher: UrlContentFetcher,
osInfo: string = "unix",
): Promise<string> {
const mentions: Set<string> = new Set()
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
if (mention.startsWith("http")) {
return `'${mention}' (see below for site content)`
}
if (
(osInfo !== "win32" && osInfo !== undefined && mention.startsWith("/")) ||
(osInfo === "win32" && mention.startsWith("\\"))
) {
const mentionPath = mention.slice(1)
return mentionPath.endsWith("/") || mentionPath.endsWith("\\")
? `'${mentionPath}' (see below for folder content)`
: `'${mentionPath}' (see below for file content)`
}
if (mention === "problems") {
return `Workspace Problems (see below for diagnostics)`
}
if (mention === "git-changes") {
return `Working directory changes (see below for details)`
}
if (/^[a-f0-9]{7,40}$/.test(mention)) {
return `Git commit '${mention}' (see below for commit info)`
}
if (mention === "terminal") {
return `Terminal Output (see below for output)`
}
return match
})
const urlMention = Array.from(mentions).find((mention) => mention.startsWith("http"))
let launchBrowserError: Error | undefined
if (urlMention) {
try {
await urlContentFetcher.launchBrowser()
} catch (error) {
launchBrowserError = error
vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${error.message}`)
}
}
const context: MentionContext = {
cwd,
urlContentFetcher,
launchBrowserError,
osInfo,
}
const mentionResults = await Promise.all(
Array.from(mentions).map(async (mention) => {
for (const handler of handlers) {
if (handler.test(mention, context)) {
return handler.handler(mention, context)
}
}
return ""
}),
)
parsedText += mentionResults.join("")
if (urlMention) {
try {
await urlContentFetcher.closeBrowser()
} catch (error) {
console.error(`Error closing browser: ${error.message}`)
}
}
return parsedText
}

View file

@ -1,21 +0,0 @@
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
export type MentionHandler = (mention: string) => Promise<string>
export type XmlTag = {
start: string
end: string
}
export interface MentionContext {
cwd: string
urlContentFetcher: UrlContentFetcher
launchBrowserError?: Error
osInfo: string
}
export interface HandlerConfig {
name: string
test: (mention: string, context: MentionContext) => boolean
handler: (mention: string, context: MentionContext) => Promise<string>
}

View file

@ -1188,7 +1188,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
return {
version: this.context.extension?.packageJSON?.version ?? "",
osInfo: os.platform() === "win32" ? "win32" : "unix",
apiConfiguration,
customInstructions,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
@ -1283,7 +1282,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
// Return the same structure as before
return {
apiConfiguration: providerSettings,
osInfo: os.platform() === "win32" ? "win32" : "unix",
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
customInstructions: stateValues.customInstructions,
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,

View file

@ -388,7 +388,6 @@ describe("ClineProvider", () => {
const mockState: ExtensionState = {
version: "1.0.0",
osInfo: "unix",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,

View file

@ -467,10 +467,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
openFile(message.text!, message.values as { create?: boolean; content?: string })
break
case "openMention":
{
const { osInfo } = (await provider.getState()) || {}
openMention(message.text, osInfo)
}
openMention(message.text)
break
case "checkpointDiff":
const result = checkoutDiffPayloadSchema.safeParse(message.payload)

View file

@ -15,12 +15,7 @@ let registeredTabChangeCallback: (() => Promise<void>) | null = null
// Mock workspace path
jest.mock("../../../utils/path", () => ({
getWorkspacePath: jest.fn().mockReturnValue("/test/workspace"),
toRelativePath: jest.fn((path, cwd) => {
// Simple mock that preserves the original behavior for tests
const relativePath = path.replace(`${cwd}/`, "")
// Add trailing slash if original path had one
return path.endsWith("/") ? relativePath + "/" : relativePath
}),
toRelativePath: jest.fn((path, cwd) => path.replace(`${cwd}/`, "")),
}))
// Mock watcher - must be defined after mockDispose but before jest.mock("vscode")

View file

@ -168,7 +168,6 @@ export type ExtensionState = Pick<
| "enhancementApiConfigId"
> & {
version: string
osInfo: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
apiConfiguration?: ApiConfiguration

View file

@ -1,325 +0,0 @@
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
interface TestResult {
actual: string | null
expected: string | null
}
function testMention(input: string, expected: string | null): TestResult {
const match = mentionRegex.exec(input)
return {
actual: match ? match[0] : null,
expected,
}
}
function expectMatch(result: TestResult) {
if (result.expected === null) {
return expect(result.actual).toBeNull()
}
if (result.actual !== result.expected) {
// Instead of console.log, use expect().toBe() with a descriptive message
expect(result.actual).toBe(result.expected)
}
}
describe("Mention Regex", () => {
describe("Windows Path Support", () => {
it("matches simple Windows paths", () => {
const cases: Array<[string, string]> = [
["@C:\\folder\\file.txt", "@C:\\folder\\file.txt"],
["@c:\\Program/ Files\\file.txt", "@c:\\Program/ Files\\file.txt"],
["@C:\\file.txt", "@C:\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches Windows network shares", () => {
const cases: Array<[string, string]> = [
["@\\\\server\\share\\file.txt", "@\\\\server\\share\\file.txt"],
["@\\\\127.0.0.1\\network-path\\file.txt", "@\\\\127.0.0.1\\network-path\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches mixed separators", () => {
const result = testMention("@C:\\folder\\file.txt", "@C:\\folder\\file.txt")
expectMatch(result)
})
it("matches Windows relative paths", () => {
const cases: Array<[string, string]> = [
["@folder\\file.txt", "@folder\\file.txt"],
["@.\\folder\\file.txt", "@.\\folder\\file.txt"],
["@..\\parent\\file.txt", "@..\\parent\\file.txt"],
["@path\\to\\directory\\", "@path\\to\\directory\\"],
["@.\\current\\path\\with/ space.txt", "@.\\current\\path\\with/ space.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Escaped Spaces Support", () => {
it("matches Unix paths with escaped spaces", () => {
const cases: Array<[string, string]> = [
["@/path/to/file\\ with\\ spaces.txt", "@/path/to/file\\ with\\ spaces.txt"],
["@/path/with\\ \\ multiple\\ spaces.txt", "@/path/with\\ \\ multiple\\ spaces.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches Windows paths with escaped spaces", () => {
const cases: Array<[string, string]> = [
["@C:\\path\\to\\file/ with/ spaces.txt", "@C:\\path\\to\\file/ with/ spaces.txt"],
["@C:\\Program/ Files\\app\\file.txt", "@C:\\Program/ Files\\app\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Combined Path Variations", () => {
it("matches complex path combinations", () => {
const cases: Array<[string, string]> = [
[
"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
],
[
"@\\\\server\\share\\path/ with/ spaces\\file.txt",
"@\\\\server\\share\\path/ with/ spaces\\file.txt",
],
["@C:\\path/ with/ spaces\\file.txt", "@C:\\path/ with/ spaces\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Edge Cases", () => {
it("handles edge cases correctly", () => {
const cases: Array<[string, string]> = [
["@C:\\", "@C:\\"],
["@/path/to/folder", "@/path/to/folder"],
["@C:\\folder\\file with spaces.txt", "@C:\\folder\\file"],
["@C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Existing Functionality", () => {
it("matches Unix paths", () => {
const cases: Array<[string, string]> = [
["@/usr/local/bin/file", "@/usr/local/bin/file"],
["@/path/to/file.txt", "@/path/to/file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches URLs", () => {
const cases: Array<[string, string]> = [
["@http://example.com", "@http://example.com"],
["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches git hashes", () => {
const cases: Array<[string, string]> = [
["@a1b2c3d4e5f6g7h8i9j0", "@a1b2c3d4e5f6g7h8i9j0"],
["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches special keywords", () => {
const cases: Array<[string, string]> = [
["@problems", "@problems"],
["@git-changes", "@git-changes"],
["@terminal", "@terminal"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Invalid Patterns", () => {
it("rejects invalid patterns", () => {
const cases: Array<[string, null]> = [
["C:\\folder\\file.txt", null],
["@", null],
["@ C:\\file.txt", null],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches only until invalid characters", () => {
const result = testMention("@C:\\folder\\file.txt invalid suffix", "@C:\\folder\\file.txt")
expectMatch(result)
})
})
describe("In Context", () => {
it("matches mentions within text", () => {
const cases: Array<[string, string]> = [
["Check the file at @C:\\folder\\file.txt for details.", "@C:\\folder\\file.txt"],
["See @/path/to/file\\ with\\ spaces.txt for an example.", "@/path/to/file\\ with\\ spaces.txt"],
["Review @problems and @git-changes.", "@problems"],
["Multiple: @/file1.txt and @C:\\file2.txt and @terminal", "@/file1.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Multiple Mentions", () => {
it("finds all mentions in a string using global regex", () => {
const text = "Check @/path/file1.txt and @C:\\folder\\file2.txt and report any @problems to @git-changes"
const matches = text.match(mentionRegexGlobal)
expect(matches).toEqual(["@/path/file1.txt", "@C:\\folder\\file2.txt", "@problems", "@git-changes"])
})
})
describe("Special Characters in Paths", () => {
it("handles special characters in file paths", () => {
const cases: Array<[string, string]> = [
["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
["@C:\\folder+plus\\file(parens)[]brackets.txt", "@C:\\folder+plus\\file(parens)[]brackets.txt"],
["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Mixed Path Types in Single String", () => {
it("correctly identifies the first path in a string with multiple path types", () => {
const text = "Check both @/unix/path and @C:\\windows\\path for details."
const result = mentionRegex.exec(text)
expect(result?.[0]).toBe("@/unix/path")
// Test starting from after the first match
const secondSearchStart = text.indexOf("@C:")
const secondResult = mentionRegex.exec(text.substring(secondSearchStart))
expect(secondResult?.[0]).toBe("@C:\\windows\\path")
})
})
describe("Non-Latin Character Support", () => {
it("handles international characters in paths", () => {
const cases: Array<[string, string]> = [
["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
["@C:\\用户\\документы\\файл.txt", "@C:\\用户\\документы\\файл.txt"],
["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
["@C:\\folder\\file_äöü.txt", "@C:\\folder\\file_äöü.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Mixed Path Delimiters", () => {
// Modifying expectations to match current behavior
it("documents behavior with mixed forward and backward slashes in Windows paths", () => {
const cases: Array<[string, null]> = [
// Current implementation doesn't support mixed slashes
["@C:\\Users/Documents\\folder/file.txt", null],
["@C:/Windows\\System32/drivers\\etc/hosts", null],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Extended Negative Tests", () => {
// Modifying expectations to match current behavior
it("documents behavior with potentially invalid characters", () => {
const cases: Array<[string, string]> = [
// Current implementation actually matches these patterns
["@/path/with<illegal>chars.txt", "@/path/with<illegal>chars.txt"],
["@C:\\folder\\file|with|pipe.txt", "@C:\\folder\\file|with|pipe.txt"],
['@/path/with"quotes".txt', '@/path/with"quotes".txt'],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
// // These are documented as "not implemented yet"
// describe("Future Enhancement Candidates", () => {
// it("identifies patterns that could be supported in future enhancements", () => {
// // These patterns aren't currently supported by the regex
// // but might be considered for future improvements
// console.log(
// "The following patterns are not currently supported but might be considered for future enhancements:",
// )
// console.log("- Paths with double slashes: @/path//with/double/slash.txt")
// console.log("- Complex path traversals: @/very/./long/../../path/.././traversal.txt")
// console.log("- Environment variables in paths: @$HOME/file.txt, @C:\\Users\\%USERNAME%\\file.txt")
// })
// })
})

View file

@ -1,90 +1,57 @@
/*
Mention regex:
- **Purpose**:
- To identify and highlight specific mentions in text that start with '@'.
- These mentions can be file paths, URLs, or the exact word 'problems'.
- Ensures that trailing punctuation marks (like commas, periods, etc.) are not included in the match, allowing punctuation to follow the mention without being part of it.
- **Regex Breakdown**:
- `/@`:
- **@**: The mention must start with the '@' symbol.
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\b)`:
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
- `(?:\/|\w+:\/\/)`:
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
- `\/`:
- **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.
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
- `|`: Logical OR.
- `problems\b`:
- **Exact Word ('problems')**: Matches the exact word 'problems'.
- **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic').
- `|`: Logical OR.
- `terminal\b`:
- **Exact Word ('terminal')**: Matches the exact word 'terminal'.
- **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals').
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
- `[.,;:!?]?`:
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
- `(?=[\s\r\n]|$)`:
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
- **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).
- 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'.
- The exact word 'terminal'.
- It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text.
1. **Pattern Components**:
- The regex is built from multiple patterns joined with OR (|) operators
- Each pattern handles a specific type of mention:
- Unix/Linux paths
- Windows paths with drive letters
- Windows relative paths
- Windows network shares
- URLs with protocols
- Git commit hashes
- Special keywords (problems, git-changes, terminal)
- **Global Regex**:
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
2. **Unix Path Pattern**:
- `(?:\\/|^)`: Starts with a forward slash or beginning of line
- `(?:[^\\/\\s\\\\]|\\\\[ \\t])+`: Path segment that can include escaped spaces
- `(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*`: Additional path segments after slashes
- `\\/?`: Optional trailing slash
3. **Windows Path Pattern**:
- `[A-Za-z]:\\\\`: Drive letter followed by colon and double backslash
- `(?:(?:[^\\\\\\s/]+|\\/[ ])+`: Path segment that can include spaces escaped with forward slash
- `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?`: Additional path segments after backslashes
4. **Windows Relative Path Pattern**:
- `(?:\\.{0,2}|[^\\\\\\s/]+)`: Path prefix that can be:
- Current directory (.)
- Parent directory (..)
- Any directory name not containing spaces, backslashes, or forward slashes
- `\\\\`: Backslash separator
- `(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+`: Path segment that can include spaces escaped with backslash or forward slash
- `(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*`: Additional path segments after backslashes
- `\\\\?`: Optional trailing backslash
5. **Network Share Pattern**:
- `\\\\\\\\`: Double backslash (escaped) to start network path
- `[^\\\\\\s]+`: Server name
- `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*`: Share name and additional path components
- `(?:\\\\)?`: Optional trailing backslash
6. **URL Pattern**:
- `\\w+:\/\/`: Protocol (http://, https://, etc.)
- `[^\\s]+`: Rest of the URL (non-whitespace characters)
7. **Git Hash Pattern**:
- `[a-zA-Z0-9]{7,40}\\b`: 7-40 alphanumeric characters followed by word boundary
8. **Special Keywords Pattern**:
- `problems\\b`, `git-changes\\b`, `terminal\\b`: Exact word matches with word boundaries
9. **Termination Logic**:
- `(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`: Positive lookahead that:
- Allows an optional punctuation mark after the mention
- Ensures the mention (and optional punctuation) is followed by whitespace or end of string
- **Behavior Summary**:
- Matches @-prefixed mentions
- Handles different path formats across operating systems
- Supports escaped spaces in paths using OS-appropriate conventions
- Cleanly terminates at whitespace or end of string
- Excludes trailing punctuation from the match
- Creates both single-match and global-match regex objects
*/
const mentionPatterns = [
// Unix paths with escaped spaces using backslash
"(?:\\/|^)(?:[^\\/\\s\\\\]|\\\\[ \\t])+(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*\\/?",
// Windows paths with drive letters (C:\path) with support for escaped spaces using forward slash
"[A-Za-z]:\\\\(?:(?:[^\\\\\\s/]+|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?",
// Windows relative paths (folder\file or .\folder\file) with support for escaped spaces
"(?:\\.{0,2}|[^\\\\\\s/]+)\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*\\\\?",
// Windows network shares (\\server\share) with support for escaped spaces using forward slash
"\\\\\\\\[^\\\\\\s]+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*(?:\\\\)?",
// URLs with protocols (http://, https://, etc.)
"\\w+:\/\/[^\\s]+",
// Git hashes (7-40 alphanumeric characters)
"[a-zA-Z0-9]{7,40}\\b",
// Special keywords
"problems\\b",
"git-changes\\b",
"terminal\\b",
]
// Build the full regex pattern by joining the patterns with OR operator
const mentionRegexPattern = `@(${mentionPatterns.join("|")})(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`
export const mentionRegex = new RegExp(mentionRegexPattern)
export const mentionRegexGlobal = new RegExp(mentionRegexPattern, "g")
export const mentionRegex =
/@((?:\/|\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 {
type: "file" | "folder" | "git" | "problems"

View file

@ -1,17 +0,0 @@
export function formatPath(path: string, os?: string, handleSpace: boolean = true): string {
let formattedPath = path
// Handle path prefix
if (os === "win32") {
formattedPath = formattedPath.startsWith("\\") ? formattedPath : `\\${formattedPath}`
} else {
formattedPath = formattedPath.startsWith("/") ? formattedPath : `/${formattedPath}`
}
// Handle space escaping
if (handleSpace) {
formattedPath = formattedPath.replaceAll(" ", os === "win32" ? "/ " : "\\ ")
}
return formattedPath
}

View file

@ -1,7 +1,6 @@
import * as path from "path"
import os from "os"
import * as vscode from "vscode"
import { formatPath } from "../shared/formatPath"
/*
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@ -103,17 +102,8 @@ export function getReadablePath(cwd: string, relPath?: string): string {
}
export const toRelativePath = (filePath: string, cwd: string) => {
// Get the relative path
const relativePath = path.relative(cwd, filePath)
// Add trailing slash if the original path had one
const pathWithTrailingSlash =
filePath.endsWith("/") || filePath.endsWith("\\")
? relativePath + (process.platform === "win32" ? "\\" : "/")
: relativePath
// Format the path based on OS and handle spaces
return formatPath(pathWithTrailingSlash, process.platform)
const relativePath = path.relative(cwd, filePath).toPosix()
return filePath.endsWith("/") ? relativePath + "/" : relativePath
}
export const getWorkspacePath = (defaultCwdPath = "") => {

View file

@ -71,7 +71,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
listApiConfigMeta,
customModes,
cwd,
osInfo,
pinnedApiConfigs,
togglePinnedApiConfig,
} = useExtensionState()
@ -188,7 +187,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
.filter((tab) => tab.path)
.map((tab) => ({
type: ContextMenuOptionType.OpenedFile,
value: tab.path,
value: "/" + tab.path,
})),
...filePaths
.map((file) => "/" + file)
@ -308,7 +307,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
queryItems,
fileSearchResults,
getAllModes(customModes),
osInfo,
)
const optionsLength = options.length
@ -345,7 +343,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
queryItems,
fileSearchResults,
getAllModes(customModes),
osInfo,
)[selectedMenuIndex]
if (
selectedOption &&
@ -401,20 +398,19 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
showContextMenu,
selectedMenuIndex,
searchQuery,
selectedType,
queryItems,
fileSearchResults,
customModes,
osInfo,
handleMentionSelect,
onSend,
showContextMenu,
searchQuery,
selectedMenuIndex,
handleMentionSelect,
selectedType,
inputValue,
cursorPosition,
justDeletedSpaceAfterMention,
setInputValue,
justDeletedSpaceAfterMention,
queryItems,
customModes,
fileSearchResults,
],
)
@ -628,7 +624,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Convert each path to a mention-friendly format
const mentionText = convertToMentionPath(line, cwd, osInfo)
const mentionText = convertToMentionPath(line, cwd)
newValue += mentionText
totalLength += mentionText.length
@ -695,15 +691,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
textAreaDisabled,
inputValue,
cursorPosition,
setInputValue,
cwd,
osInfo,
inputValue,
setInputValue,
setCursorPosition,
setIntendedCursorPosition,
textAreaDisabled,
shouldDisableImages,
t,
setSelectedImages,
t,
],
)
@ -789,7 +786,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
modes={getAllModes(customModes)}
loading={searchLoading}
dynamicSearchResults={fileSearchResults}
os={osInfo}
/>
</div>
)}

View file

@ -19,7 +19,6 @@ interface ContextMenuProps {
modes?: ModeConfig[]
loading?: boolean // New loading prop
dynamicSearchResults?: SearchResult[] // New dynamic search results prop
os?: string
}
const ContextMenu: React.FC<ContextMenuProps> = ({
@ -33,13 +32,12 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
modes,
loading = false,
dynamicSearchResults = [],
os,
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const filteredOptions = useMemo(() => {
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, os)
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, os])
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes)
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes])
useEffect(() => {
if (menuRef.current) {
@ -113,8 +111,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
if (option.value) {
return (
<>
{/* check os === window to add the leading slash */}
<span>{os === "win32" ? "\\" : "/"}</span>
<span>/</span>
{option.value?.startsWith("/.") && <span>.</span>}
<span
style={{

View file

@ -4,7 +4,6 @@ import { useExtensionState } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
import { defaultModeSlug } from "../../../../../src/shared/modes"
import * as pathMentions from "../../../utils/path-mentions"
import { formatPath } from "../../../../../src/shared/formatPath"
// Mock modules
jest.mock("../../../utils/vscode", () => ({
@ -17,11 +16,11 @@ jest.mock("../../../components/common/MarkdownBlock")
jest.mock("../../../utils/path-mentions", () => ({
convertToMentionPath: jest.fn((path, cwd) => {
// Simple mock implementation that mimics the real function's behavior
if (path.startsWith(cwd)) {
if (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) {
const relativePath = path.substring(cwd.length)
// Ensure there's a slash after the @ symbol when we create the mention path
return "@" + formatPath(relativePath, "unix", false)
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
}
return path
}),
}))
@ -68,7 +67,6 @@ describe("ChatTextArea", () => {
apiConfiguration: {
apiProvider: "anthropic",
},
osInfo: "unix",
})
})
@ -194,7 +192,6 @@ describe("ChatTextArea", () => {
filePaths: [],
openedTabs: [],
cwd: mockCwd,
osInfo: "unix",
})
mockConvertToMentionPath.mockClear()
})
@ -220,8 +217,8 @@ describe("ChatTextArea", () => {
// Verify convertToMentionPath was called for each file path
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd)
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd)
// Verify setInputValue was called with the correct value
// The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js
@ -307,7 +304,7 @@ describe("ChatTextArea", () => {
})
// Verify convertToMentionPath was called with the long path
expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd)
// The mock implementation will convert it to @/very/long/path/...
expect(setInputValue).toHaveBeenCalledWith(
@ -342,10 +339,10 @@ describe("ChatTextArea", () => {
// Verify convertToMentionPath was called for each path
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4)
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd)
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd)
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd)
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd)
// Verify setInputValue was called with the correct value
expect(setInputValue).toHaveBeenCalledWith(
@ -379,7 +376,7 @@ describe("ChatTextArea", () => {
})
// Verify convertToMentionPath was called with the outside path
expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd, "unix")
expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd)
// Verify setInputValue was called with the original path
expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ")

View file

@ -118,7 +118,6 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi
export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<ExtensionState>({
version: "",
osInfo: "",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,

View file

@ -184,7 +184,6 @@ describe("mergeExtensionState", () => {
it("should correctly merge extension states", () => {
const baseState: ExtensionState = {
version: "",
osInfo: "unix",
mcpEnabled: false,
enableMcpServerCreation: false,
clineMessages: [],

View file

@ -3,10 +3,10 @@ import { convertToMentionPath } from "../path-mentions"
describe("path-mentions", () => {
describe("convertToMentionPath", () => {
it("should convert an absolute path to a mention path when it starts with cwd", () => {
// win32-style paths
expect(
convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project", "win32"),
).toBe("@\\file.txt")
// Windows-style paths
expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe(
"@/file.txt",
)
// Unix-style paths
expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project")).toBe("@/file.txt")
@ -31,9 +31,9 @@ describe("path-mentions", () => {
})
it("should normalize backslashes to forward slashes", () => {
expect(
convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project", "win32"),
).toBe("@\\subdir\\file.txt")
expect(convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project")).toBe(
"@/subdir/file.txt",
)
})
it("should handle nested paths correctly", () => {

View file

@ -2,7 +2,6 @@ import { mentionRegex } from "../../../src/shared/context-mentions"
import { Fzf } from "fzf"
import { ModeConfig } from "../../../src/shared/modes"
import * as path from "path"
import { formatPath } from "../../../src/shared/formatPath"
export interface SearchResult {
path: string
@ -83,24 +82,12 @@ export interface ContextMenuQueryItem {
icon?: string
}
function mapSearchResult(result: SearchResult, os?: string): ContextMenuQueryItem {
const formattedPath = formatPath(result.path, os)
return {
type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: formattedPath,
label: result.label || path.basename(result.path),
description: formattedPath,
}
}
export function getContextMenuOptions(
query: string,
selectedType: ContextMenuOptionType | null = null,
queryItems: ContextMenuQueryItem[],
dynamicSearchResults: SearchResult[] = [],
modes?: ModeConfig[],
os?: string,
): ContextMenuQueryItem[] {
// Handle slash commands for modes
if (query.startsWith("/")) {
@ -242,24 +229,25 @@ export function getContextMenuOptions(
const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
// Convert search results to queryItems format
const searchResultItems = dynamicSearchResults.map((result) => mapSearchResult(result, os))
const searchResultItems = dynamicSearchResults.map((result) => {
const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
return {
type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: formattedPath,
label: result.label || path.basename(result.path),
description: formattedPath,
}
})
const allItems = [...suggestions, ...openedFileMatches, ...searchResultItems, ...gitMatches]
// Remove duplicates - normalize paths by ensuring all have leading slashes
const seen = new Set()
const deduped = allItems.filter((item) => {
const normalizedValue = item.value
let key = ""
if (
item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder ||
item.type === ContextMenuOptionType.OpenedFile
) {
key = normalizedValue!
} else {
key = `${item.type}-${normalizedValue}`
}
// Normalize paths for deduplication by ensuring leading slashes
const normalizedValue = item.value && !item.value.startsWith("/") ? `/${item.value}` : item.value
const key = `${item.type}-${normalizedValue}`
if (seen.has(key)) return false
seen.add(key)
return true

View file

@ -2,8 +2,6 @@
* Utilities for handling path-related operations in mentions
*/
import { formatPath } from "../../../src/shared/formatPath"
/**
* Converts an absolute path to a mention-friendly path
* If the provided path starts with the current working directory,
@ -13,16 +11,16 @@ import { formatPath } from "../../../src/shared/formatPath"
* @param cwd The current working directory
* @returns A mention-friendly path
*/
export function convertToMentionPath(path: string, cwd?: string, os?: string): string {
const normalizedPath = formatPath(path, os)
let normalizedCwd = cwd ? formatPath(cwd, os) : ""
export function convertToMentionPath(path: string, cwd?: string): string {
const normalizedPath = path.replace(/\\/g, "/")
let normalizedCwd = cwd ? cwd.replace(/\\/g, "/") : ""
if (!normalizedCwd) {
return path
}
// Remove trailing slash from cwd if it exists
if ((os !== "win32" && normalizedCwd.endsWith("/")) || (os === "win32" && normalizedCwd.endsWith("\\"))) {
if (normalizedCwd.endsWith("/")) {
normalizedCwd = normalizedCwd.slice(0, -1)
}
@ -33,7 +31,7 @@ export function convertToMentionPath(path: string, cwd?: string, os?: string): s
if (lowerPath.startsWith(lowerCwd)) {
const relativePath = normalizedPath.substring(normalizedCwd.length)
// Ensure there's a slash after the @ symbol when we create the mention path
return "@" + formatPath(relativePath, os, false)
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
}
return path