diff --git a/.changeset/brown-cows-jump.md b/.changeset/brown-cows-jump.md new file mode 100644 index 0000000000..d6e5d3f6f0 --- /dev/null +++ b/.changeset/brown-cows-jump.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Revert mention changes in case they're causing performance issues/crashes diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 294c423c87..005b00fc0d 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -2049,8 +2049,6 @@ export class Cline extends EventEmitter { // 2. ToolResultBlockParam's content/context text arrays if it contains "" (see formatToolDeniedFeedback, attemptCompletion, executeCommand, and consecutiveMistakeCount >= 3) or "" (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("") || text.includes("") @@ -2058,7 +2056,7 @@ export class Cline extends EventEmitter { 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 { 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 { contentBlock.text, this.cwd, this.urlContentFetcher, - osInfo, ), } } diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 1fa9453c3c..3068085425 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -941,7 +941,6 @@ describe("Cline", () => { "Text with @/some/path in task tags", expect.any(String), expect.any(Object), - expect.any(String), ) // Feedback tag content should be processed @@ -952,7 +951,6 @@ describe("Cline", () => { "Check @/some/path", expect.any(String), expect.any(Object), - expect.any(String), ) // Regular tool result should not be processed diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 24696fe070..d32b1ec08d 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -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 { +export async function openMention(mention?: string): Promise { if (!mention) { return } @@ -22,17 +21,10 @@ export async function openMention(mention?: string, osInfo?: string): Promise = {}): XmlTag => { - const attrString = Object.entries(attrs) - .map(([key, value]) => `${key}="${value}"`) - .join(" ") - return { - start: `\n\n<${name}${attrString ? " " + attrString : ""}>`, - end: ``, + +export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise { + const mentions: Set = 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}`) + } } -} -export const wrapContent = (content: string, tag: XmlTag): string => `${tag.start}\n${content}\n${tag.end}` + 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\n${result}\n` + } else if (mention.startsWith("/")) { + const mentionPath = mention.slice(1) + try { + const content = await getFileOrFolderContent(mentionPath, cwd) + if (mention.endsWith("/")) { + parsedText += `\n\n\n${content}\n` + } else { + parsedText += `\n\n\n${content}\n` + } + } catch (error) { + if (mention.endsWith("/")) { + parsedText += `\n\n\nError fetching content: ${error.message}\n` + } else { + parsedText += `\n\n\nError fetching content: ${error.message}\n` + } + } + } else if (mention === "problems") { + try { + const problems = await getWorkspaceProblems(cwd) + parsedText += `\n\n\n${problems}\n` + } catch (error) { + parsedText += `\n\n\nError fetching diagnostics: ${error.message}\n` + } + } else if (mention === "git-changes") { + try { + const workingState = await getWorkingState(cwd) + parsedText += `\n\n\n${workingState}\n` + } catch (error) { + parsedText += `\n\n\nError fetching working state: ${error.message}\n` + } + } else if (/^[a-f0-9]{7,40}$/.test(mention)) { + try { + const commitInfo = await getCommitInfo(mention, cwd) + parsedText += `\n\n\n${commitInfo}\n` + } catch (error) { + parsedText += `\n\n\nError fetching commit info: ${error.message}\n` + } + } else if (mention === "terminal") { + try { + const terminalOutput = await getLatestTerminalOutput() + parsedText += `\n\n\n${terminalOutput}\n` + } catch (error) { + parsedText += `\n\n\nError fetching terminal output: ${error.message}\n` + } + } + } -export const handleError = (error: Error, message: string): string => { - const errorMsg = `Error ${message}: ${error.message}` - if (error instanceof Error) { - vscode.window.showErrorMessage(errorMsg) + if (urlMention) { + try { + await urlContentFetcher.closeBrowser() + } catch (error) { + console.error(`Error closing browser: ${error.message}`) + } } - return errorMsg + + return parsedText } -// File utilities -export async function getFileOrFolderContent(mentionPath: string, cwd: string, osInfo: string): Promise { +async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise { 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 `\n${content}\n` + return `\n${content}\n` } 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 { +async function getWorkspaceProblems(cwd: string): Promise { const diagnostics = vscode.languages.getDiagnostics() const result = await diagnosticsToProblemsString( diagnostics, @@ -134,193 +211,3 @@ export async function getWorkspaceProblems(cwd: string): Promise { } 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 { - const mentions: Set = 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 -} diff --git a/src/core/mentions/types.ts b/src/core/mentions/types.ts deleted file mode 100644 index e31da90290..0000000000 --- a/src/core/mentions/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" - -export type MentionHandler = (mention: string) => Promise - -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 -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ff223250b0..8206375d79 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1188,7 +1188,6 @@ export class ClineProvider extends EventEmitter 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 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, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index f6de2ee5f7..9022427075 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -388,7 +388,6 @@ describe("ClineProvider", () => { const mockState: ExtensionState = { version: "1.0.0", - osInfo: "unix", clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 68ff56a79e..7e3c67593d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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) diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts index abdfa62d7d..5f7cf3d292 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts @@ -15,12 +15,7 @@ let registeredTabChangeCallback: (() => Promise) | 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") diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 60c20b6503..095279ffde 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -168,7 +168,6 @@ export type ExtensionState = Pick< | "enhancementApiConfigId" > & { version: string - osInfo: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem apiConfiguration?: ApiConfiguration diff --git a/src/shared/__tests__/context-mentions.test.ts b/src/shared/__tests__/context-mentions.test.ts deleted file mode 100644 index 99bad21ebb..0000000000 --- a/src/shared/__tests__/context-mentions.test.ts +++ /dev/null @@ -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/withchars.txt", "@/path/withchars.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") - // }) - // }) -}) diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index ddfc6650f5..915114ab93 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -1,90 +1,57 @@ /* -- **Regex Breakdown**: - - 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 +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. - 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 +- **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. - 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 +- **Global Regex**: + - `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given 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" diff --git a/src/shared/formatPath.ts b/src/shared/formatPath.ts deleted file mode 100644 index fcde4468d4..0000000000 --- a/src/shared/formatPath.ts +++ /dev/null @@ -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 -} diff --git a/src/utils/path.ts b/src/utils/path.ts index aa4446af5f..a58d630172 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -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 = "") => { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8bc681034e..ebc781e8eb 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -71,7 +71,6 @@ const ChatTextArea = forwardRef( listApiConfigMeta, customModes, cwd, - osInfo, pinnedApiConfigs, togglePinnedApiConfig, } = useExtensionState() @@ -188,7 +187,7 @@ const ChatTextArea = forwardRef( .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( queryItems, fileSearchResults, getAllModes(customModes), - osInfo, ) const optionsLength = options.length @@ -345,7 +343,6 @@ const ChatTextArea = forwardRef( queryItems, fileSearchResults, getAllModes(customModes), - osInfo, )[selectedMenuIndex] if ( selectedOption && @@ -401,20 +398,19 @@ const ChatTextArea = forwardRef( } }, [ + onSend, showContextMenu, - selectedMenuIndex, searchQuery, - selectedType, - queryItems, - fileSearchResults, - customModes, - osInfo, + selectedMenuIndex, handleMentionSelect, - onSend, + selectedType, inputValue, cursorPosition, - justDeletedSpaceAfterMention, setInputValue, + justDeletedSpaceAfterMention, + queryItems, + customModes, + fileSearchResults, ], ) @@ -628,7 +624,7 @@ const ChatTextArea = forwardRef( 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( } }, [ - textAreaDisabled, - inputValue, cursorPosition, - setInputValue, cwd, - osInfo, + inputValue, + setInputValue, + setCursorPosition, + setIntendedCursorPosition, + textAreaDisabled, shouldDisableImages, - t, setSelectedImages, + t, ], ) @@ -789,7 +786,6 @@ const ChatTextArea = forwardRef( modes={getAllModes(customModes)} loading={searchLoading} dynamicSearchResults={fileSearchResults} - os={osInfo} /> )} diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 38a8f1520b..5d2df631db 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -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 = ({ @@ -33,13 +32,12 @@ const ContextMenu: React.FC = ({ modes, loading = false, dynamicSearchResults = [], - os, }) => { const menuRef = useRef(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 = ({ if (option.value) { return ( <> - {/* check os === window to add the leading slash */} - {os === "win32" ? "\\" : "/"} + / {option.value?.startsWith("/.") && .} ({ @@ -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 ") diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 2dcf336c66..b8c8b3316b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -118,7 +118,6 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [state, setState] = useState({ version: "", - osInfo: "", clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx index 27d60593da..665bef258d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx @@ -184,7 +184,6 @@ describe("mergeExtensionState", () => { it("should correctly merge extension states", () => { const baseState: ExtensionState = { version: "", - osInfo: "unix", mcpEnabled: false, enableMcpServerCreation: false, clineMessages: [], diff --git a/webview-ui/src/utils/__tests__/path-mentions.test.ts b/webview-ui/src/utils/__tests__/path-mentions.test.ts index 725801b8a0..bb5591fbe5 100644 --- a/webview-ui/src/utils/__tests__/path-mentions.test.ts +++ b/webview-ui/src/utils/__tests__/path-mentions.test.ts @@ -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", () => { diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index eee537af67..5d240d8fd2 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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 diff --git a/webview-ui/src/utils/path-mentions.ts b/webview-ui/src/utils/path-mentions.ts index 6709c48bd3..960483f593 100644 --- a/webview-ui/src/utils/path-mentions.ts +++ b/webview-ui/src/utils/path-mentions.ts @@ -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