mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat @-mention window path style & file with space (#1924)
* feat @-mention window path style & file with space * Update src/core/mentions/index.ts Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com> --------- Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
parent
4d744bb94a
commit
c62e8f2ee2
20 changed files with 799 additions and 228 deletions
|
|
@ -3707,6 +3707,8 @@ 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>")
|
||||
|
||||
|
|
@ -3714,7 +3716,7 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
if (shouldProcessMentions(block.text)) {
|
||||
return {
|
||||
...block,
|
||||
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
|
||||
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher, osInfo),
|
||||
}
|
||||
}
|
||||
return block
|
||||
|
|
@ -3723,7 +3725,12 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
if (shouldProcessMentions(block.content)) {
|
||||
return {
|
||||
...block,
|
||||
content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
|
||||
content: await parseMentions(
|
||||
block.content,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
osInfo,
|
||||
),
|
||||
}
|
||||
}
|
||||
return block
|
||||
|
|
@ -3737,6 +3744,7 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
contentBlock.text,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
osInfo,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1023,6 +1023,7 @@ 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
|
||||
|
|
@ -1033,6 +1034,7 @@ describe("Cline", () => {
|
|||
"<feedback>Check @/some/path</feedback>",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
)
|
||||
|
||||
// Regular tool result should not be processed
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
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, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
|
||||
import fs from "fs/promises"
|
||||
import { mentionRegexGlobal } from "../../shared/context-mentions"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { HandlerConfig, MentionContext, XmlTag } from "./types"
|
||||
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): Promise<void> {
|
||||
export async function openMention(mention?: string, osInfo?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
return
|
||||
}
|
||||
|
|
@ -21,10 +22,20 @@ export async function openMention(mention?: string): Promise<void> {
|
|||
return
|
||||
}
|
||||
|
||||
if (mention.startsWith("/")) {
|
||||
if (
|
||||
(osInfo !== "win32" && mention.startsWith("/")) ||
|
||||
(osInfo === "win32" && mention.startsWith("\\"))
|
||||
) {
|
||||
const relPath = mention.slice(1)
|
||||
const absPath = path.resolve(cwd, relPath)
|
||||
if (mention.endsWith("/")) {
|
||||
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("\\"))
|
||||
) {
|
||||
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
|
||||
} else {
|
||||
openFile(absPath)
|
||||
|
|
@ -37,115 +48,29 @@ export async function openMention(mention?: string): Promise<void> {
|
|||
vscode.env.openExternal(vscode.Uri.parse(mention))
|
||||
}
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
// 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}>`,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
|
||||
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> {
|
||||
const absPath = path.resolve(cwd, mentionPath)
|
||||
|
||||
try {
|
||||
|
|
@ -177,7 +102,7 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
|||
return undefined
|
||||
}
|
||||
const content = await extractTextFromFile(absoluteFilePath)
|
||||
return `<file_content path="${filePath.toPosix()}">\n${content}\n</file_content>`
|
||||
return `<file_content path="${filePath}">\n${content}\n</file_content>`
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -199,7 +124,8 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
|||
}
|
||||
}
|
||||
|
||||
async function getWorkspaceProblems(cwd: string): Promise<string> {
|
||||
// Workspace utilities
|
||||
export async function getWorkspaceProblems(cwd: string): Promise<string> {
|
||||
const diagnostics = vscode.languages.getDiagnostics()
|
||||
const result = await diagnosticsToProblemsString(
|
||||
diagnostics,
|
||||
|
|
@ -211,3 +137,193 @@ 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
|
||||
}
|
||||
|
|
|
|||
21
src/core/mentions/types.ts
Normal file
21
src/core/mentions/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -1198,7 +1198,10 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
|
|||
openFile(message.text!, message.values as { create?: boolean; content?: string })
|
||||
break
|
||||
case "openMention":
|
||||
openMention(message.text)
|
||||
{
|
||||
const { osInfo } = (await this.getState()) || {}
|
||||
openMention(message.text, osInfo)
|
||||
}
|
||||
break
|
||||
case "checkpointDiff":
|
||||
const result = checkoutDiffPayloadSchema.safeParse(message.payload)
|
||||
|
|
@ -2601,6 +2604,7 @@ 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,
|
||||
|
|
@ -2694,6 +2698,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ describe("ClineProvider", () => {
|
|||
|
||||
const mockState: ExtensionState = {
|
||||
version: "1.0.0",
|
||||
osInfo: "unix",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ let registeredTabChangeCallback: (() => Promise<void>) | null = null
|
|||
// Mock workspace path
|
||||
jest.mock("../../../utils/path", () => ({
|
||||
getWorkspacePath: jest.fn().mockReturnValue("/test/workspace"),
|
||||
toRelativePath: jest.fn((path, cwd) => path.replace(`${cwd}/`, "")),
|
||||
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
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock watcher - must be defined after mockDispose but before jest.mock("vscode")
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export type ExtensionState = Pick<
|
|||
| "enhancementApiConfigId"
|
||||
> & {
|
||||
version: string
|
||||
osInfo: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
apiConfiguration?: ApiConfiguration
|
||||
|
|
|
|||
325
src/shared/__tests__/context-mentions.test.ts
Normal file
325
src/shared/__tests__/context-mentions.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
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")
|
||||
// })
|
||||
// })
|
||||
})
|
||||
|
|
@ -1,57 +1,90 @@
|
|||
/*
|
||||
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.
|
||||
|
||||
- **Global Regex**:
|
||||
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
|
||||
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)
|
||||
|
||||
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
|
||||
*/
|
||||
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")
|
||||
|
||||
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 interface MentionSuggestion {
|
||||
type: "file" | "folder" | "git" | "problems"
|
||||
|
|
|
|||
17
src/shared/formatPath.ts
Normal file
17
src/shared/formatPath.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
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:
|
||||
|
|
@ -102,8 +103,17 @@ export function getReadablePath(cwd: string, relPath?: string): string {
|
|||
}
|
||||
|
||||
export const toRelativePath = (filePath: string, cwd: string) => {
|
||||
const relativePath = path.relative(cwd, filePath).toPosix()
|
||||
return filePath.endsWith("/") ? relativePath + "/" : relativePath
|
||||
// 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)
|
||||
}
|
||||
|
||||
export const getWorkspacePath = (defaultCwdPath = "") => {
|
||||
|
|
|
|||
|
|
@ -70,10 +70,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
customModes,
|
||||
cwd,
|
||||
cwd,
|
||||
osInfo,
|
||||
pinnedApiConfigs,
|
||||
togglePinnedApiConfig,
|
||||
} = useExtensionState()
|
||||
} =
|
||||
useExtensionState()
|
||||
|
||||
// Find the ID and display text for the currently selected API configuration
|
||||
const { currentConfigId, displayName } = useMemo(() => {
|
||||
|
|
@ -187,7 +189,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)
|
||||
|
|
@ -307,6 +309,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
queryItems,
|
||||
fileSearchResults,
|
||||
getAllModes(customModes),
|
||||
osInfo,
|
||||
)
|
||||
const optionsLength = options.length
|
||||
|
||||
|
|
@ -343,6 +346,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
queryItems,
|
||||
fileSearchResults,
|
||||
getAllModes(customModes),
|
||||
osInfo,
|
||||
)[selectedMenuIndex]
|
||||
if (
|
||||
selectedOption &&
|
||||
|
|
@ -398,19 +402,20 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
}
|
||||
},
|
||||
[
|
||||
onSend,
|
||||
showContextMenu,
|
||||
searchQuery,
|
||||
selectedMenuIndex,
|
||||
handleMentionSelect,
|
||||
searchQuery,
|
||||
selectedType,
|
||||
queryItems,
|
||||
fileSearchResults,
|
||||
customModes,
|
||||
osInfo,
|
||||
handleMentionSelect,
|
||||
onSend,
|
||||
inputValue,
|
||||
cursorPosition,
|
||||
setInputValue,
|
||||
justDeletedSpaceAfterMention,
|
||||
queryItems,
|
||||
customModes,
|
||||
fileSearchResults,
|
||||
setInputValue,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -624,7 +629,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)
|
||||
const mentionText = convertToMentionPath(line, cwd, osInfo)
|
||||
newValue += mentionText
|
||||
totalLength += mentionText.length
|
||||
|
||||
|
|
@ -691,16 +696,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
}
|
||||
},
|
||||
[
|
||||
cursorPosition,
|
||||
cwd,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
setCursorPosition,
|
||||
setIntendedCursorPosition,
|
||||
textAreaDisabled,
|
||||
inputValue,
|
||||
cursorPosition,
|
||||
setInputValue,
|
||||
cwd,
|
||||
osInfo,
|
||||
shouldDisableImages,
|
||||
setSelectedImages,
|
||||
t,
|
||||
setSelectedImages,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -786,6 +790,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
modes={getAllModes(customModes)}
|
||||
loading={searchLoading}
|
||||
dynamicSearchResults={fileSearchResults}
|
||||
os={osInfo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface ContextMenuProps {
|
|||
modes?: ModeConfig[]
|
||||
loading?: boolean // New loading prop
|
||||
dynamicSearchResults?: SearchResult[] // New dynamic search results prop
|
||||
os?: string
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
|
|
@ -32,12 +33,13 @@ 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)
|
||||
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes])
|
||||
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, os)
|
||||
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, os])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
|
|
@ -111,7 +113,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
if (option.value) {
|
||||
return (
|
||||
<>
|
||||
<span>/</span>
|
||||
{/* check os === window to add the leading slash */}
|
||||
<span>{os === "win32" ? "\\" : "/"}</span>
|
||||
{option.value?.startsWith("/.") && <span>.</span>}
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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", () => ({
|
||||
|
|
@ -16,11 +17,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 (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) {
|
||||
if (path.startsWith(cwd)) {
|
||||
const relativePath = path.substring(cwd.length)
|
||||
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
|
||||
// Ensure there's a slash after the @ symbol when we create the mention path
|
||||
return "@" + formatPath(relativePath, "unix", false)
|
||||
}
|
||||
return path
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -67,6 +68,7 @@ describe("ChatTextArea", () => {
|
|||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
osInfo: "unix",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -192,6 +194,7 @@ describe("ChatTextArea", () => {
|
|||
filePaths: [],
|
||||
openedTabs: [],
|
||||
cwd: mockCwd,
|
||||
osInfo: "unix",
|
||||
})
|
||||
mockConvertToMentionPath.mockClear()
|
||||
})
|
||||
|
|
@ -217,8 +220,8 @@ describe("ChatTextArea", () => {
|
|||
|
||||
// Verify convertToMentionPath was called for each file path
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd, "unix")
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd, "unix")
|
||||
|
||||
// Verify setInputValue was called with the correct value
|
||||
// The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js
|
||||
|
|
@ -304,7 +307,7 @@ describe("ChatTextArea", () => {
|
|||
})
|
||||
|
||||
// Verify convertToMentionPath was called with the long path
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd, "unix")
|
||||
|
||||
// The mock implementation will convert it to @/very/long/path/...
|
||||
expect(setInputValue).toHaveBeenCalledWith(
|
||||
|
|
@ -339,10 +342,10 @@ describe("ChatTextArea", () => {
|
|||
|
||||
// Verify convertToMentionPath was called for each path
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd, "unix")
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd, "unix")
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd, "unix")
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd, "unix")
|
||||
|
||||
// Verify setInputValue was called with the correct value
|
||||
expect(setInputValue).toHaveBeenCalledWith(
|
||||
|
|
@ -376,7 +379,7 @@ describe("ChatTextArea", () => {
|
|||
})
|
||||
|
||||
// Verify convertToMentionPath was called with the outside path
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd)
|
||||
expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd, "unix")
|
||||
|
||||
// Verify setInputValue was called with the original path
|
||||
expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ")
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ describe("mergeExtensionState", () => {
|
|||
it("should correctly merge extension states", () => {
|
||||
const baseState: ExtensionState = {
|
||||
version: "",
|
||||
osInfo: "unix",
|
||||
mcpEnabled: false,
|
||||
enableMcpServerCreation: false,
|
||||
clineMessages: [],
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
// Windows-style paths
|
||||
expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe(
|
||||
"@/file.txt",
|
||||
)
|
||||
// win32-style paths
|
||||
expect(
|
||||
convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project", "win32"),
|
||||
).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")).toBe(
|
||||
"@/subdir/file.txt",
|
||||
)
|
||||
expect(
|
||||
convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project", "win32"),
|
||||
).toBe("@\\subdir\\file.txt")
|
||||
})
|
||||
|
||||
it("should handle nested paths correctly", () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -82,12 +83,24 @@ 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("/")) {
|
||||
|
|
@ -229,25 +242,24 @@ export function getContextMenuOptions(
|
|||
const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
|
||||
|
||||
// Convert search results to queryItems format
|
||||
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 searchResultItems = dynamicSearchResults.map((result) => mapSearchResult(result, os))
|
||||
|
||||
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) => {
|
||||
// Normalize paths for deduplication by ensuring leading slashes
|
||||
const normalizedValue = item.value && !item.value.startsWith("/") ? `/${item.value}` : item.value
|
||||
const key = `${item.type}-${normalizedValue}`
|
||||
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}`
|
||||
}
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* 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,
|
||||
|
|
@ -11,16 +13,16 @@
|
|||
* @param cwd The current working directory
|
||||
* @returns A mention-friendly path
|
||||
*/
|
||||
export function convertToMentionPath(path: string, cwd?: string): string {
|
||||
const normalizedPath = path.replace(/\\/g, "/")
|
||||
let normalizedCwd = cwd ? cwd.replace(/\\/g, "/") : ""
|
||||
export function convertToMentionPath(path: string, cwd?: string, os?: string): string {
|
||||
const normalizedPath = formatPath(path, os)
|
||||
let normalizedCwd = cwd ? formatPath(cwd, os) : ""
|
||||
|
||||
if (!normalizedCwd) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Remove trailing slash from cwd if it exists
|
||||
if (normalizedCwd.endsWith("/")) {
|
||||
if ((os !== "win32" && normalizedCwd.endsWith("/")) || (os === "win32" && normalizedCwd.endsWith("\\"))) {
|
||||
normalizedCwd = normalizedCwd.slice(0, -1)
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ export function convertToMentionPath(path: string, cwd?: string): string {
|
|||
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 "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
|
||||
return "@" + formatPath(relativePath, os, false)
|
||||
}
|
||||
|
||||
return path
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue