mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-25 01:25:36 -05:00
953 lines
32 KiB
TypeScript
953 lines
32 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async';
|
|
import { CancellationToken } from 'vs/base/common/cancellation';
|
|
import { onUnexpectedError, isPromiseCanceledError } 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 { isWeb } from 'vs/base/common/platform';
|
|
import { isEqual, isEqualOrParent } from 'vs/base/common/resources';
|
|
import { escape } from 'vs/base/common/strings';
|
|
import { URI, UriComponents } from 'vs/base/common/uri';
|
|
import * as modes from 'vs/editor/common/modes';
|
|
import { localize } from 'vs/nls';
|
|
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
|
|
import { IFileService } from 'vs/platform/files/common/files';
|
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
|
import { ILabelService } from 'vs/platform/label/common/label';
|
|
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
|
import { IProductService } from 'vs/platform/product/common/productService';
|
|
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
|
import { IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo';
|
|
import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol';
|
|
import { editorGroupToViewColumn, EditorViewColumn, viewColumnToEditorGroup } from 'vs/workbench/api/common/shared/editor';
|
|
import { IEditorInput, IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
|
|
import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput';
|
|
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, WebviewIcons } from 'vs/workbench/contrib/webview/browser/webview';
|
|
import { WebviewInput } from 'vs/workbench/contrib/webview/browser/webviewEditorInput';
|
|
import { ICreateWebViewShowOptions, IWebviewWorkbenchService, WebviewInputOptions } from 'vs/workbench/contrib/webview/browser/webviewWorkbenchService';
|
|
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
|
import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
|
|
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
|
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
|
|
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
|
|
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
|
import { extHostNamedCustomer } from '../common/extHostCustomers';
|
|
|
|
/**
|
|
* Bi-directional map between webview handles and inputs.
|
|
*/
|
|
class WebviewInputStore {
|
|
private readonly _handlesToInputs = new Map<string, WebviewInput>();
|
|
private readonly _inputsToHandles = new Map<WebviewInput, string>();
|
|
|
|
public add(handle: string, input: WebviewInput): void {
|
|
this._handlesToInputs.set(handle, input);
|
|
this._inputsToHandles.set(input, handle);
|
|
}
|
|
|
|
public getHandleForInput(input: WebviewInput): string | undefined {
|
|
return this._inputsToHandles.get(input);
|
|
}
|
|
|
|
public getInputForHandle(handle: string): WebviewInput | undefined {
|
|
return this._handlesToInputs.get(handle);
|
|
}
|
|
|
|
public delete(handle: string): void {
|
|
const input = this.getInputForHandle(handle);
|
|
this._handlesToInputs.delete(handle);
|
|
if (input) {
|
|
this._inputsToHandles.delete(input);
|
|
}
|
|
}
|
|
|
|
public get size(): number {
|
|
return this._handlesToInputs.size;
|
|
}
|
|
|
|
[Symbol.iterator](): Iterator<WebviewInput> {
|
|
return this._handlesToInputs.values();
|
|
}
|
|
}
|
|
|
|
class WebviewViewTypeTransformer {
|
|
public constructor(
|
|
public readonly prefix: string,
|
|
) { }
|
|
|
|
public fromExternal(viewType: string): string {
|
|
return this.prefix + viewType;
|
|
}
|
|
|
|
public toExternal(viewType: string): string | undefined {
|
|
return viewType.startsWith(this.prefix)
|
|
? viewType.substr(this.prefix.length)
|
|
: undefined;
|
|
}
|
|
}
|
|
|
|
const enum ModelType {
|
|
Custom,
|
|
Text,
|
|
}
|
|
|
|
const webviewPanelViewType = new WebviewViewTypeTransformer('mainThreadWebview-');
|
|
|
|
@extHostNamedCustomer(extHostProtocol.MainContext.MainThreadWebviews)
|
|
export class MainThreadWebviews extends Disposable implements extHostProtocol.MainThreadWebviewsShape {
|
|
|
|
private static readonly standardSupportedLinkSchemes = new Set([
|
|
Schemas.http,
|
|
Schemas.https,
|
|
Schemas.mailto,
|
|
Schemas.vscode,
|
|
'vscode-insider',
|
|
]);
|
|
|
|
private readonly _proxy: extHostProtocol.ExtHostWebviewsShape;
|
|
private readonly _webviewInputs = new WebviewInputStore();
|
|
private readonly _revivers = new Map<string, IDisposable>();
|
|
private readonly _editorProviders = new Map<string, IDisposable>();
|
|
private readonly _webviewFromDiffEditorHandles = new Set<string>();
|
|
|
|
constructor(
|
|
context: extHostProtocol.IExtHostContext,
|
|
@IExtensionService extensionService: IExtensionService,
|
|
@IWorkingCopyService workingCopyService: IWorkingCopyService,
|
|
@IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService,
|
|
@ICustomEditorService private readonly _customEditorService: ICustomEditorService,
|
|
@IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService,
|
|
@IEditorService private readonly _editorService: IEditorService,
|
|
@IOpenerService private readonly _openerService: IOpenerService,
|
|
@IProductService private readonly _productService: IProductService,
|
|
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
|
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
|
|
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
|
@IBackupFileService private readonly _backupService: IBackupFileService,
|
|
) {
|
|
super();
|
|
|
|
this._proxy = context.getProxy(extHostProtocol.ExtHostContext.ExtHostWebviews);
|
|
|
|
this._register(_editorService.onDidActiveEditorChange(() => {
|
|
const activeInput = this._editorService.activeEditor;
|
|
if (activeInput instanceof DiffEditorInput && activeInput.primary instanceof WebviewInput && activeInput.secondary instanceof WebviewInput) {
|
|
this.registerWebviewFromDiffEditorListeners(activeInput);
|
|
}
|
|
|
|
this.updateWebviewViewStates(activeInput);
|
|
}));
|
|
|
|
this._register(_editorService.onDidVisibleEditorsChange(() => {
|
|
this.updateWebviewViewStates(this._editorService.activeEditor);
|
|
}));
|
|
|
|
// This reviver's only job is to activate extensions.
|
|
// This should trigger the real reviver to be registered from the extension host side.
|
|
this._register(_webviewWorkbenchService.registerResolver({
|
|
canResolve: (webview: WebviewInput) => {
|
|
if (webview instanceof CustomEditorInput) {
|
|
extensionService.activateByEvent(`onCustomEditor:${webview.viewType}`);
|
|
return false;
|
|
}
|
|
|
|
const viewType = webviewPanelViewType.toExternal(webview.viewType);
|
|
if (typeof viewType === 'string') {
|
|
extensionService.activateByEvent(`onWebviewPanel:${viewType}`);
|
|
}
|
|
return false;
|
|
},
|
|
resolveWebview: () => { throw new Error('not implemented'); }
|
|
}));
|
|
|
|
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;
|
|
|
|
});
|
|
}
|
|
|
|
dispose() {
|
|
super.dispose();
|
|
|
|
for (const disposable of this._editorProviders.values()) {
|
|
disposable.dispose();
|
|
}
|
|
this._editorProviders.clear();
|
|
}
|
|
|
|
public $createWebviewPanel(
|
|
extensionData: extHostProtocol.WebviewExtensionDescription,
|
|
handle: extHostProtocol.WebviewPanelHandle,
|
|
viewType: string,
|
|
title: string,
|
|
showOptions: { viewColumn?: EditorViewColumn, preserveFocus?: boolean; },
|
|
options: WebviewInputOptions
|
|
): void {
|
|
const mainThreadShowOptions: ICreateWebViewShowOptions = Object.create(null);
|
|
if (showOptions) {
|
|
mainThreadShowOptions.preserveFocus = !!showOptions.preserveFocus;
|
|
mainThreadShowOptions.group = viewColumnToEditorGroup(this._editorGroupService, showOptions.viewColumn);
|
|
}
|
|
|
|
const extension = reviveWebviewExtension(extensionData);
|
|
const webview = this._webviewWorkbenchService.createWebview(handle, webviewPanelViewType.fromExternal(viewType), title, mainThreadShowOptions, reviveWebviewOptions(options), extension);
|
|
this.hookupWebviewEventDelegate(handle, webview);
|
|
|
|
this._webviewInputs.add(handle, webview);
|
|
|
|
/* __GDPR__
|
|
"webviews:createWebviewPanel" : {
|
|
"extensionId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
|
|
}
|
|
*/
|
|
this._telemetryService.publicLog('webviews:createWebviewPanel', { extensionId: extension.id.value });
|
|
}
|
|
|
|
public $disposeWebview(handle: extHostProtocol.WebviewPanelHandle): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.dispose();
|
|
}
|
|
|
|
public $setTitle(handle: extHostProtocol.WebviewPanelHandle, value: string): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.setName(value);
|
|
}
|
|
|
|
public $setIconPath(handle: extHostProtocol.WebviewPanelHandle, value: { light: UriComponents, dark: UriComponents; } | undefined): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.iconPath = reviveWebviewIcon(value);
|
|
}
|
|
|
|
public $setHtml(handle: extHostProtocol.WebviewPanelHandle, value: string): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.webview.html = value;
|
|
}
|
|
|
|
public $setOptions(handle: extHostProtocol.WebviewPanelHandle, options: modes.IWebviewOptions): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.webview.contentOptions = reviveWebviewOptions(options);
|
|
}
|
|
|
|
public $reveal(handle: extHostProtocol.WebviewPanelHandle, showOptions: extHostProtocol.WebviewPanelShowOptions): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
if (webview.isDisposed()) {
|
|
return;
|
|
}
|
|
|
|
const targetGroup = this._editorGroupService.getGroup(viewColumnToEditorGroup(this._editorGroupService, showOptions.viewColumn)) || this._editorGroupService.getGroup(webview.group || 0);
|
|
if (targetGroup) {
|
|
this._webviewWorkbenchService.revealWebview(webview, targetGroup, !!showOptions.preserveFocus);
|
|
}
|
|
}
|
|
|
|
public async $postMessage(handle: extHostProtocol.WebviewPanelHandle, message: any): Promise<boolean> {
|
|
const webview = this.getWebviewInput(handle);
|
|
webview.webview.postMessage(message);
|
|
return true;
|
|
}
|
|
|
|
public $registerSerializer(viewType: string): void {
|
|
if (this._revivers.has(viewType)) {
|
|
throw new Error(`Reviver for ${viewType} already registered`);
|
|
}
|
|
|
|
this._revivers.set(viewType, this._webviewWorkbenchService.registerResolver({
|
|
canResolve: (webviewInput) => {
|
|
return webviewInput.viewType === webviewPanelViewType.fromExternal(viewType);
|
|
},
|
|
resolveWebview: async (webviewInput): Promise<void> => {
|
|
const viewType = webviewPanelViewType.toExternal(webviewInput.viewType);
|
|
if (!viewType) {
|
|
webviewInput.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(webviewInput.viewType);
|
|
return;
|
|
}
|
|
|
|
const handle = webviewInput.id;
|
|
this._webviewInputs.add(handle, webviewInput);
|
|
this.hookupWebviewEventDelegate(handle, webviewInput);
|
|
|
|
let state = undefined;
|
|
if (webviewInput.webview.state) {
|
|
try {
|
|
state = JSON.parse(webviewInput.webview.state);
|
|
} catch (e) {
|
|
console.error('Could not load webview state', e, webviewInput.webview.state);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await this._proxy.$deserializeWebviewPanel(handle, viewType, webviewInput.getTitle(), state, editorGroupToViewColumn(this._editorGroupService, webviewInput.group || 0), webviewInput.webview.options);
|
|
} catch (error) {
|
|
onUnexpectedError(error);
|
|
webviewInput.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(viewType);
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
public $unregisterSerializer(viewType: string): void {
|
|
const reviver = this._revivers.get(viewType);
|
|
if (!reviver) {
|
|
throw new Error(`No reviver for ${viewType} registered`);
|
|
}
|
|
|
|
reviver.dispose();
|
|
this._revivers.delete(viewType);
|
|
}
|
|
|
|
public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: modes.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities): void {
|
|
this.registerEditorProvider(ModelType.Text, extensionData, viewType, options, capabilities, true);
|
|
}
|
|
|
|
public $registerCustomEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: modes.IWebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void {
|
|
this.registerEditorProvider(ModelType.Custom, extensionData, viewType, options, {}, supportsMultipleEditorsPerDocument);
|
|
}
|
|
|
|
private registerEditorProvider(
|
|
modelType: ModelType,
|
|
extensionData: extHostProtocol.WebviewExtensionDescription,
|
|
viewType: string,
|
|
options: modes.IWebviewPanelOptions,
|
|
capabilities: extHostProtocol.CustomTextEditorCapabilities,
|
|
supportsMultipleEditorsPerDocument: boolean,
|
|
): void {
|
|
if (this._editorProviders.has(viewType)) {
|
|
throw new Error(`Provider for ${viewType} already registered`);
|
|
}
|
|
|
|
const extension = reviveWebviewExtension(extensionData);
|
|
|
|
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._webviewInputs.add(handle, webviewInput);
|
|
this.hookupWebviewEventDelegate(handle, webviewInput);
|
|
webviewInput.webview.options = options;
|
|
webviewInput.webview.extension = extension;
|
|
|
|
let modelRef: IReference<ICustomEditorModel>;
|
|
try {
|
|
modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId: webviewInput.backupId }, cancellation);
|
|
} catch (error) {
|
|
onUnexpectedError(error);
|
|
webviewInput.webview.html = MainThreadWebviews.getWebviewResolvedFailedContent(viewType);
|
|
return;
|
|
}
|
|
|
|
if (cancellation.isCancellationRequested) {
|
|
modelRef.dispose();
|
|
return;
|
|
}
|
|
|
|
webviewInput.webview.onDispose(() => {
|
|
// 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._proxy.$onMoveCustomEditor(handle, newResource, viewType);
|
|
oldModel.dispose();
|
|
});
|
|
}
|
|
|
|
try {
|
|
await this._proxy.$resolveWebviewEditor(resource, handle, viewType, webviewInput.getTitle(), editorGroupToViewColumn(this._editorGroupService, webviewInput.group || 0), webviewInput.webview.options, cancellation);
|
|
} catch (error) {
|
|
onUnexpectedError(error);
|
|
webviewInput.webview.html = MainThreadWebviews.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: ModelType,
|
|
resource: URI,
|
|
viewType: string,
|
|
options: { backupId?: string },
|
|
cancellation: CancellationToken,
|
|
): Promise<IReference<ICustomEditorModel>> {
|
|
const existingModel = this._customEditorService.models.tryRetain(resource, viewType);
|
|
if (existingModel) {
|
|
return existingModel;
|
|
}
|
|
|
|
switch (modelType) {
|
|
case ModelType.Text:
|
|
{
|
|
const model = CustomTextEditorModel.create(this._instantiationService, viewType, resource);
|
|
return this._customEditorService.models.add(resource, viewType, model);
|
|
}
|
|
case ModelType.Custom:
|
|
{
|
|
const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxy, viewType, resource, options, () => {
|
|
return Array.from(this._webviewInputs)
|
|
.filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[];
|
|
}, cancellation, this._backupService);
|
|
return this._customEditorService.models.add(resource, viewType, model);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async $onDidEdit(resourceComponents: UriComponents, viewType: string, editId: number, label: string | undefined): Promise<void> {
|
|
const model = await this.getCustomEditorModel(resourceComponents, viewType);
|
|
model.pushEdit(editId, label);
|
|
}
|
|
|
|
public async $onContentChange(resourceComponents: UriComponents, viewType: string): Promise<void> {
|
|
const model = await this.getCustomEditorModel(resourceComponents, viewType);
|
|
model.changeContent();
|
|
}
|
|
|
|
private hookupWebviewEventDelegate(handle: extHostProtocol.WebviewPanelHandle, input: WebviewInput) {
|
|
const disposables = new DisposableStore();
|
|
|
|
disposables.add(input.webview.onDidClickLink((uri) => this.onDidClickLink(handle, uri)));
|
|
disposables.add(input.webview.onMessage((message: any) => { this._proxy.$onMessage(handle, message); }));
|
|
disposables.add(input.webview.onMissingCsp((extension: ExtensionIdentifier) => this._proxy.$onMissingCsp(handle, extension.value)));
|
|
|
|
disposables.add(input.webview.onDispose(() => {
|
|
disposables.dispose();
|
|
|
|
this._proxy.$onDidDisposeWebviewPanel(handle).finally(() => {
|
|
this._webviewInputs.delete(handle);
|
|
});
|
|
}));
|
|
}
|
|
|
|
private registerWebviewFromDiffEditorListeners(diffEditorInput: DiffEditorInput): void {
|
|
const primary = diffEditorInput.primary as WebviewInput;
|
|
const secondary = diffEditorInput.secondary as WebviewInput;
|
|
|
|
if (this._webviewFromDiffEditorHandles.has(primary.id) || this._webviewFromDiffEditorHandles.has(secondary.id)) {
|
|
return;
|
|
}
|
|
|
|
this._webviewFromDiffEditorHandles.add(primary.id);
|
|
this._webviewFromDiffEditorHandles.add(secondary.id);
|
|
|
|
const disposables = new DisposableStore();
|
|
disposables.add(primary.webview.onDidFocus(() => this.updateWebviewViewStates(primary)));
|
|
disposables.add(secondary.webview.onDidFocus(() => this.updateWebviewViewStates(secondary)));
|
|
disposables.add(diffEditorInput.onDispose(() => {
|
|
this._webviewFromDiffEditorHandles.delete(primary.id);
|
|
this._webviewFromDiffEditorHandles.delete(secondary.id);
|
|
dispose(disposables);
|
|
}));
|
|
}
|
|
|
|
private updateWebviewViewStates(activeEditorInput: IEditorInput | undefined) {
|
|
if (!this._webviewInputs.size) {
|
|
return;
|
|
}
|
|
|
|
const viewStates: extHostProtocol.WebviewPanelViewStateData = {};
|
|
|
|
const updateViewStatesForInput = (group: IEditorGroup, topLevelInput: IEditorInput, editorInput: IEditorInput) => {
|
|
if (!(editorInput instanceof WebviewInput)) {
|
|
return;
|
|
}
|
|
|
|
editorInput.updateGroup(group.id);
|
|
|
|
const handle = this._webviewInputs.getHandleForInput(editorInput);
|
|
if (handle) {
|
|
viewStates[handle] = {
|
|
visible: topLevelInput === group.activeEditor,
|
|
active: editorInput === activeEditorInput,
|
|
position: editorGroupToViewColumn(this._editorGroupService, group.id),
|
|
};
|
|
}
|
|
};
|
|
|
|
for (const group of this._editorGroupService.groups) {
|
|
for (const input of group.editors) {
|
|
if (input instanceof DiffEditorInput) {
|
|
updateViewStatesForInput(group, input, input.primary);
|
|
updateViewStatesForInput(group, input, input.secondary);
|
|
} else {
|
|
updateViewStatesForInput(group, input, input);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(viewStates).length) {
|
|
this._proxy.$onDidChangeWebviewPanelViewStates(viewStates);
|
|
}
|
|
}
|
|
|
|
private onDidClickLink(handle: extHostProtocol.WebviewPanelHandle, link: string): void {
|
|
const webview = this.getWebviewInput(handle);
|
|
if (this.isSupportedLink(webview, URI.parse(link))) {
|
|
this._openerService.open(link, { fromUserGesture: true });
|
|
}
|
|
}
|
|
|
|
private isSupportedLink(webview: WebviewInput, link: URI): boolean {
|
|
if (MainThreadWebviews.standardSupportedLinkSchemes.has(link.scheme)) {
|
|
return true;
|
|
}
|
|
if (!isWeb && this._productService.urlProtocol === link.scheme) {
|
|
return true;
|
|
}
|
|
return !!webview.webview.contentOptions.enableCommandUris && link.scheme === Schemas.command;
|
|
}
|
|
|
|
private getWebviewInput(handle: extHostProtocol.WebviewPanelHandle): WebviewInput {
|
|
const webview = this.tryGetWebviewInput(handle);
|
|
if (!webview) {
|
|
throw new Error(`Unknown webview handle:${handle}`);
|
|
}
|
|
return webview;
|
|
}
|
|
|
|
private tryGetWebviewInput(handle: extHostProtocol.WebviewPanelHandle): WebviewInput | undefined {
|
|
return this._webviewInputs.getInputForHandle(handle);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static getWebviewResolvedFailedContent(viewType: string) {
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
|
|
</head>
|
|
<body>${localize('errorMessage', "An error occurred while loading view: {0}", escape(viewType))}</body>
|
|
</html>`;
|
|
}
|
|
}
|
|
|
|
function reviveWebviewExtension(extensionData: extHostProtocol.WebviewExtensionDescription): WebviewExtensionDescription {
|
|
return { id: extensionData.id, location: URI.revive(extensionData.location) };
|
|
}
|
|
|
|
function reviveWebviewOptions(options: modes.IWebviewOptions): WebviewInputOptions {
|
|
return {
|
|
...options,
|
|
allowScripts: options.enableScripts,
|
|
localResourceRoots: Array.isArray(options.localResourceRoots) ? options.localResourceRoots.map(r => URI.revive(r)) : undefined,
|
|
};
|
|
}
|
|
|
|
function reviveWebviewIcon(
|
|
value: { light: UriComponents, dark: UriComponents; } | undefined
|
|
): WebviewIcons | undefined {
|
|
return value
|
|
? { light: URI.revive(value.light), dark: URI.revive(value.dark) }
|
|
: undefined;
|
|
}
|
|
|
|
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<string>,
|
|
) { }
|
|
}
|
|
|
|
export type State = typeof Allowed | typeof NotAllowed | Pending;
|
|
}
|
|
|
|
|
|
class MainThreadCustomEditorModel extends Disposable implements ICustomEditorModel, IWorkingCopy {
|
|
|
|
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<number> = [];
|
|
private _isDirtyFromContentChange = false;
|
|
|
|
private _ongoingSave?: CancelablePromise<void>;
|
|
|
|
public static async create(
|
|
instantiationService: IInstantiationService,
|
|
proxy: extHostProtocol.ExtHostWebviewsShape,
|
|
viewType: string,
|
|
resource: URI,
|
|
options: { backupId?: string },
|
|
getEditors: () => CustomEditorInput[],
|
|
cancellation: CancellationToken,
|
|
_backupFileService: IBackupFileService,
|
|
) {
|
|
const { editable } = await proxy.$createCustomDocument(resource, viewType, options.backupId, cancellation);
|
|
return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, !!options.backupId, editable, getEditors);
|
|
}
|
|
|
|
constructor(
|
|
private readonly _proxy: extHostProtocol.ExtHostWebviewsShape,
|
|
private readonly _viewType: string,
|
|
private readonly _editorResource: URI,
|
|
fromBackup: boolean,
|
|
private readonly _editable: boolean,
|
|
private readonly _getEditors: () => CustomEditorInput[],
|
|
@IWorkingCopyService workingCopyService: IWorkingCopyService,
|
|
@ILabelService private readonly _labelService: ILabelService,
|
|
@IFileService private readonly _fileService: IFileService,
|
|
@IUndoRedoService private readonly _undoService: IUndoRedoService,
|
|
) {
|
|
super();
|
|
|
|
this._fromBackup = fromBackup;
|
|
|
|
if (_editable) {
|
|
this._register(workingCopyService.registerWorkingCopy(this));
|
|
}
|
|
}
|
|
|
|
get editorResource() {
|
|
return this._editorResource;
|
|
}
|
|
|
|
dispose() {
|
|
if (this._editable) {
|
|
this._undoService.removeElements(this._editorResource);
|
|
}
|
|
this._proxy.$disposeCustomDocument(this._editorResource, this._viewType);
|
|
super.dispose();
|
|
}
|
|
|
|
//#region IWorkingCopy
|
|
|
|
public get resource() {
|
|
// Make sure each custom editor has a unique resource for backup and edits
|
|
return MainThreadCustomEditorModel.toWorkingCopyResource(this._viewType, this._editorResource);
|
|
}
|
|
|
|
private static toWorkingCopyResource(viewType: string, resource: URI) {
|
|
return URI.from({
|
|
scheme: Schemas.vscodeCustomEditor,
|
|
authority: viewType,
|
|
path: resource.path,
|
|
query: JSON.stringify(resource.toJSON()),
|
|
});
|
|
}
|
|
|
|
public get name() {
|
|
return basename(this._labelService.getUriLabel(this._editorResource));
|
|
}
|
|
|
|
public get capabilities(): WorkingCopyCapabilities {
|
|
return 0;
|
|
}
|
|
|
|
public isDirty(): boolean {
|
|
if (this._isDirtyFromContentChange) {
|
|
return true;
|
|
}
|
|
if (this._edits.length > 0) {
|
|
return this._savePoint !== this._currentEditIndex;
|
|
}
|
|
return this._fromBackup;
|
|
}
|
|
|
|
private readonly _onDidChangeDirty: Emitter<void> = this._register(new Emitter<void>());
|
|
readonly onDidChangeDirty: Event<void> = this._onDidChangeDirty.event;
|
|
|
|
private readonly _onDidChangeContent: Emitter<void> = this._register(new Emitter<void>());
|
|
readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;
|
|
|
|
//#endregion
|
|
|
|
public isReadonly() {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
|
|
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<boolean> {
|
|
return !!await this.saveCustomEditor(options);
|
|
}
|
|
|
|
public async saveCustomEditor(_options?: ISaveOptions): Promise<URI | undefined> {
|
|
if (!this._editable) {
|
|
return undefined;
|
|
}
|
|
// TODO: handle save untitled case
|
|
|
|
const savePromise = createCancelablePromise(token => this._proxy.$onSave(this._editorResource, this.viewType, token));
|
|
this._ongoingSave?.cancel();
|
|
this._ongoingSave = savePromise;
|
|
|
|
this.change(() => {
|
|
this._isDirtyFromContentChange = false;
|
|
this._savePoint = this._currentEditIndex;
|
|
this._fromBackup = false;
|
|
});
|
|
|
|
try {
|
|
await savePromise;
|
|
} finally {
|
|
if (this._ongoingSave === savePromise) {
|
|
this._ongoingSave = undefined;
|
|
}
|
|
}
|
|
return this._editorResource;
|
|
}
|
|
|
|
public async saveCustomEditorAs(resource: URI, targetResource: URI, _options?: ISaveOptions): Promise<boolean> {
|
|
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(): Promise<IWorkingCopyBackup> {
|
|
const editors = this._getEditors();
|
|
if (!editors.length) {
|
|
throw new Error('No editors found for resource, cannot back up');
|
|
}
|
|
const primaryEditor = editors[0];
|
|
|
|
const backupData: IWorkingCopyBackup<CustomDocumentBackupData> = {
|
|
meta: {
|
|
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,
|
|
}
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
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 (this._hotExitState === HotExitState.Allowed) {
|
|
return backupData;
|
|
}
|
|
|
|
throw new Error('Cannot back up in this state');
|
|
}
|
|
}
|