diff --git a/product.json b/product.json index a2398faaac..f27e1ab8d9 100644 --- a/product.json +++ b/product.json @@ -54,6 +54,11 @@ "IDERA.sqldm-performance-insights", "SentryOne.plan-explorer" ], + "recommendedExtensionsByScenario": { + "visualizerExtensions": [ + "msrvida.azdata-sanddance" + ] + }, "extensionsGallery": { "serviceUrl": "https://sqlopsextensions.blob.core.windows.net/marketplace/v1/extensionsGallery.json" } diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 0d13aa9c9c..fbec63c414 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -3974,10 +3974,18 @@ declare module 'azdata' { export type QueryEvent = | 'queryStart' | 'queryStop' - | 'executionPlan'; + | 'executionPlan' + | 'visualize'; + /** + * args for each event type + * queryStart: undefined + * queryStop: undefined + * executionPlan: string + * visualize: ResultSetSummary + */ export interface QueryEventListener { - onQueryEvent(type: QueryEvent, document: queryeditor.QueryDocument, args: any): void; + onQueryEvent(type: QueryEvent, document: queryeditor.QueryDocument, args: ResultSetSummary | string | undefined): void; } // new extensibility interfaces diff --git a/src/sql/base/browser/ui/table/media/slickColorTheme.css b/src/sql/base/browser/ui/table/media/slickColorTheme.css index bd92a83354..1b16834fcd 100644 --- a/src/sql/base/browser/ui/table/media/slickColorTheme.css +++ b/src/sql/base/browser/ui/table/media/slickColorTheme.css @@ -122,6 +122,10 @@ background-image: url("viewChart.svg"); } +.vs .icon.viewVisualizer { + background-image: url("viewVisualizer.svg"); +} + /* headers */ .vs .resultsMessageHeader { background: var(--color-bg-header); @@ -260,6 +264,11 @@ background-image: url("viewChart_inverse.svg"); } +.vs-dark .icon.viewVisualizer, +.hc-black .icon.viewVisualizer { + background-image: url("viewVisualizer_inverse.svg"); +} + .grid-panel .action-label.icon { height: 16px; min-width: 28px; diff --git a/src/sql/base/browser/ui/table/media/viewVisualizer.svg b/src/sql/base/browser/ui/table/media/viewVisualizer.svg new file mode 100644 index 0000000000..9a345e9e77 --- /dev/null +++ b/src/sql/base/browser/ui/table/media/viewVisualizer.svg @@ -0,0 +1 @@ +viewVisualizer \ No newline at end of file diff --git a/src/sql/base/browser/ui/table/media/viewVisualizer_inverse.svg b/src/sql/base/browser/ui/table/media/viewVisualizer_inverse.svg new file mode 100644 index 0000000000..2f926edff2 --- /dev/null +++ b/src/sql/base/browser/ui/table/media/viewVisualizer_inverse.svg @@ -0,0 +1 @@ +viewVisualizer \ No newline at end of file diff --git a/src/sql/platform/query/common/queryModelService.ts b/src/sql/platform/query/common/queryModelService.ts index 35ad50a507..a775bcc69f 100644 --- a/src/sql/platform/query/common/queryModelService.ts +++ b/src/sql/platform/query/common/queryModelService.ts @@ -310,6 +310,15 @@ export class QueryModelService implements IQueryModelService { this._onQueryEvent.fire(event); }); + queryRunner.onVisualize(resultSetInfo => { + let event: IQueryEvent = { + type: 'visualize', + uri: uri, + params: resultSetInfo + }; + this._onQueryEvent.fire(event); + }); + info.queryRunner = queryRunner; info.dataService = this._instantiationService.createInstance(DataService, uri); this._queryInfoMap.set(uri, info); diff --git a/src/sql/platform/query/common/queryRunner.ts b/src/sql/platform/query/common/queryRunner.ts index 4aa60be6a8..d72713d881 100644 --- a/src/sql/platform/query/common/queryRunner.ts +++ b/src/sql/platform/query/common/queryRunner.ts @@ -85,6 +85,9 @@ export default class QueryRunner extends Disposable { private _onQueryPlanAvailable = this._register(new Emitter()); public readonly onQueryPlanAvailable = this._onQueryPlanAvailable.event; + private _onVisualize = this._register(new Emitter()); + public readonly onVisualize = this._onVisualize.event; + private _queryStartTime: Date; public get queryStartTime(): Date { return this._queryStartTime; @@ -579,6 +582,17 @@ export default class QueryRunner extends Disposable { public getGridDataProvider(batchId: number, resultSetId: number): IGridDataProvider { return this.instantiationService.createInstance(QueryGridDataProvider, this, batchId, resultSetId); } + + public notifyVisualizeRequested(batchId: number, resultSetId: number): void { + let result: azdata.ResultSetSummary = { + batchId: batchId, + id: resultSetId, + columnInfo: this.batchSets[batchId].resultSetSummaries[resultSetId].columnInfo, + complete: true, + rowCount: this.batchSets[batchId].resultSetSummaries[resultSetId].rowCount + }; + this._onVisualize.fire(result); + } } export class QueryGridDataProvider implements IGridDataProvider { diff --git a/src/sql/workbench/api/common/extHostQueryEditor.ts b/src/sql/workbench/api/common/extHostQueryEditor.ts index 53df160ed0..ab02d594e4 100644 --- a/src/sql/workbench/api/common/extHostQueryEditor.ts +++ b/src/sql/workbench/api/common/extHostQueryEditor.ts @@ -57,8 +57,8 @@ export class ExtHostQueryEditor implements ExtHostQueryEditorShape { public $onQueryEvent(handle: number, fileUri: string, event: IQueryEvent): void { let listener: azdata.queryeditor.QueryEventListener = this._queryListeners[handle]; if (listener) { - let planXml = event.params ? event.params.planXml : undefined; - listener.onQueryEvent(event.type, new ExtHostQueryDocument(mssqlProviderName, fileUri, this._proxy), planXml); + let params = event.params && event.params.planXml ? event.params.planXml : event.params; + listener.onQueryEvent(event.type, new ExtHostQueryDocument(mssqlProviderName, fileUri, this._proxy), params); } } diff --git a/src/sql/workbench/contrib/extensions/constants.ts b/src/sql/workbench/contrib/extensions/constants.ts new file mode 100644 index 0000000000..e28624ffc4 --- /dev/null +++ b/src/sql/workbench/contrib/extensions/constants.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const visualizerExtensions = 'visualizerExtensions'; diff --git a/src/sql/workbench/contrib/extensions/extensionsActions.ts b/src/sql/workbench/contrib/extensions/extensionsActions.ts new file mode 100644 index 0000000000..33bf428ddc --- /dev/null +++ b/src/sql/workbench/contrib/extensions/extensionsActions.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { IAction, Action } from 'vs/base/common/actions'; +import { ShowViewletAction } from 'vs/workbench/browser/viewlet'; +import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; +import { IExtension, ExtensionState, IExtensionsWorkbenchService, VIEWLET_ID, IExtensionsViewlet, AutoUpdateConfigurationKey, IExtensionContainer, EXTENSIONS_CONFIG, ExtensionsPolicy, ExtensionsPolicyKey } from 'vs/workbench/contrib/extensions/common/extensions'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IExtensionRecommendation, IExtensionManagementServerService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { PagedModel } from 'vs/base/common/paging'; +import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; + +function getScenarioID(scenarioType: string) { + return 'workbench.extensions.action.show' + scenarioType; +} + +export class ShowRecommendedExtensionsByScenarioAction extends Action { + constructor( + private readonly scenarioType: string, + @IViewletService private readonly viewletService: IViewletService + ) { + super(getScenarioID(scenarioType), localize('showRecommendations', "Show Recommendations"), undefined, true); + } + + run(): Promise { + return this.viewletService.openViewlet(VIEWLET_ID, true) + .then(viewlet => viewlet as IExtensionsViewlet) + .then(viewlet => { + viewlet.search('@' + this.scenarioType); + viewlet.focus(); + }); + } +} + +export class InstallRecommendedExtensionsByScenarioAction extends Action { + private _recommendations: IExtensionRecommendation[] = []; + get recommendations(): IExtensionRecommendation[] { return this._recommendations; } + set recommendations(recommendations: IExtensionRecommendation[]) { this._recommendations = recommendations; this.enabled = this._recommendations.length > 0; } + + constructor( + private readonly scenarioType: string, + recommendations: IExtensionRecommendation[], + @IViewletService private readonly viewletService: IViewletService, + @INotificationService private readonly notificationService: INotificationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IOpenerService private readonly openerService: IOpenerService, + @IExtensionsWorkbenchService private readonly extensionWorkbenchService: IExtensionsWorkbenchService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService + ) { + super(getScenarioID(scenarioType), localize('Install Extensions', "Install Extensions"), 'extension-action'); + this.recommendations = recommendations; + } + + run(): Promise { + if (!this.recommendations.length) { return Promise.resolve(); } + return this.viewletService.openViewlet(VIEWLET_ID, true) + .then(viewlet => viewlet as IExtensionsViewlet) + .then(viewlet => { + viewlet.search('@' + this.scenarioType); + viewlet.focus(); + const names = this.recommendations.map(({ extensionId }) => extensionId); + return this.extensionWorkbenchService.queryGallery({ names, source: 'install-' + this.scenarioType }, CancellationToken.None).then(pager => { + let installPromises: Promise[] = []; + let model = new PagedModel(pager); + for (let i = 0; i < pager.total; i++) { + installPromises.push(model.resolve(i, CancellationToken.None).then(e => this.extensionWorkbenchService.install(e))); + } + return Promise.all(installPromises); + }); + }); + } +} \ No newline at end of file diff --git a/src/sql/workbench/parts/charts/browser/chartTab.ts b/src/sql/workbench/parts/charts/browser/chartTab.ts index e5c744a8e7..d36ff495df 100644 --- a/src/sql/workbench/parts/charts/browser/chartTab.ts +++ b/src/sql/workbench/parts/charts/browser/chartTab.ts @@ -34,4 +34,4 @@ export class ChartTab implements IPanelTab { public clear() { this.view.clear(); } -} +} \ No newline at end of file diff --git a/src/sql/workbench/parts/grid/common/gridContentEvents.ts b/src/sql/workbench/parts/grid/common/gridContentEvents.ts index 74aa5e9ada..5d39850433 100644 --- a/src/sql/workbench/parts/grid/common/gridContentEvents.ts +++ b/src/sql/workbench/parts/grid/common/gridContentEvents.ts @@ -17,5 +17,6 @@ export const SaveAsJSON = 'SaveAsJSON'; export const SaveAsExcel = 'SaveAsExcel'; export const SaveAsXML = 'SaveAsXML'; export const ViewAsChart = 'ViewAsChart'; +export const ViewAsVisualizer = 'ViewAsVisualizer'; export const GoToNextQueryOutputTab = 'GoToNextQueryOutputTab'; export const GoToNextGrid = 'GoToNextGrid'; diff --git a/src/sql/workbench/parts/grid/views/gridActions.ts b/src/sql/workbench/parts/grid/views/gridActions.ts index 9b6aa2589c..2bbb770409 100644 --- a/src/sql/workbench/parts/grid/views/gridActions.ts +++ b/src/sql/workbench/parts/grid/views/gridActions.ts @@ -22,6 +22,7 @@ export const TOGGLERESULTS_ID = 'grid.toggleResultPane'; export const TOGGLEMESSAGES_ID = 'grid.toggleMessagePane'; export const GOTONEXTQUERYOUTPUTTAB_ID = 'query.goToNextQueryOutputTab'; export const GRID_VIEWASCHART_ID = 'grid.viewAsChart'; +export const GRID_VIEWASVISUALIZER_ID = 'grid.viewAsVisualizer'; export const GRID_GOTONEXTGRID_ID = 'grid.goToNextGrid'; export class GridActionProvider { diff --git a/src/sql/workbench/parts/grid/views/gridCommands.ts b/src/sql/workbench/parts/grid/views/gridCommands.ts index 00135a29f7..3eed2c5a7b 100644 --- a/src/sql/workbench/parts/grid/views/gridCommands.ts +++ b/src/sql/workbench/parts/grid/views/gridCommands.ts @@ -83,6 +83,10 @@ export const viewAsChart = (accessor: ServicesAccessor) => { runActionOnActiveResultsEditor(accessor, GridContentEvents.ViewAsChart); }; +export const viewAsVisualizer = (accessor: ServicesAccessor) => { + runActionOnActiveResultsEditor(accessor, GridContentEvents.ViewAsVisualizer); +}; + export const goToNextGrid = (accessor: ServicesAccessor) => { runActionOnActiveResultsEditor(accessor, GridContentEvents.GoToNextGrid); }; diff --git a/src/sql/workbench/parts/grid/views/gridParentComponent.ts b/src/sql/workbench/parts/grid/views/gridParentComponent.ts index 4c2187e4a5..07d50ca48e 100644 --- a/src/sql/workbench/parts/grid/views/gridParentComponent.ts +++ b/src/sql/workbench/parts/grid/views/gridParentComponent.ts @@ -156,6 +156,9 @@ export abstract class GridParentComponent { case GridContentEvents.ViewAsChart: self.showChartForGrid(self.activeGrid); break; + case GridContentEvents.ViewAsVisualizer: + self.showVisualizerForGrid(self.activeGrid); + break; case GridContentEvents.GoToNextGrid: self.goToNextGrid(); break; @@ -277,6 +280,9 @@ export abstract class GridParentComponent { protected showChartForGrid(index: number) { } + protected showVisualizerForGrid(index: number) { + } + protected goToNextGrid() { if (this.renderedDataSets.length > 0) { let next = this.activeGrid + 1; diff --git a/src/sql/workbench/parts/query/browser/actions.ts b/src/sql/workbench/parts/query/browser/actions.ts index e2fe5d0872..5a9c5162c7 100644 --- a/src/sql/workbench/parts/query/browser/actions.ts +++ b/src/sql/workbench/parts/query/browser/actions.ts @@ -8,7 +8,7 @@ import { localize } from 'vs/nls'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { ITree } from 'vs/base/parts/tree/browser/tree'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; - +import { IExtensionTipsService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import { SaveFormat } from 'sql/workbench/parts/grid/common/interfaces'; import { Table } from 'sql/base/browser/ui/table/table'; import { QueryEditor } from './queryEditor'; @@ -17,7 +17,10 @@ import { isWindows } from 'vs/base/common/platform'; import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { IGridDataProvider } from 'sql/platform/query/common/gridDataProvider'; import { INotificationService } from 'vs/platform/notification/common/notification'; +import QueryRunner from 'sql/platform/query/common/queryRunner'; +import product from 'vs/platform/product/node/product'; import { GridTableState } from 'sql/workbench/parts/query/common/gridPanelState'; +import * as Constants from 'sql/workbench/contrib/extensions/constants'; export interface IGridActionContext { gridDataProvider: IGridDataProvider; @@ -199,13 +202,38 @@ export class ChartDataAction extends Action { public static LABEL = localize('chart', "Chart"); public static ICON = 'viewChart'; - constructor(@IEditorService private editorService: IEditorService) { + constructor( + @IEditorService private editorService: IEditorService, + @IExtensionTipsService private readonly extensionTipsService: IExtensionTipsService + ) { super(ChartDataAction.ID, ChartDataAction.LABEL, ChartDataAction.ICON); } public run(context: IGridActionContext): Promise { const activeEditor = this.editorService.activeControl as QueryEditor; + if (product.quality !== 'stable') { + this.extensionTipsService.promptRecommendedExtensionsByScenario(Constants.visualizerExtensions); + } activeEditor.chart({ batchId: context.batchId, resultId: context.resultId }); return Promise.resolve(true); } } + +export class VisualizerDataAction extends Action { + public static ID = 'grid.visualizer'; + public static LABEL = localize("visualizer", "Visualizer"); + public static ICON = 'viewVisualizer'; + + constructor( + private runner: QueryRunner, + @IEditorService private editorService: IEditorService, + + ) { + super(VisualizerDataAction.ID, VisualizerDataAction.LABEL, VisualizerDataAction.ICON); + } + + public run(context: IGridActionContext): Promise { + this.runner.notifyVisualizeRequested(context.batchId, context.resultId); + return Promise.resolve(true); + } +} diff --git a/src/sql/workbench/parts/query/browser/gridPanel.ts b/src/sql/workbench/parts/query/browser/gridPanel.ts index 7ffc4ff355..b4dad55043 100644 --- a/src/sql/workbench/parts/query/browser/gridPanel.ts +++ b/src/sql/workbench/parts/query/browser/gridPanel.ts @@ -11,7 +11,7 @@ import { ScrollableSplitView, IView } from 'sql/base/browser/ui/scrollableSplitv import { MouseWheelSupport } from 'sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin'; import { AutoColumnSize } from 'sql/base/browser/ui/table/plugins/autoSizeColumns.plugin'; import { SaveFormat } from 'sql/workbench/parts/grid/common/interfaces'; -import { IGridActionContext, SaveResultAction, CopyResultAction, SelectAllGridAction, MaximizeTableAction, RestoreTableAction, ChartDataAction } from 'sql/workbench/parts/query/browser/actions'; +import { IGridActionContext, SaveResultAction, CopyResultAction, SelectAllGridAction, MaximizeTableAction, RestoreTableAction, ChartDataAction, VisualizerDataAction } from 'sql/workbench/parts/query/browser/actions'; import { CellSelectionModel } from 'sql/base/browser/ui/table/plugins/cellSelectionModel.plugin'; import { RowNumberColumn } from 'sql/base/browser/ui/table/plugins/rowNumberColumn.plugin'; import { escape } from 'sql/base/common/strings'; @@ -24,6 +24,7 @@ import * as azdata from 'azdata'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { Emitter, Event } from 'vs/base/common/event'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { isUndefinedOrNull } from 'vs/base/common/types'; @@ -723,17 +724,18 @@ export abstract class GridTableBase extends Disposable implements IView { class GridTable extends GridTableBase { private _gridDataProvider: IGridDataProvider; constructor( - runner: QueryRunner, + private _runner: QueryRunner, resultSet: azdata.ResultSetSummary, state: GridTableState, @IContextMenuService contextMenuService: IContextMenuService, @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService private contextKeyService: IContextKeyService, @IEditorService editorService: IEditorService, @IUntitledEditorService untitledEditorService: IUntitledEditorService, @IConfigurationService configurationService: IConfigurationService ) { super(state, resultSet, contextMenuService, instantiationService, editorService, untitledEditorService, configurationService); - this._gridDataProvider = this.instantiationService.createInstance(QueryGridDataProvider, runner, resultSet.batchId, resultSet.id); + this._gridDataProvider = this.instantiationService.createInstance(QueryGridDataProvider, this._runner, resultSet.batchId, resultSet.id); } get gridDataProvider(): IGridDataProvider { @@ -760,6 +762,10 @@ class GridTable extends GridTableBase { this.instantiationService.createInstance(ChartDataAction) ); + if (this.contextKeyService.getContextKeyValue('showVisualizer')) { + actions.push(this.instantiationService.createInstance(VisualizerDataAction, this._runner)); + } + return actions; } diff --git a/src/sql/workbench/parts/query/browser/queryActions.ts b/src/sql/workbench/parts/query/browser/queryActions.ts index 1dcd685e4a..55b91b8164 100644 --- a/src/sql/workbench/parts/query/browser/queryActions.ts +++ b/src/sql/workbench/parts/query/browser/queryActions.ts @@ -11,6 +11,7 @@ import { IContextViewService } from 'vs/platform/contextview/browser/contextView import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IExtensionTipsService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import Severity from 'vs/base/common/severity'; import { append, $ } from 'vs/base/browser/dom'; @@ -107,7 +108,8 @@ export class RunQueryAction extends QueryTaskbarAction { constructor( editor: QueryEditor, @IQueryModelService protected readonly queryModelService: IQueryModelService, - @IConnectionManagementService connectionManagementService: IConnectionManagementService + @IConnectionManagementService connectionManagementService: IConnectionManagementService, + @IExtensionTipsService private readonly extensionTipsService: IExtensionTipsService ) { super(connectionManagementService, editor, RunQueryAction.ID, RunQueryAction.EnabledClass); this.label = nls.localize('runQueryLabel', "Run"); diff --git a/src/sql/workbench/parts/query/browser/queryResultsView.ts b/src/sql/workbench/parts/query/browser/queryResultsView.ts index efe5e442be..a2b7b2075f 100644 --- a/src/sql/workbench/parts/query/browser/queryResultsView.ts +++ b/src/sql/workbench/parts/query/browser/queryResultsView.ts @@ -119,7 +119,6 @@ class ResultsView extends Disposable implements IPanelView { this.gridPanel.state = val; } } - class ResultsTab implements IPanelTab { public readonly title = nls.localize('resultsTabTitle', "Results"); public readonly identifier = 'resultsTab'; diff --git a/src/sql/workbench/parts/query/common/localizedConstants.ts b/src/sql/workbench/parts/query/common/localizedConstants.ts index c6a9ba432c..aad4974a40 100644 --- a/src/sql/workbench/parts/query/common/localizedConstants.ts +++ b/src/sql/workbench/parts/query/common/localizedConstants.ts @@ -26,6 +26,7 @@ export const saveJSONLabel = localize('saveJSONLabel', "Save as JSON"); export const saveExcelLabel = localize('saveExcelLabel', "Save as Excel"); export const saveXMLLabel = localize('saveXMLLabel', "Save as XML"); export const viewChartLabel = localize('viewChartLabel', "View as Chart"); +export const viewVisualizerLabel = localize('viewVisualizerLabel', "Visualize"); export const resultPaneLabel = localize('resultPaneLabel', "Results"); export const executeQueryLabel = localize('executeQueryLabel', "Executing query "); diff --git a/src/sql/workbench/parts/query/test/browser/queryActions.test.ts b/src/sql/workbench/parts/query/test/browser/queryActions.test.ts index c7d24f5d5f..da6213401e 100644 --- a/src/sql/workbench/parts/query/test/browser/queryActions.test.ts +++ b/src/sql/workbench/parts/query/test/browser/queryActions.test.ts @@ -69,7 +69,7 @@ suite('SQL QueryAction Tests', () => { test('setClass sets child CSS class correctly', (done) => { // If I create a RunQueryAction - let queryAction: QueryTaskbarAction = new RunQueryAction(undefined, undefined, undefined); + let queryAction: QueryTaskbarAction = new RunQueryAction(undefined, undefined, undefined, undefined); // "class should automatically get set to include the base class and the RunQueryAction class let className = RunQueryAction.EnabledClass; @@ -93,7 +93,7 @@ suite('SQL QueryAction Tests', () => { editor.setup(x => x.input).returns(() => testQueryInput.object); // If I create a QueryTaskbarAction and I pass a non-connected editor to _getConnectedQueryEditorUri - let queryAction: QueryTaskbarAction = new RunQueryAction(undefined, undefined, connectionManagementService.object); + let queryAction: QueryTaskbarAction = new RunQueryAction(undefined, undefined, connectionManagementService.object, undefined); let connected: boolean = queryAction.isConnected(editor.object); // I should get an unconnected state @@ -136,7 +136,7 @@ suite('SQL QueryAction Tests', () => { }); // If I call run on RunQueryAction when I am not connected - let queryAction: RunQueryAction = new RunQueryAction(editor.object, queryModelService.object, connectionManagementService.object); + let queryAction: RunQueryAction = new RunQueryAction(editor.object, queryModelService.object, connectionManagementService.object, undefined); isConnected = false; calledRunQueryOnInput = false; queryAction.run(); @@ -195,7 +195,7 @@ suite('SQL QueryAction Tests', () => { let queryModelService = TypeMoq.Mock.ofType(QueryModelService, TypeMoq.MockBehavior.Loose); // If I call run on RunQueryAction when I have a non empty selection - let queryAction: RunQueryAction = new RunQueryAction(queryEditor.object, queryModelService.object, connectionManagementService.object); + let queryAction: RunQueryAction = new RunQueryAction(queryEditor.object, queryModelService.object, connectionManagementService.object, undefined); isSelectionEmpty = false; queryAction.run(); @@ -266,7 +266,7 @@ suite('SQL QueryAction Tests', () => { /// End Setup Test /// ////// If I call run on RunQueryAction while disconnected and with an undefined selection - let queryAction: RunQueryAction = new RunQueryAction(queryEditor.object, undefined, connectionManagementService.object); + let queryAction: RunQueryAction = new RunQueryAction(queryEditor.object, undefined, connectionManagementService.object, undefined); isConnected = false; selectionToReturnInGetSelection = undefined; queryAction.run(); diff --git a/src/sql/workbench/parts/query/test/browser/queryEditor.test.ts b/src/sql/workbench/parts/query/test/browser/queryEditor.test.ts index bbe25e9f15..9fbe720fdc 100644 --- a/src/sql/workbench/parts/query/test/browser/queryEditor.test.ts +++ b/src/sql/workbench/parts/query/test/browser/queryEditor.test.ts @@ -54,7 +54,7 @@ suite('SQL QueryEditor Tests', () => { return new Promise((resolve) => resolve(mockEditor)); }); instantiationService.setup(x => x.createInstance(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((input) => { - return new Promise((resolve) => resolve(new RunQueryAction(undefined, undefined, undefined))); + return new Promise((resolve) => resolve(new RunQueryAction(undefined, undefined, undefined, undefined))); }); // Setup hook to capture calls to create the listDatabase action instantiationService.setup(x => x.createInstance(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((classDef, editor, action) => { @@ -64,7 +64,7 @@ suite('SQL QueryEditor Tests', () => { } } // Default - return new RunQueryAction(undefined, undefined, undefined); + return new RunQueryAction(undefined, undefined, undefined, undefined); }); // Mock EditorDescriptorService to give us a mock editor description @@ -269,7 +269,7 @@ suite('SQL QueryEditor Tests', () => { queryActionInstantiationService.setup(x => x.createInstance(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((input) => { // Default - return new RunQueryAction(undefined, undefined, undefined); + return new RunQueryAction(undefined, undefined, undefined, undefined); }); // Setup hook to capture calls to create the listDatabase action @@ -280,7 +280,7 @@ suite('SQL QueryEditor Tests', () => { return item; } // Default - return new RunQueryAction(undefined, undefined, undefined); + return new RunQueryAction(undefined, undefined, undefined, undefined); }); let fileInput = new UntitledEditorInput(URI.parse('file://testUri'), false, '', '', '', instantiationService.object, undefined, undefined); diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 4fafc347a2..3d32f3d96d 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -211,4 +211,4 @@ export interface IExtensionManagementService { export const ExtensionsLabel = localize('extensions', "Extensions"); export const ExtensionsChannelId = 'extensions'; -export const PreferencesLabel = localize('preferences', "Preferences"); +export const PreferencesLabel = localize('preferences', "Preferences"); \ No newline at end of file diff --git a/src/vs/platform/product/common/product.ts b/src/vs/platform/product/common/product.ts index 71e8fc69b8..e8080310f7 100644 --- a/src/vs/platform/product/common/product.ts +++ b/src/vs/platform/product/common/product.ts @@ -74,6 +74,7 @@ export interface IProductConfiguration { }; extensionTips: { [id: string]: string; }; recommendedExtensions: string[]; // {{SQL CARBON EDIT}} + recommendedExtensionsByScenario: string[]; // {{SQL CARBON EDIT}} extensionImportantTips: { [id: string]: { name: string; pattern: string; isExtensionPack?: boolean }; }; readonly exeBasedExtensionTips: { [id: string]: IExeBasedExtensionTip; }; readonly extensionKeywords: { [extension: string]: readonly string[]; }; diff --git a/src/vs/workbench/browser/web.simpleservices.ts b/src/vs/workbench/browser/web.simpleservices.ts index c7fc53d235..b791e65a4c 100644 --- a/src/vs/workbench/browser/web.simpleservices.ts +++ b/src/vs/workbench/browser/web.simpleservices.ts @@ -71,6 +71,16 @@ export class SimpleExtensionTipsService implements IExtensionTipsService { getAllIgnoredRecommendations(): { global: string[]; workspace: string[]; } { return { global: [], workspace: [] }; } + + // {{SQL CARBON EDIT}} + getRecommendedExtensionsByScenario(scenarioType: string): Promise { + return Promise.resolve([]); + } + + promptRecommendedExtensionsByScenario(scenarioType: string): void { + return; + } + // {{SQL CARBON EDIT}} - End } registerSingleton(IExtensionTipsService, SimpleExtensionTipsService, true); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index ac6af06cb5..7a632bcff4 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -47,6 +47,9 @@ import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async import { isUIExtension } from 'vs/workbench/services/extensions/common/extensionsUtil'; import { IProductService } from 'vs/platform/product/common/product'; import { SeverityIcon } from 'vs/platform/severityIcon/common/severityIcon'; +// {{SQL CARBON EDIT}} +import product from 'vs/platform/product/node/product'; + class ExtensionsViewState extends Disposable implements IExtensionsViewState { @@ -424,6 +427,19 @@ export class ExtensionsListView extends ViewletPanel { options.sortBy = SortBy.InstallCount; } + // {{SQL CARBON EDIT}} + let promiseRecommendedExtensionsByScenario; + Object.keys(product.recommendedExtensionsByScenario).forEach(scenarioType => { + let re = new RegExp('@' + scenarioType, 'i'); + if (re.test(query.value)) { + promiseRecommendedExtensionsByScenario = this.getRecommendedExtensionsByScenario(token, scenarioType); + } + }); + if (promiseRecommendedExtensionsByScenario) { + return promiseRecommendedExtensionsByScenario; + } + // {{SQL CARBON EDIT}} - End + if (ExtensionsListView.isWorkspaceRecommendedExtensionsQuery(query.value)) { return this.getWorkspaceRecommendationsModel(query, options, token); } else if (ExtensionsListView.isKeymapsRecommendedExtensionsQuery(query.value)) { @@ -650,6 +666,31 @@ export class ExtensionsListView extends ViewletPanel { }); } + // {{SQL CARBON EDIT}} + private getRecommendedExtensionsByScenario(token: CancellationToken, scenarioType: string): Promise> { + if (!scenarioType) { + return Promise.reject(new Error(localize('scenarioTypeUndefined', 'The scenario type for extension recommendations must be provided.'))); + } + return this.extensionsWorkbenchService.queryLocal() + .then(result => result.filter(e => e.type === ExtensionType.User)) + .then(local => { + return this.tipsService.getRecommendedExtensionsByScenario(scenarioType).then((recommmended) => { + const installedExtensions = local.map(x => `${x.publisher}.${x.name}`); + return this.extensionsWorkbenchService.queryGallery(token).then((pager) => { + // filter out installed extensions and the extensions not in the recommended list + pager.firstPage = pager.firstPage.filter((p) => { + const extensionId = `${p.publisher}.${p.name}`; + return installedExtensions.indexOf(extensionId) === -1 && recommmended.findIndex(ext => ext.extensionId === extensionId) !== -1; + }); + pager.total = pager.firstPage.length; + pager.pageSize = pager.firstPage.length; + return this.getPagedModel(pager); + }); + }); + }); + } + // {{SQL CARBON EDIT}} - End + // Given all recommendations, trims and returns recommendations in the relevant order after filtering out installed extensions private getTrimmedRecommendations(installedExtensions: IExtension[], value: string, fileBasedRecommendations: IExtensionRecommendation[], otherRecommendations: IExtensionRecommendation[], workpsaceRecommendations: IExtensionRecommendation[]): string[] { const totalCount = 8; diff --git a/src/vs/workbench/contrib/extensions/electron-browser/extensionTipsService.ts b/src/vs/workbench/contrib/extensions/electron-browser/extensionTipsService.ts index 2ca6604731..708938b5c9 100644 --- a/src/vs/workbench/contrib/extensions/electron-browser/extensionTipsService.ts +++ b/src/vs/workbench/contrib/extensions/electron-browser/extensionTipsService.ts @@ -16,7 +16,11 @@ import { ITextModel } from 'vs/editor/common/model'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import product from 'vs/platform/product/node/product'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +// {{SQL CARBON EDIT}} import { ShowRecommendedExtensionsAction, InstallWorkspaceRecommendedExtensionsAction, InstallRecommendedExtensionAction } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; +import { ShowRecommendedExtensionsByScenarioAction, InstallRecommendedExtensionsByScenarioAction } from 'sql/workbench/contrib/extensions/extensionsActions'; +import * as Constants from 'sql/workbench/contrib/extensions/constants'; +// {{SQL CARBON EDIT}} - End import Severity from 'vs/base/common/severity'; import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace, IWorkspaceFoldersChangeEvent, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IFileService } from 'vs/platform/files/common/files'; @@ -1148,4 +1152,79 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe private isExtensionAllowedToBeRecommended(id: string): boolean { return this._allIgnoredRecommendations.indexOf(id.toLowerCase()) === -1; } + + // {{SQL CARBON EDIT}} + promptRecommendedExtensionsByScenario(scenarioType: string): void { + const storageKey = 'extensionAssistant/RecommendationsIgnore/' + scenarioType; + + if (this.storageService.getBoolean(storageKey, StorageScope.GLOBAL, false)) { + return; + } + + let recommendations: IExtensionRecommendation[]; + let localExtensions: ILocalExtension[]; + const getRecommendationPromise = this.getRecommendedExtensionsByScenario(scenarioType).then(recs => { recommendations = recs; }); + const getLocalExtensionPromise = this.extensionsService.getInstalled(ExtensionType.User).then(local => { localExtensions = local; }); + + let recommendationMessage = localize('ExtensionsRecommended', "Azure Data Studio has extension recommendations."); + if (scenarioType === Constants.visualizerExtensions) { + recommendationMessage = localize('VisualizerExtensionsRecommended', "Azure Data Studio has extension recommendations for data visualization.\nOnce installed, you can select the Visualizer icon to visualize your query results."); + } + Promise.all([getRecommendationPromise, getLocalExtensionPromise]).then(() => { + if (!recommendations.every(rec => { return localExtensions.findIndex(local => local.identifier.id.toLocaleLowerCase() === rec.extensionId.toLocaleLowerCase()) !== -1; })) { + return new Promise(c => { + this.notificationService.prompt( + Severity.Info, + recommendationMessage, + [{ + label: localize('installAll', "Install All"), + run: () => { + this.telemetryService.publicLog(scenarioType + 'Recommendations:popup', { userReaction: 'install' }); + const installAllAction = this.instantiationService.createInstance(InstallRecommendedExtensionsByScenarioAction, scenarioType, recommendations); + installAllAction.run(); + installAllAction.dispose(); + } + }, { + label: localize('showRecommendations', "Show Recommendations"), + run: () => { + this.telemetryService.publicLog(scenarioType + 'Recommendations:popup', { userReaction: 'show' }); + const showAction = this.instantiationService.createInstance(ShowRecommendedExtensionsByScenarioAction, scenarioType); + showAction.run(); + showAction.dispose(); + c(undefined); + } + }, { + label: choiceNever, + isSecondary: true, + run: () => { + this.telemetryService.publicLog(scenarioType + 'Recommendations:popup', { userReaction: 'neverShowAgain' }); + this.storageService.store(storageKey, true, StorageScope.GLOBAL); + c(undefined); + } + }], + { + sticky: true, + onCancel: () => { + this.telemetryService.publicLog(scenarioType + 'Recommendations:popup', { userReaction: 'cancelled' }); + c(undefined); + } + } + ); + }); + } else { + return Promise.resolve(); + } + }); + } + + getRecommendedExtensionsByScenario(scenarioType: string): Promise { + if (!scenarioType) { + return Promise.reject(new Error(localize('scenarioTypeUndefined', 'The scenario type for extension recommendations must be provided.'))); + } + + return Promise.resolve((product.recommendedExtensionsByScenario[scenarioType] || []) + .filter(extensionId => this.isExtensionAllowedToBeRecommended(extensionId)) + .map(extensionId => ({ extensionId, sources: ['application'] }))); + } + // {{SQL CARBON EDIT}} - End } diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts index 2383d94fcb..d0bc631b1d 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts @@ -106,6 +106,10 @@ export interface IExtensionTipsService { toggleIgnoredRecommendation(extensionId: string, shouldIgnore: boolean): void; getAllIgnoredRecommendations(): { global: string[], workspace: string[] }; onRecommendationChange: Event; + // {{SQL CARBON EDIT}} + getRecommendedExtensionsByScenario(scenarioType: string): Promise; + promptRecommendedExtensionsByScenario(scenarioType: string): void; + // {{SQL CARBON EDIT}} - End } export const enum ExtensionRecommendationReason {