diff --git a/src/sql/workbench/common/editor/query/executionPlanState.ts b/src/sql/workbench/common/editor/query/executionPlanState.ts index 1baffd6152..b38f74f3e4 100644 --- a/src/sql/workbench/common/editor/query/executionPlanState.ts +++ b/src/sql/workbench/common/editor/query/executionPlanState.ts @@ -5,9 +5,20 @@ import type * as azdata from 'azdata'; +/** + * This class holds the view and the graphs of the execution plans + * displayed in the results tab of a query editor + */ export class ExecutionPlanState { - graphs: azdata.executionPlan.ExecutionPlanGraph[] = []; - clearExecutionPlanState() { - this.graphs = []; + + private _graphs: azdata.executionPlan.ExecutionPlanGraph[] = []; + public executionPlanFileViewUUID: string; + + public get graphs(): azdata.executionPlan.ExecutionPlanGraph[] { + return this._graphs; + } + + public set graphs(v: azdata.executionPlan.ExecutionPlanGraph[]) { + this._graphs = v; } } diff --git a/src/sql/workbench/common/editor/query/queryResultsInput.ts b/src/sql/workbench/common/editor/query/queryResultsInput.ts index 6d6b35bb2a..7e326d444a 100644 --- a/src/sql/workbench/common/editor/query/queryResultsInput.ts +++ b/src/sql/workbench/common/editor/query/queryResultsInput.ts @@ -29,7 +29,6 @@ export class ResultsViewState { this.gridPanelState.dispose(); this.chartState.dispose(); this.queryPlanState.dispose(); - this.executionPlanState.clearExecutionPlanState(); this.dynamicModelViewTabsState.forEach((state: QueryModelViewState, identifier: string) => { state.dispose(); }); diff --git a/src/sql/workbench/contrib/executionPlan/browser/azdataGraphView.ts b/src/sql/workbench/contrib/executionPlan/browser/azdataGraphView.ts new file mode 100644 index 0000000000..0fc62004f9 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/azdataGraphView.ts @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdataGraphModule from 'azdataGraph'; +import type * as azdata from 'azdata'; +import * as sqlExtHostType from 'sql/workbench/api/common/sqlExtHostTypes'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; +import { isString } from 'vs/base/common/types'; +import { badgeIconPaths, executionPlanNodeIconPaths } from 'sql/workbench/contrib/executionPlan/browser/constants'; +import { localize } from 'vs/nls'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { editorBackground, foreground } from 'vs/platform/theme/common/colorRegistry'; +const azdataGraph = azdataGraphModule(); + +/** + * This view holds the azdataGraph diagram and provides different + * methods to manipulate the azdataGraph + */ +export class AzdataGraphView { + + private _diagram: any; + private _diagramModel: AzDataGraphCell; + private _uniqueElementId: number = -1; + + private _graphElementPropertiesSet: Set = new Set(); + + private _onElementSelectedEmitter: Emitter = new Emitter(); + public onElementSelected: Event; + + constructor( + private _parentContainer: HTMLElement, + private _executionPlan: azdata.executionPlan.ExecutionPlanGraph, + @ITextResourcePropertiesService private readonly textResourcePropertiesService: ITextResourcePropertiesService, + ) { + this._diagramModel = this.populate(this._executionPlan.root); + this._diagram = new azdataGraph.azdataQueryPlan(this._parentContainer, this._diagramModel, executionPlanNodeIconPaths, badgeIconPaths); + this.setGraphProperties(); + this.initializeGraphEvents(); + } + + private setGraphProperties(): void { + this._diagram.graph.setCellsMovable(false); // preventing drag and drop of graph nodes. + this._diagram.graph.setCellsDisconnectable(false); // preventing graph edges to be disconnected from source and target nodes. + this._diagram.graph.tooltipHandler.delay = 700; // increasing delay for tooltips + + registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const iconBackground = theme.getColor(editorBackground); + if (iconBackground) { + this._diagram.setIconBackgroundColor(iconBackground); + } + + const iconLabelColor = theme.getColor(foreground); + if (iconLabelColor) { + this._diagram.setTextFontColor(iconLabelColor); + this._diagram.setEdgeColor(iconLabelColor); + } + }); + } + + private initializeGraphEvents(): void { + this.onElementSelected = this._onElementSelectedEmitter.event; + this._diagram.graph.addListener('click', (sender, evt) => { + // Updating properties view table on node clicks + const cell = evt.properties['cell']; + let selectedGraphElement: InternalExecutionPlanElement; + if (cell) { + selectedGraphElement = this.getElementById(cell.id); + this.selectElement(cell.id); + } else if (!this.getSelectedElement()) { + selectedGraphElement = this._executionPlan.root; + this.selectElement(undefined); + } + this._onElementSelectedEmitter.fire(selectedGraphElement ?? this.getSelectedElement()); + evt.consume(); + }); + } + + /** + * Selects an execution plan node/edge in the graph diagram. + * @param element Element to be selected + * @param bringToCenter Check if the selected element has to be brought into the center of this view + */ + public selectElement(element: InternalExecutionPlanElement | undefined, bringToCenter: boolean = false): void { + let cell; + if (element) { + cell = this._diagram.graph.model.getCell(element.id); + } else { + cell = this._diagram.graph.model.getCell((this._executionPlan.root).id); + } + this._diagram.graph.getSelectionModel().setCell(cell); + if (bringToCenter) { + this.centerElement(element); + } + } + + /** + * returns the currently selected graph element. + */ + public getSelectedElement(): InternalExecutionPlanElement | undefined { + const cell = this._diagram.graph.getSelectionCell(); + if (cell?.id) { + return this.getElementById(cell.id); + } + return undefined; + } + + /** + * Zooms in to the diagram. + */ + public zoomIn(): void { + this._diagram.graph.zoomIn(); + } + + + /** + * Zooms out of the diagram + */ + public zoomOut(): void { + this._diagram.graph.zoomOut(); + } + + /** + * Fits the diagram into the parent container size. + */ + public zoomToFit(): void { + this._diagram.graph.fit(); + this._diagram.graph.view.rendering = true; + this._diagram.graph.view.refresh(); + } + + /** + * Gets the current zoom level of the diagram. + */ + public getZoomLevel(): number { + return this._diagram.graph.view.getScale() * 100; + } + + /** + * Sets the zoom level of the diagram + * @param level The scale factor to be be applied to the diagram. + */ + public setZoomLevel(level: number): void { + if (level < 1) { + throw new Error(localize('invalidExecutionPlanZoomError', "Zoom level cannot be 0 or negative")); + } + this._diagram.graph.view.setScale(level / 100); + } + + /** + * Get the diagram element by its id + * @param id id of the diagram element + */ + public getElementById(id: string): InternalExecutionPlanElement | undefined { + const nodeStack: InternalExecutionPlanNode[] = []; + nodeStack.push(this._executionPlan.root); + while (nodeStack.length !== 0) { + const currentNode = nodeStack.pop(); + if (currentNode.id === id) { + return currentNode; + } + if (currentNode.edges) { + for (let i = 0; i < currentNode.edges.length; i++) { + if ((currentNode.edges[i]).id === id) { + return currentNode.edges[i]; + } + } + } + nodeStack.push(...currentNode.children); + } + return undefined; + } + + /** + * Searches the diagram nodes based on the search query provided. + */ + public searchNodes(searchQuery: SearchQuery): InternalExecutionPlanNode[] { + const resultNodes: InternalExecutionPlanNode[] = []; + + const nodeStack: InternalExecutionPlanNode[] = []; + nodeStack.push(this._executionPlan.root); + + while (nodeStack.length !== 0) { + const currentNode = nodeStack.pop(); + + const matchingProp = currentNode.properties.find(e => e.name === searchQuery.propertyName); + let matchFound = false; + // Searching only properties with string value. + if (isString(matchingProp?.value)) { + // If the search type is '=' we look for exact match and for 'contains' we look search string occurrences in prop value + switch (searchQuery.searchType) { + case SearchType.Equals: + matchFound = matchingProp.value === searchQuery.value; + break; + case SearchType.Contains: + matchFound = matchingProp.value.includes(searchQuery.value); + break; + case SearchType.GreaterThan: + matchFound = matchingProp.value > searchQuery.value; + break; + case SearchType.LesserThan: + matchFound = matchingProp.value < searchQuery.value; + break; + case SearchType.GreaterThanEqualTo: + matchFound = matchingProp.value >= searchQuery.value; + break; + case SearchType.LesserThanEqualTo: + matchFound = matchingProp.value <= searchQuery.value; + break; + case SearchType.LesserAndGreaterThan: + matchFound = matchingProp.value < searchQuery.value || matchingProp.value > searchQuery.value; + break; + } + if (matchFound) { + resultNodes.push(currentNode); + } + } + nodeStack.push(...currentNode.children); + } + return resultNodes; + } + + + /** + * Brings a graph element to the center of the parent view. + * @param node Node to be brought into the center + */ + public centerElement(node: InternalExecutionPlanElement): void { + /** + * The selected graph node might be hidden/partially visible if the graph is overflowing the parent container. + * Apart from the obvious problems in aesthetics, user do not get a proper feedback of the search result. + * To solve this problem, we will have to scroll the node into view. (preferably into the center of the view) + * Steps for that: + * 1. Get the bounding rect of the node on graph. + * 2. Get the midpoint of the node's bounding rect. + * 3. Find the dimensions of the parent container. + * 4. Since, we are trying to position the node into center, we set the left top corner position of parent to + * below x and y. + * x = node's x midpoint - half the width of parent container + * y = node's y midpoint - half the height of parent container + * 5. If the x and y are negative, we set them 0 as that is the minimum possible scroll position. + * 6. Smoothly scroll to the left top x and y calculated in step 4, 5. + */ + + if (!node) { + return; + } + const cell = this._diagram.graph.model.getCell(node.id); + if (!cell) { + return; + } + + this._diagram.graph.setSelectionCell(cell); + const cellRect = this._diagram.graph.getCellBounds(cell); + + const cellMidPoint: Point = { + x: cellRect.x + cellRect.width / 2, + y: cellRect.y + cellRect.height / 2 + }; + + const graphContainer = this._diagram.graph.container; + + const diagramContainerRect = graphContainer.getBoundingClientRect(); + + const leftTopScrollPoint: Point = { + x: cellMidPoint.x - diagramContainerRect.width / 2, + y: cellMidPoint.y - diagramContainerRect.height / 2 + }; + + leftTopScrollPoint.x = leftTopScrollPoint.x < 0 ? 0 : leftTopScrollPoint.x; + leftTopScrollPoint.y = leftTopScrollPoint.y < 0 ? 0 : leftTopScrollPoint.y; + + graphContainer.scrollTo({ + left: leftTopScrollPoint.x, + top: leftTopScrollPoint.y, + behavior: 'smooth' + }); + } + + private populate(node: InternalExecutionPlanNode): AzDataGraphCell { + let diagramNode: AzDataGraphCell = {}; + diagramNode.label = node.subtext.join(this.textResourcePropertiesService.getEOL(undefined)); + diagramNode.tooltipTitle = node.name; + const nodeId = this.createGraphElementId(); + diagramNode.id = nodeId; + node.id = nodeId; + + if (node.type) { + diagramNode.icon = node.type; + } + + if (node.properties) { + diagramNode.metrics = this.populateProperties(node.properties); + } + + if (node.badges) { + diagramNode.badges = []; + for (let i = 0; i < node.badges.length; i++) { + diagramNode.badges.push(this.getBadgeTypeString(node.badges[i].type)); + } + } + + if (node.edges) { + diagramNode.edges = this.populateEdges(node.edges); + } + + if (node.children) { + diagramNode.children = []; + for (let i = 0; i < node.children.length; ++i) { + diagramNode.children.push(this.populate(node.children[i])); + } + } + + if (node.description) { + diagramNode.description = node.description; + } + return diagramNode; + } + + private getBadgeTypeString(badgeType: sqlExtHostType.executionPlan.BadgeType): { + type: string, + tooltip: string + } | undefined { + /** + * TODO: Need to figure out if tooltip have to be removed. For now, they are empty + */ + switch (badgeType) { + case sqlExtHostType.executionPlan.BadgeType.Warning: + return { + type: 'warning', + tooltip: '' + }; + case sqlExtHostType.executionPlan.BadgeType.CriticalWarning: + return { + type: 'criticalWarning', + tooltip: '' + }; + case sqlExtHostType.executionPlan.BadgeType.Parallelism: + return { + type: 'parallelism', + tooltip: '' + }; + default: + return undefined; + } + } + + private populateProperties(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[]): AzDataGraphCellMetric[] { + props.forEach(p => { + this._graphElementPropertiesSet.add(p.name); + }); + return props.filter(e => isString(e.displayValue) && e.showInTooltip) + .sort((a, b) => a.displayOrder - b.displayOrder) + .map(e => { + return { + name: e.name, + value: e.displayValue, + isLongString: e.positionAtBottom + }; + }); + } + + private populateEdges(edges: InternalExecutionPlanEdge[]): AzDataGraphCellEdge[] { + return edges.map(e => { + e.id = this.createGraphElementId(); + return { + id: e.id, + metrics: this.populateProperties(e.properties), + weight: Math.max(0.5, Math.min(0.5 + 0.75 * Math.log10(e.rowCount), 6)), + label: '' + }; + }); + } + + private createGraphElementId(): string { + this._uniqueElementId += 1; + return `element-${this._uniqueElementId}`; + } + + /** + * Gets a list of unique properties of the graph elements. + */ + public getUniqueElementProperties(): string[] { + return [...this._graphElementPropertiesSet].sort(); + } + + /** + * Enables/Disables the graph tooltips + * @returns state of the tooltip after toggling + */ + public toggleTooltip(): boolean { + if (this._diagram.graph.tooltipHandler.enabled) { + this._diagram.graph.tooltipHandler.setEnabled(false); + } else { + this._diagram.graph.tooltipHandler.setEnabled(true); + } + return this._diagram.graph.tooltipHandler.enabled; + } +} + +export interface InternalExecutionPlanNode extends azdata.executionPlan.ExecutionPlanNode { + /** + * Unique internal id given to graph node by ADS. + */ + id?: string; +} + +export interface InternalExecutionPlanEdge extends azdata.executionPlan.ExecutionPlanEdge { + /** + * Unique internal id given to graph edge by ADS. + */ + id?: string; +} + +export type InternalExecutionPlanElement = InternalExecutionPlanEdge | InternalExecutionPlanNode; + +export interface AzDataGraphCell { + /** + * Label for the azdata cell + */ + label: string; + /** + * unique identifier for the cell + */ + id: string; + /** + * icon for the cell + */ + icon: string; + /** + * title for the cell hover tooltip + */ + tooltipTitle: string; + /** + * metrics to be shown in the tooltip + */ + metrics: AzDataGraphCellMetric[]; + /** + * cell edges + */ + edges: AzDataGraphCellEdge[]; + /** + * child cells + */ + children: AzDataGraphCell[]; + /** + * Description to be displayed in the cell tooltip + */ + description: string; + badges: AzDataGraphNodeBadge[]; +} + +export interface AzDataGraphNodeBadge { + type: string; + tooltip: string; +} + +export interface AzDataGraphCellMetric { + /** + * name of the metric + */ + name: string; + /** + * display value of the metric + */ + value: string; + /** + * flag that indicates if the display property is a long string + * long strings will be displayed at the bottom + */ + isLongString: boolean; +} + +export interface AzDataGraphCellEdge { + /** + * Label for the edge + */ + label: string; + /** + * Unique identifier for the edge + */ + id: string; + /** + * weight of the edge. This value determines the edge thickness + */ + weight: number; + /** + * metrics to be shown in the edge tooltip + */ + metrics: AzDataGraphCellMetric[]; +} + +interface Point { + x: number; + y: number; +} + +export enum SearchType { + Equals, + Contains, + LesserThan, + GreaterThan, + GreaterThanEqualTo, + LesserThanEqualTo, + LesserAndGreaterThan +} +export interface SearchQuery { + /** + * property name to be searched + */ + propertyName: string, + /** + * expected value of the property + */ + value: string, + /** + * Type of search to be performed + */ + searchType: SearchType +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/constants.ts b/src/sql/workbench/contrib/executionPlan/browser/constants.ts index b1d0879a8d..8b5db56b1c 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/constants.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/constants.ts @@ -257,17 +257,19 @@ export const badgeIconPaths = { criticalWarning: imageBasePath + 'badge_critical_warning.svg' }; -const parentContainer = 'qps-container'; -export const savePlanIconClassNames = [parentContainer, 'save-plan-icon'].join(' '); -export const openPropertiesIconClassNames = [parentContainer, 'open-properties-icon'].join(' '); -export const openQueryIconClassNames = [parentContainer, 'open-query-icon'].join(' '); -export const openPlanFileIconClassNames = [parentContainer, 'open-plan-file-icon'].join(' '); -export const saveIconClassNames = [parentContainer, 'save-icon'].join(' '); -export const searchIconClassNames = [parentContainer, 'search-icon'].join(' '); -export const sortAlphabeticallyIconClassNames = [parentContainer, 'sort-alphabetically-icon'].join(' '); -export const sortByDisplayOrderIconClassNames = [parentContainer, 'sort-display-order-icon'].join(' '); -export const zoomInIconClassNames = [parentContainer, 'zoom-in-icon'].join(' '); -export const zoomOutIconClassNames = [parentContainer, 'zoom-out-icon'].join(' '); -export const customZoomIconClassNames = [parentContainer, 'custom-zoom-icon'].join(' '); -export const zoomToFitIconClassNames = [parentContainer, 'zoom-to-fit-icon'].join(' '); -export const zoomIconClassNames = [parentContainer, 'zoom-icon'].join(' '); +export const savePlanIconClassNames = 'ep-save-plan-icon'; +export const openPropertiesIconClassNames = 'ep-open-properties-icon'; +export const openQueryIconClassNames = 'ep-open-query-icon'; +export const openPlanFileIconClassNames = 'ep-open-plan-file-icon'; +export const saveIconClassNames = 'ep-save-icon'; +export const searchIconClassNames = 'ep-search-icon'; +export const sortAlphabeticallyIconClassNames = 'ep-sort-alphabetically-icon'; +export const sortReverseAlphabeticallyIconClassNames = 'ep-sort-reverse-alphabetically-icon'; +export const sortByDisplayOrderIconClassNames = 'ep-sort-display-order-icon'; +export const zoomInIconClassNames = 'ep-zoom-in-icon'; +export const zoomOutIconClassNames = 'ep-zoom-out-icon'; +export const customZoomIconClassNames = 'ep-custom-zoom-icon'; +export const zoomToFitIconClassNames = 'ep-zoom-to-fit-icon'; +export const zoomIconClassNames = 'ep-zoom-icon'; +export const enableTooltipIconClassName = 'ep-enable-tooltip-icon'; +export const disableTooltipIconClassName = 'ep-disable-tooltip-icon'; diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts deleted file mode 100644 index 5348b57eb5..0000000000 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts +++ /dev/null @@ -1,743 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 * as sqlExtHostType from 'sql/workbench/api/common/sqlExtHostTypes'; -import { IPanelView, IPanelTab } from 'sql/base/browser/ui/panel/panel'; -import { localize } from 'vs/nls'; -import { dispose } from 'vs/base/common/lifecycle'; -import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; -import * as DOM from 'vs/base/browser/dom'; -import * as azdataGraphModule from 'azdataGraph'; -import { customZoomIconClassNames, openPlanFileIconClassNames, openPropertiesIconClassNames, openQueryIconClassNames, executionPlanNodeIconPaths, savePlanIconClassNames, searchIconClassNames, zoomInIconClassNames, zoomOutIconClassNames, zoomToFitIconClassNames, badgeIconPaths } from 'sql/workbench/contrib/executionPlan/browser/constants'; -import { isString } from 'vs/base/common/types'; -import { PlanHeader } from 'sql/workbench/contrib/executionPlan/browser/planHeader'; -import { ExecutionPlanPropertiesView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView'; -import { Action } from 'vs/base/common/actions'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { openNewQuery } from 'sql/workbench/contrib/query/browser/queryActions'; -import { RunQueryOnConnectionMode } from 'sql/platform/connection/common/connectionManagement'; -import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; -import { contrastBorder, editorBackground, editorWidgetBackground, foreground, listHoverBackground, textLinkForeground, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; -import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; -import { ISashEvent, ISashLayoutProvider, Orientation, Sash } from 'vs/base/browser/ui/sash/sash'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; -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 { EDITOR_FONT_DEFAULTS } from 'vs/editor/common/config/editorOptions'; -import { ExecutionPlanWidgetController } from 'sql/workbench/contrib/executionPlan/browser/executionPlanWidgetController'; -import { CustomZoomWidget } from 'sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget'; -import { NodeSearchWidget } from 'sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget'; -import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { IFileService } from 'vs/platform/files/common/files'; -import { VSBuffer } from 'vs/base/common/buffer'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { URI } from 'vs/base/common/uri'; -import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; -import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces'; -import { LoadingSpinner } from 'sql/base/browser/ui/loadingSpinner/loadingSpinner'; -import { InfoBox } from 'sql/workbench/browser/ui/infoBox/infoBox'; - -let azdataGraph = azdataGraphModule(); - -export interface InternalExecutionPlanNode extends azdata.executionPlan.ExecutionPlanNode { - /** - * Unique internal id given to graph node by ADS. - */ - id?: string; -} - -export interface InternalExecutionPlanEdge extends azdata.executionPlan.ExecutionPlanEdge { - /** - * Unique internal id given to graph edge by ADS. - */ - id?: string; -} - -export class ExecutionPlanTab implements IPanelTab { - public readonly title = localize('executionPlanTitle', "Query Plan (Preview)"); - public readonly identifier = 'ExecutionPlan2Tab'; - public readonly view: ExecutionPlanView; - - constructor( - @IInstantiationService instantiationService: IInstantiationService, - ) { - this.view = instantiationService.createInstance(ExecutionPlanView); - } - - public dispose() { - dispose(this.view); - } - - public clear() { - this.view.clear(); - } - -} - -export class ExecutionPlanView implements IPanelView { - private _loadingSpinner: LoadingSpinner; - private _loadingErrorInfoBox: InfoBox; - private _eps?: ExecutionPlan[] = []; - private _graphs?: azdata.executionPlan.ExecutionPlanGraph[] = []; - private _container = DOM.$('.eps-container'); - - private _planCache: Map = new Map(); - - constructor( - @IInstantiationService private instantiationService: IInstantiationService, - @IExecutionPlanService private executionPlanService: IExecutionPlanService - ) { - } - - public render(parent: HTMLElement): void { - parent.appendChild(this._container); - } - - dispose() { - this._container.remove(); - delete this._eps; - delete this._graphs; - } - - public layout(dimension: DOM.Dimension): void { - } - - public clear() { - this._eps = []; - this._graphs = []; - DOM.clearNode(this._container); - } - - /** - * Adds executionPlanGraph to the graph controller. - * @param newGraphs ExecutionPlanGraphs to be added. - */ - public addGraphs(newGraphs: azdata.executionPlan.ExecutionPlanGraph[] | undefined) { - if (newGraphs) { - newGraphs.forEach(g => { - const ep = this.instantiationService.createInstance(ExecutionPlan, this._container, this._eps.length + 1); - ep.graphModel = g; - this._eps.push(ep); - this._graphs.push(g); - this.updateRelativeCosts(); - }); - } - } - - /** - * Loads the graph file by converting the file to generic executionPlan graphs. - * This feature requires the right providers to be registered that can handle - * the graphFileType in the graphFile - * Please note: this method clears the existing graph in the graph control - * @param graphFile graph file to be loaded. - * @returns - */ - public async loadGraphFile(graphFile: azdata.executionPlan.ExecutionPlanGraphInfo) { - this.clear(); - this._loadingSpinner = new LoadingSpinner(this._container, { showText: true, fullSize: true }); - this._loadingSpinner.loadingMessage = localize('loadingExecutionPlanFile', "Generating execution plans"); - try { - this._loadingSpinner.loading = true; - if (this._planCache.has(graphFile.graphFileContent)) { - this.addGraphs(this._planCache.get(graphFile.graphFileContent)); - return; - } else { - const graphs = (await this.executionPlanService.getExecutionPlan({ - graphFileContent: graphFile.graphFileContent, - graphFileType: graphFile.graphFileType - })).graphs; - this.addGraphs(graphs); - this._planCache.set(graphFile.graphFileContent, graphs); - } - this._loadingSpinner.loadingCompletedMessage = localize('executionPlanFileLoadingComplete', "Execution plans are generated"); - } catch (e) { - this._loadingErrorInfoBox = this.instantiationService.createInstance(InfoBox, this._container, { - text: e.toString(), - style: 'error', - isClickable: false - }); - this._loadingErrorInfoBox.isClickable = false; - this._loadingSpinner.loadingCompletedMessage = localize('executionPlanFileLoadingFailed', "Failed to load execution plan"); - } finally { - this._loadingSpinner.loading = false; - } - } - - private updateRelativeCosts() { - const sum = this._graphs.reduce((prevCost: number, cg) => { - return prevCost += cg.root.subTreeCost + cg.root.cost; - }, 0); - - if (sum > 0) { - this._eps.forEach(ep => { - ep.planHeader.relativeCost = ((ep.graphModel.root.subTreeCost + ep.graphModel.root.cost) / sum) * 100; - }); - } - } -} - -export class ExecutionPlan implements ISashLayoutProvider { - private _graphModel?: azdata.executionPlan.ExecutionPlanGraph; - - private _container: HTMLElement; - - private _actionBarContainer: HTMLElement; - private _actionBar: ActionBar; - - public planHeader: PlanHeader; - private _planContainer: HTMLElement; - private _planHeaderContainer: HTMLElement; - - public propertiesView: ExecutionPlanPropertiesView; - private _propContainer: HTMLElement; - - private _planActionContainer: HTMLElement; - public planActionView: ExecutionPlanWidgetController; - - public azdataGraphDiagram: any; - - public graphElementPropertiesSet: Set = new Set(); - - private uniqueElementId: number = -1; - - constructor( - private _parent: HTMLElement, - private _graphIndex: number, - @IInstantiationService public readonly _instantiationService: IInstantiationService, - @IThemeService private readonly _themeService: IThemeService, - @IContextViewService public readonly contextViewService: IContextViewService, - @IUntitledTextEditorService private readonly _untitledEditorService: IUntitledTextEditorService, - @IEditorService private readonly editorService: IEditorService, - @IContextMenuService private _contextMenuService: IContextMenuService, - @IFileDialogService public fileDialogService: IFileDialogService, - @IFileService public fileService: IFileService, - @IWorkspaceContextService public workspaceContextService: IWorkspaceContextService, - @ITextResourcePropertiesService private readonly textResourcePropertiesService: ITextResourcePropertiesService, - ) { - // parent container for query plan. - this._container = DOM.$('.execution-plan'); - this._parent.appendChild(this._container); - const sashContainer = DOM.$('.execution-plan-sash'); - this._parent.appendChild(sashContainer); - - const sash = new Sash(sashContainer, this, { orientation: Orientation.HORIZONTAL }); - let originalHeight = this._container.offsetHeight; - let originalTableHeight = 0; - let change = 0; - sash.onDidStart((e: ISashEvent) => { - originalHeight = this._container.offsetHeight; - originalTableHeight = this.propertiesView.tableHeight; - }); - - /** - * Using onDidChange for the smooth resizing of the graph diagram - */ - sash.onDidChange((evt: ISashEvent) => { - change = evt.startY - evt.currentY; - const newHeight = originalHeight - change; - if (newHeight < 200) { - return; - } - /** - * 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`; - }); - - /** - * Resizing properties window table only once at the end as it is a heavy operation and worsens the smooth resizing experience - */ - sash.onDidEnd(() => { - this.propertiesView.tableHeight = originalTableHeight - change; - }); - - this._planContainer = DOM.$('.plan'); - this._container.appendChild(this._planContainer); - - // container that holds plan header info - this._planHeaderContainer = DOM.$('.header'); - - // Styling header text like the query editor - this._planHeaderContainer.style.fontFamily = EDITOR_FONT_DEFAULTS.fontFamily; - this._planHeaderContainer.style.fontSize = EDITOR_FONT_DEFAULTS.fontSize.toString(); - this._planHeaderContainer.style.fontWeight = EDITOR_FONT_DEFAULTS.fontWeight; - - this._planContainer.appendChild(this._planHeaderContainer); - this.planHeader = this._instantiationService.createInstance(PlanHeader, this._planHeaderContainer, { - planIndex: this._graphIndex, - }); - - // container properties - this._propContainer = DOM.$('.properties'); - this._container.appendChild(this._propContainer); - this.propertiesView = new ExecutionPlanPropertiesView(this._propContainer, this._themeService); - - this._planActionContainer = DOM.$('.plan-action-container'); - this._planContainer.appendChild(this._planActionContainer); - this.planActionView = new ExecutionPlanWidgetController(this._planActionContainer); - - // container that holds actionbar icons - this._actionBarContainer = DOM.$('.action-bar-container'); - this._container.appendChild(this._actionBarContainer); - this._actionBar = new ActionBar(this._actionBarContainer, { - orientation: ActionsOrientation.VERTICAL, context: this - }); - - - const actions = [ - new SavePlanFile(), - new OpenPlanFile(), - new OpenQueryAction(), - new SearchNodeAction(), - new ZoomInAction(), - new ZoomOutAction(), - new ZoomToFitAction(), - new CustomZoomAction(), - new PropertiesAction(), - ]; - this._actionBar.pushAction(actions, { icon: true, label: false }); - - // Setting up context menu - const self = this; - this._container.oncontextmenu = (e: MouseEvent) => { - if (actions) { - this._contextMenuService.showContextMenu({ - getAnchor: () => { - return { - x: e.x, - y: e.y - }; - }, - getActions: () => actions, - getActionsContext: () => (self) - }); - } - }; - } - - getHorizontalSashTop(sash: Sash): number { - return 0; - } - getHorizontalSashLeft?(sash: Sash): number { - return 0; - } - getHorizontalSashWidth?(sash: Sash): number { - return this._container.clientWidth; - } - - private populate(node: InternalExecutionPlanNode, diagramNode: any): any { - diagramNode.label = node.subtext.join(this.textResourcePropertiesService.getEOL(undefined)); - diagramNode.tooltipTitle = node.name; - const nodeId = this.createGraphElementId(); - diagramNode.id = nodeId; - node.id = nodeId; - - if (node.properties && node.properties.length > 0) { - diagramNode.metrics = this.populateProperties(node.properties); - } - - if (node.type) { - diagramNode.icon = node.type; - } - - if (node.edges) { - diagramNode.edges = []; - for (let i = 0; i < node.edges.length; i++) { - diagramNode.edges.push(this.populateEdges(node.edges[i], new Object())); - } - } - - if (node.children) { - diagramNode.children = []; - for (let i = 0; i < node.children.length; ++i) { - diagramNode.children.push(this.populate(node.children[i], new Object())); - } - } - - if (node.badges) { - diagramNode.badges = []; - for (let i = 0; i < node.badges.length; i++) { - diagramNode.badges.push(this.getBadgeTypeString(node.badges[i].type)); - } - } - - if (node.description) { - diagramNode.description = node.description; - } - return diagramNode; - } - - private getBadgeTypeString(badgeType: sqlExtHostType.executionPlan.BadgeType): { - type: string, - tooltip: string - } | undefined { - /** - * TODO: Need to figure out if tooltip have to be removed. For now, they are empty - */ - switch (badgeType) { - case sqlExtHostType.executionPlan.BadgeType.Warning: - return { - type: 'warning', - tooltip: '' - }; - case sqlExtHostType.executionPlan.BadgeType.CriticalWarning: - return { - type: 'criticalWarning', - tooltip: '' - }; - case sqlExtHostType.executionPlan.BadgeType.Parallelism: - return { - type: 'parallelism', - tooltip: '' - }; - default: - return undefined; - } - } - - private populateEdges(edge: InternalExecutionPlanEdge, diagramEdge: any) { - diagramEdge.label = ''; - const edgeId = this.createGraphElementId(); - diagramEdge.id = edgeId; - edge.id = edgeId; - diagramEdge.metrics = this.populateProperties(edge.properties); - diagramEdge.weight = Math.max(0.5, Math.min(0.5 + 0.75 * Math.log10(edge.rowCount), 6)); - return diagramEdge; - } - - private populateProperties(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[]) { - return props.filter(e => isString(e.displayValue) && e.showInTooltip) - .sort((a, b) => a.displayOrder - b.displayOrder) - .map(e => { - this.graphElementPropertiesSet.add(e.name); - return { - name: e.name, - value: e.displayValue, - isLongString: e.positionAtBottom - }; - }); - } - - private createGraphElementId(): string { - this.uniqueElementId += 1; - return `element-${this.uniqueElementId}`; - } - - private createPlanDiagram(container: HTMLElement) { - let diagramRoot: any = new Object(); - let graphRoot: azdata.executionPlan.ExecutionPlanNode = this._graphModel.root; - - this.populate(graphRoot, diagramRoot); - this.azdataGraphDiagram = new azdataGraph.azdataQueryPlan(container, diagramRoot, executionPlanNodeIconPaths, badgeIconPaths); - - this.azdataGraphDiagram.graph.setCellsMovable(false); // preventing drag and drop of graph nodes. - this.azdataGraphDiagram.graph.setCellsDisconnectable(false); // preventing graph edges to be disconnected from source and target nodes. - - this.azdataGraphDiagram.graph.addListener('click', (sender, evt) => { - // Updating properties view table on node clicks - const cell = evt.properties['cell']; - if (cell) { - this.propertiesView.graphElement = this.searchNodes(cell.id); - } else if (!this.azdataGraphDiagram.graph.getSelectionCell()) { - const root = this.azdataGraphDiagram.graph.model.getCell(diagramRoot.id); - this.azdataGraphDiagram.graph.getSelectionModel().setCell(root); - this.propertiesView.graphElement = this.searchNodes(diagramRoot.id); - evt.consume(); - } else { - evt.consume(); - } - }); - - registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { - const iconBackground = theme.getColor(editorBackground); - if (iconBackground) { - this.azdataGraphDiagram.setIconBackgroundColor(iconBackground); - } - - const iconLabelColor = theme.getColor(foreground); - if (iconLabelColor) { - this.azdataGraphDiagram.setTextFontColor(iconLabelColor); - this.azdataGraphDiagram.setEdgeColor(iconLabelColor); - } - }); - } - - public set graphModel(graph: azdata.executionPlan.ExecutionPlanGraph | undefined) { - this._graphModel = graph; - if (this._graphModel) { - this.planHeader.graphIndex = this._graphIndex; - this.planHeader.query = graph.query; - if (graph.recommendations) { - this.planHeader.recommendations = graph.recommendations; - } - let diagramContainer = DOM.$('.diagram'); - this.createPlanDiagram(diagramContainer); - - /** - * We do not want to scroll the diagram through mouse wheel. - * Instead, we pass this event to parent control. So, when user - * uses the scroll wheel, they scroll through graphs present in - * the graph control. To scroll the individual graphs, users should - * use the scroll bars. - */ - diagramContainer.addEventListener('wheel', e => { - this._parent.scrollTop += e.deltaY; - //Hiding all tooltips when we scroll. - const element = document.getElementsByClassName('mxTooltip'); - for (let i = 0; i < element.length; i++) { - (element[i]).style.visibility = 'hidden'; - } - e.preventDefault(); - e.stopPropagation(); - }); - - this._planContainer.appendChild(diagramContainer); - - this.propertiesView.graphElement = this._graphModel.root; - } - } - - public get graphModel(): azdata.executionPlan.ExecutionPlanGraph | undefined { - return this._graphModel; - } - - public openQuery() { - return this._instantiationService.invokeFunction(openNewQuery, undefined, this.graphModel.query, RunQueryOnConnectionMode.none).then(); - } - - public async openGraphFile() { - const input = this._untitledEditorService.create({ mode: this.graphModel.graphFile.graphFileType, initialValue: this.graphModel.graphFile.graphFileContent }); - await input.resolve(); - await this._instantiationService.invokeFunction(formatDocumentWithSelectedProvider, input.textEditorModel, FormattingMode.Explicit, Progress.None, CancellationToken.None); - input.setDirty(false); - this.editorService.openEditor(input); - } - - - public searchNodes(searchId: string): InternalExecutionPlanNode | InternalExecutionPlanEdge | undefined { - let stack: InternalExecutionPlanNode[] = []; - stack.push(this._graphModel.root); - while (stack.length !== 0) { - const currentNode = stack.pop(); - if (currentNode.id === searchId) { - return currentNode; - } - stack.push(...currentNode.children); - const resultEdge = currentNode.edges.find(e => (e).id === searchId); - if (resultEdge) { - return resultEdge; - } - } - return undefined; - } -} - -class OpenQueryAction extends Action { - public static ID = 'ep.OpenQueryAction'; - public static LABEL = localize('openQueryAction', "Open Query"); - - constructor() { - super(OpenQueryAction.ID, OpenQueryAction.LABEL, openQueryIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.openQuery(); - } -} - -class PropertiesAction extends Action { - public static ID = 'ep.propertiesAction'; - public static LABEL = localize('executionPlanPropertiesActionLabel', "Properties"); - - constructor() { - super(PropertiesAction.ID, PropertiesAction.LABEL, openPropertiesIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.propertiesView.toggleVisibility(); - } -} - -class ZoomInAction extends Action { - public static ID = 'ep.ZoomInAction'; - public static LABEL = localize('executionPlanZoomInActionLabel', "Zoom In"); - - constructor() { - super(ZoomInAction.ID, ZoomInAction.LABEL, zoomInIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.azdataGraphDiagram.graph.zoomIn(); - } -} - -class ZoomOutAction extends Action { - public static ID = 'ep.ZoomOutAction'; - public static LABEL = localize('executionPlanZoomOutActionLabel', "Zoom Out"); - - constructor() { - super(ZoomOutAction.ID, ZoomOutAction.LABEL, zoomOutIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.azdataGraphDiagram.graph.zoomOut(); - } -} - -class ZoomToFitAction extends Action { - public static ID = 'ep.FitGraph'; - public static LABEL = localize('executionPlanFitGraphLabel', "Zoom to fit"); - - constructor() { - super(ZoomToFitAction.ID, ZoomToFitAction.LABEL, zoomToFitIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.azdataGraphDiagram.graph.fit(); - context.azdataGraphDiagram.graph.view.rendering = true; - context.azdataGraphDiagram.graph.refresh(); - } -} - -class SavePlanFile extends Action { - public static ID = 'ep.saveXML'; - public static LABEL = localize('executionPlanSavePlanXML', "Save Plan File"); - - constructor() { - super(SavePlanFile.ID, SavePlanFile.LABEL, savePlanIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - const workspaceFolders = await context.workspaceContextService.getWorkspace().folders; - const defaultFileName = 'plan'; - let currentWorkSpaceFolder: URI; - if (workspaceFolders.length !== 0) { - currentWorkSpaceFolder = workspaceFolders[0].uri; - currentWorkSpaceFolder = URI.joinPath(currentWorkSpaceFolder, defaultFileName); //appending default file name to workspace uri - } else { - currentWorkSpaceFolder = URI.parse(defaultFileName); // giving default name - } - const saveFileUri = await context.fileDialogService.showSaveDialog({ - filters: [ - { - extensions: ['sqlplan'], //TODO: Get this extension from provider - name: localize('executionPlan.SaveFileDescription', 'Execution Plan Files') //TODO: Get the names from providers. - } - ], - defaultUri: currentWorkSpaceFolder // If no workspaces are opened this will be undefined - }); - if (saveFileUri) { - await context.fileService.writeFile(saveFileUri, VSBuffer.fromString(context.graphModel.graphFile.graphFileContent)); - } - } -} - -class CustomZoomAction extends Action { - public static ID = 'ep.customZoom'; - public static LABEL = localize('executionPlanCustomZoom', "Custom Zoom"); - - constructor() { - super(CustomZoomAction.ID, CustomZoomAction.LABEL, customZoomIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.planActionView.toggleWidget(context._instantiationService.createInstance(CustomZoomWidget, context)); - } -} - -class SearchNodeAction extends Action { - public static ID = 'ep.searchNode'; - public static LABEL = localize('executionPlanSearchNodeAction', "Find Node"); - - constructor() { - super(SearchNodeAction.ID, SearchNodeAction.LABEL, searchIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - context.planActionView.toggleWidget(context._instantiationService.createInstance(NodeSearchWidget, context)); - } -} - -class OpenPlanFile extends Action { - public static ID = 'ep.openGraphFile'; - public static Label = localize('executionPlanOpenGraphFile', "Show Query Plan XML"); //TODO: add a contribution point for providers to set this text - - constructor() { - super(OpenPlanFile.ID, OpenPlanFile.Label, openPlanFileIconClassNames); - } - - public override async run(context: ExecutionPlan): Promise { - await context.openGraphFile(); - } -} - -registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { - const recommendationsColor = theme.getColor(textLinkForeground); - if (recommendationsColor) { - collector.addRule(` - .eps-container .execution-plan .plan .header .recommendations { - color: ${recommendationsColor}; - } - `); - } - const shadow = theme.getColor(widgetShadow); - if (shadow) { - collector.addRule(` - .eps-container .execution-plan .plan .plan-action-container .child { - box-shadow: 0 0 8px 2px ${shadow}; - } - `); - } - - const menuBackgroundColor = theme.getColor(listHoverBackground); - if (menuBackgroundColor) { - collector.addRule(` - .eps-container .execution-plan .plan .header, - .eps-container .execution-plan .properties .title, - .eps-container .execution-plan .properties .table-action-bar { - background-color: ${menuBackgroundColor}; - } - `); - } - - const widgetBackgroundColor = theme.getColor(editorWidgetBackground); - if (widgetBackgroundColor) { - collector.addRule(` - .eps-container .execution-plan .plan .plan-action-container .child, - .mxTooltip { - background-color: ${widgetBackgroundColor}; - } - `); - } - - const widgetBorderColor = theme.getColor(contrastBorder); - if (widgetBorderColor) { - collector.addRule(` - .eps-container .execution-plan .plan .plan-action-container .child, - .eps-container .execution-plan .plan .header, - .eps-container .execution-plan .properties .title, - .eps-container .execution-plan .properties .table-action-bar, - .mxTooltip { - border: 1px solid ${widgetBorderColor}; - } - `); - } - - const textColor = theme.getColor(foreground); - if (textColor) { - collector.addRule(` - .mxTooltip { - color: ${textColor}; - } - `); - } -}); diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts index 7808268ed0..35c94eb5a6 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanEditor.ts @@ -10,55 +10,74 @@ import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ExecutionPlanInput } from 'sql/workbench/contrib/executionPlan/common/executionPlanInput'; -import { CancellationToken } from 'vs/base/common/cancellation'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { IEditorOptions } from 'vs/platform/editor/common/editor'; -import { ExecutionPlanView } from 'sql/workbench/contrib/executionPlan/browser/executionPlan'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { ExecutionPlanFileView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileView'; +import { generateUuid } from 'vs/base/common/uuid'; +import { ExecutionPlanFileViewCache } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache'; export class ExecutionPlanEditor extends EditorPane { public static ID: string = 'workbench.editor.executionplan'; public static LABEL: string = localize('executionPlanEditor', "Query Execution Plan Editor"); - private view: ExecutionPlanView; + private _viewCache: ExecutionPlanFileViewCache = ExecutionPlanFileViewCache.getInstance(); + + private _parentContainer: HTMLElement; constructor( - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private _instantiationService: IInstantiationService, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, ) { super(ExecutionPlanEditor.ID, telemetryService, themeService, storageService); - this.view = this._register(instantiationService.createInstance(ExecutionPlanView)); } /** * Called to create the editor in the parent element. */ public createEditor(parent: HTMLElement): void { + this._parentContainer = parent; //Enable scrollbars when drawing area is larger than viewport parent.style.overflow = 'auto'; - this.view.render(parent); } - /** - * Updates the internal variable keeping track of the editor's size, and re-calculates the sash position. - * To be called when the container of this editor changes size. - */ public layout(dimension: DOM.Dimension): void { - this.view.layout(dimension); } - public override async setInput(input: ExecutionPlanInput, options: IEditorOptions, context: IEditorOpenContext): Promise { - if (this.input instanceof ExecutionPlanInput && this.input.matches(input)) { - return Promise.resolve(undefined); + public override async setInput(newInput: ExecutionPlanInput, options: IEditorOptions, context: IEditorOpenContext, token: CancellationToken): Promise { + const oldInput = this.input as ExecutionPlanInput; + + // returning if the new input is same as old input + if (oldInput && newInput.matches(oldInput)) { + return Promise.resolve(); + } + + super.setInput(newInput, options, context, token); + + // clearing old input view if present in the editor + if (oldInput?._executionPlanFileViewUUID) { + const oldView = this._viewCache.executionPlanFileViewMap.get(oldInput._executionPlanFileViewUUID); + oldView.onHide(this._parentContainer); + } + + // if new input already has a view we are just making it visible here. + let newView = this._viewCache.executionPlanFileViewMap.get(newInput.executionPlanFileViewUUID); + if (newView) { + newView.onShow(this._parentContainer); + } else { + // creating a new view for the new input + newInput._executionPlanFileViewUUID = generateUuid(); + newView = this._register(this._instantiationService.createInstance(ExecutionPlanFileView)); + newView.onShow(this._parentContainer); + newView.loadGraphFile({ + graphFileContent: await newInput.content(), + graphFileType: newInput.getFileExtension().replace('.', '') + }); + this._viewCache.executionPlanFileViewMap.set(newInput._executionPlanFileViewUUID, newView); } - await input.resolve(); - await super.setInput(input, options, context, CancellationToken.None); - this.view.loadGraphFile({ - graphFileContent: input.content, - graphFileType: input.getFileExtension().replace('.', '') - }); } } diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts new file mode 100644 index 0000000000..ef7d4c811b --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileView.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as azdata from 'azdata'; +import { InfoBox } from 'sql/workbench/browser/ui/infoBox/infoBox'; +import { LoadingSpinner } from 'sql/base/browser/ui/loadingSpinner/loadingSpinner'; +import { ExecutionPlanView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanView'; +import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces'; +import * as DOM from 'vs/base/browser/dom'; +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'; + +export class ExecutionPlanFileView { + private _parent: HTMLElement; + private _loadingSpinner: LoadingSpinner; + private _loadingErrorInfoBox: InfoBox; + private _executionPlanViews: ExecutionPlanView[] = []; + private _graphs?: azdata.executionPlan.ExecutionPlanGraph[] = []; + private _container = DOM.$('.eps-container'); + + private _planCache: Map = new Map(); + + constructor( + @IInstantiationService private instantiationService: IInstantiationService, + @IExecutionPlanService private executionPlanService: IExecutionPlanService + ) { + } + + public render(parent: HTMLElement): void { + this._parent = parent; + this._parent.appendChild(this._container); + } + + public onShow(parentContainer: HTMLElement): void { + this._parent = parentContainer; + this._parent.appendChild(this._container); + } + + public onHide(parentContainer: HTMLElement): void { + if (parentContainer === this._parent) { + this._parent.removeChild(this._container); + } + } + + dispose() { + } + + /** + * Adds executionPlanGraph to the graph controller. + * @param newGraphs ExecutionPlanGraphs to be added. + */ + public addGraphs(newGraphs: azdata.executionPlan.ExecutionPlanGraph[] | undefined) { + if (newGraphs) { + newGraphs.forEach(g => { + const ep = this.instantiationService.createInstance(ExecutionPlanView, this._container, this._executionPlanViews.length + 1); + ep.model = g; + this._executionPlanViews.push(ep); + this._graphs.push(g); + this.updateRelativeCosts(); + }); + } + } + + /** + * Loads the graph file by converting the file to generic executionPlan graphs. + * This feature requires the right providers to be registered that can handle + * the graphFileType in the graphFile + * Please note: this method clears the existing graph in the graph control + * @param graphFile graph file to be loaded. + * @returns + */ + public async loadGraphFile(graphFile: azdata.executionPlan.ExecutionPlanGraphInfo) { + this._loadingSpinner = new LoadingSpinner(this._container, { showText: true, fullSize: true }); + this._loadingSpinner.loadingMessage = localize('loadingExecutionPlanFile', "Generating execution plans"); + try { + this._loadingSpinner.loading = true; + if (this._planCache.has(graphFile.graphFileContent)) { + this.addGraphs(this._planCache.get(graphFile.graphFileContent)); + return; + } else { + const graphs = (await this.executionPlanService.getExecutionPlan({ + graphFileContent: graphFile.graphFileContent, + graphFileType: graphFile.graphFileType + })).graphs; + this.addGraphs(graphs); + this._planCache.set(graphFile.graphFileContent, graphs); + } + this._loadingSpinner.loadingCompletedMessage = localize('executionPlanFileLoadingComplete', "Execution plans are generated"); + } catch (e) { + this._loadingErrorInfoBox = this.instantiationService.createInstance(InfoBox, this._container, { + text: e.toString(), + style: 'error', + isClickable: false + }); + this._loadingErrorInfoBox.isClickable = false; + this._loadingSpinner.loadingCompletedMessage = localize('executionPlanFileLoadingFailed', "Failed to load execution plan"); + } finally { + this._loadingSpinner.loading = false; + } + } + + private updateRelativeCosts() { + const sum = this._graphs.reduce((prevCost: number, cg) => { + return prevCost += cg.root.subTreeCost + cg.root.cost; + }, 0); + + if (sum > 0) { + this._executionPlanViews.forEach(ep => { + ep.planHeader.relativeCost = ((ep.model.root.subTreeCost + ep.model.root.cost) / sum) * 100; + }); + } + } +} + + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const recommendationsColor = theme.getColor(textLinkForeground); + if (recommendationsColor) { + collector.addRule(` + .eps-container .execution-plan .plan .header .recommendations { + color: ${recommendationsColor}; + } + `); + } + const shadow = theme.getColor(widgetShadow); + if (shadow) { + collector.addRule(` + .eps-container .execution-plan .plan .plan-action-container .child { + box-shadow: 0 0 8px 2px ${shadow}; + } + `); + } + + const menuBackgroundColor = theme.getColor(listHoverBackground); + if (menuBackgroundColor) { + collector.addRule(` + .eps-container .execution-plan .plan .header, + .eps-container .execution-plan .properties .title, + .eps-container .execution-plan .properties .table-action-bar { + background-color: ${menuBackgroundColor}; + } + `); + } + + const widgetBackgroundColor = theme.getColor(editorWidgetBackground); + if (widgetBackgroundColor) { + collector.addRule(` + .eps-container .execution-plan .plan .plan-action-container .child, + .mxTooltip { + background-color: ${widgetBackgroundColor}; + } + `); + } + + const widgetBorderColor = theme.getColor(contrastBorder); + if (widgetBorderColor) { + collector.addRule(` + .eps-container .execution-plan .plan .plan-action-container .child, + .eps-container .execution-plan .plan .header, + .eps-container .execution-plan .properties .title, + .eps-container .execution-plan .properties .table-action-bar, + .mxTooltip { + border: 1px solid ${widgetBorderColor}; + } + `); + } + + const textColor = theme.getColor(foreground); + if (textColor) { + collector.addRule(` + .mxTooltip { + color: ${textColor}; + } + `); + } +}); diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache.ts new file mode 100644 index 0000000000..63026c16c9 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ExecutionPlanFileView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileView'; + +export class ExecutionPlanFileViewCache { + private static instance: ExecutionPlanFileViewCache; + + public executionPlanFileViewMap: Map = new Map(); + + private constructor() { } + + public static getInstance(): ExecutionPlanFileViewCache { + if (!ExecutionPlanFileViewCache.instance) { + ExecutionPlanFileViewCache.instance = new ExecutionPlanFileViewCache(); + } + + return ExecutionPlanFileViewCache.instance; + } +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts index aa264d08f3..cc44df0011 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts @@ -6,89 +6,84 @@ import * as DOM from 'vs/base/browser/dom'; import type * as azdata from 'azdata'; import { localize } from 'vs/nls'; -import { Action } from 'vs/base/common/actions'; -import { Codicon } from 'vs/base/common/codicons'; import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { attachTableStyler } from 'sql/platform/theme/common/styler'; -import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; -import { Table } from 'sql/base/browser/ui/table/table'; -import { RESULTS_GRID_DEFAULTS } from 'sql/workbench/common/constants'; -import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; -import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; import { removeLineBreaks } from 'sql/base/common/strings'; import { isString } from 'vs/base/common/types'; -import { sortAlphabeticallyIconClassNames, sortByDisplayOrderIconClassNames } from 'sql/workbench/contrib/executionPlan/browser/constants'; import { textFormatter } from 'sql/base/browser/ui/table/formatters'; +import { ExecutionPlanPropertiesViewBase, PropertiesSortType } from 'sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase'; - -export class ExecutionPlanPropertiesView { - - // Title bar with close button action - private _propertiesTitle!: HTMLElement; - private _titleText!: HTMLElement; - private _titleActionBarContainer!: HTMLElement; - private _titleActionBar: ActionBar; - +export class ExecutionPlanPropertiesView extends ExecutionPlanPropertiesViewBase { // Div that holds the name of the element selected private _operationName!: HTMLElement; - - // Action bar that contains sorting option for the table - private _tableActionBarContainer!: HTMLElement; - private _tableActionBar!: ActionBar; - - // Properties table - private _table: Table; - private _dataView: TableDataView; - private _data: { [key: string]: string }[]; - private _tableContainer!: HTMLElement; - private _actualTable!: HTMLElement; - - // Table dimensions. - private _tableWidth = 485; - private _tableHeight; + private _model: ExecutionPlanPropertiesViewModel; public constructor( - private _parentContainer: HTMLElement, - private _themeService: IThemeService, - private _model: GraphElementPropertyViewData = {} + parentContainer: HTMLElement, + themeService: IThemeService ) { - this._parentContainer.style.display = 'none'; - - this._propertiesTitle = DOM.$('.title'); - this._parentContainer.appendChild(this._propertiesTitle); - - this._titleText = DOM.$('h3'); - this._titleText.classList.add('text'); - this._titleText.innerText = localize('nodePropertyViewTitle', "Properties"); - this._propertiesTitle.appendChild(this._titleText); - - this._titleActionBarContainer = DOM.$('.action-bar'); - this._propertiesTitle.appendChild(this._titleActionBarContainer); - this._titleActionBar = new ActionBar(this._titleActionBarContainer, { - orientation: ActionsOrientation.HORIZONTAL, context: this - }); - this._titleActionBar.pushAction([new ClosePropertyViewAction()], { icon: true, label: false }); - + super(parentContainer, themeService); + this._model = {}; this._operationName = DOM.$('h3'); this._operationName.classList.add('operation-name'); this._parentContainer.appendChild(this._operationName); + this.setHeader(this._operationName); - this._tableActionBarContainer = DOM.$('.table-action-bar'); - this._parentContainer.appendChild(this._tableActionBarContainer); - this._tableActionBar = new ActionBar(this._tableActionBarContainer, { - orientation: ActionsOrientation.HORIZONTAL, context: this + this._parentContainer.style.display = 'none'; + } + + public set graphElement(element: azdata.executionPlan.ExecutionPlanNode | azdata.executionPlan.ExecutionPlanEdge) { + this._model.graphElement = element; + this.renderView(); + } + + public sortPropertiesAlphabetically(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[]): azdata.executionPlan.ExecutionPlanGraphElementProperty[] { + return props.sort((a, b) => { + if (!a?.name && !b?.name) { + return 0; + } else if (!a?.name) { + return -1; + } else if (!b?.name) { + return 1; + } else { + return a.name.localeCompare(b.name); + } }); - this._tableActionBar.pushAction([new SortPropertiesByDisplayOrderAction(), new SortPropertiesAlphabeticallyAction()], { icon: true, label: false }); + } + + public sortPropertiesReverseAlphabetically(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[]): azdata.executionPlan.ExecutionPlanGraphElementProperty[] { + return props.sort((a, b) => { + if (!a?.name && !b?.name) { + return 0; + } else if (!a?.name) { + return -1; + } else if (!b?.name) { + return 1; + } else { + return b.name.localeCompare(a.name); + } + }); + } - this._tableContainer = DOM.$('.table-container'); - this._parentContainer.appendChild(this._tableContainer); + public sortPropertiesByImportance(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[]): azdata.executionPlan.ExecutionPlanGraphElementProperty[] { + return props.sort((a, b) => { + if (!a?.displayOrder && !b?.displayOrder) { + return 0; + } else if (!a?.displayOrder) { + return -1; + } else if (!b?.displayOrder) { + return 1; + } else { + return a.displayOrder - b.displayOrder; + } + }); + } - this._actualTable = DOM.$('.table'); - this._tableContainer.appendChild(this._actualTable); - - this._dataView = new TableDataView(); - this._data = []; + public renderView(): void { + if (this._model.graphElement) { + const nodeName = (this._model.graphElement).name; + this._operationName.innerText = nodeName ? removeLineBreaks(nodeName) : localize('executionPlanPropertiesEdgeOperationName', "Edge"); //since edges do not have names like node, we set the operation name to 'Edge' + } const columns: Slick.Column[] = [ { @@ -111,100 +106,26 @@ export class ExecutionPlanPropertiesView { } ]; - this._table = new Table(this._actualTable, { - dataProvider: this._dataView, columns: columns - }, { - rowHeight: RESULTS_GRID_DEFAULTS.rowHeight, - forceFitColumns: true, - defaultColumnWidth: 120 - }); - - new ResizeObserver((e) => { - this.tableHeight = (this._parentContainer.getBoundingClientRect().height - 80); - }).observe(this._parentContainer); - - attachTableStyler(this._table, this._themeService); + this.populateTable(columns, this.convertModelToTableRows(this._model.graphElement.properties, -1, 0)); } - public set graphElement(element: azdata.executionPlan.ExecutionPlanNode | azdata.executionPlan.ExecutionPlanEdge) { - this._model.graphElement = element; - this.sortPropertiesByImportance(); - this.renderView(); - } - - public sortPropertiesAlphabetically(): void { - this._model.graphElement.properties = this._model.graphElement.properties.sort((a, b) => { - if (!a?.name && !b?.name) { - return 0; - } else if (!a?.name) { - return -1; - } else if (!b?.name) { - return 1; - } else { - return a.name.localeCompare(b.name); - } - }); - this.renderView(); - } - - public sortPropertiesByImportance(): void { - this._model.graphElement.properties = this._model.graphElement.properties.sort((a, b) => { - if (!a?.displayOrder && !b?.displayOrder) { - return 0; - } else if (!a?.displayOrder) { - return -1; - } else if (!b?.displayOrder) { - return 1; - } else { - return a.displayOrder - b.displayOrder; - } - }); - this.renderView(); - } - - public set tableHeight(value: number) { - if (this.tableHeight !== value) { - this._tableHeight = value; - this.renderView(); - } - } - - public get tableHeight(): number { - return this._tableHeight; - } - - public set tableWidth(value: number) { - if (this._tableWidth !== value) { - this._tableWidth = value; - this.renderView(); - } - } - - public get tableWidth(): number { - return this._tableWidth; - } - - private renderView(): void { - if (this._model.graphElement) { - const nodeName = (this._model.graphElement).name; - this._operationName.innerText = nodeName ? removeLineBreaks(nodeName) : localize('executionPlanPropertiesEdgeOperationName', "Edge"); //since edges do not have names like node, we set the operation name to 'Edge' - } - this._tableContainer.scrollTo(0, 0); - this._dataView.clear(); - this._data = this.convertPropertiesToTableRows(this._model.graphElement.properties, -1, 0); - this._dataView.push(this._data); - this._table.setData(this._dataView); - this._table.autosizeColumns(); - this._table.updateRowCount(); - this.tableHeight = (this._parentContainer.getBoundingClientRect().height - 80); //80px is the space taken by the title and toolbar - this._table.layout(new DOM.Dimension(this._tableWidth, this._tableHeight)); - this._table.resizeCanvas(); - } - - private convertPropertiesToTableRows(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[], parentIndex: number, indent: number, rows: { [key: string]: string }[] = []): { [key: string]: string }[] { + private convertModelToTableRows(props: azdata.executionPlan.ExecutionPlanGraphElementProperty[], parentIndex: number, indent: number, rows: { [key: string]: string }[] = []): { [key: string]: string }[] { if (!props) { return rows; } + + switch (this.sortType) { + case PropertiesSortType.DisplayOrder: + props = this.sortPropertiesByImportance(props); + break; + case PropertiesSortType.Alphabetical: + props = this.sortPropertiesAlphabetically(props); + break; + case PropertiesSortType.ReverseAlphabetical: + props = this.sortPropertiesReverseAlphabetically(props); + break; + } + props.forEach((p, i) => { let row = {}; rows.push(row); @@ -212,7 +133,7 @@ export class ExecutionPlanPropertiesView { row['parent'] = parentIndex; if (!isString(p.value)) { row['value'] = removeLineBreaks(p.displayValue, ' '); - this.convertPropertiesToTableRows(p.value, rows.length - 1, indent + 2, rows); + this.convertModelToTableRows(p.value, rows.length - 1, indent + 2, rows); } else { row['value'] = removeLineBreaks(p.value, ' '); row['tooltip'] = p.value; @@ -220,52 +141,8 @@ export class ExecutionPlanPropertiesView { }); return rows; } - - public toggleVisibility(): void { - this._parentContainer.style.display = this._parentContainer.style.display === 'none' ? 'block' : 'none'; - this.renderView(); - } } -export interface GraphElementPropertyViewData { +export interface ExecutionPlanPropertiesViewModel { graphElement: azdata.executionPlan.ExecutionPlanNode | azdata.executionPlan.ExecutionPlanEdge; } - -export class ClosePropertyViewAction extends Action { - public static ID = 'ep.propertiesView.close'; - public static LABEL = localize('executionPlanPropertyViewClose', "Close"); - - constructor() { - super(ClosePropertyViewAction.ID, ClosePropertyViewAction.LABEL, Codicon.close.classNames); - } - - public override async run(context: ExecutionPlanPropertiesView): Promise { - context.toggleVisibility(); - } -} - -export class SortPropertiesAlphabeticallyAction extends Action { - public static ID = 'ep.propertiesView.sortByAlphabet'; - public static LABEL = localize('executionPlanPropertyViewSortAlphabetically', "Alphabetical"); - - constructor() { - super(SortPropertiesAlphabeticallyAction.ID, SortPropertiesAlphabeticallyAction.LABEL, sortAlphabeticallyIconClassNames); - } - - public override async run(context: ExecutionPlanPropertiesView): Promise { - context.sortPropertiesAlphabetically(); - } -} - -export class SortPropertiesByDisplayOrderAction extends Action { - public static ID = 'ep.propertiesView.sortByDisplayOrder'; - public static LABEL = localize('executionPlanPropertyViewSortByDisplayOrder', "Categorized"); - - constructor() { - super(SortPropertiesByDisplayOrderAction.ID, SortPropertiesByDisplayOrderAction.LABEL, sortByDisplayOrderIconClassNames); - } - - public override async run(context: ExecutionPlanPropertiesView): Promise { - context.sortPropertiesByImportance(); - } -} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts new file mode 100644 index 0000000000..d815bda1ff --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { Table } from 'sql/base/browser/ui/table/table'; +import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; +import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; +import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { localize } from 'vs/nls'; +import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { Action } from 'vs/base/common/actions'; +import { Codicon } from 'vs/base/common/codicons'; +import { sortAlphabeticallyIconClassNames, sortByDisplayOrderIconClassNames, sortReverseAlphabeticallyIconClassNames } from 'sql/workbench/contrib/executionPlan/browser/constants'; +import { attachTableStyler } from 'sql/platform/theme/common/styler'; +import { RESULTS_GRID_DEFAULTS } from 'sql/workbench/common/constants'; +import { contrastBorder, listHoverBackground } from 'vs/platform/theme/common/colorRegistry'; + +export abstract class ExecutionPlanPropertiesViewBase { + // Title bar with close button action + private _titleBarContainer!: HTMLElement; + private _titleBarTextContainer!: HTMLElement; + private _titleBarActionsContainer!: HTMLElement; + private _titleActions: ActionBar; + + // Header container + private _headerContainer: HTMLElement; + + // Properties actions + private _headerActionsContainer!: HTMLElement; + private _headerActions: ActionBar; + + // Properties table + private _tableComponent: Table; + private _tableComponentDataView: TableDataView; + private _tableComponentDataModel: { [key: string]: string }[]; + private _tableContainer!: HTMLElement; + + private _tableWidth; + private _tableHeight; + + public sortType: PropertiesSortType = PropertiesSortType.DisplayOrder; + + constructor( + public _parentContainer: HTMLElement, + private _themeService: IThemeService + ) { + + const sashContainer = DOM.$('.properties-sash'); + this._parentContainer.appendChild(sashContainer); + + this._titleBarContainer = DOM.$('.title'); + this._parentContainer.appendChild(this._titleBarContainer); + + this._titleBarTextContainer = DOM.$('h3'); + this._titleBarTextContainer.classList.add('text'); + this._titleBarTextContainer.innerText = localize('nodePropertyViewTitle', "Properties"); + this._titleBarContainer.appendChild(this._titleBarTextContainer); + + + this._titleBarActionsContainer = DOM.$('.action-bar'); + this._titleBarContainer.appendChild(this._titleBarActionsContainer); + this._titleActions = new ActionBar(this._titleBarActionsContainer, { + orientation: ActionsOrientation.HORIZONTAL, context: this + }); + this._titleActions.pushAction([new ClosePropertyViewAction()], { icon: true, label: false }); + + this._headerContainer = DOM.$('.header'); + this._parentContainer.appendChild(this._headerContainer); + + this._headerActionsContainer = DOM.$('.table-action-bar'); + this._parentContainer.appendChild(this._headerActionsContainer); + this._headerActions = new ActionBar(this._headerActionsContainer, { + orientation: ActionsOrientation.HORIZONTAL, context: this + }); + this._headerActions.pushAction([new SortPropertiesByDisplayOrderAction(), new SortPropertiesAlphabeticallyAction(), new SortPropertiesReverseAlphabeticallyAction()], { icon: true, label: false }); + + + this._tableContainer = DOM.$('.table-container'); + this._parentContainer.appendChild(this._tableContainer); + + const table = DOM.$('.table'); + this._tableContainer.appendChild(table); + + this._tableComponentDataView = new TableDataView(); + this._tableComponentDataModel = []; + this._tableComponent = new Table(table, { + dataProvider: this._tableComponentDataView, columns: [] + }, { + rowHeight: RESULTS_GRID_DEFAULTS.rowHeight, + forceFitColumns: true, + defaultColumnWidth: 120 + }); + attachTableStyler(this._tableComponent, this._themeService); + + new ResizeObserver((e) => { + this.resizeTable(); + }).observe(this._parentContainer); + + } + + public setTitle(v: string): void { + this._titleBarTextContainer.innerText = v; + } + + public setHeader(c: HTMLElement): void { + this._headerContainer.appendChild(c); + } + + public set tableHeight(value: number) { + if (this.tableHeight !== value) { + this._tableHeight = value; + this.renderView(); + } + } + + public set tableWidth(value: number) { + if (this._tableWidth !== value) { + this._tableWidth = value; + this.renderView(); + } + } + + public get tableWidth(): number { + return this._tableWidth; + } + + public get tableHeight(): number { + return this._tableHeight; + } + + public abstract renderView(); + + public toggleVisibility(): void { + this._parentContainer.style.display = this._parentContainer.style.display === 'none' ? 'block' : 'none'; + this.renderView(); + } + + public populateTable(columns: Slick.Column[], data: { [key: string]: string }[]) { + this._tableComponent.columns = columns; + this._tableContainer.scrollTo(0, 0); + this._tableComponentDataView.clear(); + this._tableComponentDataModel = data; + this._tableComponentDataView.push(this._tableComponentDataModel); + this._tableComponent.setData(this._tableComponentDataView); + this._tableComponent.autosizeColumns(); + this._tableComponent.updateRowCount(); + this.resizeTable(); + } + + private resizeTable(): void { + const spaceOccupied = (this._titleBarContainer.getBoundingClientRect().height + + this._headerContainer.getBoundingClientRect().height + + this._headerActionsContainer.getBoundingClientRect().height); + + this.tableHeight = (this._parentContainer.getBoundingClientRect().height - spaceOccupied - 15); + this.tableWidth = (this._parentContainer.getBoundingClientRect().width - 15); + this._tableComponent.layout(new DOM.Dimension(this._tableWidth, this._tableHeight)); + this._tableComponent.resizeCanvas(); + } + +} + + +export class ClosePropertyViewAction extends Action { + public static ID = 'ep.propertiesView.close'; + public static LABEL = localize('executionPlanPropertyViewClose', "Close"); + + constructor() { + super(ClosePropertyViewAction.ID, ClosePropertyViewAction.LABEL, Codicon.close.classNames); + } + + public override async run(context: ExecutionPlanPropertiesViewBase): Promise { + context.toggleVisibility(); + } +} + + +export class SortPropertiesAlphabeticallyAction extends Action { + public static ID = 'ep.propertiesView.sortByAlphabet'; + public static LABEL = localize('executionPlanPropertyViewSortAlphabetically', "Alphabetical"); + + constructor() { + super(SortPropertiesAlphabeticallyAction.ID, SortPropertiesAlphabeticallyAction.LABEL, sortAlphabeticallyIconClassNames); + } + + public override async run(context: ExecutionPlanPropertiesViewBase): Promise { + context.sortType = PropertiesSortType.Alphabetical; + context.renderView(); + } +} + +export class SortPropertiesReverseAlphabeticallyAction extends Action { + public static ID = 'ep.propertiesView.sortByAlphabet'; + public static LABEL = localize('executionPlanPropertyViewSortReverseAlphabetically', "Reverse Alphabetical"); + + constructor() { + super(SortPropertiesAlphabeticallyAction.ID, SortPropertiesAlphabeticallyAction.LABEL, sortReverseAlphabeticallyIconClassNames); + } + + public override async run(context: ExecutionPlanPropertiesViewBase): Promise { + context.sortType = PropertiesSortType.ReverseAlphabetical; + context.renderView(); + } +} + +export class SortPropertiesByDisplayOrderAction extends Action { + public static ID = 'ep.propertiesView.sortByDisplayOrder'; + public static LABEL = localize('executionPlanPropertyViewSortByDisplayOrder', "Categorized"); + + constructor() { + super(SortPropertiesByDisplayOrderAction.ID, SortPropertiesByDisplayOrderAction.LABEL, sortByDisplayOrderIconClassNames); + } + + public override async run(context: ExecutionPlanPropertiesViewBase): Promise { + context.sortType = PropertiesSortType.DisplayOrder; + context.renderView(); + } +} + +export enum PropertiesSortType { + DisplayOrder, + Alphabetical, + ReverseAlphabetical +} + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + + const menuBackgroundColor = theme.getColor(listHoverBackground); + if (menuBackgroundColor) { + collector.addRule(` + .properties .title, + .properties .table-action-bar { + background-color: ${menuBackgroundColor}; + } + `); + } + + const widgetBorderColor = theme.getColor(contrastBorder); + if (widgetBorderColor) { + collector.addRule(` + .properties .title, + .properties .table-action-bar, + .mxTooltip { + border: 1px solid ${widgetBorderColor}; + } + `); + } +}); + diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts new file mode 100644 index 0000000000..3e4331c671 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanTab.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IPanelView, IPanelTab } from 'sql/base/browser/ui/panel/panel'; +import { localize } from 'vs/nls'; +import * as DOM from 'vs/base/browser/dom'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ExecutionPlanState } from 'sql/workbench/common/editor/query/executionPlanState'; +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'; + +export class ExecutionPlanTab implements IPanelTab { + public readonly title = localize('executionPlanTitle', "Query Plan (Preview)"); + public readonly identifier = 'ExecutionPlan2Tab'; + public readonly view: ExecutionPlanTabView; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + this.view = instantiationService.createInstance(ExecutionPlanTabView); + } + + public dispose() { + } + + public clear() { + this.view.clear(); + } + +} + +export class ExecutionPlanTabView implements IPanelView { + private _container: HTMLElement = DOM.$('.execution-plan-tab'); + private _input: ExecutionPlanState; + private _viewCache: ExecutionPlanFileViewCache = ExecutionPlanFileViewCache.getInstance(); + + constructor( + @IInstantiationService private _instantiationService: IInstantiationService, + ) { + } + + public set state(newInput: ExecutionPlanState) { + const oldInput = this._input; + + // clearing old input view + if (oldInput?.executionPlanFileViewUUID) { + const oldView = this._viewCache.executionPlanFileViewMap.get(oldInput.executionPlanFileViewUUID); + oldView.onHide(this._container); + } + + // if new input already has a view we are just making it visible here. + let newView = this._viewCache.executionPlanFileViewMap.get(newInput.executionPlanFileViewUUID); + if (newView) { + newView.onShow(this._container); + } else { + // creating a new view for the new input + newInput.executionPlanFileViewUUID = generateUuid(); + newView = this._instantiationService.createInstance(ExecutionPlanFileView); + newView.onShow(this._container); + newView.addGraphs( + newInput.graphs + ); + this._viewCache.executionPlanFileViewMap.set(newInput.executionPlanFileViewUUID, newView); + } + this._input = newInput; + } + + public render(parent: HTMLElement): void { + parent.appendChild(this._container); + } + + public layout(dimension: DOM.Dimension): void { + this._container.style.width = dimension.width + 'px'; + this._container.style.height = dimension.height + 'px'; + } + + public clearPlans(): void { + let currentView = this._viewCache.executionPlanFileViewMap.get(this._input.executionPlanFileViewUUID); + if (currentView) { + currentView.onHide(this._container); + this._input.graphs = []; + currentView = this._instantiationService.createInstance(ExecutionPlanFileView); + currentView.render(this._container); + } + } + + public clear() { + } +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts new file mode 100644 index 0000000000..f5250251e7 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanView.ts @@ -0,0 +1,453 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as DOM from 'vs/base/browser/dom'; +import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; +import { ExecutionPlanPropertiesView } from 'sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView'; +import { ExecutionPlanWidgetController } from 'sql/workbench/contrib/executionPlan/browser/executionPlanWidgetController'; +import { ExecutionPlanViewHeader } from 'sql/workbench/contrib/executionPlan/browser/executionPlanViewHeader'; +import { ISashEvent, ISashLayoutProvider, Orientation, Sash } from 'vs/base/browser/ui/sash/sash'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; +import { EDITOR_FONT_DEFAULTS } from 'vs/editor/common/config/editorOptions'; +import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { openNewQuery } from 'sql/workbench/contrib/query/browser/queryActions'; +import { RunQueryOnConnectionMode } from 'sql/platform/connection/common/connectionManagement'; +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 { localize } from 'vs/nls'; +import { customZoomIconClassNames, disableTooltipIconClassName, enableTooltipIconClassName, 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'; +import { NodeSearchWidget } from 'sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget'; +import { AzdataGraphView } from 'sql/workbench/contrib/executionPlan/browser/azdataGraphView'; + +export class ExecutionPlanView implements ISashLayoutProvider { + + // Underlying execution plan displayed in the view + private _model?: azdata.executionPlan.ExecutionPlanGraph; + + // container for the view + private _container: HTMLElement; + + // action bar for the view + private _actionBarContainer: HTMLElement; + private _actionBar: ActionBar; + + // plan header section + public planHeader: ExecutionPlanViewHeader; + private _planContainer: HTMLElement; + private _planHeaderContainer: HTMLElement; + + // properties view + public propertiesView: ExecutionPlanPropertiesView; + private _propContainer: HTMLElement; + + // plan widgets + private _widgetContainer: HTMLElement; + public widgetController: ExecutionPlanWidgetController; + + // plan diagram + public executionPlanDiagram: AzdataGraphView; + + public actionBarToggleTopTip: Action; + public contextMenuToggleTooltipAction: Action; + constructor( + private _parent: HTMLElement, + private _graphIndex: number, + @IInstantiationService public readonly _instantiationService: IInstantiationService, + @IThemeService private readonly _themeService: IThemeService, + @IContextViewService public readonly contextViewService: IContextViewService, + @IUntitledTextEditorService private readonly _untitledEditorService: IUntitledTextEditorService, + @IEditorService private readonly editorService: IEditorService, + @IContextMenuService private _contextMenuService: IContextMenuService, + @IFileDialogService public fileDialogService: IFileDialogService, + @IFileService public fileService: IFileService, + @IWorkspaceContextService public workspaceContextService: IWorkspaceContextService, + ) { + // parent container for query plan. + 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 originalTableHeight = 0; + let change = 0; + sash.onDidStart((e: ISashEvent) => { + originalHeight = this._container.offsetHeight; + originalTableHeight = this.propertiesView.tableHeight; + }); + + /** + * Using onDidChange for the smooth resizing of the graph diagram + */ + sash.onDidChange((evt: ISashEvent) => { + change = evt.startY - evt.currentY; + const newHeight = originalHeight - change; + if (newHeight < 200) { + return; + } + /** + * 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`; + }); + + /** + * Resizing properties window table only once at the end as it is a heavy operation and worsens the smooth resizing experience + */ + sash.onDidEnd(() => { + this.propertiesView.tableHeight = originalTableHeight - change; + }); + + this._planContainer = DOM.$('.plan'); + this._container.appendChild(this._planContainer); + + // container that holds plan header info + this._planHeaderContainer = DOM.$('.header'); + + // Styling header text like the query editor + this._planHeaderContainer.style.fontFamily = EDITOR_FONT_DEFAULTS.fontFamily; + this._planHeaderContainer.style.fontSize = EDITOR_FONT_DEFAULTS.fontSize.toString(); + this._planHeaderContainer.style.fontWeight = EDITOR_FONT_DEFAULTS.fontWeight; + + this._planContainer.appendChild(this._planHeaderContainer); + this.planHeader = this._instantiationService.createInstance(ExecutionPlanViewHeader, this._planHeaderContainer, { + planIndex: this._graphIndex, + }); + + // container properties + this._propContainer = DOM.$('.properties'); + this._container.appendChild(this._propContainer); + this.propertiesView = new ExecutionPlanPropertiesView(this._propContainer, this._themeService); + + this._widgetContainer = DOM.$('.plan-action-container'); + this._planContainer.appendChild(this._widgetContainer); + this.widgetController = new ExecutionPlanWidgetController(this._widgetContainer); + + // container that holds action bar icons + this._actionBarContainer = DOM.$('.action-bar-container'); + this._container.appendChild(this._actionBarContainer); + this._actionBar = new ActionBar(this._actionBarContainer, { + orientation: ActionsOrientation.VERTICAL, context: this + }); + + this.actionBarToggleTopTip = new ActionBarToggleTooltip(); + const actionBarActions = [ + new SavePlanFile(), + new OpenPlanFile(), + new OpenQueryAction(), + new SearchNodeAction(), + new ZoomInAction(), + new ZoomOutAction(), + new ZoomToFitAction(), + new CustomZoomAction(), + new PropertiesAction(), + this.actionBarToggleTopTip + ]; + this._actionBar.pushAction(actionBarActions, { icon: true, label: false }); + + // Setting up context menu + this.contextMenuToggleTooltipAction = new ContextMenuTooltipToggle(); + const contextMenuAction = [ + new SavePlanFile(), + new OpenPlanFile(), + new OpenQueryAction(), + new SearchNodeAction(), + new ZoomInAction(), + new ZoomOutAction(), + new ZoomToFitAction(), + new CustomZoomAction(), + new PropertiesAction(), + this.contextMenuToggleTooltipAction + ]; + const self = this; + this._container.oncontextmenu = (e: MouseEvent) => { + if (contextMenuAction) { + this._contextMenuService.showContextMenu({ + getAnchor: () => { + return { + x: e.x, + y: e.y + }; + }, + getActions: () => contextMenuAction, + getActionsContext: () => (self) + }); + } + }; + } + + getHorizontalSashTop(sash: Sash): number { + return 0; + } + getHorizontalSashLeft?(sash: Sash): number { + return 0; + } + getHorizontalSashWidth?(sash: Sash): number { + return this._container.clientWidth; + } + + private createPlanDiagram(container: HTMLElement) { + this.executionPlanDiagram = this._instantiationService.createInstance(AzdataGraphView, container, this._model); + this.executionPlanDiagram.onElementSelected(e => { + this.propertiesView.graphElement = e; + }); + } + + + public set model(graph: azdata.executionPlan.ExecutionPlanGraph | undefined) { + this._model = graph; + if (this._model) { + this.planHeader.graphIndex = this._graphIndex; + this.planHeader.query = graph.query; + if (graph.recommendations) { + this.planHeader.recommendations = graph.recommendations; + } + let diagramContainer = DOM.$('.diagram'); + this.createPlanDiagram(diagramContainer); + + /** + * We do not want to scroll the diagram through mouse wheel. + * Instead, we pass this event to parent control. So, when user + * uses the scroll wheel, they scroll through graphs present in + * the graph control. To scroll the individual graphs, users should + * use the scroll bars. + */ + diagramContainer.addEventListener('wheel', e => { + this._parent.scrollTop += e.deltaY; + //Hiding all tooltips when we scroll. + const element = document.getElementsByClassName('mxTooltip'); + for (let i = 0; i < element.length; i++) { + (element[i]).style.visibility = 'hidden'; + } + e.preventDefault(); + e.stopPropagation(); + }); + + this._planContainer.appendChild(diagramContainer); + + this.propertiesView.graphElement = this._model.root; + } + } + + public get model(): azdata.executionPlan.ExecutionPlanGraph | undefined { + return this._model; + } + + public openQuery() { + return this._instantiationService.invokeFunction(openNewQuery, undefined, this.model.query, RunQueryOnConnectionMode.none).then(); + } + + public async openGraphFile() { + const input = this._untitledEditorService.create({ mode: this.model.graphFile.graphFileType, initialValue: this.model.graphFile.graphFileContent }); + await input.resolve(); + await this._instantiationService.invokeFunction(formatDocumentWithSelectedProvider, input.textEditorModel, FormattingMode.Explicit, Progress.None, CancellationToken.None); + input.setDirty(false); + this.editorService.openEditor(input); + } + + public hideActionBar() { + this._actionBarContainer.style.display = 'none'; + } +} + +export class OpenQueryAction extends Action { + public static ID = 'ep.OpenQueryAction'; + public static LABEL = localize('openQueryAction', "Open Query"); + + constructor() { + super(OpenQueryAction.ID, OpenQueryAction.LABEL, openQueryIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.openQuery(); + } +} + +export class PropertiesAction extends Action { + public static ID = 'ep.propertiesAction'; + public static LABEL = localize('executionPlanPropertiesActionLabel', "Properties"); + + constructor() { + super(PropertiesAction.ID, PropertiesAction.LABEL, openPropertiesIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.propertiesView.toggleVisibility(); + } +} + +export class ZoomInAction extends Action { + public static ID = 'ep.ZoomInAction'; + public static LABEL = localize('executionPlanZoomInActionLabel', "Zoom In"); + + constructor() { + super(ZoomInAction.ID, ZoomInAction.LABEL, zoomInIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.executionPlanDiagram.zoomIn(); + } +} + +export class ZoomOutAction extends Action { + public static ID = 'ep.ZoomOutAction'; + public static LABEL = localize('executionPlanZoomOutActionLabel', "Zoom Out"); + + constructor() { + super(ZoomOutAction.ID, ZoomOutAction.LABEL, zoomOutIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.executionPlanDiagram.zoomOut(); + } +} + +export class ZoomToFitAction extends Action { + public static ID = 'ep.FitGraph'; + public static LABEL = localize('executionPlanFitGraphLabel', "Zoom to fit"); + + constructor() { + super(ZoomToFitAction.ID, ZoomToFitAction.LABEL, zoomToFitIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.executionPlanDiagram.zoomToFit(); + } +} + +export class SavePlanFile extends Action { + public static ID = 'ep.saveXML'; + public static LABEL = localize('executionPlanSavePlanXML', "Save Plan File"); + + constructor() { + super(SavePlanFile.ID, SavePlanFile.LABEL, savePlanIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + const workspaceFolders = await context.workspaceContextService.getWorkspace().folders; + const defaultFileName = 'plan'; + let currentWorkSpaceFolder: URI; + if (workspaceFolders.length !== 0) { + currentWorkSpaceFolder = workspaceFolders[0].uri; + currentWorkSpaceFolder = URI.joinPath(currentWorkSpaceFolder, defaultFileName); //appending default file name to workspace uri + } else { + currentWorkSpaceFolder = URI.parse(defaultFileName); // giving default name + } + const saveFileUri = await context.fileDialogService.showSaveDialog({ + filters: [ + { + extensions: ['sqlplan'], //TODO: Get this extension from provider + name: localize('executionPlan.SaveFileDescription', 'Execution Plan Files') //TODO: Get the names from providers. + } + ], + defaultUri: currentWorkSpaceFolder // If no workspaces are opened this will be undefined + }); + if (saveFileUri) { + await context.fileService.writeFile(saveFileUri, VSBuffer.fromString(context.model.graphFile.graphFileContent)); + } + } +} + +export class CustomZoomAction extends Action { + public static ID = 'ep.customZoom'; + public static LABEL = localize('executionPlanCustomZoom', "Custom Zoom"); + + constructor() { + super(CustomZoomAction.ID, CustomZoomAction.LABEL, customZoomIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.widgetController.toggleWidget(context._instantiationService.createInstance(CustomZoomWidget, context.widgetController, context.executionPlanDiagram)); + } +} + +export class SearchNodeAction extends Action { + public static ID = 'ep.searchNode'; + public static LABEL = localize('executionPlanSearchNodeAction', "Find Node"); + + constructor() { + super(SearchNodeAction.ID, SearchNodeAction.LABEL, searchIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + context.widgetController.toggleWidget(context._instantiationService.createInstance(NodeSearchWidget, context.widgetController, context.executionPlanDiagram)); + } +} + +export class OpenPlanFile extends Action { + public static ID = 'ep.openGraphFile'; + public static Label = localize('executionPlanOpenGraphFile', "Show Query Plan XML"); //TODO: add a contribution point for providers to set this text + + constructor() { + super(OpenPlanFile.ID, OpenPlanFile.Label, openPlanFileIconClassNames); + } + + public override async run(context: ExecutionPlanView): Promise { + await context.openGraphFile(); + } +} + +export class ActionBarToggleTooltip extends Action { + public static ID = 'ep.tooltipToggleActionBar'; + public static ENABLE_LABEL = localize('executionPlanEnableTooltip', "Tooltips enabled"); + public static DISABLE_LABEL = localize('executionPlanDisableTooltip', "Tooltips disabled"); + + constructor() { + super(ActionBarToggleTooltip.ID, ActionBarToggleTooltip.ENABLE_LABEL, enableTooltipIconClassName); + } + + public override async run(context: ExecutionPlanView): Promise { + const state = context.executionPlanDiagram.toggleTooltip(); + if (!state) { + this.class = disableTooltipIconClassName; + this.label = ActionBarToggleTooltip.DISABLE_LABEL; + context.actionBarToggleTopTip.label = ContextMenuTooltipToggle.DISABLE_LABEL; + } else { + this.class = enableTooltipIconClassName; + this.label = ActionBarToggleTooltip.ENABLE_LABEL; + context.actionBarToggleTopTip.label = ContextMenuTooltipToggle.ENABLE_LABEL; + } + } +} + +export class ContextMenuTooltipToggle extends Action { + public static ID = 'ep.tooltipToggleContextMenu'; + public static ENABLE_LABEL = localize('executionPlanContextMenuEnableTooltip', "Enable Tooltips"); + public static DISABLE_LABEL = localize('executionPlanContextMenuDisableTooltip', "Disable Tooltips"); + + constructor() { + super(ContextMenuTooltipToggle.ID, ContextMenuTooltipToggle.ENABLE_LABEL, enableTooltipIconClassName); + } + + public override async run(context: ExecutionPlanView): Promise { + const state = context.executionPlanDiagram.toggleTooltip(); + if (!state) { + this.label = ContextMenuTooltipToggle.ENABLE_LABEL; + context.actionBarToggleTopTip.class = disableTooltipIconClassName; + context.actionBarToggleTopTip.label = ActionBarToggleTooltip.DISABLE_LABEL; + } else { + this.label = ContextMenuTooltipToggle.DISABLE_LABEL; + context.actionBarToggleTopTip.class = enableTooltipIconClassName; + context.actionBarToggleTopTip.label = ActionBarToggleTooltip.ENABLE_LABEL; + } + } +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/planHeader.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanViewHeader.ts similarity index 99% rename from src/sql/workbench/contrib/executionPlan/browser/planHeader.ts rename to src/sql/workbench/contrib/executionPlan/browser/executionPlanViewHeader.ts index 4cbf207bf5..2b66fb937e 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/planHeader.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanViewHeader.ts @@ -12,7 +12,7 @@ import { RunQueryOnConnectionMode } from 'sql/platform/connection/common/connect import { Button } from 'sql/base/browser/ui/button/button'; import { removeLineBreaks } from 'sql/base/common/strings'; -export class PlanHeader { +export class ExecutionPlanViewHeader { private _graphIndex: number; // Index of the graph in the view private _relativeCost: number; // Relative cost of the graph to the script diff --git a/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/disableTooltip.svg b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/disableTooltip.svg new file mode 100644 index 0000000000..9ff6454fe8 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/disableTooltip.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/enableTooltip.svg b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/enableTooltip.svg new file mode 100644 index 0000000000..accb03e475 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/enableTooltip.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/sortReverseAlphabetically.svg b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/sortReverseAlphabetically.svg new file mode 100644 index 0000000000..ed6aa64d36 --- /dev/null +++ b/src/sql/workbench/contrib/executionPlan/browser/images/actionIcons/sortReverseAlphabetically.svg @@ -0,0 +1 @@ +SortDescending_16x \ No newline at end of file diff --git a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css index 3473e96518..5366e7dd98 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css +++ b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css @@ -227,87 +227,107 @@ However we always want it to be the width of the container it is resizing. z-index: 3; } -.eps-container .save-plan-icon { +.ep-save-plan-icon { background-image: url(../images/actionIcons/save.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .open-properties-icon { +.ep-open-properties-icon { background-image: url(../images/actionIcons/openProperties.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .open-query-icon { +.ep-open-query-icon { background-image: url(../images/actionIcons/openQuery.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .open-plan-file-icon { +.ep-open-plan-file-icon { background-image: url(../images/actionIcons/openPlanFile.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .search-icon { +.ep-search-icon { background-image: url(../images/actionIcons/search.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .sort-alphabetically-icon { +.ep-sort-alphabetically-icon { background-image: url(../images/actionIcons/sortAlphabetically.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .sort-display-order-icon { +.ep-sort-reverse-alphabetically-icon { + background-image: url(../images/actionIcons/sortReverseAlphabetically.svg); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; +} + +.ep-sort-display-order-icon { background-image: url(../images/actionIcons/sortByDisplayOrder.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .zoom-in-icon { +.ep-zoom-in-icon { background-image: url(../images/actionIcons/zoomIn.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .zoom-out-icon { +.ep-zoom-out-icon { background-image: url(../images/actionIcons/zoomOut.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .custom-zoom-icon { +.ep-custom-zoom-icon { background-image: url(../images/actionIcons/customZoom.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .zoom-to-fit-icon { +.ep-zoom-to-fit-icon { background-image: url(../images/actionIcons/zoomToFit.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } -.eps-container .zoom-icon { +.ep-zoom-icon { background-image: url(../images/actionIcons/zoom.svg); background-size: 16px 16px; background-position: center; background-repeat: no-repeat; } +.ep-enable-tooltip-icon { + background-image: url(../images/actionIcons/enableTooltip.svg); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; +} + +.ep-disable-tooltip-icon { + background-image: url(../images/actionIcons/disableTooltip.svg); + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget.ts b/src/sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget.ts index 543006fca7..b53e8a4f77 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/widgets/customZoomWidget.ts @@ -7,7 +7,6 @@ import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; import { attachInputBoxStyler } from 'sql/platform/theme/common/styler'; import { ExecutionPlanWidgetBase } from 'sql/workbench/contrib/executionPlan/browser/executionPlanWidgetBase'; -import { ExecutionPlan } from 'sql/workbench/contrib/executionPlan/browser/executionPlan'; import * as DOM from 'vs/base/browser/dom'; import { Action } from 'vs/base/common/actions'; import { Codicon } from 'vs/base/common/codicons'; @@ -17,13 +16,16 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IThemeService } from 'vs/platform/theme/common/themeService'; import { zoomIconClassNames } from 'sql/workbench/contrib/executionPlan/browser/constants'; import { Button } from 'sql/base/browser/ui/button/button'; +import { AzdataGraphView } from 'sql/workbench/contrib/executionPlan/browser/azdataGraphView'; +import { ExecutionPlanWidgetController } from 'sql/workbench/contrib/executionPlan/browser/executionPlanWidgetController'; export class CustomZoomWidget extends ExecutionPlanWidgetBase { private _actionBar: ActionBar; public customZoomInputBox: InputBox; constructor( - public readonly executionPlanView: ExecutionPlan, + public readonly widgetController: ExecutionPlanWidgetController, + public readonly executionPlanDiagram: AzdataGraphView, @IContextViewService public readonly contextViewService: IContextViewService, @IThemeService public readonly themeService: IThemeService, @INotificationService public readonly notificationService: INotificationService @@ -39,7 +41,7 @@ export class CustomZoomWidget extends ExecutionPlanWidgetBase { }); attachInputBoxStyler(this.customZoomInputBox, this.themeService); - const currentZoom = executionPlanView.azdataGraphDiagram.graph.view.getScale() * 100; + const currentZoom = this.executionPlanDiagram.getZoomLevel(); // Setting initial value to graph's current zoom this.customZoomInputBox.value = Math.round(currentZoom).toString(); @@ -50,7 +52,7 @@ export class CustomZoomWidget extends ExecutionPlanWidgetBase { if (ev.key === 'Enter') { await new CustomZoomAction().run(self); } else if (ev.key === 'Escape') { - executionPlanView.planActionView.removeWidget(self); + this.widgetController.removeWidget(self); } }; @@ -87,8 +89,8 @@ export class CustomZoomAction extends Action { public override async run(context: CustomZoomWidget): Promise { const newValue = parseInt(context.customZoomInputBox.value); if (newValue <= 200 && newValue >= 1) { // Getting max and min zoom values from SSMS - context.executionPlanView.azdataGraphDiagram.graph.view.setScale(newValue / 100); - context.executionPlanView.planActionView.removeWidget(context); + context.executionPlanDiagram.setZoomLevel(newValue); + context.widgetController.removeWidget(context); } else { context.notificationService.error( localize('invalidCustomZoomError', "Select a zoom value between 1 to 200") @@ -106,7 +108,7 @@ export class CancelZoom extends Action { } public override async run(context: CustomZoomWidget): Promise { - context.executionPlanView.planActionView.removeWidget(context); + context.widgetController.removeWidget(context); } } diff --git a/src/sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget.ts b/src/sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget.ts index 2a076b8111..46162e1edc 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/widgets/nodeSearchWidget.ts @@ -8,17 +8,22 @@ import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; import * as DOM from 'vs/base/browser/dom'; import { localize } from 'vs/nls'; import { Codicon } from 'vs/base/common/codicons'; -import { InternalExecutionPlanNode, ExecutionPlan } from 'sql/workbench/contrib/executionPlan/browser/executionPlan'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { attachInputBoxStyler, attachSelectBoxStyler } from 'sql/platform/theme/common/styler'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { Action } from 'vs/base/common/actions'; import { SelectBox } from 'sql/base/browser/ui/selectBox/selectBox'; import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; -import { isString } from 'vs/base/common/types'; +import { AzdataGraphView, InternalExecutionPlanNode, SearchType } from 'sql/workbench/contrib/executionPlan/browser/azdataGraphView'; +import { ExecutionPlanWidgetController } from 'sql/workbench/contrib/executionPlan/browser/executionPlanWidgetController'; const CONTAINS_DISPLAY_STRING = localize("executionPlanSearchTypeContains", 'Contains'); const EQUALS_DISPLAY_STRING = localize("executionPlanSearchTypeEquals", 'Equals'); +const GREATER_DISPLAY_STRING = '>'; +const LESSER_DISPLAY_STRING = '<'; +const GREATER_EQUAL_DISPLAY_STRING = '>='; +const LESSER_EQUAL_DISPLAY_STRING = '<='; +const LESSER_AND_GREATER_DISPLAY_STRING = '<>'; export class NodeSearchWidget extends ExecutionPlanWidgetBase { @@ -27,16 +32,18 @@ export class NodeSearchWidget extends ExecutionPlanWidgetBase { private _searchTypeSelectBoxContainer: HTMLElement; private _searchTypeSelectBox: SelectBox; + private _selectedSearchType: SearchType = SearchType.Equals; private _searchTextInputBox: InputBox; - private _searchResults: string[] = []; + private _searchResults: InternalExecutionPlanNode[] = []; private _currentSearchResultIndex = 0; private _usePreviousSearchResult: boolean = false; private _actionBar: ActionBar; constructor( - public readonly executionPlanView: ExecutionPlan, + public readonly planActionView: ExecutionPlanWidgetController, + public readonly executionPlanDiagram: AzdataGraphView, @IContextViewService public readonly contextViewService: IContextViewService, @IThemeService public readonly themeService: IThemeService @@ -46,7 +53,7 @@ export class NodeSearchWidget extends ExecutionPlanWidgetBase { // property name dropdown this._propertyNameSelectBoxContainer = DOM.$('.search-widget-property-name-select-box .dropdown-container'); this.container.appendChild(this._propertyNameSelectBoxContainer); - const propDropdownOptions = [...executionPlanView.graphElementPropertiesSet].sort(); + const propDropdownOptions = executionPlanDiagram.getUniqueElementProperties(); this._propertyNameSelectBox = new SelectBox(propDropdownOptions, propDropdownOptions[0], this.contextViewService, this._propertyNameSelectBoxContainer); attachSelectBoxStyler(this._propertyNameSelectBox, this.themeService); this._propertyNameSelectBoxContainer.style.width = '150px'; @@ -60,13 +67,40 @@ export class NodeSearchWidget extends ExecutionPlanWidgetBase { this.container.appendChild(this._searchTypeSelectBoxContainer); this._searchTypeSelectBox = new SelectBox([ EQUALS_DISPLAY_STRING, - CONTAINS_DISPLAY_STRING + CONTAINS_DISPLAY_STRING, + GREATER_DISPLAY_STRING, + LESSER_DISPLAY_STRING, + GREATER_EQUAL_DISPLAY_STRING, + LESSER_EQUAL_DISPLAY_STRING, + LESSER_AND_GREATER_DISPLAY_STRING ], EQUALS_DISPLAY_STRING, this.contextViewService, this._searchTypeSelectBoxContainer); this._searchTypeSelectBox.render(this._searchTypeSelectBoxContainer); attachSelectBoxStyler(this._searchTypeSelectBox, this.themeService); this._searchTypeSelectBoxContainer.style.width = '100px'; this._searchTypeSelectBox.onDidSelect(e => { this._usePreviousSearchResult = false; + switch (e.selected) { + case EQUALS_DISPLAY_STRING: + this._selectedSearchType = SearchType.Equals; + break; + case CONTAINS_DISPLAY_STRING: + this._selectedSearchType = SearchType.Contains; + break; + case GREATER_DISPLAY_STRING: + this._selectedSearchType = SearchType.GreaterThan; + break; + case LESSER_DISPLAY_STRING: + this._selectedSearchType = SearchType.LesserThan; + break; + case GREATER_EQUAL_DISPLAY_STRING: + this._selectedSearchType = SearchType.GreaterThanEqualTo; + break; + case LESSER_EQUAL_DISPLAY_STRING: + this._selectedSearchType = SearchType.LesserThanEqualTo; + break; + case LESSER_AND_GREATER_DISPLAY_STRING: + this._selectedSearchType = SearchType.LesserAndGreaterThan; + } }); // search text input box @@ -103,108 +137,39 @@ export class NodeSearchWidget extends ExecutionPlanWidgetBase { this._searchTextInputBox.focus(); } - - public searchNode(returnPreviousResult: boolean): void { - - // Searching again as the input params have changed - if (!this._usePreviousSearchResult) { - - this._searchResults = []; - this._currentSearchResultIndex = 0; //Resetting search Index to 0; - this._usePreviousSearchResult = true; - - // Doing depth first search in the graphModel to find nodes with matching prop values. - const graphModel = this.executionPlanView.graphModel; - const stack: InternalExecutionPlanNode[] = []; - stack.push(graphModel.root); - - while (stack.length !== 0) { - const currentNode = stack.pop(); - - const matchingProp = currentNode.properties.find(e => e.name === this._propertyNameSelectBox.value); - - // Searching only properties with string value. - if (isString(matchingProp?.value)) { - // If the search type is '=' we look for exact match and for 'contains' we look search string occurance in prop value - if ( - this._searchTypeSelectBox.value === EQUALS_DISPLAY_STRING && matchingProp.value === this._searchTextInputBox.value || - this._searchTypeSelectBox.value === CONTAINS_DISPLAY_STRING && matchingProp.value.includes(this._searchTextInputBox.value) - ) { - this._searchResults.push(currentNode.id); - } - } - - stack.push(...currentNode.children); - } - } - // Returning if no results found. - if (this._searchResults.length === 0) { - return; - } - - // Getting the node at search index - const resultCell = this.executionPlanView.azdataGraphDiagram.graph.model.getCell(this._searchResults[this._currentSearchResultIndex]); - // Selecting the node on graph diagram - this.executionPlanView.azdataGraphDiagram.graph.setSelectionCell(resultCell); - this.executionPlanView.propertiesView.graphElement = this.executionPlanView.searchNodes(resultCell.id); - - /** - * The selected graph node might be hidden/partially visible if the graph is overflowing the parent container. - * Apart from the obvious problems in aesthetics, user do not get a proper feedback of the search result. - * To solve this problem, we will have to scroll the node into view. (preferably into the center of the view) - * Steps for that: - * 1. Get the bounding rect of the node on graph. - * 2. Get the midpoint of the node's bounding rect. - * 3. Find the dimensions of the parent container. - * 4. Since, we are trying to position the node into center, we set the left top corner position of parent to - * below x and y. - * x = node's x midpoint - half the width of parent container - * y = node's y midpoint - half the height of parent container - * 5. If the x and y are negative, we set them 0 as that is the minimum possible scroll position. - * 6. Smoothly scroll to the left top x and y calculated in step 4, 5. - */ - - const cellRect = this.executionPlanView.azdataGraphDiagram.graph.getCellBounds(resultCell); - const cellMidPoint: Point = { - x: cellRect.x + cellRect.width / 2, - y: cellRect.y + cellRect.height / 2, - }; - - const graphContainer = this.executionPlanView.azdataGraphDiagram.container; - const containerBoundingRect = graphContainer.getBoundingClientRect(); - - const leftTopScrollPoint: Point = { - x: cellMidPoint.x - containerBoundingRect.width / 2, - y: cellMidPoint.y - containerBoundingRect.height / 2 - }; - - leftTopScrollPoint.x = leftTopScrollPoint.x < 0 ? 0 : leftTopScrollPoint.x; - leftTopScrollPoint.y = leftTopScrollPoint.y < 0 ? 0 : leftTopScrollPoint.y; - - graphContainer.scrollTo({ - left: leftTopScrollPoint.x, - top: leftTopScrollPoint.y, - behavior: 'smooth' + public searchNodes(): void { + this._currentSearchResultIndex = 0; + this._searchResults = this.executionPlanDiagram.searchNodes({ + propertyName: this._propertyNameSelectBox.value, + value: this._searchTextInputBox.value, + searchType: this._selectedSearchType }); - - // Updating search result index based on prev flag - if (returnPreviousResult) { - // going to the end of list if the index is 0 on prev - this._currentSearchResultIndex = this._currentSearchResultIndex === 0 ? - this._currentSearchResultIndex = this._searchResults.length - 1 : - this._currentSearchResultIndex = --this._currentSearchResultIndex; - } else { - // going to the front of list if we are at the last element - this._currentSearchResultIndex = this._currentSearchResultIndex === this._searchResults.length - 1 ? - this._currentSearchResultIndex = 0 : - this._currentSearchResultIndex = ++this._currentSearchResultIndex; - } + this._usePreviousSearchResult = true; } -} -interface Point { - x: number; - y: number; + public next(): void { + if (!this._usePreviousSearchResult) { + this.searchNodes(); + } + + this.executionPlanDiagram.centerElement(this._searchResults[this._currentSearchResultIndex]); + this.executionPlanDiagram.selectElement(this._searchResults[this._currentSearchResultIndex]); + this._currentSearchResultIndex = this._currentSearchResultIndex === this._searchResults.length - 1 ? + this._currentSearchResultIndex = 0 : + this._currentSearchResultIndex = ++this._currentSearchResultIndex; + } + + public previous(): void { + if (!this._usePreviousSearchResult) { + this.searchNodes(); + } + + this.executionPlanDiagram.centerElement(this._searchResults[this._currentSearchResultIndex]); + this.executionPlanDiagram.selectElement(this._searchResults[this._currentSearchResultIndex]); + this._currentSearchResultIndex = this._currentSearchResultIndex === 0 ? + this._currentSearchResultIndex = this._searchResults.length - 1 : + this._currentSearchResultIndex = --this._currentSearchResultIndex; + } } export class GoToNextMatchAction extends Action { @@ -216,7 +181,7 @@ export class GoToNextMatchAction extends Action { } public override async run(context: NodeSearchWidget): Promise { - context.searchNode(false); + context.next(); } } @@ -229,7 +194,7 @@ export class GoToPreviousMatchAction extends Action { } public override async run(context: NodeSearchWidget): Promise { - context.searchNode(true); + context.previous(); } } @@ -242,6 +207,6 @@ export class CancelSearch extends Action { } public override async run(context: NodeSearchWidget): Promise { - context.executionPlanView.planActionView.removeWidget(context); + context.planActionView.removeWidget(context); } } diff --git a/src/sql/workbench/contrib/executionPlan/common/executionPlanInput.ts b/src/sql/workbench/contrib/executionPlan/common/executionPlanInput.ts index b80dbc6e9e..76b8c1f084 100644 --- a/src/sql/workbench/contrib/executionPlan/common/executionPlanInput.ts +++ b/src/sql/workbench/contrib/executionPlan/common/executionPlanInput.ts @@ -15,6 +15,7 @@ export class ExecutionPlanInput extends EditorInput { public static SCHEMA: string = 'executionplan'; private _content?: string; + public _executionPlanFileViewUUID: string; constructor( private _uri: URI, @@ -23,6 +24,14 @@ export class ExecutionPlanInput extends EditorInput { super(); } + public get executionPlanFileViewUUID(): string { + return this._executionPlanFileViewUUID; + } + + public set executionPlanFileViewUUID(v: string) { + this._executionPlanFileViewUUID = v; + } + override get typeId(): string { return ExecutionPlanInput.ID; } @@ -31,7 +40,10 @@ export class ExecutionPlanInput extends EditorInput { return path.basename(this._uri.fsPath); } - public get content(): string | undefined { + public async content(): Promise { + if (!this._content) { + this._content = (await this._fileService.read(this._uri, { acceptTextOnly: true })).value; + } return this._content; } diff --git a/src/sql/workbench/contrib/query/browser/queryResultsView.ts b/src/sql/workbench/contrib/query/browser/queryResultsView.ts index d6ab2b6d61..295ee82f5c 100644 --- a/src/sql/workbench/contrib/query/browser/queryResultsView.ts +++ b/src/sql/workbench/contrib/query/browser/queryResultsView.ts @@ -24,7 +24,8 @@ import { URI } from 'vs/base/common/uri'; import { attachTabbedPanelStyler } from 'sql/workbench/common/styler'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { ILogService } from 'vs/platform/log/common/log'; -import { ExecutionPlanTab } from 'sql/workbench/contrib/executionPlan/browser/executionPlan'; +import { ExecutionPlanTab } from 'sql/workbench/contrib/executionPlan/browser/executionPlanTab'; +import { ExecutionPlanFileViewCache } from 'sql/workbench/contrib/executionPlan/browser/executionPlanFileViewCache'; class MessagesView extends Disposable implements IPanelView { private messagePanel: MessagePanel; @@ -223,7 +224,9 @@ export class QueryResultsView extends Disposable { this.hideResults(); this.hideChart(); this.hideTopOperations(); - this.hidePlan2(); + this.hidePlan(); + // clearing execution plans whenever a new query starts executing + this.executionPlanTab.view.clearPlans(); this.hideDynamicViewModelTabs(); this.input?.state.visibleTabs.clear(); if (this.input) { @@ -248,12 +251,19 @@ export class QueryResultsView extends Disposable { this.runnerDisposables.add(runner.onExecutionPlanAvailable(e => { if (this.executionPlanTab) { - if (!this.input.state.visibleTabs.has(this.executionPlanTab.identifier)) { - this.showPlan2(); + /** + * Adding execution plan graphs to execution plan file view + * when they become available + */ + const executionPlanFileViewCache = ExecutionPlanFileViewCache.getInstance(); + if (executionPlanFileViewCache) { + const view = executionPlanFileViewCache.executionPlanFileViewMap.get( + this.input.state.executionPlanState.executionPlanFileViewUUID + ); + if (view) { + view.addGraphs(e.planGraphs); + } } - // Adding graph to state and tab as they become available - this.input.state.executionPlanState.graphs.push(...e.planGraphs); - this.executionPlanTab.view.addGraphs(e.planGraphs); } })); @@ -301,6 +311,7 @@ export class QueryResultsView extends Disposable { this.runnerDisposables.add(runner.onQueryEnd(() => { if (runner.isQueryPlan) { runner.planXml.then(e => { + this.showPlan(); this.showTopOperations(e); }); } @@ -326,9 +337,9 @@ export class QueryResultsView extends Disposable { if (input) { this.resultsTab.view.state = input.state.gridPanelState; - this.executionPlanTab.view.addGraphs(input.state.executionPlanState.graphs); this.topOperationsTab.view.setState(input.state.topOperationsState); this.chartTab.view.state = input.state.chartState; + this.executionPlanTab.view.state = input.state.executionPlanState; this.dynamicModelViewTabs.forEach((dynamicTab: QueryModelViewTab) => { dynamicTab.captureState(input.state.dynamicModelViewTabsState); }); @@ -368,6 +379,7 @@ export class QueryResultsView extends Disposable { this.messagesTab.clear(); this.topOperationsTab.clear(); this.chartTab.clear(); + this.executionPlanTab.clear(); this.dynamicModelViewTabs.forEach(t => t.clear()); } @@ -416,7 +428,7 @@ export class QueryResultsView extends Disposable { this.topOperationsTab.view.showPlan(xml); } - public showPlan2() { + public showPlan() { if (!this._panelView.contains(this.executionPlanTab.identifier)) { this.input?.state.visibleTabs.add(this.executionPlanTab.identifier); if (!this._panelView.contains(this.executionPlanTab.identifier)) { @@ -432,11 +444,10 @@ export class QueryResultsView extends Disposable { } } - public hidePlan2() { + public hidePlan() { if (this._panelView.contains(this.executionPlanTab.identifier)) { - this.executionPlanTab.clear(); - this.input.state.executionPlanState.clearExecutionPlanState(); this._panelView.removeTab(this.executionPlanTab.identifier); + this.executionPlanTab.clear(); } }