/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { multibyteAwareBtoa } from 'vs/base/browser/dom'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { isPromiseCanceledError, onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, dispose, IDisposable, IReference } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { basename } from 'vs/base/common/path'; import { isEqual, isEqualOrParent, toLocalResource } from 'vs/base/common/resources'; import { URI, UriComponents } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { FileOperation, IFileService } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo'; import { MainThreadWebviewPanels } from 'vs/workbench/api/browser/mainThreadWebviewPanels'; import { MainThreadWebviews, reviveWebviewExtension } from 'vs/workbench/api/browser/mainThreadWebviews'; import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol'; import { IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor'; import { editorGroupToColumn } from 'vs/workbench/services/editor/common/editorGroupColumn'; import { CustomEditorInput } from 'vs/workbench/contrib/customEditor/browser/customEditorInput'; import { CustomDocumentBackupData } from 'vs/workbench/contrib/customEditor/browser/customEditorInputFactory'; import { ICustomEditorModel, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor'; import { CustomTextEditorModel } from 'vs/workbench/contrib/customEditor/common/customTextEditorModel'; import { WebviewExtensionDescription } from 'vs/workbench/contrib/webview/browser/webview'; import { WebviewInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditorInput'; import { IWebviewWorkbenchService } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IPathService } from 'vs/workbench/services/path/common/pathService'; import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IWorkingCopy, IWorkingCopyBackup, NO_TYPE_ID, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; import { ResourceWorkingCopy } from 'vs/workbench/services/workingCopy/common/resourceWorkingCopy'; const enum CustomEditorModelType { Custom, Text, } export class MainThreadCustomEditors extends Disposable implements extHostProtocol.MainThreadCustomEditorsShape { private readonly _proxyCustomEditors: extHostProtocol.ExtHostCustomEditorsShape; private readonly _editorProviders = new Map(); private readonly _editorRenameBackups = new Map(); constructor( context: extHostProtocol.IExtHostContext, private readonly mainThreadWebview: MainThreadWebviews, private readonly mainThreadWebviewPanels: MainThreadWebviewPanels, @IExtensionService extensionService: IExtensionService, @IWorkingCopyService workingCopyService: IWorkingCopyService, @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, @ICustomEditorService private readonly _customEditorService: ICustomEditorService, @IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService, @IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._proxyCustomEditors = context.getProxy(extHostProtocol.ExtHostContext.ExtHostCustomEditors); this._register(workingCopyFileService.registerWorkingCopyProvider((editorResource) => { const matchedWorkingCopies: IWorkingCopy[] = []; for (const workingCopy of workingCopyService.workingCopies) { if (workingCopy instanceof MainThreadCustomEditorModel) { if (isEqualOrParent(editorResource, workingCopy.editorResource)) { matchedWorkingCopies.push(workingCopy); } } } return matchedWorkingCopies; })); // This reviver's only job is to activate custom editor extensions. this._register(_webviewWorkbenchService.registerResolver({ canResolve: (webview: WebviewInput) => { if (webview instanceof CustomEditorInput) { extensionService.activateByEvent(`onCustomEditor:${webview.viewType}`); } return false; }, resolveWebview: () => { throw new Error('not implemented'); } })); // Working copy operations this._register(workingCopyFileService.onWillRunWorkingCopyFileOperation(async e => this.onWillRunWorkingCopyFileOperation(e))); } override dispose() { super.dispose(); dispose(this._editorProviders.values()); this._editorProviders.clear(); } public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities, serializeBuffersForPostMessage: boolean): void { this.registerEditorProvider(CustomEditorModelType.Text, reviveWebviewExtension(extensionData), viewType, options, capabilities, true, serializeBuffersForPostMessage); } public $registerCustomEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void { this.registerEditorProvider(CustomEditorModelType.Custom, reviveWebviewExtension(extensionData), viewType, options, {}, supportsMultipleEditorsPerDocument, serializeBuffersForPostMessage); } private registerEditorProvider( modelType: CustomEditorModelType, extension: WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean, ): void { if (this._editorProviders.has(viewType)) { throw new Error(`Provider for ${viewType} already registered`); } const disposables = new DisposableStore(); disposables.add(this._customEditorService.registerCustomEditorCapabilities(viewType, { supportsMultipleEditorsPerDocument })); disposables.add(this._webviewWorkbenchService.registerResolver({ canResolve: (webviewInput) => { return webviewInput instanceof CustomEditorInput && webviewInput.viewType === viewType; }, resolveWebview: async (webviewInput: CustomEditorInput, cancellation: CancellationToken) => { const handle = webviewInput.id; const resource = webviewInput.resource; this.mainThreadWebviewPanels.addWebviewInput(handle, webviewInput, { serializeBuffersForPostMessage }); webviewInput.webview.options = options; webviewInput.webview.extension = extension; // If there's an old resource this was a move and we must resolve the backup at the same time as the webview // This is because the backup must be ready upon model creation, and the input resolve method comes after let backupId = webviewInput.backupId; if (webviewInput.oldResource && !webviewInput.backupId) { const backup = this._editorRenameBackups.get(webviewInput.oldResource.toString()); backupId = backup?.backupId; this._editorRenameBackups.delete(webviewInput.oldResource.toString()); } let modelRef: IReference; try { modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId }, cancellation); } catch (error) { onUnexpectedError(error); webviewInput.webview.html = this.mainThreadWebview.getWebviewResolvedFailedContent(viewType); return; } if (cancellation.isCancellationRequested) { modelRef.dispose(); return; } webviewInput.webview.onDidDispose(() => { // If the model is still dirty, make sure we have time to save it if (modelRef.object.isDirty()) { const sub = modelRef.object.onDidChangeDirty(() => { if (!modelRef.object.isDirty()) { sub.dispose(); modelRef.dispose(); } }); return; } modelRef.dispose(); }); if (capabilities.supportsMove) { webviewInput.onMove(async (newResource: URI) => { const oldModel = modelRef; modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None); this._proxyCustomEditors.$onMoveCustomEditor(handle, newResource, viewType); oldModel.dispose(); }); } try { await this._proxyCustomEditors.$resolveWebviewEditor(resource, handle, viewType, { title: webviewInput.getTitle(), webviewOptions: webviewInput.webview.contentOptions, panelOptions: webviewInput.webview.options, }, editorGroupToColumn(this._editorGroupService, webviewInput.group || 0), cancellation); } catch (error) { onUnexpectedError(error); webviewInput.webview.html = this.mainThreadWebview.getWebviewResolvedFailedContent(viewType); modelRef.dispose(); return; } } })); this._editorProviders.set(viewType, disposables); } public $unregisterEditorProvider(viewType: string): void { const provider = this._editorProviders.get(viewType); if (!provider) { throw new Error(`No provider for ${viewType} registered`); } provider.dispose(); this._editorProviders.delete(viewType); this._customEditorService.models.disposeAllModelsForView(viewType); } private async getOrCreateCustomEditorModel( modelType: CustomEditorModelType, resource: URI, viewType: string, options: { backupId?: string }, cancellation: CancellationToken, ): Promise> { const existingModel = this._customEditorService.models.tryRetain(resource, viewType); if (existingModel) { return existingModel; } switch (modelType) { case CustomEditorModelType.Text: { const model = CustomTextEditorModel.create(this._instantiationService, viewType, resource); return this._customEditorService.models.add(resource, viewType, model); } case CustomEditorModelType.Custom: { const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxyCustomEditors, viewType, resource, options, () => { return Array.from(this.mainThreadWebviewPanels.webviewInputs) .filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[]; }, cancellation); return this._customEditorService.models.add(resource, viewType, model); } } } public async $onDidEdit(resourceComponents: UriComponents, viewType: string, editId: number, label: string | undefined): Promise { const model = await this.getCustomEditorModel(resourceComponents, viewType); model.pushEdit(editId, label); } public async $onContentChange(resourceComponents: UriComponents, viewType: string): Promise { const model = await this.getCustomEditorModel(resourceComponents, viewType); model.changeContent(); } private async getCustomEditorModel(resourceComponents: UriComponents, viewType: string) { const resource = URI.revive(resourceComponents); const model = await this._customEditorService.models.get(resource, viewType); if (!model || !(model instanceof MainThreadCustomEditorModel)) { throw new Error('Could not find model for webview editor'); } return model; } //#region Working Copy private async onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent) { if (e.operation !== FileOperation.MOVE) { return; } e.waitUntil((async () => { const models = []; for (const file of e.files) { if (file.source) { models.push(...(await this._customEditorService.models.getAllModels(file.source))); } } for (const model of models) { if (model instanceof MainThreadCustomEditorModel && model.isDirty()) { const workingCopy = await model.backup(CancellationToken.None); if (workingCopy.meta) { // This cast is safe because we do an instanceof check above and a custom document backup data is always returned this._editorRenameBackups.set(model.editorResource.toString(), workingCopy.meta as CustomDocumentBackupData); } } } })()); } //#endregion } namespace HotExitState { export const enum Type { Allowed, NotAllowed, Pending, } export const Allowed = Object.freeze({ type: Type.Allowed } as const); export const NotAllowed = Object.freeze({ type: Type.NotAllowed } as const); export class Pending { readonly type = Type.Pending; constructor( public readonly operation: CancelablePromise, ) { } } export type State = typeof Allowed | typeof NotAllowed | Pending; } class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustomEditorModel { private _fromBackup: boolean = false; private _hotExitState: HotExitState.State = HotExitState.Allowed; private _backupId: string | undefined; private _currentEditIndex: number = -1; private _savePoint: number = -1; private readonly _edits: Array = []; private _isDirtyFromContentChange = false; private _ongoingSave?: CancelablePromise; // TODO@mjbvz consider to enable a `typeId` that is specific for custom // editors. Using a distinct `typeId` allows the working copy to have // any resource (including file based resources) even if other working // copies exist with the same resource. // // IMPORTANT: changing the `typeId` has an impact on backups for this // working copy. Any value that is not the empty string will be used // as seed to the backup. Only change the `typeId` if you have implemented // a fallback solution to resolve any existing backups that do not have // this seed. readonly typeId = NO_TYPE_ID; public static async create( instantiationService: IInstantiationService, proxy: extHostProtocol.ExtHostCustomEditorsShape, viewType: string, resource: URI, options: { backupId?: string }, getEditors: () => CustomEditorInput[], cancellation: CancellationToken, ): Promise { const editors = getEditors(); let untitledDocumentData: VSBuffer | undefined; if (editors.length !== 0) { untitledDocumentData = editors[0].untitledDocumentData; } const { editable } = await proxy.$createCustomDocument(resource, viewType, options.backupId, untitledDocumentData, cancellation); return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, !!options.backupId, editable, !!untitledDocumentData, getEditors); } constructor( private readonly _proxy: extHostProtocol.ExtHostCustomEditorsShape, private readonly _viewType: string, private readonly _editorResource: URI, fromBackup: boolean, private readonly _editable: boolean, startDirty: boolean, private readonly _getEditors: () => CustomEditorInput[], @IFileDialogService private readonly _fileDialogService: IFileDialogService, @IFileService fileService: IFileService, @ILabelService private readonly _labelService: ILabelService, @IUndoRedoService private readonly _undoService: IUndoRedoService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, @IWorkingCopyService workingCopyService: IWorkingCopyService, @IPathService private readonly _pathService: IPathService, ) { super(MainThreadCustomEditorModel.toWorkingCopyResource(_viewType, _editorResource), fileService); this._fromBackup = fromBackup; if (_editable) { this._register(workingCopyService.registerWorkingCopy(this)); } // Normally means we're re-opening an untitled file if (startDirty) { this._isDirtyFromContentChange = true; } } get editorResource() { return this._editorResource; } override dispose() { if (this._editable) { this._undoService.removeElements(this._editorResource); } this._proxy.$disposeCustomDocument(this._editorResource, this._viewType); super.dispose(); } //#region IWorkingCopy // Make sure each custom editor has a unique resource for backup and edits private static toWorkingCopyResource(viewType: string, resource: URI) { const authority = viewType.replace(/[^a-z0-9\-_]/gi, '-'); const path = `/${multibyteAwareBtoa(resource.with({ query: null, fragment: null }).toString(true))}`; return URI.from({ scheme: Schemas.vscodeCustomEditor, authority: authority, path: path, query: JSON.stringify(resource.toJSON()), }); } public get name() { return basename(this._labelService.getUriLabel(this._editorResource)); } public get capabilities(): WorkingCopyCapabilities { return this.isUntitled() ? WorkingCopyCapabilities.Untitled : WorkingCopyCapabilities.None; } public isDirty(): boolean { if (this._isDirtyFromContentChange) { return true; } if (this._edits.length > 0) { return this._savePoint !== this._currentEditIndex; } return this._fromBackup; } private isUntitled() { return this._editorResource.scheme === Schemas.untitled; } private readonly _onDidChangeDirty: Emitter = this._register(new Emitter()); readonly onDidChangeDirty: Event = this._onDidChangeDirty.event; private readonly _onDidChangeContent: Emitter = this._register(new Emitter()); readonly onDidChangeContent: Event = this._onDidChangeContent.event; readonly onDidChangeReadonly = Event.None; //#endregion public isReadonly(): boolean { return !this._editable; } public get viewType() { return this._viewType; } public get backupId() { return this._backupId; } public pushEdit(editId: number, label: string | undefined) { if (!this._editable) { throw new Error('Document is not editable'); } this.change(() => { this.spliceEdits(editId); this._currentEditIndex = this._edits.length - 1; }); this._undoService.pushElement({ type: UndoRedoElementType.Resource, resource: this._editorResource, label: label ?? localize('defaultEditLabel', "Edit"), undo: () => this.undo(), redo: () => this.redo(), }); } public changeContent() { this.change(() => { this._isDirtyFromContentChange = true; }); } private async undo(): Promise { if (!this._editable) { return; } if (this._currentEditIndex < 0) { // nothing to undo return; } const undoneEdit = this._edits[this._currentEditIndex]; this.change(() => { --this._currentEditIndex; }); await this._proxy.$undo(this._editorResource, this.viewType, undoneEdit, this.isDirty()); } private async redo(): Promise { if (!this._editable) { return; } if (this._currentEditIndex >= this._edits.length - 1) { // nothing to redo return; } const redoneEdit = this._edits[this._currentEditIndex + 1]; this.change(() => { ++this._currentEditIndex; }); await this._proxy.$redo(this._editorResource, this.viewType, redoneEdit, this.isDirty()); } private spliceEdits(editToInsert?: number) { const start = this._currentEditIndex + 1; const toRemove = this._edits.length - this._currentEditIndex; const removedEdits = typeof editToInsert === 'number' ? this._edits.splice(start, toRemove, editToInsert) : this._edits.splice(start, toRemove); if (removedEdits.length) { this._proxy.$disposeEdits(this._editorResource, this._viewType, removedEdits); } } private change(makeEdit: () => void): void { const wasDirty = this.isDirty(); makeEdit(); this._onDidChangeContent.fire(); if (this.isDirty() !== wasDirty) { this._onDidChangeDirty.fire(); } } public async revert(options?: IRevertOptions) { if (!this._editable) { return; } if (this._currentEditIndex === this._savePoint && !this._isDirtyFromContentChange && !this._fromBackup) { return; } if (!options?.soft) { this._proxy.$revert(this._editorResource, this.viewType, CancellationToken.None); } this.change(() => { this._isDirtyFromContentChange = false; this._fromBackup = false; this._currentEditIndex = this._savePoint; this.spliceEdits(); }); } public async save(options?: ISaveOptions): Promise { return !!await this.saveCustomEditor(options); } public async saveCustomEditor(options?: ISaveOptions): Promise { if (!this._editable) { return undefined; } if (this.isUntitled()) { const targetUri = await this.suggestUntitledSavePath(options); if (!targetUri) { return undefined; } await this.saveCustomEditorAs(this._editorResource, targetUri, options); return targetUri; } const savePromise = createCancelablePromise(token => this._proxy.$onSave(this._editorResource, this.viewType, token)); this._ongoingSave?.cancel(); this._ongoingSave = savePromise; try { await savePromise; if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save this.change(() => { this._isDirtyFromContentChange = false; this._savePoint = this._currentEditIndex; this._fromBackup = false; }); } } finally { if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save this._ongoingSave = undefined; } } return this._editorResource; } private suggestUntitledSavePath(options: ISaveOptions | undefined): Promise { if (!this.isUntitled()) { throw new Error('Resource is not untitled'); } const remoteAuthority = this._environmentService.remoteAuthority; const localResource = toLocalResource(this._editorResource, remoteAuthority, this._pathService.defaultUriScheme); return this._fileDialogService.pickFileToSave(localResource, options?.availableFileSystems); } public async saveCustomEditorAs(resource: URI, targetResource: URI, _options?: ISaveOptions): Promise { if (this._editable) { // TODO: handle cancellation await createCancelablePromise(token => this._proxy.$onSaveAs(this._editorResource, this.viewType, targetResource, token)); this.change(() => { this._savePoint = this._currentEditIndex; }); return true; } else { // Since the editor is readonly, just copy the file over await this.fileService.copy(resource, targetResource, false /* overwrite */); return true; } } public async backup(token: CancellationToken): Promise { const editors = this._getEditors(); if (!editors.length) { throw new Error('No editors found for resource, cannot back up'); } const primaryEditor = editors[0]; const backupMeta: CustomDocumentBackupData = { viewType: this.viewType, editorResource: this._editorResource, backupId: '', extension: primaryEditor.extension ? { id: primaryEditor.extension.id.value, location: primaryEditor.extension.location, } : undefined, webview: { id: primaryEditor.id, options: primaryEditor.webview.options, state: primaryEditor.webview.state, } }; const backupData: IWorkingCopyBackup = { meta: backupMeta }; if (!this._editable) { return backupData; } if (this._hotExitState.type === HotExitState.Type.Pending) { this._hotExitState.operation.cancel(); } const pendingState = new HotExitState.Pending( createCancelablePromise(token => this._proxy.$backup(this._editorResource.toJSON(), this.viewType, token))); this._hotExitState = pendingState; token.onCancellationRequested(() => { pendingState.operation.cancel(); }); let errorMessage = ''; try { const backupId = await pendingState.operation; // Make sure state has not changed in the meantime if (this._hotExitState === pendingState) { this._hotExitState = HotExitState.Allowed; backupData.meta!.backupId = backupId; this._backupId = backupId; } } catch (e) { if (isPromiseCanceledError(e)) { // This is expected throw e; } // Otherwise it could be a real error. Make sure state has not changed in the meantime. if (this._hotExitState === pendingState) { this._hotExitState = HotExitState.NotAllowed; } if (e.message) { errorMessage = e.message; } } if (this._hotExitState === HotExitState.Allowed) { return backupData; } throw new Error(`Cannot back up in this state: ${errorMessage}`); } }