Skip to content

Commit

Permalink
📓 OnWillSaveNotebookDocument (#177421)
Browse files Browse the repository at this point in the history
* 📓 OnWillSaveNotebookDocument

* Proposed api check
  • Loading branch information
rebornix authored Mar 17, 2023
1 parent 91a6ae4 commit 1a23242
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/vs/workbench/api/browser/extensionHost.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import './mainThreadNotebook';
import './mainThreadNotebookKernels';
import './mainThreadNotebookDocumentsAndEditors';
import './mainThreadNotebookRenderers';
import './mainThreadNotebookSaveParticipant';
import './mainThreadInteractive';
import './mainThreadInteractiveEditor';
import './mainThreadInteractiveSession';
Expand Down
70 changes: 70 additions & 0 deletions src/vs/workbench/api/browser/mainThreadNotebookSaveParticipant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* 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 { localize } from 'vs/nls';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IProgressStep, IProgress } from 'vs/platform/progress/common/progress';
import { extHostCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers';
import { SaveReason } from 'vs/workbench/common/editor';
import { ExtHostContext, ExtHostNotebookDocumentSaveParticipantShape } from '../common/extHost.protocol';
import { IDisposable } from 'vs/base/common/lifecycle';
import { raceCancellationError } from 'vs/base/common/async';
import { IStoredFileWorkingCopySaveParticipant, IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy';
import { NotebookFileWorkingCopyModel } from 'vs/workbench/contrib/notebook/common/notebookEditorModel';

class ExtHostNotebookDocumentSaveParticipant implements IStoredFileWorkingCopySaveParticipant {

private readonly _proxy: ExtHostNotebookDocumentSaveParticipantShape;

constructor(extHostContext: IExtHostContext) {
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostNotebookDocumentSaveParticipant);
}

async participate(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, env: { reason: SaveReason }, _progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {

if (!workingCopy.model || !(workingCopy.model instanceof NotebookFileWorkingCopyModel)) {
return undefined;
}

let _warningTimeout: any;

const p = new Promise<any>((resolve, reject) => {

_warningTimeout = setTimeout(
() => reject(new Error(localize('timeout.onWillSave', "Aborted onWillSaveTextDocument-event after 1750ms"))),
1750
);
this._proxy.$participateInSave(workingCopy.resource, env.reason).then(values => {
clearTimeout(_warningTimeout);
if (!values.every(success => success)) {
return Promise.reject(new Error('listener failed'));
}
return undefined;
}).then(resolve, reject);
});

return raceCancellationError(p, token);
}
}

@extHostCustomer
export class SaveParticipant {

private _saveParticipantDisposable: IDisposable;

constructor(
extHostContext: IExtHostContext,
@IInstantiationService instantiationService: IInstantiationService,
@IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService
) {
this._saveParticipantDisposable = this.workingCopyFileService.addSaveParticipant(instantiationService.createInstance(ExtHostNotebookDocumentSaveParticipant, extHostContext));
}

dispose(): void {
this._saveParticipantDisposable.dispose();
}
}
7 changes: 7 additions & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import { ExtHostProfileContentHandlers } from 'vs/workbench/api/common/extHostPr
import { ExtHostQuickDiff } from 'vs/workbench/api/common/extHostQuickDiff';
import { ExtHostInteractiveSession } from 'vs/workbench/api/common/extHostInteractiveSession';
import { ExtHostInteractiveEditor } from 'vs/workbench/api/common/extHostInteractiveEditor';
import { ExtHostNotebookDocumentSaveParticipant } from 'vs/workbench/api/common/extHostNotebookDocumentSaveParticipant';

export interface IExtensionRegistries {
mine: ExtensionDescriptionRegistry;
Expand Down Expand Up @@ -168,6 +169,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
const extHostNotebookEditors = rpcProtocol.set(ExtHostContext.ExtHostNotebookEditors, new ExtHostNotebookEditors(extHostLogService, extHostNotebook));
const extHostNotebookKernels = rpcProtocol.set(ExtHostContext.ExtHostNotebookKernels, new ExtHostNotebookKernels(rpcProtocol, initData, extHostNotebook, extHostCommands, extHostLogService));
const extHostNotebookRenderers = rpcProtocol.set(ExtHostContext.ExtHostNotebookRenderers, new ExtHostNotebookRenderers(rpcProtocol, extHostNotebook));
const extHostNotebookDocumentSaveParticipant = rpcProtocol.set(ExtHostContext.ExtHostNotebookDocumentSaveParticipant, new ExtHostNotebookDocumentSaveParticipant(extHostLogService, extHostNotebook, rpcProtocol.getProxy(MainContext.MainThreadBulkEdits)));
const extHostEditors = rpcProtocol.set(ExtHostContext.ExtHostEditors, new ExtHostEditors(rpcProtocol, extHostDocumentsAndEditors));
const extHostTreeViews = rpcProtocol.set(ExtHostContext.ExtHostTreeViews, new ExtHostTreeViews(rpcProtocol.getProxy(MainContext.MainThreadTreeViews), extHostCommands, extHostLogService));
const extHostEditorInsets = rpcProtocol.set(ExtHostContext.ExtHostEditorInsets, new ExtHostEditorInsets(rpcProtocol.getProxy(MainContext.MainThreadEditorInsets), extHostEditors, initData.remote));
Expand Down Expand Up @@ -954,6 +956,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
onDidChangeNotebookDocument(listener, thisArg, disposables) {
return extHostNotebookDocuments.onDidChangeNotebookDocument(listener, thisArg, disposables);
},
onWillSaveNotebookDocument(listener, thisArg, disposables) {
checkProposedApiEnabled(extension, 'notebokDocumentWillSave');
return extHostNotebookDocumentSaveParticipant.getOnWillSaveNotebookDocumentEvent(extension)(listener, thisArg, disposables);
},
get onDidOpenNotebookDocument(): Event<vscode.NotebookDocument> {
return extHostNotebook.onDidOpenNotebookDocument;
},
Expand Down Expand Up @@ -1413,6 +1419,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
NotebookCellStatusBarItem: extHostTypes.NotebookCellStatusBarItem,
NotebookControllerAffinity: extHostTypes.NotebookControllerAffinity,
NotebookControllerAffinity2: extHostTypes.NotebookControllerAffinity2,
NotebookDocumentSaveReason: extHostTypes.NotebookDocumentSaveReason,
NotebookEdit: extHostTypes.NotebookEdit,
NotebookKernelSourceAction: extHostTypes.NotebookKernelSourceAction,
PortAttributes: extHostTypes.PortAttributes,
Expand Down
5 changes: 5 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2166,6 +2166,10 @@ export interface ExtHostNotebookShape extends ExtHostNotebookDocumentsAndEditors
$notebookToData(handle: number, data: SerializableObjectWithBuffers<NotebookDataDto>, token: CancellationToken): Promise<VSBuffer>;
}

export interface ExtHostNotebookDocumentSaveParticipantShape {
$participateInSave(resource: UriComponents, reason: SaveReason): Promise<boolean[]>;
}

export interface ExtHostNotebookRenderersShape {
$postRendererMessage(editorId: string, rendererId: string, message: unknown): void;
}
Expand Down Expand Up @@ -2492,6 +2496,7 @@ export const ExtHostContext = {
ExtHostNotebookEditors: createProxyIdentifier<ExtHostNotebookEditorsShape>('ExtHostNotebookEditors'),
ExtHostNotebookKernels: createProxyIdentifier<ExtHostNotebookKernelsShape>('ExtHostNotebookKernels'),
ExtHostNotebookRenderers: createProxyIdentifier<ExtHostNotebookRenderersShape>('ExtHostNotebookRenderers'),
ExtHostNotebookDocumentSaveParticipant: createProxyIdentifier<ExtHostNotebookDocumentSaveParticipantShape>('ExtHostNotebookDocumentSaveParticipant'),
ExtHostInteractive: createProxyIdentifier<ExtHostInteractiveShape>('ExtHostInteractive'),
ExtHostInteractiveEditor: createProxyIdentifier<ExtHostInteractiveEditorShape>('ExtHostInteractiveEditor'),
ExtHostInteractiveSession: createProxyIdentifier<ExtHostInteractiveSessionShape>('ExtHostInteractiveSession'),
Expand Down
165 changes: 165 additions & 0 deletions src/vs/workbench/api/common/extHostNotebookDocumentSaveParticipant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Event } from 'vs/base/common/event';
import { URI, UriComponents } from 'vs/base/common/uri';
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ExtHostNotebookDocumentSaveParticipantShape, IWorkspaceEditDto, MainThreadBulkEditsShape } from 'vs/workbench/api/common/extHost.protocol';
import { LinkedList } from 'vs/base/common/linkedList';
import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook';
import { ILogService } from 'vs/platform/log/common/log';
import { NotebookDocumentWillSaveEvent, WorkspaceEdit } from 'vscode';
import { illegalState } from 'vs/base/common/errors';
import { NotebookDocumentSaveReason, WorkspaceEdit as WorksapceEditConverter } from 'vs/workbench/api/common/extHostTypeConverters';
import { SaveReason } from 'vs/workbench/common/editor';

type Listener = [Function, any, IExtensionDescription];
export class ExtHostNotebookDocumentSaveParticipant implements ExtHostNotebookDocumentSaveParticipantShape {
private readonly _callbacks = new LinkedList<Listener>();
private readonly _badListeners = new WeakMap<Function, number>();

constructor(
private readonly _logService: ILogService,
private readonly _notebooksAndEditors: ExtHostNotebookController,
private readonly _mainThreadBulkEdits: MainThreadBulkEditsShape,
private readonly _thresholds: { timeout: number; errors: number } = { timeout: 1500, errors: 3 }) {

}

dispose(): void {
this._callbacks.clear();
}

getOnWillSaveNotebookDocumentEvent(extension: IExtensionDescription): Event<NotebookDocumentWillSaveEvent> {
return (listener, thisArg, disposables) => {
const remove = this._callbacks.push([listener, thisArg, extension]);
const result = { dispose: remove };
if (Array.isArray(disposables)) {
disposables.push(result);
}
return result;
};
}

async $participateInSave(resource: UriComponents, reason: SaveReason): Promise<boolean[]> {
const revivedUri = URI.revive(resource);
let didTimeout = false;
const didTimeoutHandle = setTimeout(() => didTimeout = true, this._thresholds.timeout);

const results: boolean[] = [];

try {
for (const listener of [...this._callbacks]) { // copy to prevent concurrent modifications
if (didTimeout) {
break;
}
const document = this._notebooksAndEditors.getNotebookDocument(revivedUri);
const success = await this._deliverEventAsyncAndBlameBadListeners(listener, <any>{ document, reason: NotebookDocumentSaveReason.to(reason) });
results.push(success);
}
} finally {
clearTimeout(didTimeoutHandle);
}

return results;
}

private _deliverEventAsyncAndBlameBadListeners([listener, thisArg, extension]: Listener, stubEvent: NotebookDocumentWillSaveEvent): Promise<any> {
const errors = this._badListeners.get(listener);
if (typeof errors === 'number' && errors > this._thresholds.errors) {
// bad listener - ignore
return Promise.resolve(false);
}

return this._deliverEventAsync(extension, listener, thisArg, stubEvent).then(() => {
// don't send result across the wire
return true;

}, err => {

this._logService.error(`onWillSaveNotebookDocument-listener from extension '${extension.identifier.value}' threw ERROR`);
this._logService.error(err);

if (!(err instanceof Error) || (<Error>err).message !== 'concurrent_edits') {
const errors = this._badListeners.get(listener);
this._badListeners.set(listener, !errors ? 1 : errors + 1);

if (typeof errors === 'number' && errors > this._thresholds.errors) {
this._logService.info(`onWillSaveNotebookDocument-listener from extension '${extension.identifier.value}' will now be IGNORED because of timeouts and/or errors`);
}
}
return false;
});
}

private _deliverEventAsync(extension: IExtensionDescription, listener: Function, thisArg: any, stubEvent: NotebookDocumentWillSaveEvent): Promise<any> {

const promises: Promise<WorkspaceEdit[]>[] = [];

const t1 = Date.now();
const { document, reason } = stubEvent;
const { version } = document;

const event = Object.freeze<NotebookDocumentWillSaveEvent>({
document,
reason,
waitUntil(p: Promise<any | WorkspaceEdit[]>) {
if (Object.isFrozen(promises)) {
throw illegalState('waitUntil can not be called async');
}
promises.push(Promise.resolve(p));
}
});

try {
// fire event
listener.apply(thisArg, [event]);
} catch (err) {
return Promise.reject(err);
}

// freeze promises after event call
Object.freeze(promises);

return new Promise<WorkspaceEdit[][]>((resolve, reject) => {
// join on all listener promises, reject after timeout
const handle = setTimeout(() => reject(new Error('timeout')), this._thresholds.timeout);

return Promise.all(promises).then(edits => {
this._logService.debug(`onWillSaveNotebookDocument-listener from extension '${extension.identifier.value}' finished after ${(Date.now() - t1)}ms`);
clearTimeout(handle);
resolve(edits);
}).catch(err => {
clearTimeout(handle);
reject(err);
});

}).then(values => {
const dto: IWorkspaceEditDto = { edits: [] };
for (const value of values) {
if (Array.isArray(value)) {
for (const edit of value) {
if (edit) {
const editDto = WorksapceEditConverter.from(edit);
dto.edits.push(...editDto.edits);
}
}
}
}

// apply edits if any and if document
// didn't change somehow in the meantime
if (dto.edits.length === 0) {
return undefined;
}

if (version === document.version) {
return this._mainThreadBulkEdits.$tryApplyWorkspaceEdit(dto);
}

return Promise.reject(new Error('concurrent_edits'));
});
}
}
15 changes: 15 additions & 0 deletions src/vs/workbench/api/common/extHostTypeConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,21 @@ export namespace LanguageSelector {
}
}

export namespace NotebookDocumentSaveReason {

export function to(reason: SaveReason): vscode.NotebookDocumentSaveReason {
switch (reason) {
case SaveReason.AUTO:
return types.NotebookDocumentSaveReason.AfterDelay;
case SaveReason.EXPLICIT:
return types.NotebookDocumentSaveReason.Manual;
case SaveReason.FOCUS_CHANGE:
case SaveReason.WINDOW_CHANGE:
return types.NotebookDocumentSaveReason.FocusOut;
}
}
}

export namespace NotebookRange {

export function from(range: vscode.NotebookRange): ICellRange {
Expand Down
6 changes: 6 additions & 0 deletions src/vs/workbench/api/common/extHostTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,12 @@ export class TextEdit {
}
}

export enum NotebookDocumentSaveReason {
Manual = 1,
AfterDelay = 2,
FocusOut = 3
}

@es5ClassCompat
export class NotebookEdit implements vscode.NotebookEdit {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const allApiProposals = Object.freeze({
interactive: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.interactive.d.ts',
interactiveWindow: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.interactiveWindow.d.ts',
ipc: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.ipc.d.ts',
notebokDocumentWillSave: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebokDocumentWillSave.d.ts',
notebookCellExecutionState: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookCellExecutionState.d.ts',
notebookControllerAffinityHidden: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookControllerAffinityHidden.d.ts',
notebookDeprecated: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookDeprecated.d.ts',
Expand Down
57 changes: 57 additions & 0 deletions src/vscode-dts/vscode.proposed.notebokDocumentWillSave.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

declare module 'vscode' {
/**
* Represents reasons why a notebook document is saved.
*/
export enum NotebookDocumentSaveReason {

/**
* Manually triggered, e.g. by the user pressing save, by starting debugging,
* or by an API call.
*/
Manual = 1,

/**
* Automatic after a delay.
*/
AfterDelay = 2,

/**
* When the editor lost focus.
*/
FocusOut = 3
}

/**
* An event that is fired when a {@link NotebookDocument document} will be saved.
*
* To make modifications to the document before it is being saved, call the
* {@linkcode NotebookDocumentWillSaveEvent.waitUntil waitUntil}-function with a thenable
* that resolves to an array of {@link TextEdit text edits}.
*/
export interface NotebookDocumentWillSaveEvent {

/**
* The document that will be saved.
*/
readonly document: NotebookDocument;

/**
* The reason why save was triggered.
*/
readonly reason: NotebookDocumentSaveReason;

waitUntil(thenable: Thenable<readonly WorkspaceEdit[]>): void;

waitUntil(thenable: Thenable<any>): void;
}

export namespace workspace {

export const onWillSaveNotebookDocument: Event<NotebookDocumentWillSaveEvent>;
}
}

0 comments on commit 1a23242

Please sign in to comment.