Add option for parallel diff edits

This commit is contained in:
Matt Rubens 2024-12-19 02:38:50 -05:00
parent 7845c8a296
commit 4058c2bca3
15 changed files with 1068 additions and 68 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add option for parallel diff edits

View file

@ -1 +1,72 @@
- Before attempting completion, always make sure that any code changes have test coverage and that the tests pass.
# 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
<VSCodeCheckbox
checked={multisearchDiffEnabled}
onChange={(e: any) => setMultisearchDiffEnabled(e.target.checked)}
>
<span style={{ fontWeight: "500" }}>Enable multi-search diff matching</span>
</VSCodeCheckbox>
```
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

View file

@ -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

View file

@ -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();
});

View file

@ -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 }

View file

@ -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)
})
})

View file

@ -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}
}`)
}
})
})
})

View file

@ -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:
<apply_diff>
<path>File path here</path>
<diff>
[search/replace blocks here]
</diff>
</apply_diff>`
}
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
};
}
}

View file

@ -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;

49
src/core/diff/utils.ts Normal file
View file

@ -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);
}

View file

@ -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<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -1112,6 +1125,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("soundVolume") as Promise<number | undefined>,
this.getGlobalState("browserLargeViewport") as Promise<boolean | undefined>,
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
this.getGlobalState("multisearchDiffEnabled") as Promise<boolean | undefined>,
])
let apiProvider: ApiProvider
@ -1170,6 +1184,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
soundVolume,
browserLargeViewport: browserLargeViewport ?? false,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
multisearchDiffEnabled: multisearchDiffEnabled ?? false,
}
}

View file

@ -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<string, ModelInfo>
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 {

View file

@ -40,6 +40,7 @@ export interface WebviewMessage {
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
| "fuzzyMatchThreshold"
| "multisearchDiffEnabled"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse

View file

@ -40,6 +40,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
allowedCommands,
fuzzyMatchThreshold,
setFuzzyMatchThreshold,
multisearchDiffEnabled,
setMultisearchDiffEnabled,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(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) => {
</p>
</div>
{diffEnabled && (
<div style={{ marginBottom: 10 }}>
<VSCodeCheckbox checked={multisearchDiffEnabled} onChange={(e: any) => setMultisearchDiffEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable parallel diff edits</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will attempt to apply multiple diff edits in parallel, making editing faster.
</p>
</div>
)}
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 10 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>

View file

@ -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<ExtensionStateContextType | undefined>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>