mirror of
https://github.com/Microsoft/vscode
synced 2024-09-13 21:55:38 +00:00
Text save participants are overwritten for each extension host (fixes #90359)
This commit is contained in:
parent
6ec6f9e3f4
commit
d8e7eb36a2
|
@ -215,13 +215,6 @@ suite('workspace-namespace', () => {
|
|||
});
|
||||
|
||||
test('eol, change via onWillSave', async function () {
|
||||
if (vscode.env.uiKind === vscode.UIKind.Web) {
|
||||
// TODO@Jo Test seems to fail when running in web due to
|
||||
// onWillSaveTextDocument not getting called
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
let called = false;
|
||||
let sub = vscode.workspace.onWillSaveTextDocument(e => {
|
||||
called = true;
|
||||
|
|
|
@ -3,304 +3,19 @@
|
|||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IdleValue, raceCancellation } from 'vs/base/common/async';
|
||||
import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { trimTrailingWhitespace } from 'vs/editor/common/commands/trimTrailingWhitespaceCommand';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { CodeAction, CodeActionTriggerType } from 'vs/editor/common/modes';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { shouldSynchronizeModel } from 'vs/editor/common/services/modelService';
|
||||
import { getCodeActions } from 'vs/editor/contrib/codeAction/codeAction';
|
||||
import { applyCodeAction } from 'vs/editor/contrib/codeAction/codeActionCommands';
|
||||
import { CodeActionKind } from 'vs/editor/contrib/codeAction/types';
|
||||
import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { IProgressService, ProgressLocation, IProgressStep, IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { IProgressStep, IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { extHostCustomer } from 'vs/workbench/api/common/extHostCustomers';
|
||||
import { ISaveParticipant, IResolvedTextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { ITextFileSaveParticipant, IResolvedTextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { ExtHostContext, ExtHostDocumentSaveParticipantShape, IExtHostContext } from '../common/extHost.protocol';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
import { canceled } from 'vs/base/common/errors';
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
|
||||
export interface ISaveParticipantParticipant {
|
||||
participate(model: IResolvedTextFileEditorModel, env: { reason: SaveReason }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void>;
|
||||
}
|
||||
|
||||
class TrimWhitespaceParticipant implements ISaveParticipantParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimTrailingWhitespace(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean): void {
|
||||
let prevSelection: Selection[] = [];
|
||||
let cursors: Position[] = [];
|
||||
|
||||
const 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 = prevSelection.map(s => s.getPosition());
|
||||
const snippetsRange = SnippetController2.get(editor).getSessionEnclosingRange();
|
||||
if (snippetsRange) {
|
||||
for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {
|
||||
cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ops = trimTrailingWhitespace(model, cursors);
|
||||
if (!ops.length) {
|
||||
return; // Nothing to do
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);
|
||||
}
|
||||
}
|
||||
|
||||
function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {
|
||||
let candidate: IActiveCodeEditor | null = null;
|
||||
|
||||
if (model.isAttachedToEditor()) {
|
||||
for (const editor of codeEditorService.listCodeEditors()) {
|
||||
if (editor.hasModel() && editor.getModel() === model) {
|
||||
if (editor.hasTextFocus()) {
|
||||
return editor; // favour focused editor if there are multiple
|
||||
}
|
||||
|
||||
candidate = editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export class FinalNewLineParticipant implements ISaveParticipantParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, _env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doInsertFinalNewLine(model.textEditorModel);
|
||||
}
|
||||
}
|
||||
|
||||
private doInsertFinalNewLine(model: ITextModel): void {
|
||||
const lineCount = model.getLineCount();
|
||||
const lastLine = model.getLineContent(lineCount);
|
||||
const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;
|
||||
|
||||
if (!lineCount || lastLineIsEmptyOrWhitespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());
|
||||
} else {
|
||||
model.pushEditOperations([], edits, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TrimFinalNewLinesParticipant implements ISaveParticipantParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimFinalNewLines(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns 0 if the entire file is empty or whitespace only
|
||||
*/
|
||||
private findLastLineWithContent(model: ITextModel): number {
|
||||
for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {
|
||||
const lineContent = model.getLineContent(lineNumber);
|
||||
if (strings.lastNonWhitespaceIndex(lineContent) !== -1) {
|
||||
// this line has content
|
||||
return lineNumber;
|
||||
}
|
||||
}
|
||||
// no line has content
|
||||
return 0;
|
||||
}
|
||||
|
||||
private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {
|
||||
const lineCount = model.getLineCount();
|
||||
|
||||
// Do not insert new line if file does not end with new line
|
||||
if (lineCount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevSelection: Selection[] = [];
|
||||
let cannotTouchLineNumber = 0;
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
prevSelection = editor.getSelections();
|
||||
if (isAutoSaved) {
|
||||
for (let i = 0, len = prevSelection.length; i < len; i++) {
|
||||
const positionLineNumber = prevSelection[i].positionLineNumber;
|
||||
if (positionLineNumber > cannotTouchLineNumber) {
|
||||
cannotTouchLineNumber = positionLineNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastLineNumberWithContent = this.findLastLineWithContent(model);
|
||||
const deleteFromLineNumber = Math.max(lastLineNumberWithContent + 1, cannotTouchLineNumber + 1);
|
||||
const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));
|
||||
|
||||
if (deletionRange.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);
|
||||
|
||||
if (editor) {
|
||||
editor.setSelections(prevSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FormatOnSaveParticipant implements ISaveParticipantParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(editorModel: IResolvedTextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
|
||||
const model = editorModel.textEditorModel;
|
||||
const overrides = { overrideIdentifier: model.getLanguageIdentifier().language, resource: model.uri };
|
||||
|
||||
if (env.reason === SaveReason.AUTO || !this._configurationService.getValue('editor.formatOnSave', overrides)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
progress.report({ message: localize('formatting', "Formatting") });
|
||||
const editorOrModel = findEditor(model, this._codeEditorService) || model;
|
||||
await this._instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, token);
|
||||
}
|
||||
}
|
||||
|
||||
class CodeActionOnSaveParticipant implements ISaveParticipantParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) { }
|
||||
|
||||
async participate(editorModel: IResolvedTextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
if (env.reason === SaveReason.AUTO) {
|
||||
return undefined;
|
||||
}
|
||||
const model = editorModel.textEditorModel;
|
||||
|
||||
const settingsOverrides = { overrideIdentifier: model.getLanguageIdentifier().language, resource: editorModel.resource };
|
||||
const setting = this._configurationService.getValue<{ [kind: string]: boolean }>('editor.codeActionsOnSave', settingsOverrides);
|
||||
if (!setting) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const codeActionsOnSave = Object.keys(setting)
|
||||
.filter(x => setting[x]).map(x => new CodeActionKind(x))
|
||||
.sort((a, b) => {
|
||||
if (CodeActionKind.SourceFixAll.contains(a)) {
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (!codeActionsOnSave.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const excludedActions = Object.keys(setting)
|
||||
.filter(x => setting[x] === false)
|
||||
.map(x => new CodeActionKind(x));
|
||||
|
||||
progress.report({ message: localize('codeaction', "Quick Fixes") });
|
||||
await this.applyOnSaveActions(model, codeActionsOnSave, excludedActions, token);
|
||||
}
|
||||
|
||||
private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly CodeActionKind[], excludes: readonly CodeActionKind[], token: CancellationToken): Promise<void> {
|
||||
for (const codeActionKind of codeActionsOnSave) {
|
||||
const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, token);
|
||||
try {
|
||||
await this.applyCodeActions(actionsToRun.validActions);
|
||||
} catch {
|
||||
// Failure to apply a code action should not block other on save actions
|
||||
} finally {
|
||||
actionsToRun.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCodeActions(actionsToRun: readonly CodeAction[]) {
|
||||
for (const action of actionsToRun) {
|
||||
await this._instantiationService.invokeFunction(applyCodeAction, action);
|
||||
}
|
||||
}
|
||||
|
||||
private getActionsToRun(model: ITextModel, codeActionKind: CodeActionKind, excludes: readonly CodeActionKind[], token: CancellationToken) {
|
||||
return getCodeActions(model, model.getFullModelRange(), {
|
||||
type: CodeActionTriggerType.Auto,
|
||||
filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },
|
||||
}, token);
|
||||
}
|
||||
}
|
||||
|
||||
class ExtHostSaveParticipant implements ISaveParticipantParticipant {
|
||||
class ExtHostSaveParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
private readonly _proxy: ExtHostDocumentSaveParticipantShape;
|
||||
|
||||
|
@ -336,65 +51,19 @@ class ExtHostSaveParticipant implements ISaveParticipantParticipant {
|
|||
|
||||
// The save participant can change a model before its saved to support various scenarios like trimming trailing whitespace
|
||||
@extHostCustomer
|
||||
export class SaveParticipant implements ISaveParticipant {
|
||||
export class SaveParticipant {
|
||||
|
||||
private readonly _saveParticipants: IdleValue<ISaveParticipantParticipant[]>;
|
||||
private _saveParticipantDisposable: IDisposable;
|
||||
|
||||
constructor(
|
||||
extHostContext: IExtHostContext,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IProgressService private readonly _progressService: IProgressService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@ILabelService private readonly _labelService: ILabelService,
|
||||
@ITextFileService private readonly _textFileService: ITextFileService
|
||||
) {
|
||||
this._saveParticipants = new IdleValue(() => [
|
||||
instantiationService.createInstance(TrimWhitespaceParticipant),
|
||||
instantiationService.createInstance(CodeActionOnSaveParticipant),
|
||||
instantiationService.createInstance(FormatOnSaveParticipant),
|
||||
instantiationService.createInstance(FinalNewLineParticipant),
|
||||
instantiationService.createInstance(TrimFinalNewLinesParticipant),
|
||||
instantiationService.createInstance(ExtHostSaveParticipant, extHostContext),
|
||||
]);
|
||||
// Set as save participant for all text files
|
||||
this._textFileService.saveParticipant = this;
|
||||
this._saveParticipantDisposable = this._textFileService.files.addSaveParticipant(instantiationService.createInstance(ExtHostSaveParticipant, extHostContext));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._textFileService.saveParticipant = undefined;
|
||||
this._saveParticipants.dispose();
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> {
|
||||
|
||||
const cts = new CancellationTokenSource(token);
|
||||
|
||||
return this._progressService.withProgress({
|
||||
title: localize('saveParticipants', "Running Save Participants for '{0}'", this._labelService.getUriLabel(model.resource, { relative: true })),
|
||||
location: ProgressLocation.Notification,
|
||||
cancellable: true,
|
||||
delay: model.isDirty() ? 3000 : 5000
|
||||
}, async progress => {
|
||||
// undoStop before participation
|
||||
model.textEditorModel.pushStackElement();
|
||||
|
||||
for (let p of this._saveParticipants.getValue()) {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const promise = p.participate(model, context, progress, cts.token);
|
||||
await raceCancellation(promise, cts.token);
|
||||
} catch (err) {
|
||||
this._logService.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
// undoStop after participation
|
||||
model.textEditorModel.pushStackElement();
|
||||
}, () => {
|
||||
// user cancel
|
||||
cts.dispose(true);
|
||||
});
|
||||
this._saveParticipantDisposable.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import './diffEditorHelper';
|
|||
import './inspectKeybindings';
|
||||
import './largeFileOptimizations';
|
||||
import './inspectEditorTokens/inspectEditorTokens';
|
||||
import './saveParticipants';
|
||||
import './toggleMinimap';
|
||||
import './toggleMultiCursorModifier';
|
||||
import './toggleRenderControlCharacter';
|
||||
|
|
316
src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts
Normal file
316
src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts
Normal file
|
@ -0,0 +1,316 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { trimTrailingWhitespace } from 'vs/editor/common/commands/trimTrailingWhitespaceCommand';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { CodeAction, CodeActionTriggerType } from 'vs/editor/common/modes';
|
||||
import { getCodeActions } from 'vs/editor/contrib/codeAction/codeAction';
|
||||
import { applyCodeAction } from 'vs/editor/contrib/codeAction/codeActionCommands';
|
||||
import { CodeActionKind } from 'vs/editor/contrib/codeAction/types';
|
||||
import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IProgressStep, IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { IResolvedTextFileEditorModel, ITextFileService, ITextFileSaveParticipant } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { IWorkbenchContribution, Extensions as WorkbenchContributionsExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle';
|
||||
|
||||
class TrimWhitespaceParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimTrailingWhitespace(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean): void {
|
||||
let prevSelection: Selection[] = [];
|
||||
let cursors: Position[] = [];
|
||||
|
||||
const 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 = prevSelection.map(s => s.getPosition());
|
||||
const snippetsRange = SnippetController2.get(editor).getSessionEnclosingRange();
|
||||
if (snippetsRange) {
|
||||
for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {
|
||||
cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ops = trimTrailingWhitespace(model, cursors);
|
||||
if (!ops.length) {
|
||||
return; // Nothing to do
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);
|
||||
}
|
||||
}
|
||||
|
||||
function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {
|
||||
let candidate: IActiveCodeEditor | null = null;
|
||||
|
||||
if (model.isAttachedToEditor()) {
|
||||
for (const editor of codeEditorService.listCodeEditors()) {
|
||||
if (editor.hasModel() && editor.getModel() === model) {
|
||||
if (editor.hasTextFocus()) {
|
||||
return editor; // favour focused editor if there are multiple
|
||||
}
|
||||
|
||||
candidate = editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export class FinalNewLineParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, _env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doInsertFinalNewLine(model.textEditorModel);
|
||||
}
|
||||
}
|
||||
|
||||
private doInsertFinalNewLine(model: ITextModel): void {
|
||||
const lineCount = model.getLineCount();
|
||||
const lastLine = model.getLineContent(lineCount);
|
||||
const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;
|
||||
|
||||
if (!lineCount || lastLineIsEmptyOrWhitespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());
|
||||
} else {
|
||||
model.pushEditOperations([], edits, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TrimFinalNewLinesParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: IResolvedTextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimFinalNewLines(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns 0 if the entire file is empty or whitespace only
|
||||
*/
|
||||
private findLastLineWithContent(model: ITextModel): number {
|
||||
for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {
|
||||
const lineContent = model.getLineContent(lineNumber);
|
||||
if (strings.lastNonWhitespaceIndex(lineContent) !== -1) {
|
||||
// this line has content
|
||||
return lineNumber;
|
||||
}
|
||||
}
|
||||
// no line has content
|
||||
return 0;
|
||||
}
|
||||
|
||||
private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {
|
||||
const lineCount = model.getLineCount();
|
||||
|
||||
// Do not insert new line if file does not end with new line
|
||||
if (lineCount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevSelection: Selection[] = [];
|
||||
let cannotTouchLineNumber = 0;
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
prevSelection = editor.getSelections();
|
||||
if (isAutoSaved) {
|
||||
for (let i = 0, len = prevSelection.length; i < len; i++) {
|
||||
const positionLineNumber = prevSelection[i].positionLineNumber;
|
||||
if (positionLineNumber > cannotTouchLineNumber) {
|
||||
cannotTouchLineNumber = positionLineNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastLineNumberWithContent = this.findLastLineWithContent(model);
|
||||
const deleteFromLineNumber = Math.max(lastLineNumberWithContent + 1, cannotTouchLineNumber + 1);
|
||||
const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));
|
||||
|
||||
if (deletionRange.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);
|
||||
|
||||
if (editor) {
|
||||
editor.setSelections(prevSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FormatOnSaveParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(editorModel: IResolvedTextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
const model = editorModel.textEditorModel;
|
||||
const overrides = { overrideIdentifier: model.getLanguageIdentifier().language, resource: model.uri };
|
||||
|
||||
if (env.reason === SaveReason.AUTO || !this.configurationService.getValue('editor.formatOnSave', overrides)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
progress.report({ message: localize('formatting', "Formatting") });
|
||||
const editorOrModel = findEditor(model, this.codeEditorService) || model;
|
||||
await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, token);
|
||||
}
|
||||
}
|
||||
|
||||
class CodeActionOnSaveParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) { }
|
||||
|
||||
async participate(editorModel: IResolvedTextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
if (env.reason === SaveReason.AUTO) {
|
||||
return undefined;
|
||||
}
|
||||
const model = editorModel.textEditorModel;
|
||||
|
||||
const settingsOverrides = { overrideIdentifier: model.getLanguageIdentifier().language, resource: editorModel.resource };
|
||||
const setting = this.configurationService.getValue<{ [kind: string]: boolean }>('editor.codeActionsOnSave', settingsOverrides);
|
||||
if (!setting) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const codeActionsOnSave = Object.keys(setting)
|
||||
.filter(x => setting[x]).map(x => new CodeActionKind(x))
|
||||
.sort((a, b) => {
|
||||
if (CodeActionKind.SourceFixAll.contains(a)) {
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (!codeActionsOnSave.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const excludedActions = Object.keys(setting)
|
||||
.filter(x => setting[x] === false)
|
||||
.map(x => new CodeActionKind(x));
|
||||
|
||||
progress.report({ message: localize('codeaction', "Quick Fixes") });
|
||||
await this.applyOnSaveActions(model, codeActionsOnSave, excludedActions, token);
|
||||
}
|
||||
|
||||
private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly CodeActionKind[], excludes: readonly CodeActionKind[], token: CancellationToken): Promise<void> {
|
||||
for (const codeActionKind of codeActionsOnSave) {
|
||||
const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, token);
|
||||
try {
|
||||
await this.applyCodeActions(actionsToRun.validActions);
|
||||
} catch {
|
||||
// Failure to apply a code action should not block other on save actions
|
||||
} finally {
|
||||
actionsToRun.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCodeActions(actionsToRun: readonly CodeAction[]) {
|
||||
for (const action of actionsToRun) {
|
||||
await this.instantiationService.invokeFunction(applyCodeAction, action);
|
||||
}
|
||||
}
|
||||
|
||||
private getActionsToRun(model: ITextModel, codeActionKind: CodeActionKind, excludes: readonly CodeActionKind[], token: CancellationToken) {
|
||||
return getCodeActions(model, model.getFullModelRange(), {
|
||||
type: CodeActionTriggerType.Auto,
|
||||
filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },
|
||||
}, token);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ITextFileService private readonly textFileService: ITextFileService
|
||||
) {
|
||||
super();
|
||||
|
||||
this.registerSaveParticipants();
|
||||
}
|
||||
|
||||
private registerSaveParticipants(): void {
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));
|
||||
}
|
||||
}
|
||||
|
||||
const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchContributionsExtensions.Workbench);
|
||||
workbenchContributionsRegistry.registerWorkbenchContribution(SaveParticipantsContribution, LifecyclePhase.Restored);
|
|
@ -5,9 +5,9 @@
|
|||
|
||||
import * as assert from 'assert';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { FinalNewLineParticipant, TrimFinalNewLinesParticipant } from 'vs/workbench/api/browser/mainThreadSaveParticipant';
|
||||
import { FinalNewLineParticipant, TrimFinalNewLinesParticipant } from 'vs/workbench/contrib/codeEditor/browser/saveParticipants';
|
||||
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
|
||||
import { workbenchInstantiationService, TestTextFileService } from 'vs/workbench/test/electron-browser/workbenchTestServices';
|
||||
import { workbenchInstantiationService, TestTextFileService } from 'vs/workbench/test/browser/workbenchTestServices';
|
||||
import { toResource } from 'vs/base/test/common/utils';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
|
@ -6,7 +6,7 @@
|
|||
import * as nls from 'vs/nls';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { Emitter, AsyncEmitter } from 'vs/base/common/event';
|
||||
import { IResult, ITextFileOperationResult, ITextFileService, ITextFileStreamContent, ITextFileEditorModel, ITextFileContent, IResourceEncodings, IReadTextFileOptions, IWriteTextFileOptions, toBufferOrReadable, TextFileOperationError, TextFileOperationResult, FileOperationWillRunEvent, FileOperationDidRunEvent, ITextFileSaveOptions, ITextFileEditorModelManager, ISaveParticipant } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { IResult, ITextFileOperationResult, ITextFileService, ITextFileStreamContent, ITextFileEditorModel, ITextFileContent, IResourceEncodings, IReadTextFileOptions, IWriteTextFileOptions, toBufferOrReadable, TextFileOperationError, TextFileOperationResult, FileOperationWillRunEvent, FileOperationDidRunEvent, ITextFileSaveOptions, ITextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { IRevertOptions, IEncodingSupport } from 'vs/workbench/common/editor';
|
||||
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
|
||||
import { IFileService, FileOperationError, FileOperationResult, IFileStatWithMetadata, ICreateFileOptions, FileOperation } from 'vs/platform/files/common/files';
|
||||
|
@ -69,8 +69,6 @@ export abstract class AbstractTextFileService extends Disposable implements ITex
|
|||
};
|
||||
})();
|
||||
|
||||
saveParticipant: ISaveParticipant | undefined = undefined;
|
||||
|
||||
abstract get encoding(): IResourceEncodings;
|
||||
|
||||
constructor(
|
||||
|
|
|
@ -616,7 +616,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
|
|||
this.textEditorModel.pushStackElement();
|
||||
}
|
||||
|
||||
const saveParticipantCancellation = new CancellationTokenSource();
|
||||
const saveCancellation = new CancellationTokenSource();
|
||||
|
||||
return this.saveSequentializer.setPending(versionId, (async () => {
|
||||
|
||||
|
@ -625,14 +625,26 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
|
|||
// In addition we update our version right after in case it changed because of a model change
|
||||
//
|
||||
// Save participants can also be skipped through API.
|
||||
if (this.isResolved() && this.textFileService.saveParticipant && !options.skipSaveParticipants) {
|
||||
if (this.isResolved() && !options.skipSaveParticipants) {
|
||||
try {
|
||||
await this.textFileService.saveParticipant.participate(this, { reason: options.reason ?? SaveReason.EXPLICIT }, saveParticipantCancellation.token);
|
||||
await this.textFileService.files.runSaveParticipants(this, { reason: options.reason ?? SaveReason.EXPLICIT }, saveCancellation.token);
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
this.logService.error(`[text file model] runSaveParticipants(${versionId}) - resulted in an error: ${error.toString()}`, this.resource.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// It is possible that a subsequent save is cancelling this
|
||||
// running save. As such we return early when we detect that
|
||||
// However, we do not pass the token into the file service
|
||||
// because that is an atomic operation currently without
|
||||
// cancellation support, so we dispose the cancellation if
|
||||
// it was not cancelled yet.
|
||||
if (saveCancellation.token.isCancellationRequested) {
|
||||
return;
|
||||
} else {
|
||||
saveCancellation.dispose();
|
||||
}
|
||||
|
||||
// We have to protect against being disposed at this point. It could be that the save() operation
|
||||
// was triggerd followed by a dispose() operation right after without waiting. Typically we cannot
|
||||
// be disposed if we are dirty, but if we are not dirty, save() and dispose() can still be triggered
|
||||
|
@ -687,7 +699,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
|
|||
this.handleSaveError(error, versionId, options);
|
||||
}
|
||||
})());
|
||||
})(), () => saveParticipantCancellation.cancel());
|
||||
})(), () => saveCancellation.cancel());
|
||||
}
|
||||
|
||||
private handleSaveSuccess(stat: IFileStatWithMetadata, versionId: number, options: ITextFileSaveOptions): void {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Emitter } from 'vs/base/common/event';
|
|||
import { URI } from 'vs/base/common/uri';
|
||||
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
|
||||
import { dispose, IDisposable, Disposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { ITextFileEditorModel, ITextFileEditorModelManager, IModelLoadOrCreateOptions, ITextFileModelLoadEvent, ITextFileModelSaveEvent } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { ITextFileEditorModel, ITextFileEditorModelManager, IModelLoadOrCreateOptions, ITextFileModelLoadEvent, ITextFileModelSaveEvent, ITextFileSaveParticipant, IResolvedTextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ResourceMap } from 'vs/base/common/map';
|
||||
|
@ -15,6 +15,9 @@ import { IFileService, FileChangesEvent } from 'vs/platform/files/common/files';
|
|||
import { distinct, coalesce } from 'vs/base/common/arrays';
|
||||
import { ResourceQueue } from 'vs/base/common/async';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { TextFileSaveParticipant } from 'vs/workbench/services/textfile/common/textFileSaveParticipant';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
|
||||
export class TextFileEditorModelManager extends Disposable implements ITextFileEditorModelManager {
|
||||
|
||||
|
@ -227,6 +230,20 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE
|
|||
}
|
||||
}
|
||||
|
||||
//#region Save participants
|
||||
|
||||
private readonly saveParticipants = this._register(this.instantiationService.createInstance(TextFileSaveParticipant));
|
||||
|
||||
addSaveParticipant(participant: ITextFileSaveParticipant): IDisposable {
|
||||
return this.saveParticipants.addSaveParticipant(participant);
|
||||
}
|
||||
|
||||
runSaveParticipants(model: IResolvedTextFileEditorModel, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> {
|
||||
return this.saveParticipants.participate(model, context, token);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
clear(): void {
|
||||
|
||||
// model caches
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { raceCancellation } from 'vs/base/common/async';
|
||||
import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { localize } from 'vs/nls';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
|
||||
import { ITextFileSaveParticipant, IResolvedTextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { IDisposable, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
|
||||
export class TextFileSaveParticipant extends Disposable {
|
||||
|
||||
private readonly saveParticipants: ITextFileSaveParticipant[] = [];
|
||||
|
||||
constructor(
|
||||
@IProgressService private readonly progressService: IProgressService,
|
||||
@ILogService private readonly logService: ILogService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
addSaveParticipant(participant: ITextFileSaveParticipant): IDisposable {
|
||||
this.saveParticipants.push(participant);
|
||||
|
||||
return toDisposable(() => this.saveParticipants.splice(this.saveParticipants.indexOf(participant), 1));
|
||||
}
|
||||
|
||||
participate(model: IResolvedTextFileEditorModel, context: { reason: SaveReason; }, token: CancellationToken): Promise<void> {
|
||||
const cts = new CancellationTokenSource(token);
|
||||
|
||||
return this.progressService.withProgress({
|
||||
title: localize('saveParticipants', "Running Save Participants for '{0}'", model.name),
|
||||
location: ProgressLocation.Notification,
|
||||
cancellable: true,
|
||||
delay: model.isDirty() ? 3000 : 5000
|
||||
}, async progress => {
|
||||
|
||||
// undoStop before participation
|
||||
model.textEditorModel.pushStackElement();
|
||||
|
||||
for (const saveParticipant of this.saveParticipants) {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const promise = saveParticipant.participate(model, context, progress, cts.token);
|
||||
await raceCancellation(promise, cts.token);
|
||||
} catch (err) {
|
||||
this.logService.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
// undoStop after participation
|
||||
model.textEditorModel.pushStackElement();
|
||||
}, () => {
|
||||
// user cancel
|
||||
cts.dispose(true);
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.saveParticipants.splice(0, this.saveParticipants.length);
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ import { isNative } from 'vs/base/common/platform';
|
|||
import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
||||
import { IUntitledTextEditorModelManager } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress';
|
||||
|
||||
export const ITextFileService = createDecorator<ITextFileService>('textFileService');
|
||||
|
||||
|
@ -57,11 +58,6 @@ export interface ITextFileService extends IDisposable {
|
|||
*/
|
||||
saveErrorHandler: ISaveErrorHandler;
|
||||
|
||||
/**
|
||||
* The save participant if any. By default, no save participant is registered.
|
||||
*/
|
||||
saveParticipant: ISaveParticipant | undefined;
|
||||
|
||||
/**
|
||||
* A resource is dirty if it has unsaved changes or is an untitled file not yet saved.
|
||||
*
|
||||
|
@ -226,14 +222,6 @@ export interface ISaveErrorHandler {
|
|||
onSaveError(error: Error, model: ITextFileEditorModel): void;
|
||||
}
|
||||
|
||||
export interface ISaveParticipant {
|
||||
|
||||
/**
|
||||
* Participate in a save of a model. Allows to change the model before it is being saved to disk.
|
||||
*/
|
||||
participate(model: IResolvedTextFileEditorModel, context: { reason: SaveReason }, token: CancellationToken): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* States the text file editor model can be in.
|
||||
*/
|
||||
|
@ -357,6 +345,20 @@ export interface ITextFileModelLoadEvent {
|
|||
reason: LoadReason;
|
||||
}
|
||||
|
||||
export interface ITextFileSaveParticipant {
|
||||
|
||||
/**
|
||||
* Participate in a save of a model. Allows to change the model
|
||||
* before it is being saved to disk.
|
||||
*/
|
||||
participate(
|
||||
model: IResolvedTextFileEditorModel,
|
||||
context: { reason: SaveReason },
|
||||
progress: IProgress<IProgressStep>,
|
||||
token: CancellationToken
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ITextFileEditorModelManager {
|
||||
|
||||
readonly onDidLoad: Event<ITextFileModelLoadEvent>;
|
||||
|
@ -372,6 +374,9 @@ export interface ITextFileEditorModelManager {
|
|||
|
||||
resolve(resource: URI, options?: IModelLoadOrCreateOptions): Promise<ITextFileEditorModel>;
|
||||
|
||||
addSaveParticipant(participant: ITextFileSaveParticipant): IDisposable;
|
||||
runSaveParticipants(model: IResolvedTextFileEditorModel, context: { reason: SaveReason; }, token: CancellationToken): Promise<void>
|
||||
|
||||
disposeModel(model: ITextFileEditorModel): void;
|
||||
}
|
||||
|
||||
|
|
|
@ -502,55 +502,98 @@ suite('Files - TextFileEditorModel', () => {
|
|||
eventCounter++;
|
||||
});
|
||||
|
||||
accessor.textFileService.saveParticipant = {
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: async model => {
|
||||
assert.ok(model.isDirty());
|
||||
model.textEditorModel!.setValue('bar');
|
||||
assert.ok(model.isDirty());
|
||||
eventCounter++;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await model.load();
|
||||
model.textEditorModel!.setValue('foo');
|
||||
|
||||
await model.save();
|
||||
model.dispose();
|
||||
assert.equal(eventCounter, 2);
|
||||
|
||||
participant.dispose();
|
||||
model.textEditorModel!.setValue('bar');
|
||||
|
||||
await model.save();
|
||||
assert.equal(eventCounter, 3);
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
test('Save Participant - skip', async function () {
|
||||
let eventCounter = 0;
|
||||
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined);
|
||||
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: async model => {
|
||||
eventCounter++;
|
||||
}
|
||||
});
|
||||
|
||||
await model.load();
|
||||
model.textEditorModel!.setValue('foo');
|
||||
|
||||
await model.save({ skipSaveParticipants: true });
|
||||
assert.equal(eventCounter, 0);
|
||||
|
||||
participant.dispose();
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
test('Save Participant, async participant', async function () {
|
||||
let eventCounter = 0;
|
||||
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined);
|
||||
|
||||
accessor.textFileService.saveParticipant = {
|
||||
participate: (model) => {
|
||||
model.onDidSave(e => {
|
||||
assert.ok(!model.isDirty());
|
||||
eventCounter++;
|
||||
});
|
||||
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: model => {
|
||||
assert.ok(model.isDirty());
|
||||
model.textEditorModel!.setValue('bar');
|
||||
assert.ok(model.isDirty());
|
||||
eventCounter++;
|
||||
|
||||
return timeout(10);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await model.load();
|
||||
model.textEditorModel!.setValue('foo');
|
||||
|
||||
const now = Date.now();
|
||||
await model.save();
|
||||
assert.equal(eventCounter, 2);
|
||||
assert.ok(Date.now() - now >= 10);
|
||||
|
||||
model.dispose();
|
||||
participant.dispose();
|
||||
});
|
||||
|
||||
test('Save Participant, bad participant', async function () {
|
||||
const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined);
|
||||
|
||||
accessor.textFileService.saveParticipant = {
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: async model => {
|
||||
new Error('boom');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await model.load();
|
||||
model.textEditorModel!.setValue('foo');
|
||||
|
||||
await model.save();
|
||||
|
||||
model.dispose();
|
||||
participant.dispose();
|
||||
});
|
||||
|
||||
test('Save Participant, participant cancelled when saved again', async function () {
|
||||
|
@ -558,12 +601,15 @@ suite('Files - TextFileEditorModel', () => {
|
|||
|
||||
let participations: boolean[] = [];
|
||||
|
||||
accessor.textFileService.saveParticipant = {
|
||||
participate: async model => {
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: async (model, context, progress, token) => {
|
||||
await timeout(10);
|
||||
participations.push(true);
|
||||
|
||||
if (!token.isCancellationRequested) {
|
||||
participations.push(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await model.load();
|
||||
|
||||
|
@ -574,12 +620,16 @@ suite('Files - TextFileEditorModel', () => {
|
|||
const p2 = model.save();
|
||||
|
||||
model.textEditorModel!.setValue('foo 2');
|
||||
await model.save();
|
||||
const p3 = model.save();
|
||||
|
||||
await p1;
|
||||
await p2;
|
||||
model.textEditorModel!.setValue('foo 3');
|
||||
const p4 = model.save();
|
||||
|
||||
await Promise.all([p1, p2, p3, p4]);
|
||||
assert.equal(participations.length, 1);
|
||||
|
||||
model.dispose();
|
||||
participant.dispose();
|
||||
});
|
||||
|
||||
test('Save Participant, calling save from within is unsupported but does not explode (sync save)', async function () {
|
||||
|
@ -602,7 +652,7 @@ suite('Files - TextFileEditorModel', () => {
|
|||
let savePromise: Promise<boolean>;
|
||||
let breakLoop = false;
|
||||
|
||||
accessor.textFileService.saveParticipant = {
|
||||
const participant = accessor.textFileService.files.addSaveParticipant({
|
||||
participate: async model => {
|
||||
if (breakLoop) {
|
||||
return;
|
||||
|
@ -618,12 +668,14 @@ suite('Files - TextFileEditorModel', () => {
|
|||
// assert that this is the same promise as the outer one
|
||||
assert.equal(savePromise, newSavePromise);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await model.load();
|
||||
model.textEditorModel!.setValue('foo');
|
||||
|
||||
savePromise = model.save();
|
||||
await savePromise;
|
||||
|
||||
participant.dispose();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -89,6 +89,7 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/
|
|||
import { createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
|
||||
import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService';
|
||||
import { Direction } from 'vs/base/browser/ui/grid/grid';
|
||||
import { IProgressService, IProgressOptions, IProgressWindowOptions, IProgressNotificationOptions, IProgressCompositeOptions, IProgress, IProgressStep, emptyProgress } from 'vs/platform/progress/common/progress';
|
||||
|
||||
export import TestTextResourcePropertiesService = CommonWorkbenchTestServices.TestTextResourcePropertiesService;
|
||||
export import TestContextService = CommonWorkbenchTestServices.TestContextService;
|
||||
|
@ -175,6 +176,7 @@ export function workbenchInstantiationService(overrides?: { textFileService?: (i
|
|||
instantiationService.stub(IEnvironmentService, TestEnvironmentService);
|
||||
const contextKeyService = <IContextKeyService>instantiationService.createInstance(MockContextKeyService);
|
||||
instantiationService.stub(IContextKeyService, contextKeyService);
|
||||
instantiationService.stub(IProgressService, new TestProgressService());
|
||||
const workspaceContextService = new TestContextService(TestWorkspace);
|
||||
instantiationService.stub(IWorkspaceContextService, workspaceContextService);
|
||||
const configService = new TestConfigurationService();
|
||||
|
@ -217,6 +219,19 @@ export function workbenchInstantiationService(overrides?: { textFileService?: (i
|
|||
return instantiationService;
|
||||
}
|
||||
|
||||
export class TestProgressService implements IProgressService {
|
||||
|
||||
_serviceBrand: undefined;
|
||||
|
||||
withProgress(
|
||||
options: IProgressOptions | IProgressWindowOptions | IProgressNotificationOptions | IProgressCompositeOptions,
|
||||
task: (progress: IProgress<IProgressStep>) => Promise<any>,
|
||||
onDidCancel?: ((choice?: number | undefined) => void) | undefined
|
||||
): Promise<any> {
|
||||
return task(emptyProgress);
|
||||
}
|
||||
}
|
||||
|
||||
export class TestAccessibilityService implements IAccessibilityService {
|
||||
|
||||
_serviceBrand: undefined;
|
||||
|
|
Loading…
Reference in a new issue