mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cbf31cc3 | |||
| c2354c9832 | |||
| df36557c04 | |||
| dc9d3ae407 |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -47,7 +47,10 @@ func (s *DiffService) generateDiffID() string {
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
// splitLines splits content into lines, preserving trailing newlines.
|
||||
// This matches the behavior of JavaScript's String.split("\n"):
|
||||
// - "hello\nworld\n" -> ["hello", "world", ""]
|
||||
// - "hello\nworld" -> ["hello", "world"]
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
@@ -65,10 +68,9 @@ func splitLines(content string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
// Always add the last segment - if content ends with newline, this will be
|
||||
// an empty string which preserves the trailing newline when joined back
|
||||
lines = append(lines, current)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -176,9 +178,19 @@ func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextReq
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
replacingToEnd := endLine >= len(session.lines)
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
|
||||
newLines = newLines[:len(newLines)-1]
|
||||
}
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
|
||||
@@ -529,7 +529,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
changes[path] = {
|
||||
type: PatchActionType.UPDATE,
|
||||
oldContent: originalFiles[path],
|
||||
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path).trimEnd(),
|
||||
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path),
|
||||
movePath: action.movePath,
|
||||
}
|
||||
break
|
||||
|
||||
@@ -481,8 +481,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
return
|
||||
}
|
||||
|
||||
newContent = newContent.trimEnd() // remove any trailing newlines, since it's automatically inserted by the editor
|
||||
|
||||
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext }
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -42,6 +42,18 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
|
||||
})
|
||||
}
|
||||
|
||||
protected override async getDocumentLineCount(): Promise<number> {
|
||||
const text = await this.getDocumentText()
|
||||
if (!text) {
|
||||
return 0
|
||||
}
|
||||
// Count lines: split by newline, but handle trailing newline correctly
|
||||
const lines = text.split("\n")
|
||||
// If text ends with newline, split creates an extra empty string at the end
|
||||
// which represents the "line" after the final newline - this is correct line count
|
||||
return lines.length
|
||||
}
|
||||
|
||||
protected async saveDocument(): Promise<Boolean> {
|
||||
if (!this.activeDiffEditorId) {
|
||||
return false
|
||||
|
||||
@@ -102,11 +102,31 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
|
||||
// Replace the text in the diff editor document.
|
||||
const document = this.activeDiffEditor?.document
|
||||
const replacingToEnd = rangeToReplace.endLine >= document.lineCount
|
||||
const edit = new vscode.WorkspaceEdit()
|
||||
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
|
||||
edit.replace(document.uri, range, content)
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
|
||||
// VS Code can normalize trailing newlines on full-document replacements.
|
||||
// Only fix up when replacing to the end to avoid touching untouched content.
|
||||
if (replacingToEnd) {
|
||||
const desiredTrailingNewlines = countTrailingNewlines(content)
|
||||
const actualTrailingNewlines = countTrailingNewlines(document.getText())
|
||||
const newlineDelta = desiredTrailingNewlines - actualTrailingNewlines
|
||||
|
||||
if (newlineDelta > 0) {
|
||||
const fixEdit = new vscode.WorkspaceEdit()
|
||||
fixEdit.insert(document.uri, document.lineAt(document.lineCount - 1).range.end, "\n".repeat(newlineDelta))
|
||||
await vscode.workspace.applyEdit(fixEdit)
|
||||
} else if (newlineDelta < 0) {
|
||||
const fixEdit = new vscode.WorkspaceEdit()
|
||||
const startLine = Math.max(0, document.lineCount + newlineDelta)
|
||||
fixEdit.delete(document.uri, new vscode.Range(startLine, 0, document.lineCount, 0))
|
||||
await vscode.workspace.applyEdit(fixEdit)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine !== undefined) {
|
||||
// Update decorations for the entire changed section
|
||||
this.activeLineController?.setActiveLine(currentLine)
|
||||
@@ -147,11 +167,18 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
edit.delete(document.uri, new vscode.Range(lineNumber, 0, document.lineCount, 0))
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
}
|
||||
// Clear all decorations at the end (before applying final edit)
|
||||
}
|
||||
|
||||
protected override async onFinalUpdate(): Promise<void> {
|
||||
// Clear all decorations at the end of streaming
|
||||
this.fadedOverlayController?.clear()
|
||||
this.activeLineController?.clear()
|
||||
}
|
||||
|
||||
protected override async getDocumentLineCount(): Promise<number> {
|
||||
return this.activeDiffEditor?.document.lineCount ?? 0
|
||||
}
|
||||
|
||||
protected override async getDocumentText(): Promise<string | undefined> {
|
||||
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
|
||||
return undefined
|
||||
@@ -193,3 +220,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
this.activeLineController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function countTrailingNewlines(text: string): number {
|
||||
let count = 0
|
||||
for (let i = text.length - 1; i >= 0 && text[i] === "\n"; i -= 1) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -96,6 +96,24 @@ export abstract class DiffViewProvider {
|
||||
*/
|
||||
protected abstract truncateDocument(lineNumber: number): Promise<void>
|
||||
|
||||
/**
|
||||
* Returns the current line count of the document being edited.
|
||||
* Used for boundary validation before calling truncateDocument.
|
||||
*/
|
||||
protected abstract getDocumentLineCount(): Promise<number>
|
||||
|
||||
/**
|
||||
* Safely truncates the document, ensuring the line number is within bounds.
|
||||
* This prevents errors on hosts that strictly validate line numbers (e.g., JetBrains via gRPC).
|
||||
*/
|
||||
private async safelyTruncateDocument(lineNumber: number): Promise<void> {
|
||||
const lineCount = await this.getDocumentLineCount()
|
||||
// Only truncate if there's content beyond the specified line
|
||||
if (lineNumber < lineCount) {
|
||||
await this.truncateDocument(lineNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contents of the diff editor document.
|
||||
*
|
||||
@@ -182,8 +200,20 @@ export abstract class DiffViewProvider {
|
||||
// Replace all content up to the current line with accumulated lines
|
||||
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags
|
||||
// on previous lines are auto closed for example
|
||||
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
|
||||
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
|
||||
let contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n")
|
||||
if (!isFinal) {
|
||||
// During streaming, add trailing newline for cursor positioning
|
||||
contentToReplace += "\n"
|
||||
}
|
||||
|
||||
// For the final update, replace the entire document to prevent concatenation
|
||||
// when content doesn't end with a newline. Without this, replacing lines 0-N
|
||||
// with content lacking a trailing newline causes line N+1's content to be
|
||||
// directly appended to our content (e.g., "Hello World" + "# Old Header" becomes
|
||||
// "Hello World# Old Header").
|
||||
const endLine = isFinal ? await this.getDocumentLineCount() : currentLine + 1
|
||||
|
||||
const rangeToReplace = { startLine: 0, endLine }
|
||||
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
|
||||
|
||||
// Scroll to the actual change location if provided.
|
||||
@@ -211,19 +241,19 @@ export abstract class DiffViewProvider {
|
||||
this.streamedLines = accumulatedLines
|
||||
if (isFinal) {
|
||||
// Handle any remaining lines if the new content is shorter than the original
|
||||
await this.truncateDocument(this.streamedLines.length)
|
||||
|
||||
// Add empty last line if original content had one
|
||||
const hasEmptyLastLine = this.originalContent?.endsWith("\n")
|
||||
if (hasEmptyLastLine) {
|
||||
const accumulatedLines = accumulatedContent.split("\n")
|
||||
if (accumulatedLines[accumulatedLines.length - 1] !== "") {
|
||||
accumulatedContent += "\n"
|
||||
}
|
||||
}
|
||||
await this.safelyTruncateDocument(this.streamedLines.length)
|
||||
// Allow subclasses to perform cleanup (e.g., clearing decorations)
|
||||
await this.onFinalUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the final update is complete. Subclasses can override to perform cleanup.
|
||||
*/
|
||||
protected async onFinalUpdate(): Promise<void> {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
async showFile(absolutePath: string): Promise<void> {
|
||||
await openFile(absolutePath, true)
|
||||
}
|
||||
|
||||
@@ -44,10 +44,16 @@ export class FileEditProvider extends DiffViewProvider {
|
||||
// Split the document into lines
|
||||
const lines = this.documentContent.split("\n")
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
const replacingToEnd = rangeToReplace.endLine >= lines.length
|
||||
|
||||
// Replace the specified range with the new content
|
||||
const newContentLines = content.split("\n")
|
||||
// Remove trailing empty line if present in newContentLines for proper splicing
|
||||
if (newContentLines[newContentLines.length - 1] === "") {
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if (!replacingToEnd && newContentLines[newContentLines.length - 1] === "") {
|
||||
newContentLines.pop()
|
||||
}
|
||||
|
||||
@@ -78,6 +84,13 @@ export class FileEditProvider extends DiffViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDocumentLineCount(): Promise<number> {
|
||||
if (!this.documentContent) {
|
||||
return 0
|
||||
}
|
||||
return this.documentContent.split("\n").length
|
||||
}
|
||||
|
||||
protected async getDocumentText(): Promise<string | undefined> {
|
||||
return this.documentContent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as assert from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { DiffViewProvider } from "../DiffViewProvider"
|
||||
|
||||
class TestBoundaryDiffViewProvider extends DiffViewProvider {
|
||||
public documentText: string = ""
|
||||
public truncatedAt: number | undefined
|
||||
|
||||
async openDiffEditor(): Promise<void> {}
|
||||
async scrollEditorToLine(line: number): Promise<void> {}
|
||||
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
|
||||
|
||||
async truncateDocument(lineNumber: number): Promise<void> {
|
||||
this.truncatedAt = lineNumber
|
||||
const lines = this.documentText.split("\n")
|
||||
if (lineNumber < lines.length) {
|
||||
this.documentText = lines.slice(0, lineNumber).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
async getDocumentLineCount(): Promise<number> {
|
||||
return this.documentText.split("\n").length
|
||||
}
|
||||
|
||||
async getDocumentText(): Promise<string | undefined> {
|
||||
return this.documentText
|
||||
}
|
||||
|
||||
async saveDocument(): Promise<Boolean> {
|
||||
return true
|
||||
}
|
||||
async closeAllDiffViews(): Promise<void> {}
|
||||
async resetDiffView(): Promise<void> {}
|
||||
|
||||
async replaceText(
|
||||
content: string,
|
||||
rangeToReplace: { startLine: number; endLine: number },
|
||||
currentLine: number | undefined,
|
||||
): Promise<void> {
|
||||
// Minimal implementation for update() to work
|
||||
const lines = this.documentText.split("\n")
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
const replacingToEnd = rangeToReplace.endLine >= lines.length
|
||||
|
||||
const newLines = content.split("\n")
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if (!replacingToEnd && newLines[newLines.length - 1] === "") {
|
||||
newLines.pop()
|
||||
}
|
||||
|
||||
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newLines)
|
||||
this.documentText = lines.join("\n")
|
||||
}
|
||||
|
||||
public setup(initialContent: string) {
|
||||
this.isEditing = true
|
||||
this.documentText = initialContent
|
||||
this.originalContent = initialContent
|
||||
this.truncatedAt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe("DiffViewProvider Boundary Validation", () => {
|
||||
it("should replace entire document on final update to prevent concatenation", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Start with multi-line content
|
||||
provider.setup("line1\nline2\nline3\n")
|
||||
|
||||
// Update with content that has no trailing newline
|
||||
// This previously caused "Hello World" + "line2" concatenation
|
||||
await provider.update("Hello World", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
// Should be just "Hello World", not "Hello Worldline2\nline3\n"
|
||||
assert.strictEqual(result, "Hello World")
|
||||
})
|
||||
|
||||
it("safelyTruncateDocument should no-op when lineNumber >= lineCount", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3")
|
||||
// lineCount is 3
|
||||
|
||||
// Access private method via any cast or just call update which calls it
|
||||
// But update calls it with streamedLines.length.
|
||||
// Let's use update to trigger it.
|
||||
|
||||
// If we update with same content, streamedLines.length will be 3.
|
||||
// safelyTruncateDocument(3) should be called.
|
||||
// 3 >= 3, so it should NOT call truncateDocument.
|
||||
|
||||
await provider.update("line1\nline2\nline3", true)
|
||||
|
||||
assert.strictEqual(provider.truncatedAt, undefined, "Should not have called truncateDocument")
|
||||
})
|
||||
|
||||
it("final update replaces entire document so truncation is no-op", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3")
|
||||
|
||||
// Update with fewer lines
|
||||
await provider.update("line1\n", true)
|
||||
|
||||
// With the fix, the final update replaces the entire document (0 to lineCount).
|
||||
// So replaceText handles all the content, and truncation becomes unnecessary.
|
||||
// The document should contain just "line1\n" and truncation should NOT be called
|
||||
// because after replaceText, the document already has the correct content.
|
||||
|
||||
// Note: truncation might still be called but should be a no-op since document is already correct
|
||||
assert.strictEqual(provider.documentText, "line1\n")
|
||||
})
|
||||
|
||||
it("update() with shorter content replaces entire document", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3\nline4")
|
||||
|
||||
// Update with 2 lines
|
||||
await provider.update("line1\nline2", true)
|
||||
|
||||
// With the fix, the final update replaces the entire document (0 to lineCount).
|
||||
// The document should contain just "line1\nline2".
|
||||
|
||||
assert.strictEqual(provider.documentText, "line1\nline2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("DiffViewProvider Newline Preservation", () => {
|
||||
it("preserves trailing newline when content ends with newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file has trailing newline
|
||||
provider.setup("line1\nline2\n")
|
||||
|
||||
// New content also has trailing newline
|
||||
await provider.update("new1\nnew2\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2\n", "Trailing newline should be preserved")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("does not add trailing newline when content does not end with newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file has trailing newline
|
||||
provider.setup("line1\nline2\n")
|
||||
|
||||
// New content does NOT have trailing newline
|
||||
await provider.update("new1\nnew2", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("adds trailing newline when content ends with newline but original did not", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file does NOT have trailing newline
|
||||
provider.setup("line1\nline2")
|
||||
|
||||
// New content has trailing newline
|
||||
await provider.update("new1\nnew2\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2\n", "Should add trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("preserves no trailing newline when neither original nor new content has one", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file does NOT have trailing newline
|
||||
provider.setup("line1\nline2")
|
||||
|
||||
// New content also does NOT have trailing newline
|
||||
await provider.update("new1\nnew2", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("handles shortening file while preserving trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original: 10 lines with trailing newline
|
||||
provider.setup("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n")
|
||||
|
||||
// New: 3 lines with trailing newline
|
||||
await provider.update("line1\nline2\nline3\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "line1\nline2\nline3\n", "Should shorten and preserve trailing newline")
|
||||
})
|
||||
|
||||
it("handles lengthening file while preserving trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original: 3 lines with trailing newline
|
||||
provider.setup("line1\nline2\nline3\n")
|
||||
|
||||
// New: 5 lines with trailing newline
|
||||
await provider.update("line1\nline2\nline3\nline4\nline5\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "line1\nline2\nline3\nline4\nline5\n", "Should lengthen and preserve trailing newline")
|
||||
})
|
||||
|
||||
it("handles single line content with trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("old content\n")
|
||||
|
||||
await provider.update("Hello World\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "Hello World\n")
|
||||
})
|
||||
|
||||
it("handles single line content without trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("old content\nline2\n")
|
||||
|
||||
await provider.update("Hello World", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "Hello World")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user