diff --git a/src/vs/workbench/api/electron-browser/mainThreadSaveParticipant.ts b/src/vs/workbench/api/electron-browser/mainThreadSaveParticipant.ts index 1935207a490..777ec4918af 100644 --- a/src/vs/workbench/api/electron-browser/mainThreadSaveParticipant.ts +++ b/src/vs/workbench/api/electron-browser/mainThreadSaveParticipant.ts @@ -127,6 +127,53 @@ export class FinalNewLineParticipant implements INamedSaveParticpant { } } +export class TrimFinalNewLinesParticipant implements INamedSaveParticpant { + + readonly name = 'TrimFinalNewLinesParticipant'; + + constructor( + @IConfigurationService private configurationService: IConfigurationService, + @ICodeEditorService private codeEditorService: ICodeEditorService + ) { + // Nothing + } + + public participate(model: ITextFileEditorModel, env: { reason: SaveReason }): void { + if (this.configurationService.lookup('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.getResource() }).value) { + this.doTrimFinalNewLines(model.textEditorModel); + } + } + + private doTrimFinalNewLines(model: IModel): void { + const lineCount = model.getLineCount(); + + // Do not insert new line if file does not end with new line + if (!lineCount) { + return; + } + + let prevSelection: Selection[] = [new Selection(1, 1, 1, 1)]; + const editor = findEditor(model, this.codeEditorService); + if (editor) { + prevSelection = editor.getSelections(); + } + + let currentLineNumber = model.getLineCount(); + let currentLine = model.getLineContent(currentLineNumber); + let currentLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(currentLine) === -1; + while (currentLineIsEmptyOrWhitespace) { + currentLineNumber--; + currentLine = model.getLineContent(currentLineNumber); + currentLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(currentLine) === -1; + } + model.pushEditOperations(prevSelection, [EditOperation.delete(new Range(currentLineNumber + 1, 1, lineCount + 1, 1))], edits => prevSelection); + + if (editor) { + editor.setSelections(prevSelection); + } + } +} + class FormatOnSaveParticipant implements INamedSaveParticpant { readonly name = 'FormatOnSaveParticipant'; @@ -237,6 +284,7 @@ export class SaveParticipant implements ISaveParticipant { new TrimWhitespaceParticipant(configurationService, codeEditorService), new FormatOnSaveParticipant(codeEditorService, configurationService), new FinalNewLineParticipant(configurationService, codeEditorService), + new TrimFinalNewLinesParticipant(configurationService, codeEditorService), new ExtHostSaveParticipant(extHostContext) ]; diff --git a/src/vs/workbench/parts/files/browser/files.contribution.ts b/src/vs/workbench/parts/files/browser/files.contribution.ts index 2a066642142..43c4e3841c8 100644 --- a/src/vs/workbench/parts/files/browser/files.contribution.ts +++ b/src/vs/workbench/parts/files/browser/files.contribution.ts @@ -235,6 +235,13 @@ configurationRegistry.registerConfiguration({ 'overridable': true, 'scope': ConfigurationScope.RESOURCE }, + 'files.trimFinalNewlines': { + 'type': 'boolean', + 'default': false, + 'description': nls.localize('trimFinalNewlines', "When enabled, will trim all new lines after the final new line at the end of the file when saving it."), + 'overridable': true, + 'scope': ConfigurationScope.RESOURCE + }, 'files.autoSave': { 'type': 'string', 'enum': [AutoSaveConfiguration.OFF, AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, , AutoSaveConfiguration.ON_WINDOW_CHANGE], diff --git a/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts b/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts index 35fc82deda8..e3e90a945e4 100644 --- a/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts +++ b/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { FinalNewLineParticipant } from 'vs/workbench/api/electron-browser/mainThreadSaveParticipant'; +import { FinalNewLineParticipant, TrimFinalNewLinesParticipant } from 'vs/workbench/api/electron-browser/mainThreadSaveParticipant'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; import { workbenchInstantiationService, TestTextFileService } from 'vs/workbench/test/workbenchTestServices'; import { toResource } from 'vs/base/test/common/utils'; @@ -72,4 +72,44 @@ suite('MainThreadSaveParticipant', function () { done(); }); }); + + test('trim final new lines', function (done) { + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8'); + + model.load().then(() => { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('files', { 'trimFinalNewlines': true }); + + const participant = new TrimFinalNewLinesParticipant(configService, undefined); + + const textContent = 'Trim New Line'; + const eol = `${model.textEditorModel.getEOL()}`; + + // No new line removal if last line is not new line + let lineContent = `${textContent}`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), lineContent); + + // No new line removal if last line is single new line + lineContent = `${textContent}${eol}`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), lineContent); + + // Remove new line (single line with two new lines) + lineContent = `${textContent}${eol}${eol}`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), `${textContent}${eol}`); + + // Remove new lines (multiple lines with multiple new lines) + lineContent = `${textContent}${eol}${textContent}${eol}${eol}${eol}`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), `${textContent}${eol}${textContent}${eol}`); + + done(); + }); + }); });