提交 d8e7eb36 编写于 作者: B Benjamin Pasero

Text save participants are overwritten for each extension host (fixes #90359)

上级 6ec6f9e3
......@@ -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';
......
/*---------------------------------------------------------------------------------------------
* 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
......
/*---------------------------------------------------------------------------------------------
* 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();
model.textEditorModel!.setValue('foo 3');
const p4 = model.save();
await p1;
await p2;
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;
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册