From 8ac09917ca0733c899544dfa65d808249ef379ee Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 10 Feb 2022 14:43:35 -0800 Subject: [PATCH] Adding search, zoom, custom zoom and additional stylings to showplan (#18255) * Lot of changes -Updating azdata to 0.0.13 -Updating prop views on node clicks -Context menu on graphs -Updating edge color on theme -Scrolling graph control like ssms -Zooming in, out and custom zoom on graph -Custom zoom widget -Node search widget * Fixing hygine errors * Code cleanup * Fixing action name * Renaming actions * equals dropdown * fixing tooltip * Code cleanup Fixing sorting function Adding functionality for replacement strings * Removing internal facing props from azdata proposed * Fixing hygine issue * Fixing web package hygiene * Updating yarn lock files * Fixing initial click --- package.json | 2 +- remote/package.json | 2 +- remote/web/package.json | 2 +- remote/web/yarn.lock | 6 +- remote/yarn.lock | 6 +- src/sql/azdata.proposed.d.ts | 8 +- src/sql/base/common/strings.ts | 7 +- .../queryplan2/browser/media/queryPlan2.css | 29 +- .../contrib/queryplan2/browser/planHeader.ts | 2 +- .../contrib/queryplan2/browser/queryPlan.ts | 229 +++++++++++++--- ...tiesView.ts => queryPlanPropertiesView.ts} | 15 +- .../queryplan2/browser/queryPlanWidgetBase.ts | 22 ++ .../browser/queryPlanWidgetController.ts | 48 ++++ .../browser/widgets/customZoomWidget.ts | 102 ++++++++ .../browser/widgets/nodeSearchWidget.ts | 247 ++++++++++++++++++ yarn.lock | 6 +- 16 files changed, 656 insertions(+), 77 deletions(-) rename src/sql/workbench/contrib/queryplan2/browser/{graphElementPropertiesView.ts => queryPlanPropertiesView.ts} (93%) create mode 100644 src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase.ts create mode 100644 src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetController.ts create mode 100644 src/sql/workbench/contrib/queryplan2/browser/widgets/customZoomWidget.ts create mode 100644 src/sql/workbench/contrib/queryplan2/browser/widgets/nodeSearchWidget.ts diff --git a/package.json b/package.json index 1c38151fc3..223afe4b38 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "angular2-grid": "2.0.6", "ansi_up": "^3.0.0", "applicationinsights": "1.0.8", - "azdataGraph": "github:Microsoft/azdataGraph#0.0.11", + "azdataGraph": "github:Microsoft/azdataGraph#0.0.14", "chart.js": "^2.9.4", "chokidar": "3.5.2", "graceful-fs": "4.2.6", diff --git a/remote/package.json b/remote/package.json index 3e08d46878..4c4a5f58b4 100644 --- a/remote/package.json +++ b/remote/package.json @@ -16,7 +16,7 @@ "applicationinsights": "1.0.8", "angular2-grid": "2.0.6", "ansi_up": "^3.0.0", - "azdataGraph": "github:Microsoft/azdataGraph#0.0.11", + "azdataGraph": "github:Microsoft/azdataGraph#0.0.14", "chart.js": "^2.9.4", "chokidar": "3.5.2", "cookie": "^0.4.0", diff --git a/remote/web/package.json b/remote/web/package.json index 13a2aaddba..10078d9481 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -15,7 +15,7 @@ "@vscode/vscode-languagedetection": "1.0.18", "angular2-grid": "2.0.6", "ansi_up": "^3.0.0", - "azdataGraph": "github:Microsoft/azdataGraph#0.0.11", + "azdataGraph": "github:Microsoft/azdataGraph#0.0.14", "chart.js": "^2.9.4", "gridstack": "^3.1.3", "kburtram-query-plan": "2.6.1", diff --git a/remote/web/yarn.lock b/remote/web/yarn.lock index 82905d3e89..342ba29ee7 100644 --- a/remote/web/yarn.lock +++ b/remote/web/yarn.lock @@ -150,9 +150,9 @@ array-uniq@^1.0.2: resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= -"azdataGraph@github:Microsoft/azdataGraph#0.0.11": - version "0.0.11" - resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/efeb59abc720c33e35386749e4345af028613672" +"azdataGraph@github:Microsoft/azdataGraph#0.0.14": + version "0.0.14" + resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/1fba9e94e5520ef78121f6dc23a5a2cdee20c8a4" chalk@^2.3.0, chalk@^2.4.1: version "2.4.2" diff --git a/remote/yarn.lock b/remote/yarn.lock index 7621dcf759..d5ec23bb21 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -198,9 +198,9 @@ array-uniq@^1.0.2: resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= -"azdataGraph@github:Microsoft/azdataGraph#0.0.11": - version "0.0.11" - resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/efeb59abc720c33e35386749e4345af028613672" +"azdataGraph@github:Microsoft/azdataGraph#0.0.14": + version "0.0.14" + resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/1fba9e94e5520ef78121f6dc23a5a2cdee20c8a4" binary-extensions@^2.0.0: version "2.0.0" diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index e156c56b9f..7f2cb73070 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -1536,7 +1536,7 @@ declare module 'azdata' { /** * Flag to show/hide props in tooltip */ - showInToolTip: boolean; + showInTooltip: boolean; /** * Display order of property */ @@ -1544,7 +1544,11 @@ declare module 'azdata' { /** * Flag to indicate if the property has a longer value so that it will be shown at the bottom of the tooltip */ - isLongString: boolean; + positionAtBottom: boolean; + /** + * Display value of property to show in tooltip and other UI element. + */ + displayValue: string; } export interface ExecutionPlanRecommendations { diff --git a/src/sql/base/common/strings.ts b/src/sql/base/common/strings.ts index da7e1921fc..455a7484db 100644 --- a/src/sql/base/common/strings.ts +++ b/src/sql/base/common/strings.ts @@ -74,7 +74,10 @@ export function endsWith(haystack: string, needle: string): boolean { /** * Remove line breaks/eols from a string across different operating systems. + * @param str target strings that needs line breaks removed. + * @param replace optional string that replaces the line breaks. + * @returns string with removed line breaks. */ -export function removeLineBreaks(str: string): string { - return str.replace(/(\r\n|\n|\r)/gm, ''); +export function removeLineBreaks(str: string, replace?: string): string { + return str.replace(/(\r\n|\n|\r)/gm, replace ?? ''); } diff --git a/src/sql/workbench/contrib/queryplan2/browser/media/queryPlan2.css b/src/sql/workbench/contrib/queryplan2/browser/media/queryPlan2.css index 4afdc4a24e..0c00b98ccb 100644 --- a/src/sql/workbench/contrib/queryplan2/browser/media/queryPlan2.css +++ b/src/sql/workbench/contrib/queryplan2/browser/media/queryPlan2.css @@ -41,7 +41,7 @@ However we always want it to be the width of the container it is resizing. display: flex; flex-direction: column; position: absolute; - right: 0; + right: 10px; } /* views created by the action-bar actions */ @@ -51,22 +51,21 @@ However we always want it to be the width of the container it is resizing. } /* Search node action view */ -.qps-container .query-plan .plan .plan-action-container .search-node-view { +.qps-container .query-plan .plan .plan-action-container .search-node-widget { display: flex; flex-direction: row; - border: 1px solid; padding: 5px; height: auto; width: 470px; } /* input bar styling in search node action view */ -.qps-container .query-plan .plan .plan-action-container .search-node-view .search-bar-container{ +.qps-container .query-plan .plan .plan-action-container .search-node-widget .select-container{ margin-left: 5px; } /* styling for select element in search node action view */ -.qps-container .query-plan .plan .plan-action-container .search-node-view .search-bar-container > select{ +.qps-container .query-plan .plan .plan-action-container .search-node-widget .select-container > select{ height: 100%; } @@ -74,7 +73,6 @@ However we always want it to be the width of the container it is resizing. .qps-container .query-plan .plan .plan-action-container .custom-zoom-view { display: flex; flex-direction: row; - border: 1px solid; padding: 5px; height: auto; width: 180px; @@ -82,11 +80,10 @@ However we always want it to be the width of the container it is resizing. /* query plan header that contains the relative query cost, query statement and recommendations */ .qps-container .query-plan .plan .header { - font-family: 'Courier New', Courier, monospace; - border-bottom: solid 1px; + padding: 5px; + border-top: 1px solid; + border-bottom: 1px solid; font-weight: bolder; - padding-left: 5px; - font-size: 13px; } /* each link in query plan recommendations */ @@ -108,7 +105,7 @@ However we always want it to be the width of the container it is resizing. flex: 0 0 500px; overflow: hidden; width: 500px; - height: 100%; + height: calc( 100% - 2px ); border: 1px solid; display: flex; flex-direction: column; @@ -213,3 +210,13 @@ However we always want it to be the width of the container it is resizing. .qps-container .properties-toggle .collapse { background: url(../images/collapse.gif) no-repeat center center; } + +/* Stylings necessary for tooltips to show up next to target nodes*/ +.mxTooltip { + border-style: solid; + border-width: 1px; + position: absolute; + cursor: default; + padding: 4px; + z-index: 3; +} diff --git a/src/sql/workbench/contrib/queryplan2/browser/planHeader.ts b/src/sql/workbench/contrib/queryplan2/browser/planHeader.ts index 99431d0345..37036ee966 100644 --- a/src/sql/workbench/contrib/queryplan2/browser/planHeader.ts +++ b/src/sql/workbench/contrib/queryplan2/browser/planHeader.ts @@ -57,7 +57,7 @@ export class PlanHeader { this.renderGraphIndexAndCost(); } public set query(query: string) { - this._query = removeLineBreaks(query); + this._query = removeLineBreaks(query, ' '); this.renderQueryText(); } diff --git a/src/sql/workbench/contrib/queryplan2/browser/queryPlan.ts b/src/sql/workbench/contrib/queryplan2/browser/queryPlan.ts index a6f10fd956..d3b069611a 100644 --- a/src/sql/workbench/contrib/queryplan2/browser/queryPlan.ts +++ b/src/sql/workbench/contrib/queryplan2/browser/queryPlan.ts @@ -16,15 +16,15 @@ import * as azdataGraphModule from 'azdataGraph'; import { queryPlanNodeIconPaths } from 'sql/workbench/contrib/queryplan2/browser/constants'; import { isString } from 'vs/base/common/types'; import { PlanHeader } from 'sql/workbench/contrib/queryplan2/browser/planHeader'; -import { GraphElementPropertiesView } from 'sql/workbench/contrib/queryplan2/browser/graphElementPropertiesView'; +import { QueryPlanPropertiesView } from 'sql/workbench/contrib/queryplan2/browser/queryPlanPropertiesView'; import { Action } from 'vs/base/common/actions'; import { Codicon } from 'vs/base/common/codicons'; 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 { IContextViewService } from 'vs/platform/contextview/browser/contextView'; -import { editorBackground, foreground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { editorBackground, editorWidgetBackground, foreground, 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'; @@ -32,11 +32,29 @@ import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/commo 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 { QueryPlanWidgetController } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetController'; +import { CustomZoomWidget } from 'sql/workbench/contrib/queryplan2/browser/widgets/customZoomWidget'; +import { NodeSearchWidget } from 'sql/workbench/contrib/queryplan2/browser/widgets/nodeSearchWidget'; let azdataGraph = azdataGraphModule(); +export interface InternalExecutionPlanNode extends azdata.ExecutionPlanNode { + /** + * Unique internal id given to graph node by ADS. + */ + id?: string; +} + +export interface InternalExecutionPlanEdge extends azdata.ExecutionPlanEdge { + /** + * Unique internal id given to graph edge by ADS. + */ + id?: string; +} + export class QueryPlan2Tab implements IPanelTab { - public readonly title = localize('queryPlanTitle', "Query Plan"); + public readonly title = localize('queryPlanTitle', "Query Plan (Preview)"); public readonly identifier = 'QueryPlan2Tab'; public readonly view: QueryPlan2View; @@ -92,7 +110,7 @@ export class QueryPlan2View implements IPanelView { if (newGraphs) { newGraphs.forEach(g => { const qp2 = this.instantiationService.createInstance(QueryPlan2, this._container, this._qps.length + 1); - qp2.graph = g; + qp2.graphModel = g; this._qps.push(qp2); this._graphs.push(g); this.updateRelativeCosts(); @@ -107,14 +125,14 @@ export class QueryPlan2View implements IPanelView { if (sum > 0) { this._qps.forEach(qp => { - qp.planHeader.relativeCost = ((qp.graph.root.subTreeCost + qp.graph.root.cost) / sum) * 100; + qp.planHeader.relativeCost = ((qp.graphModel.root.subTreeCost + qp.graphModel.root.cost) / sum) * 100; }); } } } export class QueryPlan2 implements ISashLayoutProvider { - private _graph?: azdata.ExecutionPlanGraph; + private _graphModel?: azdata.ExecutionPlanGraph; private _container: HTMLElement; @@ -125,25 +143,33 @@ export class QueryPlan2 implements ISashLayoutProvider { private _planContainer: HTMLElement; private _planHeaderContainer: HTMLElement; - public propertiesView: GraphElementPropertiesView; + public propertiesView: QueryPlanPropertiesView; private _propContainer: HTMLElement; - private _azdataGraphDiagram: any; + private _planActionContainer: HTMLElement; + public planActionView: QueryPlanWidgetController; + + public azdataGraphDiagram: any; + + public graphElementPropertiesSet: Set = new Set(); + + private uniqueElementId: number = -1; constructor( - parent: HTMLElement, + 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 + @IEditorService private readonly editorService: IEditorService, + @IContextMenuService private _contextMenuService: IContextMenuService, ) { // parent container for query plan. this._container = DOM.$('.query-plan'); - parent.appendChild(this._container); + this._parent.appendChild(this._container); const sashContainer = DOM.$('.query-plan-sash'); - parent.appendChild(sashContainer); + this._parent.appendChild(sashContainer); const sash = new Sash(sashContainer, this, { orientation: Orientation.HORIZONTAL }); let originalHeight = this._container.offsetHeight; @@ -178,6 +204,12 @@ export class QueryPlan2 implements ISashLayoutProvider { // 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, @@ -186,7 +218,11 @@ export class QueryPlan2 implements ISashLayoutProvider { // container properties this._propContainer = DOM.$('.properties'); this._container.appendChild(this._propContainer); - this.propertiesView = new GraphElementPropertiesView(this._propContainer, this._themeService); + this.propertiesView = new QueryPlanPropertiesView(this._propContainer, this._themeService); + + this._planActionContainer = DOM.$('.plan-action-container'); + this._planContainer.appendChild(this._planActionContainer); + this.planActionView = new QueryPlanWidgetController(this._planActionContainer); // container that holds actionbar icons this._actionBarContainer = DOM.$('.action-bar-container'); @@ -197,7 +233,7 @@ export class QueryPlan2 implements ISashLayoutProvider { const actions = [ - new SaveXml(), + new SavePlanFile(), new OpenGraphFile(), new OpenQueryAction(), new SearchNodeAction(), @@ -209,7 +245,22 @@ export class QueryPlan2 implements ISashLayoutProvider { ]; 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 { @@ -222,8 +273,11 @@ export class QueryPlan2 implements ISashLayoutProvider { return this._container.clientWidth; } - private populate(node: azdata.ExecutionPlanNode, diagramNode: any): any { + private populate(node: InternalExecutionPlanNode, diagramNode: any): any { diagramNode.label = 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); @@ -233,6 +287,13 @@ export class QueryPlan2 implements ISashLayoutProvider { 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) { @@ -240,55 +301,80 @@ export class QueryPlan2 implements ISashLayoutProvider { } } - 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.description) { + diagramNode.description = node.description; } return diagramNode; } - private populateEdges(edge: azdata.ExecutionPlanEdge, diagramEdge: any) { + 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.ExecutionPlanGraphElementProperty[]) { - return props.filter(e => isString(e.value)) + 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.value.toString().substring(0, 75) + value: e.displayValue, + isLongString: e.positionAtBottom }; }); } - private createPlanDiagram(container: HTMLElement): void { + private createGraphElementId(): string { + this.uniqueElementId += 1; + return `element-${this.uniqueElementId}`; + } + + private createPlanDiagram(container: HTMLElement) { let diagramRoot: any = new Object(); - let graphRoot: azdata.ExecutionPlanNode = this._graph.root; + let graphRoot: azdata.ExecutionPlanNode = this._graphModel.root; + this.populate(graphRoot, diagramRoot); - this._azdataGraphDiagram = new azdataGraph.azdataQueryPlan(container, diagramRoot, queryPlanNodeIconPaths); + this.azdataGraphDiagram = new azdataGraph.azdataQueryPlan(container, diagramRoot, queryPlanNodeIconPaths); + + 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); + this.azdataGraphDiagram.setIconBackgroundColor(iconBackground); } const iconLabelColor = theme.getColor(foreground); if (iconLabelColor) { - this._azdataGraphDiagram.setTextFontColor(iconLabelColor); + this.azdataGraphDiagram.setTextFontColor(iconLabelColor); + this.azdataGraphDiagram.setEdgeColor(iconLabelColor); } }); } - public set graph(graph: azdata.ExecutionPlanGraph | undefined) { - this._graph = graph; - if (this._graph) { + public set graphModel(graph: azdata.ExecutionPlanGraph | undefined) { + this._graphModel = graph; + if (this._graphModel) { this.planHeader.graphIndex = this._graphIndex; this.planHeader.query = graph.query; if (graph.recommendations) { @@ -296,27 +382,59 @@ export class QueryPlan2 implements ISashLayoutProvider { } 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 => { + e.preventDefault(); + e.stopPropagation(); + this._parent.scrollTop += e.deltaY; + }); + this._planContainer.appendChild(diagramContainer); - this.propertiesView.graphElement = this._graph.root; + this.propertiesView.graphElement = this._graphModel.root; } } - public get graph(): azdata.ExecutionPlanGraph | undefined { - return this._graph; + public get graphModel(): azdata.ExecutionPlanGraph | undefined { + return this._graphModel; } public openQuery() { - return this._instantiationService.invokeFunction(openNewQuery, undefined, this.graph.query, RunQueryOnConnectionMode.none).then(); + return this._instantiationService.invokeFunction(openNewQuery, undefined, this.graphModel.query, RunQueryOnConnectionMode.none).then(); } public async openGraphFile() { - const input = this._untitledEditorService.create({ mode: this.graph.graphFile.graphFileType, initialValue: this.graph.graphFile.graphFileContent }); + 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 { @@ -354,6 +472,7 @@ class ZoomInAction extends Action { } public override async run(context: QueryPlan2): Promise { + context.azdataGraphDiagram.graph.zoomIn(); } } @@ -366,6 +485,7 @@ class ZoomOutAction extends Action { } public override async run(context: QueryPlan2): Promise { + context.azdataGraphDiagram.graph.zoomOut(); } } @@ -378,22 +498,24 @@ class ZoomToFitAction extends Action { } public override async run(context: QueryPlan2): Promise { + context.azdataGraphDiagram.graph.fit(); + context.azdataGraphDiagram.graph.view.rendering = true; + context.azdataGraphDiagram.graph.refresh(); } } -class SaveXml extends Action { +class SavePlanFile extends Action { public static ID = 'qp.saveXML'; - public static LABEL = localize('queryPlanSavePlanXML', "Save XML"); + public static LABEL = localize('queryPlanSavePlanXML', "Save Plan File"); constructor() { - super(SaveXml.ID, SaveXml.LABEL, Codicon.save.classNames); + super(SavePlanFile.ID, SavePlanFile.LABEL, Codicon.save.classNames); } public override async run(context: QueryPlan2): Promise { } } - class CustomZoomAction extends Action { public static ID = 'qp.customZoom'; public static LABEL = localize('queryPlanCustomZoom', "Custom Zoom"); @@ -403,6 +525,7 @@ class CustomZoomAction extends Action { } public override async run(context: QueryPlan2): Promise { + context.planActionView.toggleWidget(context._instantiationService.createInstance(CustomZoomWidget, context)); } } @@ -415,6 +538,7 @@ class SearchNodeAction extends Action { } public override async run(context: QueryPlan2): Promise { + context.planActionView.toggleWidget(context._instantiationService.createInstance(NodeSearchWidget, context)); } } @@ -448,6 +572,27 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = } `); } + const shadow = theme.getColor(widgetShadow); + const widgetBackgroundColor = theme.getColor(editorWidgetBackground); + + if (shadow) { + collector.addRule(` + .qps-container .query-plan .plan .plan-action-container .child { + box-shadow: 0 0 8px 2px ${shadow}; + background-color: ${widgetBackgroundColor}; + } + `); + } + + const textColor = theme.getColor(foreground); + if (widgetBackgroundColor && foreground) { + collector.addRule(` + .mxTooltip { + color: ${textColor}; + background-color: ${widgetBackgroundColor}; + } + `); + } }); diff --git a/src/sql/workbench/contrib/queryplan2/browser/graphElementPropertiesView.ts b/src/sql/workbench/contrib/queryplan2/browser/queryPlanPropertiesView.ts similarity index 93% rename from src/sql/workbench/contrib/queryplan2/browser/graphElementPropertiesView.ts rename to src/sql/workbench/contrib/queryplan2/browser/queryPlanPropertiesView.ts index 1dab069ae8..59f4fcdad4 100644 --- a/src/sql/workbench/contrib/queryplan2/browser/graphElementPropertiesView.ts +++ b/src/sql/workbench/contrib/queryplan2/browser/queryPlanPropertiesView.ts @@ -13,12 +13,13 @@ 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 { isString } from 'vs/base/common/types'; 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'; -export class GraphElementPropertiesView { +export class QueryPlanPropertiesView { // Title bar with close button action private _propertiesTitle!: HTMLElement; @@ -178,7 +179,7 @@ export class GraphElementPropertiesView { private renderView(): void { if (this._model.graphElement) { const nodeName = (this._model.graphElement).name; - this._operationName.innerText = nodeName ?? localize('queryPlanPropertiesEdgeOperationName', "Edge"); //since edges do not have names like node, we set the operation name to 'Edge' + this._operationName.innerText = nodeName ? removeLineBreaks(nodeName) : localize('queryPlanPropertiesEdgeOperationName', "Edge"); //since edges do not have names like node, we set the operation name to 'Edge' } this._tableContainer.scrollTo(0, 0); this._dataView.clear(); @@ -199,13 +200,13 @@ export class GraphElementPropertiesView { let row = {}; row['name'] = '\t'.repeat(indent) + p.name; row['parent'] = parentIndex; - rows.push(row); if (!isString(p.value)) { row['value'] = ''; this.convertPropertiesToTableRows(p.value, rows.length - 1, indent + 2, rows); } else { row['value'] = p.value; } + rows.push(row); }); return rows; } @@ -228,7 +229,7 @@ export class ClosePropertyViewAction extends Action { super(ClosePropertyViewAction.ID, ClosePropertyViewAction.LABEL, Codicon.close.classNames); } - public override async run(context: GraphElementPropertiesView): Promise { + public override async run(context: QueryPlanPropertiesView): Promise { context.toggleVisibility(); } } @@ -241,7 +242,7 @@ export class SortPropertiesAlphabeticallyAction extends Action { super(SortPropertiesAlphabeticallyAction.ID, SortPropertiesAlphabeticallyAction.LABEL, Codicon.sortPrecedence.classNames); } - public override async run(context: GraphElementPropertiesView): Promise { + public override async run(context: QueryPlanPropertiesView): Promise { context.sortPropertiesAlphabetically(); } } @@ -254,7 +255,7 @@ export class SortPropertiesByDisplayOrderAction extends Action { super(SortPropertiesByDisplayOrderAction.ID, SortPropertiesByDisplayOrderAction.LABEL, Codicon.listOrdered.classNames); } - public override async run(context: GraphElementPropertiesView): Promise { + public override async run(context: QueryPlanPropertiesView): Promise { context.sortPropertiesByImportance(); } } diff --git a/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase.ts b/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase.ts new file mode 100644 index 0000000000..1d002ea9ce --- /dev/null +++ b/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase.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. + *--------------------------------------------------------------------------------------------*/ + +export abstract class QueryPlanWidgetBase { + /** + * + * @param container HTML Element that contains the UI for the plan action view. + * @param identifier Uniquely identify the view to be added or removed. Note: Only 1 view with the same id can be added to the controller + */ + constructor(public container: HTMLElement, public identifier: string) { + this.container = container; + this.identifier = identifier; + } + + /** + * This method is called when the view is added to PlanActionView. + * Generally, the view should focus the first input element in the view + */ + public abstract focus(); +} diff --git a/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetController.ts b/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetController.ts new file mode 100644 index 0000000000..98f3694334 --- /dev/null +++ b/src/sql/workbench/contrib/queryplan2/browser/queryPlanWidgetController.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { QueryPlanWidgetBase } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase'; + +export class QueryPlanWidgetController { + private _queryPlanWidgetMap: Map = new Map(); + + constructor(private _parentContainer: HTMLElement) { + + } + + private addWidget(widget: QueryPlanWidgetBase) { + if (widget.identifier && !this._queryPlanWidgetMap.has(widget.identifier)) { + this._queryPlanWidgetMap.set(widget.identifier, widget); + if (widget.container) { + widget.container.classList.add('child'); + this._parentContainer.appendChild(widget.container); + widget.focus(); + } + } + } + + public removeWidget(widget: QueryPlanWidgetBase) { + if (widget.identifier) { + if (this._queryPlanWidgetMap.has(widget.identifier)) { + this._parentContainer.removeChild(this._queryPlanWidgetMap.get(widget.identifier).container); + this._queryPlanWidgetMap.delete(widget.identifier); + } else { + throw new Error('The view is not present in the container'); + } + } + } + + /** + * Adds or removes view from the controller. + * @param widget PlanActionView to be added. + */ + public toggleWidget(widget: QueryPlanWidgetBase) { + if (!this._queryPlanWidgetMap.has(widget.identifier)) { + this.addWidget(widget); + } else { + this.removeWidget(widget); + } + } +} diff --git a/src/sql/workbench/contrib/queryplan2/browser/widgets/customZoomWidget.ts b/src/sql/workbench/contrib/queryplan2/browser/widgets/customZoomWidget.ts new file mode 100644 index 0000000000..d8c2642833 --- /dev/null +++ b/src/sql/workbench/contrib/queryplan2/browser/widgets/customZoomWidget.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { QueryPlanWidgetBase } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase'; +import { QueryPlan2 } from 'sql/workbench/contrib/queryplan2/browser/queryPlan'; +import * as DOM from 'vs/base/browser/dom'; +import { Action } from 'vs/base/common/actions'; +import { Codicon } from 'vs/base/common/codicons'; +import { localize } from 'vs/nls'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; + +export class CustomZoomWidget extends QueryPlanWidgetBase { + private _actionBar: ActionBar; + public customZoomInputBox: InputBox; + + constructor( + public readonly queryPlanView: QueryPlan2, + @IContextViewService public readonly contextViewService: IContextViewService, + @IThemeService public readonly themeService: IThemeService, + @INotificationService public readonly notificationService: INotificationService + ) { + super(DOM.$('.custom-zoom-view'), 'customZoom'); + + // Custom zoom input box + const zoomValueLabel = localize("qpZoomValueLabel", 'Zoom (percent)'); + this.customZoomInputBox = new InputBox(this.container, this.contextViewService, { + type: 'number', + ariaLabel: zoomValueLabel, + flexibleWidth: false + }); + attachInputBoxStyler(this.customZoomInputBox, this.themeService); + + const currentZoom = queryPlanView.azdataGraphDiagram.graph.view.getScale(); + + // Setting initial value to graph's current zoom + this.customZoomInputBox.value = Math.round(currentZoom).toString(); + + // Setting up keyboard shortcuts + const self = this; + this.customZoomInputBox.element.onkeydown = async (ev) => { + if (ev.key === 'Enter') { + await new CustomZoomAction().run(self); + } else if (ev.key === 'Escape') { + queryPlanView.planActionView.removeWidget(self); + } + }; + + // Adding action bar + this._actionBar = new ActionBar(this.container); + this._actionBar.context = this; + this._actionBar.pushAction(new CustomZoomAction(), { label: false, icon: true }); + this._actionBar.pushAction(new CancelZoom(), { label: false, icon: true }); + } + + // Setting initial focus to input box + public focus() { + this.customZoomInputBox.focus(); + } +} + +export class CustomZoomAction extends Action { + public static ID = 'qp.customZoomAction'; + public static LABEL = localize('zoomAction', "Zoom (Enter)"); + + constructor() { + super(CustomZoomAction.ID, CustomZoomAction.LABEL, Codicon.zoomOut.classNames); + } + + 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.queryPlanView.azdataGraphDiagram.graph.view.setScale(newValue / 100); + context.queryPlanView.planActionView.removeWidget(context); + } else { + context.notificationService.error( + localize('invalidCustomZoomError', "Select a zoom value between 1 to 200") + ); + } + } +} + +export class CancelZoom extends Action { + public static ID = 'qp.cancelCustomZoomAction'; + public static LABEL = localize('cancelCustomZoomAction', "Close (Escape)"); + + constructor() { + super(CancelZoom.ID, CancelZoom.LABEL, Codicon.chromeClose.classNames); + } + + public override async run(context: CustomZoomWidget): Promise { + context.queryPlanView.planActionView.removeWidget(context); + } +} + + diff --git a/src/sql/workbench/contrib/queryplan2/browser/widgets/nodeSearchWidget.ts b/src/sql/workbench/contrib/queryplan2/browser/widgets/nodeSearchWidget.ts new file mode 100644 index 0000000000..dc2ee6e77c --- /dev/null +++ b/src/sql/workbench/contrib/queryplan2/browser/widgets/nodeSearchWidget.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { QueryPlanWidgetBase } from 'sql/workbench/contrib/queryplan2/browser/queryPlanWidgetBase'; +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, QueryPlan2 } from 'sql/workbench/contrib/queryplan2/browser/queryPlan'; +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'; + +const CONTAINS_DISPLAY_STRING = localize("queryPlanSearchTypeContains", 'Contains'); +const EQUALS_DISPLAY_STRING = localize("queryPlanSearchTypeEquals", 'Equals'); + +export class NodeSearchWidget extends QueryPlanWidgetBase { + + private _propertyNameSelectBoxContainer: HTMLElement; + private _propertyNameSelectBox: SelectBox; + + private _searchTypeSelectBoxContainer: HTMLElement; + private _searchTypeSelectBox: SelectBox; + + private _searchTextInputBox: InputBox; + private _searchResults: string[] = []; + private _currentSearchResultIndex = 0; + private _usePreviousSearchResult: boolean = false; + + private _actionBar: ActionBar; + + constructor( + public readonly queryPlanView: QueryPlan2, + @IContextViewService public readonly contextViewService: IContextViewService, + @IThemeService public readonly themeService: IThemeService + + ) { + super(DOM.$('.search-node-widget'), 'searchWidget'); + + // property name dropdown + this._propertyNameSelectBoxContainer = DOM.$('.search-widget-property-name-select-box .dropdown-container'); + this.container.appendChild(this._propertyNameSelectBoxContainer); + const propDropdownOptions = [...queryPlanView.graphElementPropertiesSet].sort(); + this._propertyNameSelectBox = new SelectBox(propDropdownOptions, propDropdownOptions[0], this.contextViewService, this._propertyNameSelectBoxContainer); + attachSelectBoxStyler(this._propertyNameSelectBox, this.themeService); + this._propertyNameSelectBoxContainer.style.width = '150px'; + this._propertyNameSelectBox.render(this._propertyNameSelectBoxContainer); + this._propertyNameSelectBox.onDidSelect(e => { + this._usePreviousSearchResult = false; + }); + + // search type dropdown + this._searchTypeSelectBoxContainer = DOM.$('.search-widget-search-type-select-box .dropdown-container'); + this.container.appendChild(this._searchTypeSelectBoxContainer); + this._searchTypeSelectBox = new SelectBox([ + EQUALS_DISPLAY_STRING, + CONTAINS_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; + }); + + // search text input box + this._searchTextInputBox = new InputBox(this.container, this.contextViewService, {}); + attachInputBoxStyler(this._searchTextInputBox, this.themeService); + this._searchTextInputBox.element.style.marginLeft = '5px'; + this._searchTextInputBox.onDidChange(e => { + this._usePreviousSearchResult = false; + }); + + + // setting up key board shortcuts + const self = this; + this._searchTextInputBox.element.onkeydown = async e => { + if (e.key === 'Enter' && e.shiftKey) { + await new GoToPreviousMatchAction().run(self); + } else if (e.key === 'Enter') { + await new GoToNextMatchAction().run(self); + } else if (e.key === 'Escape') { + await new CancelSearch().run(self); + } + }; + + // Adding action bar + this._actionBar = new ActionBar(this.container); + this._actionBar.context = this; + this._actionBar.pushAction(new GoToPreviousMatchAction(), { label: false, icon: true }); + this._actionBar.pushAction(new GoToNextMatchAction(), { label: false, icon: true }); + this._actionBar.pushAction(new CancelSearch(), { label: false, icon: true }); + } + + // Initial focus is set to the search text input box + public focus() { + 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.queryPlanView.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.queryPlanView.azdataGraphDiagram.graph.model.getCell(this._searchResults[this._currentSearchResultIndex]); + // Selecting the node on graph diagram + this.queryPlanView.azdataGraphDiagram.graph.setSelectionCell(resultCell); + this.queryPlanView.propertiesView.graphElement = this.queryPlanView.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.queryPlanView.azdataGraphDiagram.graph.getCellBounds(resultCell); + const cellMidPoint: Point = { + x: cellRect.x + cellRect.width / 2, + y: cellRect.y + cellRect.height / 2, + }; + + const graphContainer = this.queryPlanView.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' + }); + + // 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; + } + } +} + +interface Point { + x: number; + y: number; +} + +export class GoToNextMatchAction extends Action { + public static ID = 'qp.NextSearchAction'; + public static LABEL = localize('nextSearchItemAction', "Next Match (Enter)"); + + constructor() { + super(GoToNextMatchAction.ID, GoToNextMatchAction.LABEL, Codicon.arrowDown.classNames); + } + + public override async run(context: NodeSearchWidget): Promise { + context.searchNode(false); + } +} + +export class GoToPreviousMatchAction extends Action { + public static ID = 'qp.PreviousSearchAction'; + public static LABEL = localize('previousSearchItemAction', "Previous Match (Shift+Enter)"); + + constructor() { + super(GoToPreviousMatchAction.ID, GoToPreviousMatchAction.LABEL, Codicon.arrowUp.classNames); + } + + public override async run(context: NodeSearchWidget): Promise { + context.searchNode(true); + } +} + +export class CancelSearch extends Action { + public static ID = 'qp.cancelSearchAction'; + public static LABEL = localize('cancelSearchAction', "Close (Escape)"); + + constructor() { + super(CancelSearch.ID, CancelSearch.LABEL, Codicon.chromeClose.classNames); + } + + public override async run(context: NodeSearchWidget): Promise { + context.queryPlanView.planActionView.removeWidget(context); + } +} diff --git a/yarn.lock b/yarn.lock index 2d8fef088d..976e943ef4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1831,9 +1831,9 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== -"azdataGraph@github:Microsoft/azdataGraph#0.0.11": - version "0.0.11" - resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/efeb59abc720c33e35386749e4345af028613672" +"azdataGraph@github:Microsoft/azdataGraph#0.0.14": + version "0.0.14" + resolved "https://codeload.github.com/Microsoft/azdataGraph/tar.gz/1fba9e94e5520ef78121f6dc23a5a2cdee20c8a4" azure-storage@^2.10.2: version "2.10.2"