diff --git a/.changeset/tame-buses-unite.md b/.changeset/tame-buses-unite.md new file mode 100644 index 00000000000..80f1b7cbf17 --- /dev/null +++ b/.changeset/tame-buses-unite.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add option for parallel diff edits diff --git a/.clinerules b/.clinerules index 9eaef00abef..1c86ff29857 100644 --- a/.clinerules +++ b/.clinerules @@ -1 +1,72 @@ -- Before attempting completion, always make sure that any code changes have test coverage and that the tests pass. \ No newline at end of file +# Code Quality Rules + +1. Test Coverage: + - Before attempting completion, always make sure that any code changes have test coverage + - Ensure all tests pass before submitting changes + +2. Git Commits: + - When finishing a task, always output a git commit command + - Include a descriptive commit message that follows conventional commit format + +# Adding a New Settings Checkbox + +To add a new settings checkbox that persists its state, follow these steps: + +1. Add the message type to WebviewMessage.ts: + - Add the setting name to the WebviewMessage type's type union + - Example: `| "multisearchDiffEnabled"` + +2. Add the setting to ExtensionStateContext.tsx: + - Add the setting to the ExtensionStateContextType interface + - Add the setter function to the interface + - Add the setting to the initial state in useState + - Add the setting to the contextValue object + - Example: + ```typescript + interface ExtensionStateContextType { + multisearchDiffEnabled: boolean; + setMultisearchDiffEnabled: (value: boolean) => void; + } + ``` + +3. Add the setting to ClineProvider.ts: + - Add the setting name to the GlobalStateKey type union + - Add the setting to the Promise.all array in getState + - Add the setting to the return value in getState with a default value + - Add the setting to the destructured variables in getStateToPostToWebview + - Add the setting to the return value in getStateToPostToWebview + - Add a case in setWebviewMessageListener to handle the setting's message type + - Example: + ```typescript + case "multisearchDiffEnabled": + await this.updateGlobalState("multisearchDiffEnabled", message.bool) + await this.postStateToWebview() + break + ``` + +4. Add the checkbox UI to SettingsView.tsx: + - Import the setting and its setter from ExtensionStateContext + - Add the VSCodeCheckbox component with the setting's state and onChange handler + - Add appropriate labels and description text + - Example: + ```typescript + setMultisearchDiffEnabled(e.target.checked)} + > + Enable multi-search diff matching + + ``` + +5. Add the setting to handleSubmit in SettingsView.tsx: + - Add a vscode.postMessage call to send the setting's value when clicking Done + - Example: + ```typescript + vscode.postMessage({ type: "multisearchDiffEnabled", bool: multisearchDiffEnabled }) + ``` + +These steps ensure that: +- The setting's state is properly typed throughout the application +- The setting persists between sessions +- The setting's value is properly synchronized between the webview and extension +- The setting has a proper UI representation in the settings view diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 63704c22f8b..479f1c49e0d 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -103,6 +103,7 @@ export class Cline { task?: string | undefined, images?: string[] | undefined, historyItem?: HistoryItem | undefined, + multisearchDiffEnabled?: boolean, ) { this.providerRef = new WeakRef(provider) this.api = buildApiHandler(apiConfiguration) @@ -113,7 +114,7 @@ export class Cline { this.customInstructions = customInstructions this.diffEnabled = enableDiff ?? false if (this.diffEnabled && this.api.getModel().id) { - this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0) + this.diffStrategy = getDiffStrategy(this.api.getModel().id, fuzzyMatchThreshold ?? 1.0, multisearchDiffEnabled ?? false) } if (historyItem) { this.taskId = historyItem.id diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index ed755bd3997..8206b38bf1a 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -316,8 +316,8 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); - expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9); - + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 0.9, false); + getDiffStrategySpy.mockRestore(); }); @@ -335,8 +335,8 @@ describe('Cline', () => { expect(cline.diffEnabled).toBe(true); expect(cline.diffStrategy).toBeDefined(); - expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0); - + expect(getDiffStrategySpy).toHaveBeenCalledWith('claude-3-5-sonnet-20241022', 1.0, false); + getDiffStrategySpy.mockRestore(); }); diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index c6118564e98..1bee16641e2 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -1,16 +1,19 @@ import type { DiffStrategy } from './types' import { UnifiedDiffStrategy } from './strategies/unified' import { SearchReplaceDiffStrategy } from './strategies/search-replace' +import { SearchReplaceMultisearchDiffStrategy } from './strategies/search-replace-multisearch' /** * Get the appropriate diff strategy for the given model * @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus') * @returns The appropriate diff strategy for the model */ -export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number): DiffStrategy { - // For now, return SearchReplaceDiffStrategy for all models - // This architecture allows for future optimizations based on model capabilities - return new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0) +export function getDiffStrategy(model: string, fuzzyMatchThreshold?: number, multisearchDiffEnabled?: boolean): DiffStrategy { + // Use SearchReplaceMultisearchDiffStrategy when multisearch diff is enabled + // Otherwise fall back to regular SearchReplaceDiffStrategy + return multisearchDiffEnabled + ? new SearchReplaceMultisearchDiffStrategy(fuzzyMatchThreshold ?? 1.0) + : new SearchReplaceDiffStrategy(fuzzyMatchThreshold ?? 1.0) } export type { DiffStrategy } -export { UnifiedDiffStrategy, SearchReplaceDiffStrategy } +export { UnifiedDiffStrategy, SearchReplaceDiffStrategy, SearchReplaceMultisearchDiffStrategy } diff --git a/src/core/diff/__tests__/utils.test.ts b/src/core/diff/__tests__/utils.test.ts new file mode 100644 index 00000000000..0439c4b763b --- /dev/null +++ b/src/core/diff/__tests__/utils.test.ts @@ -0,0 +1,61 @@ +import { levenshteinDistance, getSimilarity } from "../utils" + +describe("levenshteinDistance", () => { + it("should return 0 for identical strings", () => { + expect(levenshteinDistance("hello", "hello")).toBe(0) + }) + + it("should handle single character differences", () => { + expect(levenshteinDistance("hello", "hallo")).toBe(1) + }) + + it("should handle insertions", () => { + expect(levenshteinDistance("hello", "hello!")).toBe(1) + }) + + it("should handle deletions", () => { + expect(levenshteinDistance("hello!", "hello")).toBe(1) + }) + + it("should handle completely different strings", () => { + expect(levenshteinDistance("hello", "world")).toBe(4) + }) + + it("should handle empty strings", () => { + expect(levenshteinDistance("", "")).toBe(0) + expect(levenshteinDistance("hello", "")).toBe(5) + expect(levenshteinDistance("", "hello")).toBe(5) + }) +}) + +describe("getSimilarity", () => { + it("should return 1 for identical strings", () => { + expect(getSimilarity("hello world", "hello world")).toBe(1) + }) + + it("should handle empty search string", () => { + expect(getSimilarity("hello world", "")).toBe(1) + }) + + it("should normalize whitespace", () => { + expect(getSimilarity("hello world", "hello world")).toBe(1) + expect(getSimilarity("hello\tworld", "hello world")).toBe(1) + expect(getSimilarity("hello\nworld", "hello world")).toBe(1) + }) + + it("should preserve case sensitivity", () => { + expect(getSimilarity("Hello World", "hello world")).toBeLessThan(1) + }) + + it("should handle partial matches", () => { + const similarity = getSimilarity("hello world", "hello there") + expect(similarity).toBeGreaterThan(0) + expect(similarity).toBeLessThan(1) + }) + + it("should handle completely different strings", () => { + const similarity = getSimilarity("hello world", "goodbye universe") + expect(similarity).toBeGreaterThan(0) + expect(similarity).toBeLessThan(0.5) + }) +}) \ No newline at end of file diff --git a/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts b/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts new file mode 100644 index 00000000000..9fc000449c4 --- /dev/null +++ b/src/core/diff/strategies/__tests__/search-replace-multisearch.test.ts @@ -0,0 +1,569 @@ +import { SearchReplaceMultisearchDiffStrategy } from '../search-replace-multisearch' + +describe('SearchReplaceMultisearchDiffStrategy', () => { + describe('constructor', () => { + it('should use default values when no parameters provided', () => { + const strategy = new SearchReplaceMultisearchDiffStrategy() + expect(strategy['fuzzyThreshold']).toBe(1.0) + expect(strategy['bufferLines']).toBe(20) + }) + + it('should use provided values', () => { + const strategy = new SearchReplaceMultisearchDiffStrategy(0.9, 10) + expect(strategy['fuzzyThreshold']).toBe(0.9) + expect(strategy['bufferLines']).toBe(10) + }) + }) + + describe('fuzzy matching', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy(0.8) // 80% similarity threshold + }) + + it('should match content with small differences (>80% similar)', () => { + const originalContent = 'function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n' + const diffContent = `<<<<<<< SEARCH (1) +function getData() { + const result = fetchData(); + return results.filter(Boolean); +} +======= +function getData() { + const data = fetchData(); + return data.filter(Boolean); +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n') + } + }) + + it('should not match when content is too different (<80% similar)', () => { + const originalContent = 'function processUsers(data) {\n return data.map(user => user.name);\n}\n' + const diffContent = `<<<<<<< SEARCH (1) +function handleItems(items) { + return items.map(item => item.username); +} +======= +function processData(data) { + return data.map(d => d.value); +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it('should normalize whitespace in similarity comparison', () => { + const originalContent = 'function sum(a, b) {\n return a + b;\n}\n' + const diffContent = `<<<<<<< SEARCH (1) +function sum(a, b) { + return a + b; +} +======= +function sum(a, b) { + return a + b + 1; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function sum(a, b) {\n return a + b + 1;\n}\n') + } + }) + }) + + describe('buffer zone search', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy(1.0, 5) // Exact matching with 5 line buffer + }) + + it('should find matches within buffer zone', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +}`.trim() + + const diffContent = `<<<<<<< SEARCH (5) +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Even though we target line 5, it should find the match at lines 9-11 + // because it's within the 5-line buffer zone + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it('should not find matches outside buffer zone', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} + +function four() { + return 4; +} + +function five() { + return 5; +}`.trim() + + const diffContent = `<<<<<<< SEARCH (5) +function five() { + return 5; +} +======= +function five() { + return "five"; +} +>>>>>>> REPLACE` + + // Targeting line 5, function five() is more than 5 lines away + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + }) + + describe('multiple search/replace blocks', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy() + }) + + it('should handle overlapping search blocks', () => { + const originalContent = ` +function process() { + const data = getData(); + const result = transform(data); + return format(result); +}`.trim() + + const diffContent = `<<<<<<< SEARCH (1) +function process() { + const data = getData(); + const result = transform(data); +======= +function process() { + const input = getData(); + const output = transform(input); +>>>>>>> REPLACE + +<<<<<<< SEARCH (3) + const result = transform(data); + return format(result); +} +======= + const output = transform(input); + return format(output); +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Start line must be greater than previous block') + } + }) + + it('should handle multiple potential matches', () => { + const originalContent = ` +function log(msg) { + console.log(msg); +} + +function debug(msg) { + console.log(msg); +} + +function error(msg) { + console.log(msg); +}`.trim() + + const diffContent = `<<<<<<< SEARCH (2) +function log(msg) { + console.log(msg); +} +======= +function log(msg) { + console.log('[LOG]', msg); +} +>>>>>>> REPLACE + +<<<<<<< SEARCH (6) +function debug(msg) { + console.log(msg); +} +======= +function debug(msg) { + console.log('[DEBUG]', msg); +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function log(msg) { + console.log('[LOG]', msg); +} + +function debug(msg) { + console.log('[DEBUG]', msg); +} + +function error(msg) { + console.log(msg); +}`) + } + }) + + it('should handle replacements affecting later matches', () => { + const originalContent = ` +const config = { + port: 3000, + host: 'localhost', + timeout: 5000 +}; + +function getPort() { + return config.port; +} + +function getTimeout() { + return config.timeout; +}`.trim() + + const diffContent = `<<<<<<< SEARCH (1) +const config = { + port: 3000, + host: 'localhost', + timeout: 5000 +}; +======= +const CONFIG = { + PORT: 3000, + HOST: 'localhost', + TIMEOUT: 5000 +}; +>>>>>>> REPLACE + +<<<<<<< SEARCH (8) +function getPort() { + return config.port; +} +======= +function getPort() { + return CONFIG.PORT; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`const CONFIG = { + PORT: 3000, + HOST: 'localhost', + TIMEOUT: 5000 +}; + +function getPort() { + return CONFIG.PORT; +} + +function getTimeout() { + return config.timeout; +}`) + } + }) + }) + + describe('line number adjustments', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy() + }) + + it('should adjust line numbers for subsequent blocks when lines are added', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +}`.trim() + + const diffContent = `<<<<<<< SEARCH (1) +function one() { + return 1; +} +======= +function one() { + console.log("Starting..."); + return 1; +} +>>>>>>> REPLACE + +<<<<<<< SEARCH (5) +function two() { + return 2; +} +======= +function two() { + console.log("Processing..."); + return 2; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + console.log("Starting..."); + return 1; +} + +function two() { + console.log("Processing..."); + return 2; +} + +function three() { + return 3; +}`) + } + }) + + it('should adjust line numbers for subsequent blocks when lines are removed', () => { + const originalContent = ` +function one() { + // Debug line 1 + // Debug line 2 + return 1; +} + +function two() { + return 2; +}`.trim() + + const diffContent = `<<<<<<< SEARCH (1) +function one() { + // Debug line 1 + // Debug line 2 + return 1; +} +======= +function one() { + return 1; +} +>>>>>>> REPLACE + +<<<<<<< SEARCH (7) +function two() { + return 2; +} +======= +function two() { + console.log("Processing..."); + return 2; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + console.log("Processing..."); + return 2; +}`) + } + }) + }) + + describe('error handling', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy() + }) + + it('should return error for invalid line range', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `<<<<<<< SEARCH (5) +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Line range 5-7 is invalid') + } + }) + + it('should return error for empty search content', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `<<<<<<< SEARCH (1) +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Empty search content is not allowed') + } + }) + + it('should return error with debug info when no match found', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `<<<<<<< SEARCH (1) +function test() { + return different; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Debug Info:') + expect(result.error).toContain('Similarity Score:') + expect(result.error).toContain('Required Threshold:') + } + }) + }) + + describe('indentation handling', () => { + let strategy: SearchReplaceMultisearchDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceMultisearchDiffStrategy() + }) + + it('should preserve indentation when adding lines', () => { + const originalContent = ` +class Example { + constructor() { + this.value = 0; + } +}`.trim() + + const diffContent = `<<<<<<< SEARCH (2) + constructor() { + this.value = 0; + } +======= + constructor() { + // Initialize value + this.value = 0; + this.ready = true; + } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + // Initialize value + this.value = 0; + this.ready = true; + } +}`) + } + }) + + it('should handle mixed indentation styles', () => { + const originalContent = `class Example { +\tconstructor() { +\t this.value = 0; +\t} +}` + + const diffContent = `<<<<<<< SEARCH (2) +\tconstructor() { +\t this.value = 0; +\t} +======= +\tconstructor() { +\t // Add comment +\t this.value = 1; +\t} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { +\tconstructor() { +\t // Add comment +\t this.value = 1; +\t} +}`) + } + }) + }) +}) diff --git a/src/core/diff/strategies/search-replace-multisearch.ts b/src/core/diff/strategies/search-replace-multisearch.ts new file mode 100644 index 00000000000..43c60e5d59d --- /dev/null +++ b/src/core/diff/strategies/search-replace-multisearch.ts @@ -0,0 +1,248 @@ +import { DiffStrategy, DiffResult } from "../types" +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" +import { getSimilarity } from "../utils" + +const BUFFER_LINES = 20; // Number of extra context lines to show before and after matches + +export class SearchReplaceMultisearchDiffStrategy implements DiffStrategy { + private fuzzyThreshold: number; + private bufferLines: number; + + constructor(fuzzyThreshold?: number, bufferLines?: number) { + // Use provided threshold or default to exact matching (1.0) + this.fuzzyThreshold = fuzzyThreshold ?? 1.0; + this.bufferLines = bufferLines ?? BUFFER_LINES; + } + + getToolDescription(cwd: string): string { + return `## apply_diff +Description: Request to replace existing code using a search and replace block. +This tool allows for precise, surgical replaces to files by specifying exactly what content to search for and what to replace it with. +The tool will maintain proper indentation and formatting while making changes. +Multiple search/replace blocks can be specified in a single diff, but they must be in order. +The SEARCH section must exactly match existing content including whitespace and indentation. +If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. + +Parameters: +- path: (required) The path of the file to modify (relative to the current working directory ${cwd}) +- diff: (required) The search/replace block defining the changes. + +Line Number Behavior: +- Line numbers are specified in the SEARCH marker: <<<<<<< SEARCH (start_line) +- For multiple blocks, line numbers are automatically adjusted based on lines added/removed by previous blocks +- Example: If block 1 adds 2 lines and block 2's target was at line 10, it will be automatically adjusted to line 12 + +Diff format: +\`\`\` +<<<<<<< SEARCH (start_line) +[exact content to find including whitespace] +======= +[new content to replace with] +>>>>>>> REPLACE +\`\`\` + +Example with multiple blocks: +\`\`\` +<<<<<<< SEARCH (1) +function one() { + return 1; +} +======= +function one() { + console.log("Starting..."); + return 1; +} +>>>>>>> REPLACE +<<<<<<< SEARCH (5) +function two() { + return 2; +} +======= +function two() { + console.log("Processing..."); + return 2; +} +>>>>>>> REPLACE +\`\`\` + +In this example: +1. First block starts at line 1 and matches 3 lines (the function definition) +2. First block adds 1 line (console.log), so subsequent line numbers are shifted by +1 +3. Second block starts at line 5, but is automatically adjusted to line 6 due to the previous +1 shift + +Usage: + +File path here + +[search/replace blocks here] + +` + } + + applyDiff(originalContent: string, diffContent: string): DiffResult { + // Extract all search and replace blocks with start line numbers and compute end lines + const blockPattern = /<<<<<<< SEARCH \((\d+)(?:-\d+)?\)\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g; + const rawBlocks = Array.from(diffContent.matchAll(blockPattern)); + const blocks = rawBlocks.map(([full, startStr, searchContent, replaceContent]) => { + const start = parseInt(startStr, 10); + const searchLines = searchContent.split(/\r?\n/); + const end = start + searchLines.length - 1; + return [full, startStr, end.toString(), searchContent, replaceContent]; + }); + + // Validate blocks + if (blocks.length === 0) { + return { + success: false, + error: "Invalid diff format - missing required SEARCH/REPLACE sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH (start-end)\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers and line numbers" + }; + } + + let prevEnd = -1; + + // Then validate individual blocks + for (const block of blocks) { + const [_, startStr, endStr, searchContent] = block; + // Check for empty search content + const searchLines = searchContent.split(/\r?\n/); + if (searchLines.length === 0 || searchLines.every(line => line.trim() === '')) { + return { + success: false, + error: "Empty search content is not allowed\n\nDebug Info:\n- Each SEARCH block must contain content to match" + }; + } + + // Validate line numbers + const startLine = parseInt(startStr, 10); + const endLine = parseInt(endStr, 10); + if (startLine < 1 || endLine < startLine || startLine <= prevEnd) { + return { + success: false, + error: `Invalid line range ${startLine}-${endLine}\n\nDebug Info:\n- Start line must be >= 1\n- End line must be >= start line\n- Start line must be greater than previous block's end line` + }; + } + + prevEnd = endLine; + } + + // Process blocks sequentially + let lineAdjustment = 0; + let currentContent = originalContent; + const lineEnding = currentContent.includes('\r\n') ? '\r\n' : '\n'; + + for (const [_, startStr, endStr, searchContent, replaceContent] of blocks) { + let currentSearchContent = searchContent; + let currentReplaceContent = replaceContent; + + // Parse line numbers and apply adjustment + const startLine = parseInt(startStr, 10); + const endLine = parseInt(endStr, 10); + const adjustedStartLine = startLine + lineAdjustment; + const adjustedEndLine = endLine + lineAdjustment; + + // Split content into lines for validation + const originalLines = currentContent.split(/\r?\n/); + + // Validate line range + if (adjustedStartLine < 1 || adjustedEndLine > originalLines.length) { + return { + success: false, + error: `Line range ${startLine}-${endLine} is invalid\n\nDebug Info:\n- Original Range: lines ${startLine}-${endLine}\n- Adjusted Range: lines ${adjustedStartLine}-${adjustedEndLine}\n- Line Adjustment: ${lineAdjustment}\n- File Bounds: lines 1-${originalLines.length}` + }; + } + + // Strip line numbers if present + if (everyLineHasLineNumbers(currentSearchContent) && everyLineHasLineNumbers(currentReplaceContent)) { + currentSearchContent = stripLineNumbers(currentSearchContent); + currentReplaceContent = stripLineNumbers(currentReplaceContent); + } + + // Split search and replace content into lines + const searchLines = currentSearchContent.split(/\r?\n/); + const replaceLines = currentReplaceContent.split(/\r?\n/); + + // Initialize search variables + let matchIndex = -1; + let bestMatchScore = 0; + let bestMatchContent = ""; + const searchChunk = searchLines.join('\n'); + + // Try exact match at adjusted line first + const exactMatchChunk = originalLines.slice(adjustedStartLine - 1, adjustedEndLine).join('\n'); + const exactMatchScore = getSimilarity(exactMatchChunk, searchChunk); + if (exactMatchScore >= this.fuzzyThreshold) { + matchIndex = adjustedStartLine - 1; + bestMatchScore = exactMatchScore; + bestMatchContent = exactMatchChunk; + } + + // If exact match fails, try buffer zone + if (matchIndex === -1) { + // Search within buffer zone + const searchStartIndex = Math.max(0, adjustedStartLine - this.bufferLines - 1); + const searchEndIndex = Math.min(originalLines.length - searchLines.length + 1, adjustedEndLine + this.bufferLines); + + // Sequential search through buffer zone + for (let i = searchStartIndex; i <= searchEndIndex; i++) { + const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n'); + const similarity = getSimilarity(originalChunk, searchChunk); + if (similarity >= this.fuzzyThreshold && similarity > bestMatchScore) { + bestMatchScore = similarity; + matchIndex = i; + bestMatchContent = originalChunk; + } + } + } + + // If no match found, fail with debug info + if (matchIndex === -1) { + return { + success: false, + error: `No matches found within buffer zone\n\nDebug Info:\n- Buffer Zone: ${this.bufferLines} lines\n- Target Line: ${startLine}\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: lines ${adjustedStartLine}-${adjustedEndLine}\n- Content to match:\n${searchChunk}\n- Best match found:\n${bestMatchContent}` + }; + } + + // Get matched lines and handle indentation + const matchedLines = originalLines.slice(matchIndex, matchIndex + searchLines.length); + const originalIndents = matchedLines.map((line: string) => { + const match = line.match(/^[\t ]*/); + return match ? match[0] : ''; + }); + + const searchIndents = searchLines.map((line: string) => { + const match = line.match(/^[\t ]*/); + return match ? match[0] : ''; + }); + + const indentedReplaceLines = replaceLines.map((line: string, i: number) => { + const matchedIndent = originalIndents[0] || ''; + const currentIndentMatch = line.match(/^[\t ]*/); + const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ''; + const searchBaseIndent = searchIndents[0] || ''; + + const searchBaseLevel = searchBaseIndent.length; + const currentLevel = currentIndent.length; + const relativeLevel = currentLevel - searchBaseLevel; + + const finalIndent = relativeLevel < 0 + ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) + : matchedIndent + currentIndent.slice(searchBaseLevel); + + return finalIndent + line.trim(); + }); + + // Update content and line adjustment for next iteration + const beforeMatch = originalLines.slice(0, matchIndex); + const afterMatch = originalLines.slice(matchIndex + searchLines.length); + currentContent = [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding); + + // Update line adjustment for next block + lineAdjustment += replaceLines.length - searchLines.length; + } + + return { + success: true, + content: currentContent + }; + } +} diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index 3990848e00d..3d08d166363 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -1,58 +1,9 @@ import { DiffStrategy, DiffResult } from "../types" import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" +import { getSimilarity } from "../utils" const BUFFER_LINES = 20; // Number of extra context lines to show before and after matches -function levenshteinDistance(a: string, b: string): number { - const matrix: number[][] = []; - - // Initialize matrix - for (let i = 0; i <= a.length; i++) { - matrix[i] = [i]; - } - for (let j = 0; j <= b.length; j++) { - matrix[0][j] = j; - } - - // Fill matrix - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - if (a[i-1] === b[j-1]) { - matrix[i][j] = matrix[i-1][j-1]; - } else { - matrix[i][j] = Math.min( - matrix[i-1][j-1] + 1, // substitution - matrix[i][j-1] + 1, // insertion - matrix[i-1][j] + 1 // deletion - ); - } - } - } - - return matrix[a.length][b.length]; -} - -function getSimilarity(original: string, search: string): number { - if (search === '') { - return 1; - } - - // Normalize strings by removing extra whitespace but preserve case - const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim(); - - const normalizedOriginal = normalizeStr(original); - const normalizedSearch = normalizeStr(search); - - if (normalizedOriginal === normalizedSearch) { return 1; } - - // Calculate Levenshtein distance - const distance = levenshteinDistance(normalizedOriginal, normalizedSearch); - - // Calculate similarity ratio (0 to 1, where 1 is exact match) - const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length); - return 1 - (distance / maxLength); -} - export class SearchReplaceDiffStrategy implements DiffStrategy { private fuzzyThreshold: number; private bufferLines: number; diff --git a/src/core/diff/utils.ts b/src/core/diff/utils.ts new file mode 100644 index 00000000000..2b75c009890 --- /dev/null +++ b/src/core/diff/utils.ts @@ -0,0 +1,49 @@ +export function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + + // Initialize matrix + for (let i = 0; i <= a.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= b.length; j++) { + matrix[0][j] = j; + } + + // Fill matrix + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + if (a[i-1] === b[j-1]) { + matrix[i][j] = matrix[i-1][j-1]; + } else { + matrix[i][j] = Math.min( + matrix[i-1][j-1] + 1, // substitution + matrix[i][j-1] + 1, // insertion + matrix[i-1][j] + 1 // deletion + ); + } + } + } + + return matrix[a.length][b.length]; +} + +export function getSimilarity(original: string, search: string): number { + if (search === '') { + return 1; + } + + // Normalize strings by removing extra whitespace but preserve case + const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim(); + + const normalizedOriginal = normalizeStr(original); + const normalizedSearch = normalizeStr(search); + + if (normalizedOriginal === normalizedSearch) { return 1; } + + // Calculate Levenshtein distance + const distance = levenshteinDistance(normalizedOriginal, normalizedSearch); + + // Calculate similarity ratio (0 to 1, where 1 is exact match) + const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length); + return 1 - (distance / maxLength); +} \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2245d8036fc..a5ff929b182 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -71,6 +71,7 @@ type GlobalStateKey = | "alwaysAllowMcp" | "browserLargeViewport" | "fuzzyMatchThreshold" + | "multisearchDiffEnabled" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -219,7 +220,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions, diffEnabled, - fuzzyMatchThreshold + fuzzyMatchThreshold, + multisearchDiffEnabled } = await this.getState() this.cline = new Cline( @@ -229,7 +231,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold, task, - images + images, + undefined, + multisearchDiffEnabled ) } @@ -239,7 +243,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { apiConfiguration, customInstructions, diffEnabled, - fuzzyMatchThreshold + fuzzyMatchThreshold, + multisearchDiffEnabled } = await this.getState() this.cline = new Cline( @@ -250,7 +255,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { fuzzyMatchThreshold, undefined, undefined, - historyItem + historyItem, + multisearchDiffEnabled ) } @@ -622,6 +628,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("fuzzyMatchThreshold", message.value) await this.postStateToWebview() break + case "multisearchDiffEnabled": + await this.updateGlobalState("multisearchDiffEnabled", message.bool) + await this.postStateToWebview() + break } }, null, @@ -937,9 +947,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async getStateToPostToWebview() { - const { - apiConfiguration, - lastShownAnnouncementId, + const { + apiConfiguration, + lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, @@ -951,6 +961,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { taskHistory, soundVolume, browserLargeViewport, + multisearchDiffEnabled, } = await this.getState() const allowedCommands = vscode.workspace @@ -977,6 +988,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { allowedCommands, soundVolume: soundVolume ?? 0.5, browserLargeViewport: browserLargeViewport ?? false, + multisearchDiffEnabled: multisearchDiffEnabled ?? false, } } @@ -1072,6 +1084,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundVolume, browserLargeViewport, fuzzyMatchThreshold, + multisearchDiffEnabled, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1112,6 +1125,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("soundVolume") as Promise, this.getGlobalState("browserLargeViewport") as Promise, this.getGlobalState("fuzzyMatchThreshold") as Promise, + this.getGlobalState("multisearchDiffEnabled") as Promise, ]) let apiProvider: ApiProvider @@ -1170,6 +1184,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundVolume, browserLargeViewport: browserLargeViewport ?? false, fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, + multisearchDiffEnabled: multisearchDiffEnabled ?? false, } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index dcd352f8154..a1843471d93 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -18,6 +18,7 @@ export interface ExtensionMessage { | "partialMessage" | "openRouterModels" | "mcpServers" + | "multilineDiffEnabled" text?: string action?: | "chatButtonClicked" @@ -34,6 +35,7 @@ export interface ExtensionMessage { partialMessage?: ClineMessage openRouterModels?: Record mcpServers?: McpServer[] + bool?: boolean } export interface ExtensionState { @@ -55,6 +57,7 @@ export interface ExtensionState { diffEnabled?: boolean browserLargeViewport?: boolean fuzzyMatchThreshold?: number + multisearchDiffEnabled?: boolean } export interface ClineMessage { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7a0983afdba..fb55d84adfd 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -40,6 +40,7 @@ export interface WebviewMessage { | "toggleToolAlwaysAllow" | "toggleMcpServer" | "fuzzyMatchThreshold" + | "multisearchDiffEnabled" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 465fd5a9750..5db9252ffae 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -40,6 +40,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { allowedCommands, fuzzyMatchThreshold, setFuzzyMatchThreshold, + multisearchDiffEnabled, + setMultisearchDiffEnabled, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -67,6 +69,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "browserLargeViewport", bool: browserLargeViewport }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) + vscode.postMessage({ type: "multisearchDiffEnabled", bool: multisearchDiffEnabled }) onDone() } } @@ -365,6 +368,22 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

+ {diffEnabled && ( +
+ setMultisearchDiffEnabled(e.target.checked)}> + Enable parallel diff edits + +

+ When enabled, Cline will attempt to apply multiple diff edits in parallel, making editing faster. +

+
+ )} +
setSoundEnabled(e.target.checked)}> diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c2a1cf0d290..e2a99f3081b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -33,6 +33,7 @@ export interface ExtensionStateContextType extends ExtensionState { setDiffEnabled: (value: boolean) => void setBrowserLargeViewport: (value: boolean) => void setFuzzyMatchThreshold: (value: number) => void + setMultisearchDiffEnabled: (value: boolean) => void } const ExtensionStateContext = createContext(undefined) @@ -48,6 +49,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode soundVolume: 0.5, diffEnabled: false, fuzzyMatchThreshold: 1.0, + multisearchDiffEnabled: false, }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -136,6 +138,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode filePaths, soundVolume: state.soundVolume, fuzzyMatchThreshold: state.fuzzyMatchThreshold, + multisearchDiffEnabled: state.multisearchDiffEnabled, setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value @@ -153,6 +156,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), setBrowserLargeViewport: (value) => setState((prevState) => ({ ...prevState, browserLargeViewport: value })), setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })), + setMultisearchDiffEnabled: (value: boolean) => setState((prevState) => ({ ...prevState, multisearchDiffEnabled: value })), } return {children}