fix: preserve file endings and trailing newlines across all edit tools (#8341)

This commit is contained in:
Robin Newhouse
2026-01-03 10:12:25 -08:00
committed by GitHub
parent 4b9dbf11a0
commit 0d04205dc4
8 changed files with 205 additions and 24 deletions
+5
View File
@@ -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 }
}
+20 -3
View File
@@ -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)
+9 -13
View File
@@ -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) {
+34 -5
View File
@@ -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> {
+54
View File
@@ -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)
})
})
+82
View File
@@ -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)
})
})