diff --git a/src/vs/workbench/api/node/mainThreadSaveParticipant.ts b/src/vs/workbench/api/node/mainThreadSaveParticipant.ts index 5e1f28bbf11..e07ed69d1da 100644 --- a/src/vs/workbench/api/node/mainThreadSaveParticipant.ts +++ b/src/vs/workbench/api/node/mainThreadSaveParticipant.ts @@ -7,6 +7,7 @@ import { TPromise } from 'vs/base/common/winjs.base'; import { sequence } from 'vs/base/common/async'; +import * as strings from 'vs/base/common/strings'; import { ICodeEditorService } from 'vs/editor/common/services/codeEditorService'; import { IThreadService } from 'vs/workbench/services/thread/common/threadService'; import { ISaveParticipant, ITextFileEditorModel, SaveReason } from 'vs/workbench/services/textfile/common/textfiles'; @@ -21,8 +22,9 @@ import { EditOperationsCommand } from 'vs/editor/contrib/format/common/formatCom import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel'; import { ExtHostContext, ExtHostDocumentSaveParticipantShape } from './extHost.protocol'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; -interface INamedSaveParticpant extends ISaveParticipant { +export interface INamedSaveParticpant extends ISaveParticipant { readonly name: string; } @@ -47,29 +49,18 @@ class TrimWhitespaceParticipant implements INamedSaveParticpant { let prevSelection: Selection[] = [new Selection(1, 1, 1, 1)]; const cursors: IPosition[] = []; - // Find `prevSelection` in any case do ensure a good undo stack when pushing the edit - // Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump - if (model.isAttachedToEditor()) { - const allEditors = this.codeEditorService.listCodeEditors(); - for (let i = 0, len = allEditors.length; i < len; i++) { - const editor = allEditors[i]; - const editorModel = editor.getModel(); - - if (!editorModel) { - continue; // empty editor - } - - if (model === editorModel) { - prevSelection = editor.getSelections(); - if (isAutoSaved) { - cursors.push(...prevSelection.map(s => { - return { - lineNumber: s.positionLineNumber, - column: s.positionColumn - }; - })); - } - } + let editor = findEditor(model, this.codeEditorService); + if (editor) { + // Find `prevSelection` in any case do ensure a good undo stack when pushing the edit + // Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump + prevSelection = editor.getSelections(); + if (isAutoSaved) { + cursors.push(...prevSelection.map(s => { + return { + lineNumber: s.positionLineNumber, + column: s.positionColumn + }; + })); } } @@ -82,6 +73,62 @@ class TrimWhitespaceParticipant implements INamedSaveParticpant { } } +function findEditor(model: IModel, codeEditorService: ICodeEditorService): ICommonCodeEditor { + if (model.isAttachedToEditor()) { + const allEditors = codeEditorService.listCodeEditors(); + for (let i = 0, len = allEditors.length; i < len; i++) { + const editor = allEditors[i]; + const editorModel = editor.getModel(); + + if (!editorModel) { + continue; // empty editor + } + + if (model === editorModel) { + return editor; + } + } + } + + return null; +} + +export class FinalNewLineParticipant implements INamedSaveParticpant { + + readonly name = 'FinalNewLineParticipant'; + + constructor( + @IConfigurationService private configurationService: IConfigurationService, + @ICodeEditorService private codeEditorService: ICodeEditorService + ) { + // Nothing + } + + public participate(model: ITextFileEditorModel, env: { reason: SaveReason }): any { + if (this.configurationService.lookup('files.insertFinalNewline').value) { + this.doInsertFinalNewLine(model.textEditorModel); + } + } + + private doInsertFinalNewLine(model: IModel): void { + const lineCount = model.getLineCount(); + const lastLine = model.getLineContent(lineCount); + const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1; + + if (!lineCount || lastLineIsEmptyOrWhitespace) { + return; + } + + let prevSelection: Selection[] = [new Selection(1, 1, 1, 1)]; + const editor = findEditor(model, this.codeEditorService); + if (editor) { + prevSelection = editor.getSelections(); + } + + model.pushEditOperations(prevSelection, [EditOperation.insert({ lineNumber: lineCount + 1, column: 0 }, model.getEOL())], (edits) => prevSelection); + } +} + class FormatOnSaveParticipant implements INamedSaveParticpant { readonly name = 'FormatOnSaveParticipant'; @@ -204,6 +251,7 @@ export class SaveParticipant implements ISaveParticipant { this._saveParticipants = [ instantiationService.createInstance(TrimWhitespaceParticipant), + instantiationService.createInstance(FinalNewLineParticipant), instantiationService.createInstance(FormatOnSaveParticipant), instantiationService.createInstance(ExtHostSaveParticipant) ]; diff --git a/src/vs/workbench/parts/files/browser/files.contribution.ts b/src/vs/workbench/parts/files/browser/files.contribution.ts index b750e467a74..62b37885cf0 100644 --- a/src/vs/workbench/parts/files/browser/files.contribution.ts +++ b/src/vs/workbench/parts/files/browser/files.contribution.ts @@ -195,7 +195,12 @@ configurationRegistry.registerConfiguration({ 'files.trimTrailingWhitespace': { 'type': 'boolean', 'default': false, - 'description': nls.localize('trimTrailingWhitespace', "When enabled, will trim trailing whitespace when you save a file.") + 'description': nls.localize('trimTrailingWhitespace', "When enabled, will trim trailing whitespace when saving a file.") + }, + 'files.insertFinalNewline': { + 'type': 'boolean', + 'default': false, + 'description': nls.localize('insertFinalNewline', "When enabled, insert a final new line at the end of the file when saving it.") }, 'files.autoSave': { 'type': 'string', diff --git a/src/vs/workbench/test/node/api/mainThreadSaveParticipant.test.ts b/src/vs/workbench/test/node/api/mainThreadSaveParticipant.test.ts new file mode 100644 index 00000000000..59092f586f8 --- /dev/null +++ b/src/vs/workbench/test/node/api/mainThreadSaveParticipant.test.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as assert from 'assert'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { FinalNewLineParticipant } from 'vs/workbench/api/node/mainThreadSaveParticipant'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { workbenchInstantiationService, TestTextFileService, toResource } from 'vs/test/utils/servicesTestUtils'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel'; +import { IEventService } from 'vs/platform/event/common/event'; +import { ITextFileService, SaveReason } from 'vs/workbench/services/textfile/common/textfiles'; +import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager'; + +class ServiceAccessor { + constructor( @IEventService public eventService: IEventService, @ITextFileService public textFileService: TestTextFileService, @IModelService public modelService: IModelService) { + } +} + +suite('MainThreadSaveParticipant', function () { + + let instantiationService: IInstantiationService; + let accessor: ServiceAccessor; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(ServiceAccessor); + }); + + teardown(() => { + (accessor.textFileService.models).clear(); + TextFileEditorModel.setSaveParticipant(null); // reset any set participant + }); + + test('insert final new line', function (done) { + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/final_new_line.txt'), 'utf8'); + + model.load().then(() => { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('files', { 'insertFinalNewline': true }); + + const participant = new FinalNewLineParticipant(configService, undefined); + + // No new line for empty lines + let lineContent = ''; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), lineContent); + + // No new line if last line already empty + lineContent = `Hello New Line${model.textEditorModel.getEOL()}`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), lineContent); + + // New empty line added (single line) + lineContent = 'Hello New Line'; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), `${lineContent}${model.textEditorModel.getEOL()}`); + + // New empty line added (multi line) + lineContent = `Hello New Line${model.textEditorModel.getEOL()}Hello New Line${model.textEditorModel.getEOL()}Hello New Line`; + model.textEditorModel.setValue(lineContent); + participant.participate(model, { reason: SaveReason.EXPLICIT }); + assert.equal(model.getValue(), `${lineContent}${model.textEditorModel.getEOL()}`); + + done(); + }); + }); +}); \ No newline at end of file