mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 18:46:40 -05:00
Support notebook file types contribution (#3196)
* Support notebook file types contribution - Extensions can define a provider and what file types it should be used for - Verified that this works for Jupyter Content & Server Managers. - Starts Jupyter server as expected Not in this PR: - Support for session manager end to end - Tests
This commit is contained in:
@@ -3,16 +3,22 @@
|
|||||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { Registry } from 'vs/platform/registry/common/platform';
|
||||||
import { EditorInput, IEditorInput } from 'vs/workbench/common/editor';
|
import { EditorInput, IEditorInput } from 'vs/workbench/common/editor';
|
||||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||||
import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput';
|
import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput';
|
||||||
import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput';
|
import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput';
|
||||||
|
import URI from 'vs/base/common/uri';
|
||||||
|
|
||||||
import { QueryResultsInput } from 'sql/parts/query/common/queryResultsInput';
|
import { QueryResultsInput } from 'sql/parts/query/common/queryResultsInput';
|
||||||
import { QueryInput } from 'sql/parts/query/common/queryInput';
|
import { QueryInput } from 'sql/parts/query/common/queryInput';
|
||||||
import URI from 'vs/base/common/uri';
|
|
||||||
import { IQueryEditorOptions } from 'sql/parts/query/common/queryEditorService';
|
import { IQueryEditorOptions } from 'sql/parts/query/common/queryEditorService';
|
||||||
import { QueryPlanInput } from 'sql/parts/queryPlan/queryPlanInput';
|
import { QueryPlanInput } from 'sql/parts/queryPlan/queryPlanInput';
|
||||||
import { NotebookInput, NotebookInputModel } from 'sql/parts/notebook/notebookInput';
|
import { NotebookInput, NotebookInputModel } from 'sql/parts/notebook/notebookInput';
|
||||||
|
import { Extensions, INotebookProviderRegistry } from 'sql/services/notebook/notebookRegistry';
|
||||||
|
import { DEFAULT_NOTEBOOK_PROVIDER } from 'sql/services/notebook/notebookService';
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -54,9 +60,15 @@ export function convertEditorInput(input: EditorInput, options: IQueryEditorOpti
|
|||||||
uri = getNotebookEditorUri(input);
|
uri = getNotebookEditorUri(input);
|
||||||
if(uri){
|
if(uri){
|
||||||
//TODO: We need to pass in notebook data either through notebook input or notebook service
|
//TODO: We need to pass in notebook data either through notebook input or notebook service
|
||||||
let fileName: string = input? input.getName() : 'untitled';
|
let fileName: string = 'untitled';
|
||||||
|
let providerId: string = DEFAULT_NOTEBOOK_PROVIDER;
|
||||||
|
if (input) {
|
||||||
|
fileName = input.getName();
|
||||||
|
providerId = getProviderForFileName(fileName);
|
||||||
|
}
|
||||||
let notebookInputModel = new NotebookInputModel(uri, undefined, false, undefined);
|
let notebookInputModel = new NotebookInputModel(uri, undefined, false, undefined);
|
||||||
//TO DO: Second paramter has to be the content.
|
notebookInputModel.providerId = providerId;
|
||||||
|
//TO DO: Second parameter has to be the content.
|
||||||
let notebookInput: NotebookInput = instantiationService.createInstance(NotebookInput, fileName, notebookInputModel);
|
let notebookInput: NotebookInput = instantiationService.createInstance(NotebookInput, fileName, notebookInputModel);
|
||||||
return notebookInput;
|
return notebookInput;
|
||||||
}
|
}
|
||||||
@@ -91,7 +103,6 @@ export function getSupportedInputResource(input: IEditorInput): URI {
|
|||||||
// file extensions for the inputs we support (should be all upper case for comparison)
|
// file extensions for the inputs we support (should be all upper case for comparison)
|
||||||
const sqlFileTypes = ['SQL'];
|
const sqlFileTypes = ['SQL'];
|
||||||
const sqlPlanFileTypes = ['SQLPLAN'];
|
const sqlPlanFileTypes = ['SQLPLAN'];
|
||||||
const notebookFileType = ['IPYNB'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If input is a supported query editor file, return it's URI. Otherwise return undefined.
|
* If input is a supported query editor file, return it's URI. Otherwise return undefined.
|
||||||
@@ -155,7 +166,7 @@ function getNotebookEditorUri(input: EditorInput): URI {
|
|||||||
if (!(input instanceof NotebookInput)) {
|
if (!(input instanceof NotebookInput)) {
|
||||||
let uri: URI = getSupportedInputResource(input);
|
let uri: URI = getSupportedInputResource(input);
|
||||||
if (uri) {
|
if (uri) {
|
||||||
if (hasFileExtension(notebookFileType, input, false)) {
|
if (hasFileExtension(getNotebookFileExtensions(), input, false)) {
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,6 +175,22 @@ function getNotebookEditorUri(input: EditorInput): URI {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNotebookFileExtensions() {
|
||||||
|
let notebookRegistry = Registry.as<INotebookProviderRegistry>(Extensions.NotebookProviderContribution);
|
||||||
|
return notebookRegistry.getSupportedFileExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderForFileName(fileName: string) {
|
||||||
|
let fileExt = path.extname(fileName);
|
||||||
|
if (fileExt && fileExt.startsWith('.')) {
|
||||||
|
fileExt = fileExt.slice(1,fileExt.length);
|
||||||
|
let notebookRegistry = Registry.as<INotebookProviderRegistry>(Extensions.NotebookProviderContribution);
|
||||||
|
return notebookRegistry.getProviderForFileType(fileExt);
|
||||||
|
}
|
||||||
|
return DEFAULT_NOTEBOOK_PROVIDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the given EditorInput is set to either undefined or sql mode
|
* Checks whether the given EditorInput is set to either undefined or sql mode
|
||||||
* @param input The EditorInput to check the mode of
|
* @param input The EditorInput to check the mode of
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import { TPromise } from 'vs/base/common/winjs.base';
|
|||||||
import { Schemas } from 'vs/base/common/network';
|
import { Schemas } from 'vs/base/common/network';
|
||||||
import URI from 'vs/base/common/uri';
|
import URI from 'vs/base/common/uri';
|
||||||
import { localize } from 'vs/nls';
|
import { localize } from 'vs/nls';
|
||||||
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||||
|
|
||||||
import { NotebookInput, NotebookInputModel } from 'sql/parts/notebook/notebookInput';
|
import { NotebookInput, NotebookInputModel } from 'sql/parts/notebook/notebookInput';
|
||||||
import { NotebookEditor } from 'sql/parts/notebook/notebookEditor';
|
import { NotebookEditor } from 'sql/parts/notebook/notebookEditor';
|
||||||
|
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +33,8 @@ export class OpenNotebookAction extends Action {
|
|||||||
constructor(
|
constructor(
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
@IEditorService private _editorService: IEditorService
|
@IEditorService private _editorService: IEditorService,
|
||||||
|
@IInstantiationService private _instantiationService: IInstantiationService
|
||||||
) {
|
) {
|
||||||
super(id, label);
|
super(id, label);
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,7 @@ export class OpenNotebookAction extends Action {
|
|||||||
return new TPromise<void>((resolve, reject) => {
|
return new TPromise<void>((resolve, reject) => {
|
||||||
let untitledUri = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter++}`});
|
let untitledUri = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter++}`});
|
||||||
let model = new NotebookInputModel(untitledUri, undefined, false, undefined);
|
let model = new NotebookInputModel(untitledUri, undefined, false, undefined);
|
||||||
let input = new NotebookInput('modelViewId', model,);
|
let input = this._instantiationService.createInstance(NotebookInput, 'modelViewId', model);
|
||||||
this._editorService.openEditor(input, { pinned: true });
|
this._editorService.openEditor(input, { pinned: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { IEditorModel } from 'vs/platform/editor/common/editor';
|
|||||||
import { EditorInput, EditorModel, ConfirmResult } from 'vs/workbench/common/editor';
|
import { EditorInput, EditorModel, ConfirmResult } from 'vs/workbench/common/editor';
|
||||||
import { Emitter, Event } from 'vs/base/common/event';
|
import { Emitter, Event } from 'vs/base/common/event';
|
||||||
import URI from 'vs/base/common/uri';
|
import URI from 'vs/base/common/uri';
|
||||||
|
import { INotebookService } from 'sql/services/notebook/notebookService';
|
||||||
|
|
||||||
export type ModeViewSaveHandler = (handle: number) => Thenable<boolean>;
|
export type ModeViewSaveHandler = (handle: number) => Thenable<boolean>;
|
||||||
|
|
||||||
@@ -66,11 +67,17 @@ export class NotebookInput extends EditorInput {
|
|||||||
// Holds the HTML content for the editor when the editor discards this input and loads another
|
// Holds the HTML content for the editor when the editor discards this input and loads another
|
||||||
private _parentContainer: HTMLElement;
|
private _parentContainer: HTMLElement;
|
||||||
|
|
||||||
constructor(private _title: string, private _model: NotebookInputModel,
|
constructor(private _title: string,
|
||||||
|
private _model: NotebookInputModel,
|
||||||
|
@INotebookService private notebookService: INotebookService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this._model.onDidChangeDirty(() => this._onDidChangeDirty.fire());
|
this._model.onDidChangeDirty(() => this._onDidChangeDirty.fire());
|
||||||
|
this.onDispose(() => {
|
||||||
|
if (this.notebookService) {
|
||||||
|
this.notebookService.handleNotebookClosed(this.notebookUri);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public get title(): string {
|
public get title(): string {
|
||||||
|
|||||||
116
src/sql/services/notebook/notebookRegistry.ts
Normal file
116
src/sql/services/notebook/notebookRegistry.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { IJSONSchema } from 'vs/base/common/jsonSchema';
|
||||||
|
import { ExtensionsRegistry, IExtensionPointUser } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||||
|
import { localize } from 'vs/nls';
|
||||||
|
import * as platform from 'vs/platform/registry/common/platform';
|
||||||
|
|
||||||
|
export const Extensions = {
|
||||||
|
NotebookProviderContribution: 'notebook.providers'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NotebookProviderDescription {
|
||||||
|
provider: string;
|
||||||
|
fileExtensions: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let notebookProviderType: IJSONSchema = {
|
||||||
|
type: 'object',
|
||||||
|
default: { provider: '', fileExtensions: [] },
|
||||||
|
properties: {
|
||||||
|
provider: {
|
||||||
|
description: localize('carbon.extension.contributes.notebook.provider', 'Identifier of the notebook provider.'),
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
fileExtensions: {
|
||||||
|
description: localize('carbon.extension.contributes.notebook.fileExtensions', 'What file extensions should be registered to this notebook provider'),
|
||||||
|
oneOf: [
|
||||||
|
{ type: 'string' },
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let notebookContrib: IJSONSchema = {
|
||||||
|
description: localize('vscode.extension.contributes.notebook.providers', "Contributes notebook providers."),
|
||||||
|
oneOf: [
|
||||||
|
notebookProviderType,
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
items: notebookProviderType
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface INotebookProviderRegistry {
|
||||||
|
registerNotebookProvider(provider: NotebookProviderDescription): void;
|
||||||
|
getSupportedFileExtensions(): string[];
|
||||||
|
getProviderForFileType(fileType: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotebookProviderRegistry implements INotebookProviderRegistry {
|
||||||
|
private providerIdToProviders = new Map<string, NotebookProviderDescription>();
|
||||||
|
private fileToProviders = new Map<string, NotebookProviderDescription>();
|
||||||
|
|
||||||
|
registerNotebookProvider(provider: NotebookProviderDescription): void {
|
||||||
|
// Note: this method intentionally overrides default provider for a file type.
|
||||||
|
// This means that any built-in provider will be overridden by registered extensions
|
||||||
|
this.providerIdToProviders.set(provider.provider, provider);
|
||||||
|
if (provider.fileExtensions) {
|
||||||
|
if (Array.isArray<string>(provider.fileExtensions)) {
|
||||||
|
for (let fileType of provider.fileExtensions) {
|
||||||
|
this.addFileProvider(fileType, provider);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addFileProvider(provider.fileExtensions, provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFileProvider(fileType: string, provider: NotebookProviderDescription) {
|
||||||
|
this.fileToProviders.set(fileType.toUpperCase(), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedFileExtensions(): string[] {
|
||||||
|
return Array.from(this.fileToProviders.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderForFileType(fileType: string): string {
|
||||||
|
fileType = fileType.toUpperCase();
|
||||||
|
let provider = this.fileToProviders.get(fileType);
|
||||||
|
return provider ? provider.provider : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notebookProviderRegistry = new NotebookProviderRegistry();
|
||||||
|
platform.Registry.add(Extensions.NotebookProviderContribution, notebookProviderRegistry);
|
||||||
|
|
||||||
|
|
||||||
|
ExtensionsRegistry.registerExtensionPoint<NotebookProviderDescription | NotebookProviderDescription[]>(Extensions.NotebookProviderContribution, [], notebookContrib).setHandler(extensions => {
|
||||||
|
|
||||||
|
function handleExtension(contrib: NotebookProviderDescription, extension: IExtensionPointUser<any>) {
|
||||||
|
notebookProviderRegistry.registerNotebookProvider(contrib);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let extension of extensions) {
|
||||||
|
const { value } = extension;
|
||||||
|
if (Array.isArray<NotebookProviderDescription>(value)) {
|
||||||
|
for (let command of value) {
|
||||||
|
handleExtension(command, extension);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleExtension(value, extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,6 +40,8 @@ export interface INotebookService {
|
|||||||
*/
|
*/
|
||||||
getOrCreateNotebookManager(providerId: string, uri: URI): Thenable<INotebookManager>;
|
getOrCreateNotebookManager(providerId: string, uri: URI): Thenable<INotebookManager>;
|
||||||
|
|
||||||
|
handleNotebookClosed(uri: URI): void;
|
||||||
|
|
||||||
shutdown(): void;
|
shutdown(): void;
|
||||||
|
|
||||||
getMimeRegistry(): RenderMimeRegistry;
|
getMimeRegistry(): RenderMimeRegistry;
|
||||||
|
|||||||
@@ -6,26 +6,37 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import { nb } from 'sqlops';
|
import { nb } from 'sqlops';
|
||||||
import * as nls from 'vs/nls';
|
import { localize } from 'vs/nls';
|
||||||
import { INotebookService, INotebookManager, INotebookProvider, DEFAULT_NOTEBOOK_PROVIDER } from 'sql/services/notebook/notebookService';
|
|
||||||
import URI from 'vs/base/common/uri';
|
import URI from 'vs/base/common/uri';
|
||||||
|
import { Registry } from 'vs/platform/registry/common/platform';
|
||||||
|
|
||||||
|
import { INotebookService, INotebookManager, INotebookProvider, DEFAULT_NOTEBOOK_PROVIDER } from 'sql/services/notebook/notebookService';
|
||||||
import { RenderMimeRegistry } from 'sql/parts/notebook/outputs/registry';
|
import { RenderMimeRegistry } from 'sql/parts/notebook/outputs/registry';
|
||||||
import { standardRendererFactories } from 'sql/parts/notebook/outputs/factories';
|
import { standardRendererFactories } from 'sql/parts/notebook/outputs/factories';
|
||||||
import { LocalContentManager } from 'sql/services/notebook/localContentManager';
|
import { LocalContentManager } from 'sql/services/notebook/localContentManager';
|
||||||
import { session } from 'electron';
|
|
||||||
import { SessionManager } from 'sql/services/notebook/sessionManager';
|
import { SessionManager } from 'sql/services/notebook/sessionManager';
|
||||||
|
import { Extensions, INotebookProviderRegistry } from 'sql/services/notebook/notebookRegistry';
|
||||||
|
|
||||||
|
const DEFAULT_NOTEBOOK_FILETYPE = 'IPYNB';
|
||||||
|
|
||||||
export class NotebookService implements INotebookService {
|
export class NotebookService implements INotebookService {
|
||||||
_serviceBrand: any;
|
_serviceBrand: any;
|
||||||
private _mimeRegistry: RenderMimeRegistry;
|
private _mimeRegistry: RenderMimeRegistry;
|
||||||
private _providers: Map<string, INotebookProvider> = new Map();
|
private _providers: Map<string, INotebookProvider> = new Map();
|
||||||
private _managers: Map<URI, INotebookManager> = new Map();
|
private _managers: Map<string, INotebookManager> = new Map();
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
mimeRegistry: RenderMimeRegistry;
|
this.registerDefaultProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDefaultProvider() {
|
||||||
let defaultProvider = new BuiltinProvider();
|
let defaultProvider = new BuiltinProvider();
|
||||||
this.registerProvider(defaultProvider.providerId, defaultProvider);
|
this.registerProvider(defaultProvider.providerId, defaultProvider);
|
||||||
|
let registry = Registry.as<INotebookProviderRegistry>(Extensions.NotebookProviderContribution);
|
||||||
|
registry.registerNotebookProvider({
|
||||||
|
provider: defaultProvider.providerId,
|
||||||
|
fileExtensions: DEFAULT_NOTEBOOK_FILETYPE
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
registerProvider(providerId: string, provider: INotebookProvider): void {
|
registerProvider(providerId: string, provider: INotebookProvider): void {
|
||||||
@@ -47,24 +58,37 @@ export class NotebookService implements INotebookService {
|
|||||||
|
|
||||||
async getOrCreateNotebookManager(providerId: string, uri: URI): Promise<INotebookManager> {
|
async getOrCreateNotebookManager(providerId: string, uri: URI): Promise<INotebookManager> {
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
throw new Error(nls.localize('notebookUriNotDefined', 'No URI was passed when creating a notebook manager'));
|
throw new Error(localize('notebookUriNotDefined', 'No URI was passed when creating a notebook manager'));
|
||||||
}
|
}
|
||||||
let manager = this._managers.get(uri);
|
let uriString = uri.toString();
|
||||||
|
let manager = this._managers.get(uriString);
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
manager = await this.doWithProvider(providerId, (provider) => provider.getNotebookManager(uri));
|
manager = await this.doWithProvider(providerId, (provider) => provider.getNotebookManager(uri));
|
||||||
if (manager) {
|
if (manager) {
|
||||||
this._managers.set(uri, manager);
|
this._managers.set(uriString, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleNotebookClosed(notebookUri: URI): void {
|
||||||
|
// Remove the manager from the tracked list, and let the notebook provider know that it should update its mappings
|
||||||
|
let uriString = notebookUri.toString();
|
||||||
|
let manager = this._managers.get(uriString);
|
||||||
|
if (manager) {
|
||||||
|
this._managers.delete(uriString);
|
||||||
|
let provider = this._providers.get(manager.providerId);
|
||||||
|
provider.handleNotebookClosed(notebookUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// PRIVATE HELPERS /////////////////////////////////////////////////////
|
// PRIVATE HELPERS /////////////////////////////////////////////////////
|
||||||
private doWithProvider<T>(providerId: string, op: (provider: INotebookProvider) => Thenable<T>): Thenable<T> {
|
private doWithProvider<T>(providerId: string, op: (provider: INotebookProvider) => Thenable<T>): Thenable<T> {
|
||||||
// Make sure the provider exists before attempting to retrieve accounts
|
// Make sure the provider exists before attempting to retrieve accounts
|
||||||
let provider = this._providers.get(providerId);
|
let provider = this._providers.get(providerId);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return Promise.reject(new Error(nls.localize('notebookServiceNoProvider', 'Notebook provider does not exist'))).then();
|
return Promise.reject(new Error(localize('notebookServiceNoProvider', 'Notebook provider does not exist'))).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
return op(provider);
|
return op(provider);
|
||||||
|
|||||||
Reference in New Issue
Block a user