Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

📓 OnWillSaveNotebookDocument #177421

Merged
merged 2 commits into from
Mar 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -43,6 +43,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>;
}
}