diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index fda24939b9..8f182df555 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -1310,6 +1310,10 @@ declare module 'azdata' { * Warning/parallelism badges applicable to the current node */ badges: ExecutionPlanBadge[]; + /** + * Data to show in top operations table for the node. + */ + topOperationsData: TopOperationsDataItem[]; } export interface ExecutionPlanBadge { @@ -1475,6 +1479,21 @@ declare module 'azdata' { */ compareExecutionPlanGraph(firstPlanFile: ExecutionPlanGraphInfo, secondPlanFile: ExecutionPlanGraphInfo): Thenable; } + + export interface TopOperationsDataItem { + /** + * Column name for the top operation data item + */ + columnName: string; + /** + * Cell data type for the top operation data item + */ + dataType: ExecutionPlanGraphElementPropertyDataType; + /** + * Cell value for the top operation data item + */ + displayValue: string | number | boolean; + } } /** diff --git a/src/sql/base/browser/ui/table/formatters.ts b/src/sql/base/browser/ui/table/formatters.ts index dc61c62cab..6915aee476 100644 --- a/src/sql/base/browser/ui/table/formatters.ts +++ b/src/sql/base/browser/ui/table/formatters.ts @@ -77,7 +77,7 @@ export function hyperLinkFormatter(row: number | undefined, cell: any | undefine cellClasses += ' missing-value'; } } else if (isHyperlinkCellValue(value)) { - return `${escape(value.displayText)}`; + return `${escape(value.displayText)}`; } return `${valueToDisplay}`; } diff --git a/src/sql/base/browser/ui/table/interfaces.ts b/src/sql/base/browser/ui/table/interfaces.ts index a936823f6b..9864f01d4b 100644 --- a/src/sql/base/browser/ui/table/interfaces.ts +++ b/src/sql/base/browser/ui/table/interfaces.ts @@ -31,3 +31,8 @@ export interface FilterableColumn extends Slick.Column { filterable?: boolean; filterValues?: Array; } + +export interface ITableKeyboardEvent { + cell?: { row: number, cell: number }; + event: KeyboardEvent; +} diff --git a/src/sql/base/browser/ui/table/table.ts b/src/sql/base/browser/ui/table/table.ts index c2e4beec95..83b9660318 100644 --- a/src/sql/base/browser/ui/table/table.ts +++ b/src/sql/base/browser/ui/table/table.ts @@ -8,7 +8,7 @@ import 'vs/css!./media/slick.grid'; import 'vs/css!./media/slickColorTheme'; import { TableDataView } from './tableDataView'; -import { ITableSorter, ITableMouseEvent, ITableConfiguration, ITableStyles } from 'sql/base/browser/ui/table/interfaces'; +import { ITableSorter, ITableMouseEvent, ITableConfiguration, ITableStyles, ITableKeyboardEvent } from 'sql/base/browser/ui/table/interfaces'; import * as DOM from 'vs/base/browser/dom'; import { mixin } from 'vs/base/common/objects'; @@ -59,6 +59,9 @@ export class Table extends Widget implements IDisposa private _onColumnResize = new Emitter(); public readonly onColumnResize = this._onColumnResize.event; + private _onKeyDown = new Emitter(); + public readonly onKeyDown = this._onKeyDown.event; + private _onBlur = new Emitter(); public readonly onBlur = this._onBlur.event; @@ -126,6 +129,17 @@ export class Table extends Widget implements IDisposa this.mapMouseEvent(this._grid.onHeaderClick, this._onHeaderClick); this.mapMouseEvent(this._grid.onDblClick, this._onDoubleClick); this._grid.onColumnsResized.subscribe(() => this._onColumnResize.fire()); + + this._grid.onKeyDown.subscribe((e, args: Slick.OnKeyDownEventArgs) => { + const evt = (e as JQuery.Event).originalEvent as KeyboardEvent; + this._onKeyDown.fire({ + event: evt, + cell: { + row: args.row, + cell: args.cell + } + }); + }); } public rerenderGrid() { diff --git a/src/sql/workbench/contrib/executionPlan/browser/constants.ts b/src/sql/workbench/contrib/executionPlan/browser/constants.ts index 3d72c13acf..d4d1b55c19 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/constants.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/constants.ts @@ -282,6 +282,7 @@ export const executionPlanCompareIconClassName = 'ep-plan-compare-icon'; export const executionPlanComparisonPropertiesDifferent = 'ep-properties-different'; export const executionPlanComparisonPropertiesDownArrow = 'ep-properties-down-arrow'; export const executionPlanComparisonPropertiesUpArrow = 'ep-properties-up-arrow'; +export const executionPlanTopOperations = 'ep-top-operations'; /** * Plan comparison polygon border colors diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts index 35c94eb5a6..7133b59581 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts @@ -71,7 +71,7 @@ export class ExecutionPlanEditor extends EditorPane { } else { // creating a new view for the new input newInput._executionPlanFileViewUUID = generateUuid(); - newView = this._register(this._instantiationService.createInstance(ExecutionPlanFileView)); + newView = this._register(this._instantiationService.createInstance(ExecutionPlanFileView, undefined)); newView.onShow(this._parentContainer); newView.loadGraphFile({ graphFileContent: await newInput.content(), diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts index 966117b0bc..ddf5cbb98b 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts @@ -13,6 +13,7 @@ import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { contrastBorder, editorWidgetBackground, foreground, listHoverBackground, textLinkForeground, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { QueryResultsView } from 'sql/workbench/contrib/query/browser/queryResultsView'; export class ExecutionPlanFileView { private _parent: HTMLElement; @@ -25,6 +26,7 @@ export class ExecutionPlanFileView { private _planCache: Map = new Map(); constructor( + private _queryResultsView: QueryResultsView, @IInstantiationService private instantiationService: IInstantiationService, @IExecutionPlanService private executionPlanService: IExecutionPlanService ) { @@ -56,7 +58,7 @@ export class ExecutionPlanFileView { public addGraphs(newGraphs: azdata.executionPlan.ExecutionPlanGraph[] | undefined) { if (newGraphs) { newGraphs.forEach(g => { - const ep = this.instantiationService.createInstance(ExecutionPlanView, this._container, this._executionPlanViews.length + 1, this); + const ep = this.instantiationService.createInstance(ExecutionPlanView, this._container, this._executionPlanViews.length + 1, this, this._queryResultsView); ep.model = g; this._executionPlanViews.push(ep); this.graphs.push(g); @@ -114,6 +116,13 @@ export class ExecutionPlanFileView { }); } } + + public scrollToNode(planId: number, nodeId: string): void { + this._executionPlanViews[planId].container.scrollIntoView(true); + const element = this._executionPlanViews[planId].executionPlanDiagram.getElementById(nodeId); + this._executionPlanViews[planId].executionPlanDiagram.centerElement(element); + this._executionPlanViews[planId].executionPlanDiagram.selectElement(element); + } } diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts index 28a68b7c26..606734c15a 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts @@ -12,6 +12,7 @@ import { ExecutionPlanState } from 'sql/workbench/common/editor/query/executionP import { ExecutionPlanFileView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileView'; import { ExecutionPlanFileViewCache } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache'; import { generateUuid } from 'vs/base/common/uuid'; +import { QueryResultsView } from 'sql/workbench/contrib/query/browser/queryResultsView'; export class ExecutionPlanTab implements IPanelTab { public readonly title = localize('executionPlanTitle', "Query Plan (Preview)"); @@ -19,9 +20,10 @@ export class ExecutionPlanTab implements IPanelTab { public readonly view: ExecutionPlanTabView; constructor( + private _queryResultsView: QueryResultsView, @IInstantiationService instantiationService: IInstantiationService, ) { - this.view = instantiationService.createInstance(ExecutionPlanTabView); + this.view = instantiationService.createInstance(ExecutionPlanTabView, this._queryResultsView); } public dispose() { @@ -37,8 +39,10 @@ export class ExecutionPlanTabView implements IPanelView { private _container: HTMLElement = DOM.$('.execution-plan-tab'); private _input: ExecutionPlanState; private _viewCache: ExecutionPlanFileViewCache = ExecutionPlanFileViewCache.getInstance(); + public currentFileView: ExecutionPlanFileView; constructor( + private _queryResultsView: QueryResultsView, @IInstantiationService private _instantiationService: IInstantiationService, ) { } @@ -59,13 +63,14 @@ export class ExecutionPlanTabView implements IPanelView { } else { // creating a new view for the new input newInput.executionPlanFileViewUUID = generateUuid(); - newView = this._instantiationService.createInstance(ExecutionPlanFileView); + newView = this._instantiationService.createInstance(ExecutionPlanFileView, this._queryResultsView); newView.onShow(this._container); newView.addGraphs( newInput.graphs ); this._viewCache.executionPlanFileViewMap.set(newInput.executionPlanFileViewUUID, newView); } + this.currentFileView = newView; this._input = newInput; } @@ -83,10 +88,11 @@ export class ExecutionPlanTabView implements IPanelView { if (currentView) { currentView.onHide(this._container); this._input.graphs = []; - currentView = this._instantiationService.createInstance(ExecutionPlanFileView); + currentView = this._instantiationService.createInstance(ExecutionPlanFileView, this._queryResultsView); this._viewCache.executionPlanFileViewMap.set(this._input.executionPlanFileViewUUID, currentView); currentView.render(this._container); } + this.currentFileView = currentView; } public clear() { diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts index 55b2e2d146..2ef0c5adeb 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts @@ -25,9 +25,9 @@ import { RunQueryOnConnectionMode } from 'sql/platform/connection/common/connect import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format'; import { Progress } from 'vs/platform/progress/common/progress'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { Action } from 'vs/base/common/actions'; +import { Action, Separator } from 'vs/base/common/actions'; import { localize } from 'vs/nls'; -import { customZoomIconClassNames, disableTooltipIconClassName, enableTooltipIconClassName, executionPlanCompareIconClassName, openPlanFileIconClassNames, openPropertiesIconClassNames, openQueryIconClassNames, savePlanIconClassNames, searchIconClassNames, zoomInIconClassNames, zoomOutIconClassNames, zoomToFitIconClassNames } from 'sql/workbench/contrib/executionPlan/browser/constants'; +import { customZoomIconClassNames, disableTooltipIconClassName, enableTooltipIconClassName, executionPlanCompareIconClassName, executionPlanTopOperations, openPlanFileIconClassNames, openPropertiesIconClassNames, openQueryIconClassNames, savePlanIconClassNames, searchIconClassNames, zoomInIconClassNames, zoomOutIconClassNames, zoomToFitIconClassNames } from 'sql/workbench/contrib/executionPlan/browser/constants'; import { URI } from 'vs/base/common/uri'; import { VSBuffer } from 'vs/base/common/buffer'; import { CustomZoomWidget } from 'sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget'; @@ -37,6 +37,7 @@ import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import { ExecutionPlanComparisonInput } from 'sql/workbench/contrib/executionPlan/browser/compareExecutionPlanInput'; import { ExecutionPlanFileView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileView'; +import { QueryResultsView } from 'sql/workbench/contrib/query/browser/queryResultsView'; export class ExecutionPlanView implements ISashLayoutProvider { @@ -44,7 +45,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { private _model?: azdata.executionPlan.ExecutionPlanGraph; // container for the view - private _container: HTMLElement; + public container: HTMLElement; // action bar for the view private _actionBarContainer: HTMLElement; @@ -72,6 +73,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { private _parent: HTMLElement, private _graphIndex: number, private _executionPlanFileView: ExecutionPlanFileView, + private _queryResultsView: QueryResultsView, @IInstantiationService public readonly _instantiationService: IInstantiationService, @IThemeService private readonly _themeService: IThemeService, @IContextViewService public readonly contextViewService: IContextViewService, @@ -84,18 +86,18 @@ export class ExecutionPlanView implements ISashLayoutProvider { @IEditorService private _editorService: IEditorService ) { // parent container for query plan. - this._container = DOM.$('.execution-plan'); - this._parent.appendChild(this._container); + this.container = DOM.$('.execution-plan'); + this._parent.appendChild(this.container); const sashContainer = DOM.$('.execution-plan-sash'); this._parent.appendChild(sashContainer); // resizing sash for the query plan. const sash = new Sash(sashContainer, this, { orientation: Orientation.HORIZONTAL, size: 3 }); - let originalHeight = this._container.offsetHeight; + let originalHeight = this.container.offsetHeight; let originalTableHeight = 0; let change = 0; sash.onDidStart((e: ISashEvent) => { - originalHeight = this._container.offsetHeight; + originalHeight = this.container.offsetHeight; originalTableHeight = this.propertiesView.tableHeight; }); @@ -112,8 +114,8 @@ export class ExecutionPlanView implements ISashLayoutProvider { * Since the parent container is flex, we will have * to change the flex-basis property to change the height. */ - this._container.style.minHeight = '200px'; - this._container.style.flex = `0 0 ${newHeight}px`; + this.container.style.minHeight = '200px'; + this.container.style.flex = `0 0 ${newHeight}px`; }); /** @@ -124,7 +126,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { }); this._planContainer = DOM.$('.plan'); - this._container.appendChild(this._planContainer); + this.container.appendChild(this._planContainer); // container that holds plan header info this._planHeaderContainer = DOM.$('.header'); @@ -141,7 +143,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { // container properties this._propContainer = DOM.$('.properties'); - this._container.appendChild(this._propContainer); + this.container.appendChild(this._propContainer); this.propertiesView = new ExecutionPlanPropertiesView(this._propContainer, this._themeService); this._widgetContainer = DOM.$('.plan-action-container'); @@ -150,7 +152,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { // container that holds action bar icons this._actionBarContainer = DOM.$('.action-bar-container'); - this._container.appendChild(this._actionBarContainer); + this.container.appendChild(this._actionBarContainer); this._actionBar = new ActionBar(this._actionBarContainer, { orientation: ActionsOrientation.VERTICAL, context: this }); @@ -160,34 +162,42 @@ export class ExecutionPlanView implements ISashLayoutProvider { new SavePlanFile(), new OpenPlanFile(), this._instantiationService.createInstance(OpenQueryAction, 'ActionBar'), - this._instantiationService.createInstance(SearchNodeAction, 'ActionBar'), this._instantiationService.createInstance(ZoomInAction, 'ActionBar'), this._instantiationService.createInstance(ZoomOutAction, 'ActionBar'), this._instantiationService.createInstance(ZoomToFitAction, 'ActionBar'), this._instantiationService.createInstance(CustomZoomAction, 'ActionBar'), + this._instantiationService.createInstance(SearchNodeAction, 'ActionBar'), this._instantiationService.createInstance(PropertiesAction, 'ActionBar'), this._instantiationService.createInstance(CompareExecutionPlanAction, 'ActionBar'), this.actionBarToggleTopTip ]; - this._actionBar.pushAction(actionBarActions, { icon: true, label: false }); - // Setting up context menu this.contextMenuToggleTooltipAction = new ContextMenuTooltipToggle(); const contextMenuAction = [ new SavePlanFile(), new OpenPlanFile(), this._instantiationService.createInstance(OpenQueryAction, 'ContextMenu'), - this._instantiationService.createInstance(SearchNodeAction, 'ContextMenu'), + new Separator(), this._instantiationService.createInstance(ZoomInAction, 'ContextMenu'), this._instantiationService.createInstance(ZoomOutAction, 'ContextMenu'), this._instantiationService.createInstance(ZoomToFitAction, 'ContextMenu'), this._instantiationService.createInstance(CustomZoomAction, 'ContextMenu'), + new Separator(), + this._instantiationService.createInstance(SearchNodeAction, 'ContextMenu'), this._instantiationService.createInstance(PropertiesAction, 'ContextMenu'), this._instantiationService.createInstance(CompareExecutionPlanAction, 'ContextMenu'), this.contextMenuToggleTooltipAction ]; + + if (this._queryResultsView) { + actionBarActions.push(this._instantiationService.createInstance(TopOperationsAction)); + contextMenuAction.push(this._instantiationService.createInstance(TopOperationsAction)); + } + + this._actionBar.pushAction(actionBarActions, { icon: true, label: false }); + const self = this; - this._container.oncontextmenu = (e: MouseEvent) => { + this.container.oncontextmenu = (e: MouseEvent) => { if (contextMenuAction) { this._contextMenuService.showContextMenu({ getAnchor: () => { @@ -202,7 +212,7 @@ export class ExecutionPlanView implements ISashLayoutProvider { } }; - this._container.onkeydown = (e: KeyboardEvent) => { + this.container.onkeydown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { let searchNodeAction = self._instantiationService.createInstance(SearchNodeAction, 'HotKey'); searchNodeAction.run(self); @@ -219,12 +229,13 @@ export class ExecutionPlanView implements ISashLayoutProvider { return 0; } getHorizontalSashWidth?(sash: Sash): number { - return this._container.clientWidth; + return this.container.clientWidth; } private createPlanDiagram(container: HTMLElement) { this.executionPlanDiagram = this._instantiationService.createInstance(AzdataGraphView, container, this._model); this.executionPlanDiagram.onElementSelected(e => { + container.focus(); this.propertiesView.graphElement = e; }); } @@ -293,6 +304,11 @@ export class ExecutionPlanView implements ISashLayoutProvider { pinned: true }); } + + public openTopOperations() { + this._queryResultsView.switchToTopOperationsTab(); + this._queryResultsView.scrollToTable(this._graphIndex); + } } type ExecutionPlanActionSource = 'ContextMenu' | 'ActionBar' | 'HotKey'; @@ -548,3 +564,18 @@ export class CompareExecutionPlanAction extends Action { context.compareCurrentExecutionPlan(); } } + +export class TopOperationsAction extends Action { + + public static ID = 'ep.topOperationsAction'; + public static LABEL = localize('executionPlanTopOperationsAction', "Top Operations"); + + constructor + () { + super(TopOperationsAction.ID, TopOperationsAction.LABEL, executionPlanTopOperations); + } + + public override async run(context: ExecutionPlanView): Promise { + context.openTopOperations(); + } +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperations.svg b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperations.svg new file mode 100644 index 0000000000..a1dfbd6d5b --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperations.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperationsDark.svg b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperationsDark.svg new file mode 100644 index 0000000000..f073d18bc9 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/topOperationsDark.svg @@ -0,0 +1 @@ + diff --git a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css index bab255a4de..8d51622b94 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css +++ b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css @@ -89,13 +89,15 @@ However we always want it to be the width of the container it is resizing. } /* execution plan header that contains the relative query cost, query statement and recommendations */ -.eps-container .execution-plan .plan .header { +.eps-container .execution-plan .plan .header, +.top-operations-tab .top-operations-container .query-row { padding: 5px; font-weight: bolder; } /* execution plan header that contains the relative query cost, query statement and recommendations */ -.eps-container .execution-plan .plan .header .query-row { +.eps-container .execution-plan .plan .header .query-row, +.top-operations-tab .top-operations-container .query-row{ overflow: hidden; text-overflow: ellipsis; display: -webkit-box; @@ -117,6 +119,8 @@ However we always want it to be the width of the container it is resizing. height: 100%; overflow: scroll; position: relative; + outline-offset: -3px; + outline-width: 3px; } .eps-container .properties { @@ -613,6 +617,23 @@ However we always want it to be the width of the container it is resizing. height: auto; } +.eps-container .ep-top-operations { + background-image: url(../images/actionIcons/topOperations.svg); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; +} + + +.vs-dark .eps-container .ep-top-operations, +.hc-black .eps-container .ep-top-operations { + background-image: url(../images/actionIcons/topOperationsDark.svg); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; +} + + .eps-container .comparison-editor { width: 100%; height: 100%; @@ -759,3 +780,22 @@ However we always want it to be the width of the container it is resizing. .eps-container .comparison-editor .parent-row-styling { font-weight: bold; } + +.top-operations-tab { + overflow: scroll; + display: flex; + flex-direction: column; +} + + +.top-operations-tab .top-operations-container { + flex: 1; + width: 100%; + min-height: 300px; + overflow: scroll; +} + +.top-operations-tab .top-operations-container .table-container { + flex: 1; + height: calc(100% - 50px); +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/topOperationsTab.ts b/src/sql/workbench/contrib/executionPlan/browser/topOperationsTab.ts new file mode 100644 index 0000000000..47bff3a1c7 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/topOperationsTab.ts @@ -0,0 +1,403 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/executionPlan'; + +import * as azdata from 'azdata'; +import { IPanelTab, IPanelView } from 'sql/base/browser/ui/panel/panel'; +import { localize } from 'vs/nls'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import * as DOM from 'vs/base/browser/dom'; +import { ExecutionPlanState } from 'sql/workbench/common/editor/query/executionPlanState'; +import { Table } from 'sql/base/browser/ui/table/table'; +import { hyperLinkFormatter, textFormatter } from 'sql/base/browser/ui/table/formatters'; +import { RESULTS_GRID_DEFAULTS } from 'sql/workbench/common/constants'; +import { attachTableStyler } from 'sql/platform/theme/common/styler'; +import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { ExecutionPlanViewHeader } from 'sql/workbench/contrib/executionPlan/browser/executionPlanViewHeader'; +import { QueryResultsView } from 'sql/workbench/contrib/query/browser/queryResultsView'; +import { RowNumberColumn } from 'sql/base/browser/ui/table/plugins/rowNumberColumn.plugin'; +import { CopyKeybind } from 'sql/base/browser/ui/table/plugins/copyKeybind.plugin'; +import { CellSelectionModel } from 'sql/base/browser/ui/table/plugins/cellSelectionModel.plugin'; +import * as sqlExtHostType from 'sql/workbench/api/common/sqlExtHostTypes'; +import { listHoverBackground } from 'vs/platform/theme/common/colorRegistry'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { Action } from 'vs/base/common/actions'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { ITableKeyboardEvent } from 'sql/base/browser/ui/table/interfaces'; +import { Disposable } from 'vs/base/common/lifecycle'; +export class TopOperationsTab extends Disposable implements IPanelTab { + public readonly title = localize('topOperationsTabTitle', "Top Operations (Preview)"); + public readonly identifier: string = 'TopOperationsTab'; + public readonly view: TopOperationsTabView; + + constructor( + private _queryResultsView: QueryResultsView, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + this.view = this._register(instantiationService.createInstance(TopOperationsTabView, this._queryResultsView)); + } + + public clear() { + } +} + +export class TopOperationsTabView extends Disposable implements IPanelView { + private _container: HTMLElement = DOM.$('.top-operations-tab'); + private _input: ExecutionPlanState; + private _topOperationsContainers: HTMLElement[] = []; + private _tables: Table[] = []; + + constructor( + private _queryResultsView: QueryResultsView, + @IThemeService private _themeService: IThemeService, + @IInstantiationService private _instantiationService: IInstantiationService, + @IContextMenuService private _contextMenuService: IContextMenuService, + ) { + super(); + } + + public scrollToIndex(index: number) { + index = index - 1; + this._topOperationsContainers[index].scrollIntoView(true); + this._tables.forEach(t => { + t.getSelectionModel().setSelectedRanges([]); + }); + this._tables[index].getSelectionModel().setSelectedRanges([new Slick.Range(0, 1, 0, 1)]); + this._tables[index].focus(); + } + + public set state(newInput: ExecutionPlanState) { + const oldInput = this._input; + + if (oldInput === newInput) { + return; + } + + this._input = newInput; + this.renderInput(); + } + + public render(parent: HTMLElement): void { + parent.appendChild(this._container); + } + + public renderInput(): void { + while (this._container.firstChild) { + this._container.removeChild(this._container.firstChild); + } + this._input.graphs.forEach((g, i) => { + this.convertExecutionPlanGraphToTable(g, i); + }); + } + + + public convertExecutionPlanGraphToTable(graph: azdata.executionPlan.ExecutionPlanGraph, index: number): Table { + + const dataMap: { [key: string]: any }[] = []; + const columnValues: string[] = []; + + const stack: azdata.executionPlan.ExecutionPlanNode[] = []; + stack.push(...graph.root.children); + while (stack.length !== 0) { + const node = stack.pop(); + const row: { [key: string]: any } = {}; + node.topOperationsData.forEach((d, i) => { + let displayText = d.displayValue.toString(); + if (i === 0) { + row[d.columnName] = { + displayText: displayText, + linkOrCommand: ' ', + dataType: d.dataType + }; + } else { + row[d.columnName] = { + text: displayText, + ariaLabel: d.displayValue, + dataType: d.dataType + }; + } + if (columnValues.indexOf(d.columnName) === -1) { + columnValues.splice(i, 0, d.columnName); + } + }); + row['nodeId'] = node.id; + if (node.children) { + node.children.forEach(c => stack.push(c)); + } + + dataMap.push(row); + } + const columns = columnValues.map((c, i) => { + return >{ + id: c.toString(), + name: c, + field: c.toString(), + formatter: i === 0 ? hyperLinkFormatter : textFormatter, + sortable: true, + }; + }); + + const topOperationContainer = DOM.$('.top-operations-container'); + this._container.appendChild(topOperationContainer); + const header = this._instantiationService.createInstance(ExecutionPlanViewHeader, topOperationContainer, { + planIndex: index, + }); + header.query = graph.query; + header.relativeCost = graph.root.relativeCost; + const tableContainer = DOM.$('.table-container'); + topOperationContainer.appendChild(tableContainer); + this._topOperationsContainers.push(topOperationContainer); + + const rowNumberColumn = new RowNumberColumn({ numberOfRows: dataMap.length }); + columns.unshift(rowNumberColumn.getColumnDefinition()); + + let copyHandler = new CopyKeybind(); + this._register(copyHandler.onCopy(e => { + + const selectedDataRange = selectionModel.getSelectedRanges()[0]; + let csvString = ''; + if (selectedDataRange) { + const data = []; + + for (let rowIndex = selectedDataRange.fromRow; rowIndex <= selectedDataRange.toRow; rowIndex++) { + const dataRow = table.getData().getItem(rowIndex); + const row = []; + for (let colIndex = selectedDataRange.fromCell; colIndex <= selectedDataRange.toCell; colIndex++) { + const dataItem = dataRow[table.columns[colIndex].field]; + if (dataItem) { + row.push(dataItem.displayText ?? dataItem.text); + } else { + row.push(' '); + } + } + data.push(row); + } + csvString = data.map(row => + row.map(x => `${x}`).join('\t') + ).join('\n'); + + const columns = []; + + for (let colIndex = selectedDataRange.fromCell; colIndex <= selectedDataRange.toCell; colIndex++) { + columns.push(table.columns[colIndex].name); + } + + } + + this._instantiationService.createInstance(CopyTableData).run({ + selectedText: csvString + }); + })); + + const selectionModel = new CellSelectionModel(); + + const table = new Table(tableContainer, { + columns: columns, + sorter: (args) => { + const column = args.sortCol.field; + dataMap.sort((a, b) => { + let result = -1; + + if (!a[column]) { + result = 1; + } else { + if (!b[column]) { + result = -1; + } else { + const dataType = a[column].dataType; + + const aText = a[column].displayText ?? a[column].text; + const bText = b[column].displayText ?? b[column].text; + if (aText === bText) { + result = 0; + } else { + switch (dataType) { + case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.String: + case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.Boolean: + result = aText.localeCompare(bText); + break; + case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.Number: + result = parseFloat(aText) - parseFloat(bText); + break; + } + } + } + } + return args.sortAsc ? result : -result; + }); + table.setData(dataMap); + } + }, { + rowHeight: RESULTS_GRID_DEFAULTS.rowHeight, + forceFitColumns: false, + defaultColumnWidth: 120, + showRowNumber: true + }); + table.setSelectionModel(selectionModel); + table.setData(dataMap); + + table.registerPlugin(copyHandler); + + table.setTableTitle(localize('topOperationsTableTitle', "Top Operations")); + this._register(table.onClick(e => { + if (e.cell.cell === 1) { + const row = table.getData().getItem(e.cell.row); + const nodeId = row['nodeId']; + const planId = index; + this._queryResultsView.switchToExecutionPlanTab(); + this._queryResultsView.focusOnNode(planId, nodeId); + } + })); + this._tables.push(table); + const contextMenuAction = [ + this._instantiationService.createInstance(CopyTableData), + this._instantiationService.createInstance(CopyTableDataWithHeader), + this._instantiationService.createInstance(SelectAll) + ]; + + this._register(table.onKeyDown((evt: ITableKeyboardEvent) => { + if (evt.event.ctrlKey && (evt.event.key === 'a' || evt.event.key === 'A')) { + selectionModel.setSelectedRanges([new Slick.Range(0, 1, table.getData().getLength() - 1, table.columns.length - 1)]); + table.focus(); + evt.event.preventDefault(); + evt.event.stopPropagation(); + } + })); + + this._register(table.onContextMenu(e => { + const selectedDataRange = selectionModel.getSelectedRanges()[0]; + let csvString = ''; + let csvStringWithHeader = ''; + if (selectedDataRange) { + const data = []; + + for (let rowIndex = selectedDataRange.fromRow; rowIndex <= selectedDataRange.toRow; rowIndex++) { + const dataRow = table.getData().getItem(rowIndex); + const row = []; + for (let colIndex = selectedDataRange.fromCell; colIndex <= selectedDataRange.toCell; colIndex++) { + const dataItem = dataRow[table.columns[colIndex].field]; + if (dataItem) { + row.push(dataItem.displayText ?? dataItem.text); + } else { + row.push(''); + } + } + data.push(row); + } + csvString = data.map(row => + row.map(x => `${x}`).join('\t') + ).join('\n'); + + const columns = []; + + for (let colIndex = selectedDataRange.fromCell; colIndex <= selectedDataRange.toCell; colIndex++) { + columns.push(table.columns[colIndex].name); + } + + csvStringWithHeader = columns.join('\t') + '\n' + csvString; + } + + this._contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => contextMenuAction, + getActionsContext: () => ({ + selectedText: csvString, + selectionModel: selectionModel, + table: table, + selectionTextWithHeader: csvStringWithHeader + }) + }); + + })); + attachTableStyler(table, this._themeService); + + new ResizeObserver((e) => { + table.layout(new DOM.Dimension(tableContainer.clientWidth, tableContainer.clientHeight)); + }).observe(tableContainer); + return table; + } + + layout(dimension: DOM.Dimension): void { + this._container.style.width = dimension.width + 'px'; + this._container.style.height = dimension.height + 'px'; + } + + remove?(): void { + } + + onShow?(): void { + } + + onHide?(): void { + } + +} + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const menuBackgroundColor = theme.getColor(listHoverBackground); + if (menuBackgroundColor) { + collector.addRule(` + .top-operations-tab .top-operations-container .query-row { + background-color: ${menuBackgroundColor}; + } + `); + } +}); + + +export class CopyTableData extends Action { + public static ID = 'ep.CopyTableData'; + public static LABEL = localize('ep.topOperationsCopyTableData', "Copy"); + + constructor( + @IClipboardService private _clipboardService: IClipboardService + ) { + super(CopyTableData.ID, CopyTableData.LABEL, ''); + } + + public override async run(context: ContextMenuModel): Promise { + + this._clipboardService.writeText(context.selectedText); + } +} + +export class CopyTableDataWithHeader extends Action { + public static ID = 'ep.CopyTableData'; + public static LABEL = localize('ep.topOperationsCopyWithHeader', "Copy with Header"); + + constructor( + @IClipboardService private _clipboardService: IClipboardService + ) { + super(CopyTableDataWithHeader.ID, CopyTableDataWithHeader.LABEL, ''); + } + + public override async run(context: ContextMenuModel): Promise { + + this._clipboardService.writeText(context.selectionTextWithHeader); + } +} + +export class SelectAll extends Action { + public static ID = 'ep.SelectAllTableData'; + public static LABEL = localize('ep.topOperationsSelectAll', "Select All"); + + constructor( + ) { + super(SelectAll.ID, SelectAll.LABEL, ''); + } + + public override async run(context: ContextMenuModel): Promise { + context.selectionModel.setSelectedRanges([new Slick.Range(0, 1, context.table.getData().getLength() - 1, context.table.columns.length - 1)]); + } +} + +interface ContextMenuModel { + selectedText?: string; + selectionModel?: CellSelectionModel; + table?: Table; + selectionTextWithHeader?: string; +} diff --git a/src/sql/workbench/contrib/query/browser/queryResultsView.ts b/src/sql/workbench/contrib/query/browser/queryResultsView.ts index 295ee82f5c..6e3424182b 100644 --- a/src/sql/workbench/contrib/query/browser/queryResultsView.ts +++ b/src/sql/workbench/contrib/query/browser/queryResultsView.ts @@ -10,7 +10,6 @@ import QueryRunner from 'sql/workbench/services/query/common/queryRunner'; import { MessagePanel } from 'sql/workbench/contrib/query/browser/messagePanel'; import { GridPanel } from 'sql/workbench/contrib/query/browser/gridPanel'; import { ChartTab } from 'sql/workbench/contrib/charts/browser/chartTab'; -import { TopOperationsTab } from 'sql/workbench/contrib/queryPlan/browser/topOperations'; import { QueryModelViewTab } from 'sql/workbench/contrib/query/browser/modelViewTab/queryModelViewTab'; import { GridPanelState } from 'sql/workbench/common/editor/query/gridTableState'; @@ -26,6 +25,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { ILogService } from 'vs/platform/log/common/log'; import { ExecutionPlanTab } from 'sql/workbench/contrib/executionPlan/browser/executionPlanTab'; import { ExecutionPlanFileViewCache } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache'; +import { TopOperationsTab } from 'sql/workbench/contrib/executionPlan/browser/topOperationsTab'; class MessagesView extends Disposable implements IPanelView { private messagePanel: MessagePanel; @@ -183,9 +183,8 @@ export class QueryResultsView extends Disposable { this.chartTab = this._register(new ChartTab(instantiationService)); this._panelView = this._register(new TabbedPanel(container, { showHeaderWhenSingleView: true })); this._register(attachTabbedPanelStyler(this._panelView, themeService)); - this.executionPlanTab = this._register(this.instantiationService.createInstance(ExecutionPlanTab)); - this.topOperationsTab = this._register(new TopOperationsTab(instantiationService)); - + this.executionPlanTab = this._register(this.instantiationService.createInstance(ExecutionPlanTab, this)); + this.topOperationsTab = this._register(this.instantiationService.createInstance(TopOperationsTab, this)); this._panelView.pushTab(this.resultsTab); this._panelView.pushTab(this.messagesTab); this._register(this._panelView.onTabChange(e => { @@ -260,9 +259,11 @@ export class QueryResultsView extends Disposable { const view = executionPlanFileViewCache.executionPlanFileViewMap.get( this.input.state.executionPlanState.executionPlanFileViewUUID ); + this.input.state.executionPlanState.graphs.push(...e.planGraphs); if (view) { view.addGraphs(e.planGraphs); } + this.topOperationsTab.view.renderInput(); } } })); @@ -337,7 +338,7 @@ export class QueryResultsView extends Disposable { if (input) { this.resultsTab.view.state = input.state.gridPanelState; - this.topOperationsTab.view.setState(input.state.topOperationsState); + this.topOperationsTab.view.state = input.state.executionPlanState; this.chartTab.view.state = input.state.chartState; this.executionPlanTab.view.state = input.state.executionPlanState; this.dynamicModelViewTabs.forEach((dynamicTab: QueryModelViewTab) => { @@ -425,7 +426,14 @@ export class QueryResultsView extends Disposable { if (!this._panelView.contains(this.topOperationsTab.identifier)) { this._panelView.pushTab(this.topOperationsTab); } - this.topOperationsTab.view.showPlan(xml); + } + + public switchToTopOperationsTab() { + this._panelView.showTab(this.topOperationsTab.identifier); + } + + public scrollToTable(planId: number) { + this.topOperationsTab.view.scrollToIndex(planId); } public showPlan() { @@ -438,6 +446,14 @@ export class QueryResultsView extends Disposable { } } + public switchToExecutionPlanTab() { + this._panelView.showTab(this.executionPlanTab.identifier); + } + + public focusOnNode(planId: number, nodeId: string) { + this.executionPlanTab.view.currentFileView.scrollToNode(planId, nodeId); + } + public hideTopOperations() { if (this._panelView.contains(this.topOperationsTab.identifier)) { this._panelView.removeTab(this.topOperationsTab.identifier);