mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
fix: preserve file endings and trailing newlines across all edit tools (#8341)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: preserve file endings and trailing newlines across all edit tools
|
||||
@@ -468,7 +468,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
|
||||
|
||||
@@ -480,8 +480,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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -96,17 +96,34 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
|
||||
throw new Error("User closed text editor, unable to edit file...")
|
||||
}
|
||||
|
||||
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
|
||||
const beginningOfDocument = new vscode.Position(0, 0)
|
||||
this.activeDiffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
|
||||
|
||||
// Replace the text in the diff editor document.
|
||||
const document = this.activeDiffEditor?.document
|
||||
const document = this.activeDiffEditor.document
|
||||
const edit = new vscode.WorkspaceEdit()
|
||||
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
|
||||
edit.replace(document.uri, range, content)
|
||||
|
||||
// IMPORTANT: VS Code may treat an out-of-bounds end position as an insertion instead of a
|
||||
// replacement. Always validate the range against the current document to keep edits
|
||||
// strictly within the real end-of-file.
|
||||
const startLine = Math.max(0, Math.min(rangeToReplace.startLine, document.lineCount - 1))
|
||||
const desiredEndLine = Math.max(rangeToReplace.startLine, rangeToReplace.endLine)
|
||||
const validatedRange = document.validateRange(
|
||||
new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(desiredEndLine, 0)),
|
||||
)
|
||||
|
||||
edit.replace(document.uri, validatedRange, content)
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
|
||||
// Preserve trailing newline: if content ends with newline, ensure document does too
|
||||
if (content.endsWith("\n") && !document.getText().endsWith("\n")) {
|
||||
const fixEdit = new vscode.WorkspaceEdit()
|
||||
fixEdit.insert(document.uri, document.lineAt(Math.max(0, document.lineCount - 1)).range.end, "\n")
|
||||
await vscode.workspace.applyEdit(fixEdit)
|
||||
}
|
||||
|
||||
if (currentLine !== undefined) {
|
||||
// Update decorations for the entire changed section
|
||||
this.activeLineController?.setActiveLine(currentLine)
|
||||
|
||||
@@ -182,7 +182,12 @@ 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"
|
||||
let contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n")
|
||||
if (!isFinal) {
|
||||
// During streaming, add trailing newline for cursor positioning
|
||||
contentToReplace += "\n"
|
||||
}
|
||||
|
||||
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
|
||||
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
|
||||
|
||||
@@ -212,15 +217,6 @@ export abstract class DiffViewProvider {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,10 +273,10 @@ export abstract class DiffViewProvider {
|
||||
|
||||
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
|
||||
const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
|
||||
const normalizedPreSaveContent = preSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
|
||||
const normalizedPostSaveContent = postSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // this is the final content we return to the model to use as the new baseline for future edits
|
||||
const normalizedPreSaveContent = preSaveContent.replace(/\r\n|\n/g, newContentEOL)
|
||||
const normalizedPostSaveContent = postSaveContent.replace(/\r\n|\n/g, newContentEOL) // this is the final content we return to the model to use as the new baseline for future edits
|
||||
// just in case the new content has a mix of varying EOL characters
|
||||
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
|
||||
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL)
|
||||
|
||||
let userEdits: string | undefined
|
||||
if (normalizedPreSaveContent !== normalizedNewContent) {
|
||||
|
||||
@@ -43,19 +43,48 @@ export class FileEditProvider extends DiffViewProvider {
|
||||
|
||||
// Split the document into lines
|
||||
const lines = this.documentContent.split("\n")
|
||||
const originalEndsWithNewline = this.documentContent.endsWith("\n")
|
||||
|
||||
// If original ends with newline, split creates a trailing empty string that isn't a real line.
|
||||
// Remove it for line-based operations, we'll add it back at the end if needed.
|
||||
const realLines = originalEndsWithNewline && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
|
||||
|
||||
// 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] === "") {
|
||||
const contentEndsWithNewline = content.endsWith("\n")
|
||||
|
||||
// Determine if we're replacing to the end of the document
|
||||
const replacingToEnd = rangeToReplace.endLine >= realLines.length
|
||||
|
||||
// Handle trailing empty string from split:
|
||||
// - If content ends with \n, split creates an empty string at the end
|
||||
// - When replacing to end: this empty string becomes the document's trailing newline - keep it
|
||||
// - When replacing middle: this empty string would create an extra newline - remove it
|
||||
// (the join operation will naturally add newlines between lines)
|
||||
// - If content doesn't end with \n but split created empty string, remove it
|
||||
if (!contentEndsWithNewline && newContentLines[newContentLines.length - 1] === "") {
|
||||
newContentLines.pop()
|
||||
} else if (contentEndsWithNewline && !replacingToEnd && newContentLines[newContentLines.length - 1] === "") {
|
||||
// Content ends with newline but we're replacing middle section - remove trailing empty string
|
||||
newContentLines.pop()
|
||||
}
|
||||
|
||||
// Splice the lines array to replace the range
|
||||
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newContentLines)
|
||||
// Splice the real lines array to replace the range
|
||||
realLines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newContentLines)
|
||||
|
||||
// Join the lines back together
|
||||
this.documentContent = lines.join("\n")
|
||||
let result = realLines.join("\n")
|
||||
|
||||
// Preserve trailing newline: add it back if original had one OR if we replaced to end with content that ends with newline
|
||||
const shouldHaveTrailingNewline = originalEndsWithNewline || (replacingToEnd && contentEndsWithNewline)
|
||||
if (shouldHaveTrailingNewline && !result.endsWith("\n")) {
|
||||
result += "\n"
|
||||
} else if (!shouldHaveTrailingNewline && result.endsWith("\n")) {
|
||||
// Shouldn't have trailing newline but result has one - remove it
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
|
||||
this.documentContent = result
|
||||
}
|
||||
|
||||
protected async scrollEditorToLine(_line: number): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as assert from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { FileEditProvider } from "../integrations/editor/FileEditProvider"
|
||||
|
||||
describe("FileEditProvider Trailing Newline", () => {
|
||||
// Helper to set up provider without calling open()
|
||||
function setupProvider(initialContent: string): FileEditProvider {
|
||||
const provider = new FileEditProvider()
|
||||
provider["isEditing"] = true
|
||||
provider["documentContent"] = initialContent
|
||||
provider["originalContent"] = initialContent
|
||||
return provider
|
||||
}
|
||||
|
||||
it("preserves trailing newline when content ends with newline", async () => {
|
||||
const provider = setupProvider("line1\nline2\n")
|
||||
|
||||
await provider.replaceText("new1\nnew2\n", { startLine: 0, endLine: 2 }, undefined)
|
||||
const result = await provider.getContent()
|
||||
|
||||
assert.strictEqual(result, "new1\nnew2\n")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("does not add trailing newline when content does not end with newline", async () => {
|
||||
const provider = setupProvider("line1\nline2")
|
||||
|
||||
await provider.replaceText("new1\nnew2", { startLine: 0, endLine: 2 }, undefined)
|
||||
const result = await provider.getContent()
|
||||
|
||||
assert.strictEqual(result, "new1\nnew2")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("preserves trailing newline when replacing middle section", async () => {
|
||||
const provider = setupProvider("line1\nline2\nline3\n")
|
||||
|
||||
await provider.replaceText("new2\n", { startLine: 1, endLine: 2 }, undefined)
|
||||
const result = await provider.getContent()
|
||||
|
||||
assert.strictEqual(result, "line1\nnew2\nline3\n")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("handles file without trailing newline correctly", async () => {
|
||||
const provider = setupProvider("line1\nline2")
|
||||
|
||||
await provider.replaceText("new1\nnew2\n", { startLine: 0, endLine: 2 }, undefined)
|
||||
const result = await provider.getContent()
|
||||
|
||||
assert.strictEqual(result, "new1\nnew2\n")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as assert from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
|
||||
|
||||
class TestDiffViewProvider extends DiffViewProvider {
|
||||
public documentText: string = ""
|
||||
public replacements: { content: string; range: { startLine: number; endLine: number } }[] = []
|
||||
|
||||
async openDiffEditor(): Promise<void> {}
|
||||
async scrollEditorToLine(line: number): Promise<void> {}
|
||||
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
|
||||
async truncateDocument(lineNumber: number): Promise<void> {
|
||||
const lines = this.documentText.split("\n")
|
||||
this.documentText = lines.slice(0, lineNumber).join("\n")
|
||||
}
|
||||
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> {
|
||||
this.replacements.push({ content, range: rangeToReplace })
|
||||
// Simulate the replacement
|
||||
const lines = this.documentText.split("\n")
|
||||
const newLines = content.split("\n")
|
||||
// Preserve trailing newline logic (simplified)
|
||||
if (!content.endsWith("\n") && 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
|
||||
}
|
||||
}
|
||||
|
||||
describe("DiffViewProvider Newline handling", () => {
|
||||
it("preserves trailing newline through update() when content ends with newline", async () => {
|
||||
const provider = new TestDiffViewProvider()
|
||||
provider.setup("line1\nline2\n")
|
||||
|
||||
await provider.update("new1\nnew2\n", true)
|
||||
const result = await provider.getDocumentText()
|
||||
|
||||
assert.strictEqual(result, "new1\nnew2\n")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("does not add trailing newline when content does not end with newline", async () => {
|
||||
const provider = new TestDiffViewProvider()
|
||||
provider.setup("line1\nline2")
|
||||
|
||||
await provider.update("new1\nnew2", true)
|
||||
const result = await provider.getDocumentText()
|
||||
|
||||
assert.strictEqual(result, "new1\nnew2")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("handles file without trailing newline correctly", async () => {
|
||||
const provider = new TestDiffViewProvider()
|
||||
provider.setup("[6]: http://chris.beams.io/posts/git-commit/#seven-rules")
|
||||
|
||||
await provider.update("new content\n", true)
|
||||
const result = await provider.getDocumentText()
|
||||
|
||||
assert.strictEqual(result, "new content\n")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user