From 10b066d300a1e8bff37a724e31e96bd162852683 Mon Sep 17 00:00:00 2001 From: Kevin Cunnane Date: Wed, 3 Jul 2019 14:34:03 -0700 Subject: [PATCH] Share Notebook grid rendering with Query editor (#6241) This is a staged refactor to use the exact same grid logic in the Notebook and query editors, including context menu support, font settings, and sizing logic. The goal long term is: - As the core Query grid is updated, Notebook can benefit from the changes - As we add in support for contributions like new buttons & actions working on the grid, can share the logic - Ideally if and when we refactor things like the action bar for grid results, we can apply in both places though this is TBD. Fixes a number of issues: - Fixes #5755 Grids don't respond to font settings. @anthonydresser can we remove setting from each query results editor and just use Notebook Styles since these are global (not scoped) settings? - Fixes #5501 Copy from grid settings. - Fixes #4938 SQL Notebook result sets are missing the actions provide for SQL File results sets. this now has the core ability to solve this, and separate work items for specific asks (serialization, charting) are tracked. Currently hidden: - Save as... options in context menu - All right toolbar actions (save as, chart). Remaining issues to address in future commits: - Need to implement support for serialization (#5137). - Need to add charting support - Need to solve the layout of buttons on the right hand side when a small number of columns are output. It doesn't look right that buttons are so far away from the results - Will work with UX on this. For now, mitigating this by hiding all buttons, but will need to solve in the future - Would like to make buttons contributable via extension, but need to refactor similar to ObjectExplorer context menu so that we can serialize context menu options across to extension host while still having internal actions with full support --- .../platform/query/common/gridDataProvider.ts | 102 +++++++ src/sql/platform/query/common/queryRunner.ts | 169 ++++++----- .../notebook/cellViews/output.component.ts | 5 +- .../parts/notebook/notebook.contribution.ts | 36 ++- .../parts/notebook/notebookStyles.ts | 24 +- .../notebook/outputs/gridOutput.component.ts | 282 ++++++++++++++++++ .../outputs/markdownOutput.component.ts | 3 + .../parts/notebook/outputs/tableRenderers.ts | 4 +- .../workbench/parts/query/browser/actions.ts | 35 ++- .../parts/query/browser/queryResultsEditor.ts | 2 +- .../parts/query/electron-browser/gridPanel.ts | 159 ++++++---- .../notebook/common/notebookServiceImpl.ts | 5 +- 12 files changed, 643 insertions(+), 183 deletions(-) create mode 100644 src/sql/platform/query/common/gridDataProvider.ts create mode 100644 src/sql/workbench/parts/notebook/outputs/gridOutput.component.ts diff --git a/src/sql/platform/query/common/gridDataProvider.ts b/src/sql/platform/query/common/gridDataProvider.ts new file mode 100644 index 0000000000..e6cb2dc15c --- /dev/null +++ b/src/sql/platform/query/common/gridDataProvider.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 * as azdata from 'azdata'; +import * as types from 'vs/base/common/types'; +import { SaveFormat } from 'sql/workbench/parts/grid/common/interfaces'; + +export interface IGridDataProvider { + + /** + * Gets N rows of data + * @param rowStart 0-indexed start row to retrieve data from + * @param numberOfRows total number of rows of data to retrieve + */ + getRowData(rowStart: number, numberOfRows: number): Thenable; + + /** + * Sends a copy request to copy data to the clipboard + * @param selection The selection range to copy + * @param batchId The batch id of the result to copy from + * @param resultId The result id of the result to copy from + * @param includeHeaders [Optional]: Should column headers be included in the copy selection + */ + copyResults(selection: Slick.Range[], includeHeaders?: boolean): void; + + /** + * Gets the EOL terminator to use for this data type. + */ + getEolString(): string; + + shouldIncludeHeaders(includeHeaders: boolean): boolean; + + shouldRemoveNewLines(): boolean; + + getColumnHeaders(range: Slick.Range): string[]; + + readonly canSerialize: boolean; + + serializeResults(format: SaveFormat, selection: Slick.Range[]): Thenable; + +} + +export async function getResultsString(provider: IGridDataProvider, selection: Slick.Range[], includeHeaders?: boolean): Promise { + let copyString = ''; + const eol = provider.getEolString(); + + // create a mapping of the ranges to get promises + let tasks = selection.map((range, i) => { + return async () => { + const result = await provider.getRowData(range.fromRow, range.toRow - range.fromRow + 1); + // If there was a previous selection separate it with a line break. Currently + // when there are multiple selections they are never on the same line + if (i > 0) { + copyString += eol; + } + if (provider.shouldIncludeHeaders(includeHeaders)) { + let columnHeaders = provider.getColumnHeaders(range); + if (columnHeaders !== undefined) { + copyString += columnHeaders.join('\t') + eol; + } + } + // Iterate over the rows to paste into the copy string + for (let rowIndex: number = 0; rowIndex < result.resultSubset.rows.length; rowIndex++) { + let row = result.resultSubset.rows[rowIndex]; + let cellObjects = row.slice(range.fromCell, (range.toCell + 1)); + // Remove newlines if requested + let cells = provider.shouldRemoveNewLines() + ? cellObjects.map(x => removeNewLines(x.displayValue)) + : cellObjects.map(x => x.displayValue); + copyString += cells.join('\t'); + if (rowIndex < result.resultSubset.rows.length - 1) { + copyString += eol; + } + } + }; + }); + + if (tasks.length > 0) { + let p = tasks[0](); + for (let i = 1; i < tasks.length; i++) { + p = p.then(tasks[i]); + } + await p; + } + return copyString; +} + + +function removeNewLines(inputString: string): string { + // This regex removes all newlines in all OS types + // Windows(CRLF): \r\n + // Linux(LF)/Modern MacOS: \n + // Old MacOs: \r + if (types.isUndefinedOrNull(inputString)) { + return 'null'; + } + + let outputString: string = inputString.replace(/(\r\n|\n|\r)/gm, ''); + return outputString; +} \ No newline at end of file diff --git a/src/sql/platform/query/common/queryRunner.ts b/src/sql/platform/query/common/queryRunner.ts index 96ac0235e4..53c1435f29 100644 --- a/src/sql/platform/query/common/queryRunner.ts +++ b/src/sql/platform/query/common/queryRunner.ts @@ -26,6 +26,8 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { ITextResourcePropertiesService } from 'vs/editor/common/services/resourceConfiguration'; import { URI } from 'vs/base/common/uri'; import { mssqlProviderName } from 'sql/platform/connection/common/constants'; +import { IGridDataProvider, getResultsString } from 'sql/platform/query/common/gridDataProvider'; +import { getErrorMessage } from 'sql/workbench/parts/notebook/notebookUtils'; export interface IEditSessionReadyEvent { ownerUri: string; @@ -98,7 +100,6 @@ export default class QueryRunner extends Disposable { @IQueryManagementService private _queryManagementService: IQueryManagementService, @INotificationService private _notificationService: INotificationService, @IConfigurationService private _configurationService: IConfigurationService, - @IClipboardService private _clipboardService: IClipboardService, @IInstantiationService private instantiationService: IInstantiationService, @ITextResourcePropertiesService private _textResourcePropertiesService: ITextResourcePropertiesService ) { @@ -212,7 +213,7 @@ export default class QueryRunner extends Disposable { private handleFailureRunQueryResult(error: any) { // Attempting to launch the query failed, show the error message - const eol = this.getEolString(); + const eol = getEolString(this._textResourcePropertiesService, this.uri); if (error instanceof Error) { error = error.message; } @@ -437,10 +438,7 @@ export default class QueryRunner extends Disposable { // TODO issue #228 add statusview callbacks here this._isExecuting = false; - this._notificationService.notify({ - severity: Severity.Error, - message: nls.localize('query.initEditExecutionFailed', 'Init Edit Execution failed: ') + error - }); + this._notificationService.error(nls.localize('query.initEditExecutionFailed', "Initialize edit data session failed: ") + error); }); } @@ -541,76 +539,12 @@ export default class QueryRunner extends Disposable { * @param includeHeaders [Optional]: Should column headers be included in the copy selection */ copyResults(selection: Slick.Range[], batchId: number, resultId: number, includeHeaders?: boolean): void { - const self = this; - let copyString = ''; - const eol = this.getEolString(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range, i) => { - return () => { - return self.getQueryRows(range.fromRow, range.toRow - range.fromRow + 1, batchId, resultId).then((result) => { - // If there was a previous selection separate it with a line break. Currently - // when there are multiple selections they are never on the same line - if (i > 0) { - copyString += eol; - } - - if (self.shouldIncludeHeaders(includeHeaders)) { - let columnHeaders = self.getColumnHeaders(batchId, resultId, range); - if (columnHeaders !== undefined) { - copyString += columnHeaders.join('\t') + eol; - } - } - - // Iterate over the rows to paste into the copy string - for (let rowIndex: number = 0; rowIndex < result.resultSubset.rows.length; rowIndex++) { - let row = result.resultSubset.rows[rowIndex]; - let cellObjects = row.slice(range.fromCell, (range.toCell + 1)); - // Remove newlines if requested - let cells = self.shouldRemoveNewLines() - ? cellObjects.map(x => self.removeNewLines(x.displayValue)) - : cellObjects.map(x => x.displayValue); - copyString += cells.join('\t'); - if (rowIndex < result.resultSubset.rows.length - 1) { - copyString += eol; - } - } - }); - }; - }); - - if (tasks.length > 0) { - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - p.then(() => { - this._clipboardService.writeText(copyString); - }); - } + let provider = this.getGridDataProvider(batchId, resultId); + provider.copyResults(selection, includeHeaders); } - private getEolString(): string { - return this._textResourcePropertiesService.getEOL(URI.parse(this.uri), 'sql'); - } - private shouldIncludeHeaders(includeHeaders: boolean): boolean { - if (includeHeaders !== undefined) { - // Respect the value explicity passed into the method - return includeHeaders; - } - // else get config option from vscode config - includeHeaders = WorkbenchUtils.getSqlConfigValue(this._configurationService, Constants.copyIncludeHeaders); - return !!includeHeaders; - } - - private shouldRemoveNewLines(): boolean { - // get config copyRemoveNewLine option from vscode config - let removeNewLines: boolean = WorkbenchUtils.getSqlConfigValue(this._configurationService, Constants.configCopyRemoveNewLine); - return !!removeNewLines; - } - - private getColumnHeaders(batchId: number, resultId: number, range: Slick.Range): string[] { + public getColumnHeaders(batchId: number, resultId: number, range: Slick.Range): string[] { let headers: string[] = undefined; let batchSummary: azdata.BatchSummary = this._batchSets[batchId]; if (batchSummary !== undefined) { @@ -622,19 +556,6 @@ export default class QueryRunner extends Disposable { return headers; } - private removeNewLines(inputString: string): string { - // This regex removes all newlines in all OS types - // Windows(CRLF): \r\n - // Linux(LF)/Modern MacOS: \n - // Old MacOs: \r - if (types.isUndefinedOrNull(inputString)) { - return 'null'; - } - - let outputString: string = inputString.replace(/(\r\n|\n|\r)/gm, ''); - return outputString; - } - private sendBatchTimeMessage(batchId: number, executionTime: string): void { // get config copyRemoveNewLine option from vscode config let showBatchTime: boolean = WorkbenchUtils.getSqlConfigValue(this._configurationService, Constants.configShowBatchTime); @@ -654,4 +575,80 @@ export default class QueryRunner extends Disposable { public serializeResults(batchId: number, resultSetId: number, format: SaveFormat, selection: Slick.Range[]) { return this.instantiationService.createInstance(ResultSerializer).saveResults(this.uri, { selection, format, batchIndex: batchId, resultSetNumber: resultSetId }); } + + public getGridDataProvider(batchId: number, resultSetId: number): IGridDataProvider { + return this.instantiationService.createInstance(QueryGridDataProvider, this, batchId, resultSetId); + } } + +export class QueryGridDataProvider implements IGridDataProvider { + + constructor( + private queryRunner: QueryRunner, + private batchId: number, + private resultSetId: number, + @INotificationService private _notificationService: INotificationService, + @IClipboardService private _clipboardService: IClipboardService, + @IConfigurationService private _configurationService: IConfigurationService, + @ITextResourcePropertiesService private _textResourcePropertiesService: ITextResourcePropertiesService + ) { + } + + getRowData(rowStart: number, numberOfRows: number): Thenable { + return this.queryRunner.getQueryRows(rowStart, numberOfRows, this.batchId, this.resultSetId); + } + + copyResults(selection: Slick.Range[], includeHeaders?: boolean): void { + this.copyResultsAsync(selection, includeHeaders); + } + + private async copyResultsAsync(selection: Slick.Range[], includeHeaders?: boolean): Promise { + try { + let results = await getResultsString(this, selection, includeHeaders); + this._clipboardService.writeText(results); + } catch (error) { + this._notificationService.error(nls.localize('copyFailed', "Copy failed with error {0}", getErrorMessage(error))); + } + } + getEolString(): string { + return getEolString(this._textResourcePropertiesService, this.queryRunner.uri); + } + shouldIncludeHeaders(includeHeaders: boolean): boolean { + return shouldIncludeHeaders(includeHeaders, this._configurationService); + } + shouldRemoveNewLines(): boolean { + return shouldRemoveNewLines(this._configurationService); + } + getColumnHeaders(range: Slick.Range): string[] { + return this.queryRunner.getColumnHeaders(this.batchId, this.resultSetId, range); + } + + get canSerialize(): boolean { + return true; + } + + serializeResults(format: SaveFormat, selection: Slick.Range[]): Thenable { + return this.queryRunner.serializeResults(this.batchId, this.resultSetId, format, selection); + } +} + + +export function getEolString(textResourcePropertiesService: ITextResourcePropertiesService, uri: string): string { + return textResourcePropertiesService.getEOL(URI.parse(uri), 'sql'); +} + +export function shouldIncludeHeaders(includeHeaders: boolean, configurationService: IConfigurationService): boolean { + if (includeHeaders !== undefined) { + // Respect the value explicity passed into the method + return includeHeaders; + } + // else get config option from vscode config + includeHeaders = WorkbenchUtils.getSqlConfigValue(configurationService, Constants.copyIncludeHeaders); + return !!includeHeaders; +} + +export function shouldRemoveNewLines(configurationService: IConfigurationService): boolean { + // get config copyRemoveNewLine option from vscode config + let removeNewLines: boolean = WorkbenchUtils.getSqlConfigValue(configurationService, Constants.configCopyRemoveNewLine); + return !!removeNewLines; +} \ No newline at end of file diff --git a/src/sql/workbench/parts/notebook/cellViews/output.component.ts b/src/sql/workbench/parts/notebook/cellViews/output.component.ts index 1568e653ed..7aaca0ef03 100644 --- a/src/sql/workbench/parts/notebook/cellViews/output.component.ts +++ b/src/sql/workbench/parts/notebook/cellViews/output.component.ts @@ -54,6 +54,7 @@ export class OutputComponent extends AngularDisposable implements OnInit, AfterV ngOnInit() { this._register(this._themeService.onThemeChange(event => this.updateTheme(event))); + this.loadComponent(); this.layout(); this._initialized = true; this._register(Event.debounce(this.cellModel.notebookModel.layoutChanged, (l, e) => e, 50, /*leading=*/false) @@ -62,10 +63,6 @@ export class OutputComponent extends AngularDisposable implements OnInit, AfterV ngAfterViewInit() { this.updateTheme(this._themeService.getTheme()); - if (this.componentHost) { - this.loadComponent(); - } - this._changeref.detectChanges(); } ngOnChanges(changes: { [propKey: string]: SimpleChange }) { diff --git a/src/sql/workbench/parts/notebook/notebook.contribution.ts b/src/sql/workbench/parts/notebook/notebook.contribution.ts index 72f5a22301..83b1e40699 100644 --- a/src/sql/workbench/parts/notebook/notebook.contribution.ts +++ b/src/sql/workbench/parts/notebook/notebook.contribution.ts @@ -19,6 +19,7 @@ import product from 'vs/platform/product/node/product'; import { registerComponentType } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; import { MimeRendererComponent as MimeRendererComponent } from 'sql/workbench/parts/notebook/outputs/mimeRenderer.component'; import { MarkdownOutputComponent } from 'sql/workbench/parts/notebook/outputs/markdownOutput.component'; +import { GridOutputComponent } from 'sql/workbench/parts/notebook/outputs/gridOutput.component'; import { PlotlyOutputComponent } from 'sql/workbench/parts/notebook/outputs/plotlyOutput.component'; // Model View editor registration @@ -126,16 +127,31 @@ registerComponentType({ * A mime renderer component for grid data. * This will be replaced by a dedicated component in the future */ -registerComponentType({ - mimeTypes: [ - 'application/vnd.dataresource+json', - 'application/vnd.dataresource' - ], - rank: 40, - safe: true, - ctor: MimeRendererComponent, - selector: MimeRendererComponent.SELECTOR -}); +if (product.quality !== 'stable') { + registerComponentType({ + mimeTypes: [ + 'application/vnd.dataresource+json', + 'application/vnd.dataresource' + ], + rank: 40, + safe: true, + ctor: GridOutputComponent, + selector: GridOutputComponent.SELECTOR + }); +} else { + // Default to existing grid view until we're sure the new + // implementation is fully stable + registerComponentType({ + mimeTypes: [ + 'application/vnd.dataresource+json', + 'application/vnd.dataresource' + ], + rank: 40, + safe: true, + ctor: MimeRendererComponent, + selector: MimeRendererComponent.SELECTOR + }); +} /** * A mime renderer component for LaTeX. diff --git a/src/sql/workbench/parts/notebook/notebookStyles.ts b/src/sql/workbench/parts/notebook/notebookStyles.ts index 1c61b06a89..7d9122688f 100644 --- a/src/sql/workbench/parts/notebook/notebookStyles.ts +++ b/src/sql/workbench/parts/notebook/notebookStyles.ts @@ -9,8 +9,12 @@ import { SIDE_BAR_BACKGROUND, SIDE_BAR_SECTION_HEADER_BACKGROUND, EDITOR_GROUP_H import { activeContrastBorder, contrastBorder, buttonBackground, textLinkForeground, textLinkActiveForeground, textPreformatForeground, textBlockQuoteBackground, textBlockQuoteBorder } from 'vs/platform/theme/common/colorRegistry'; import { IDisposable } from 'vscode-xterm'; import { editorLineHighlight, editorLineHighlightBorder } from 'vs/editor/common/view/editorColorRegistry'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { BareResultsGridInfo, getBareResultsGridInfoStyles } from 'sql/workbench/parts/query/browser/queryResultsEditor'; +import { getZoomLevel } from 'vs/base/browser/browser'; +import * as types from 'vs/base/common/types'; -export function registerNotebookThemes(overrideEditorThemeSetting: boolean): IDisposable { +export function registerNotebookThemes(overrideEditorThemeSetting: boolean, configurationService: IConfigurationService): IDisposable { return registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { let lightBoxShadow = '0px 4px 6px 0px rgba(0, 0, 0, 0.14)'; @@ -231,5 +235,23 @@ export function registerNotebookThemes(overrideEditorThemeSetting: boolean): IDi } `); } + + // Results grid options. Putting these here since query editor only adds them on query editor load. + // We may want to remove from query editor as it can just live here and be loaded once, instead of once + // per editor group which is inefficient + let rawOptions = BareResultsGridInfo.createFromRawSettings(configurationService.getValue('resultsGrid'), getZoomLevel()); + + let cssRuleText = ''; + if (types.isNumber(rawOptions.cellPadding)) { + cssRuleText = rawOptions.cellPadding + 'px'; + } else { + cssRuleText = rawOptions.cellPadding.join('px ') + 'px;'; + } + collector.addRule(`.grid-panel .monaco-table .slick-cell { + padding: ${cssRuleText} + } + .grid-panel .monaco-table, .message-tree { + ${getBareResultsGridInfoStyles(rawOptions)} + }`); }); } \ No newline at end of file diff --git a/src/sql/workbench/parts/notebook/outputs/gridOutput.component.ts b/src/sql/workbench/parts/notebook/outputs/gridOutput.component.ts new file mode 100644 index 0000000000..7dfbcbaae8 --- /dev/null +++ b/src/sql/workbench/parts/notebook/outputs/gridOutput.component.ts @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OnInit, Component, Input, Inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import * as azdata from 'azdata'; + +import { AngularDisposable } from 'sql/base/node/lifecycle'; +import { IMimeComponent } from 'sql/workbench/parts/notebook/outputs/mimeRegistry'; +import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel'; +import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'; +import { GridTableBase, GridTableState } from 'sql/workbench/parts/query/electron-browser/gridPanel'; +import { IGridDataProvider, getResultsString } from 'sql/platform/query/common/gridDataProvider'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { SaveFormat } from 'sql/workbench/parts/grid/common/interfaces'; +import { IDataResource } from 'sql/workbench/services/notebook/sql/sqlSessionManager'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/resourceConfiguration'; +import { getEolString, shouldIncludeHeaders, shouldRemoveNewLines } from 'sql/platform/query/common/queryRunner'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { attachTableStyler } from 'sql/platform/theme/common/styler'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { getErrorMessage } from 'sql/workbench/parts/notebook/notebookUtils'; +import { localize } from 'vs/nls'; +import { IAction } from 'vs/base/common/actions'; + +@Component({ + selector: GridOutputComponent.SELECTOR, + template: `
` +}) +export class GridOutputComponent extends AngularDisposable implements IMimeComponent, OnInit { + public static readonly SELECTOR: string = 'grid-output'; + + @ViewChild('output', { read: ElementRef }) private output: ElementRef; + + private _initialized: boolean = false; + private _cellModel: ICellModel; + private _bundleOptions: MimeModel.IOptions; + private _table: DataResourceTable; + constructor( + @Inject(IInstantiationService) private instantiationService: IInstantiationService, + @Inject(IThemeService) private readonly themeService: IThemeService + ) { + super(); + } + + @Input() set bundleOptions(value: MimeModel.IOptions) { + this._bundleOptions = value; + if (this._initialized) { + this.renderGrid(); + } + } + + @Input() mimeType: string; + + get cellModel(): ICellModel { + return this._cellModel; + } + + @Input() set cellModel(value: ICellModel) { + this._cellModel = value; + if (this._initialized) { + this.renderGrid(); + } + } + + ngOnInit() { + this.renderGrid(); + } + + renderGrid(): void { + if (!this._bundleOptions || !this._cellModel || !this.mimeType) { + return; + } + if (!this._table) { + let source = this._bundleOptions.data[this.mimeType]; + let state = new GridTableState(0, 0); + this._table = this.instantiationService.createInstance(DataResourceTable, source, this.cellModel.notebookModel.notebookUri.toString(), state); + let outputElement = this.output.nativeElement; + outputElement.appendChild(this._table.element); + this._register(attachTableStyler(this._table, this.themeService)); + this.layout(); + this._table.onAdd(); + this._initialized = true; + } + } + + layout(): void { + if (this._table) { + let maxSize = Math.min(this._table.maximumSize, 500); + this._table.layout(maxSize); + } + } +} + +class DataResourceTable extends GridTableBase { + + private _gridDataProvider: IGridDataProvider; + + constructor(source: IDataResource, + documentUri: string, + state: GridTableState, + @IContextMenuService contextMenuService: IContextMenuService, + @IInstantiationService instantiationService: IInstantiationService, + @IEditorService editorService: IEditorService, + @IUntitledEditorService untitledEditorService: IUntitledEditorService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(state, createResultSet(source), contextMenuService, instantiationService, editorService, untitledEditorService, configurationService); + this._gridDataProvider = this.instantiationService.createInstance(DataResourceDataProvider, source, this.resultSet, documentUri); + } + + get gridDataProvider(): IGridDataProvider { + return this._gridDataProvider; + } + + protected getCurrentActions(): IAction[] { + return []; + } + + protected getContextActions(): IAction[] { + return []; + } + + public get maximumSize(): number { + // Overriding action bar size calculation for now. + // When we add this back in, we should update this calculation + return Math.max(this.maxSize, /* ACTIONBAR_HEIGHT + BOTTOM_PADDING */ 0); + } +} + +class DataResourceDataProvider implements IGridDataProvider { + private rows: azdata.DbCellValue[][]; + constructor(source: IDataResource, + private resultSet: azdata.ResultSetSummary, + private documentUri: string, + @INotificationService private _notificationService: INotificationService, + @IClipboardService private _clipboardService: IClipboardService, + @IConfigurationService private _configurationService: IConfigurationService, + @ITextResourcePropertiesService private _textResourcePropertiesService: ITextResourcePropertiesService + ) { + this.transformSource(source); + } + + private transformSource(source: IDataResource): void { + this.rows = source.data.map(row => { + let rowData: azdata.DbCellValue[] = []; + Object.keys(row).forEach((val, index) => { + let displayValue = String(Object.values(row)[index]); + // Since the columns[0] represents the row number, start at 1 + rowData.push({ + displayValue: displayValue, + isNull: false, + invariantCultureDisplayValue: displayValue + }); + }); + return rowData; + }); + } + + getRowData(rowStart: number, numberOfRows: number): Thenable { + let rowEnd = rowStart + numberOfRows; + if (rowEnd > this.rows.length) { + rowEnd = this.rows.length; + } + let resultSubset: azdata.QueryExecuteSubsetResult = { + message: undefined, + resultSubset: { + rowCount: rowEnd - rowStart, + rows: this.rows.slice(rowStart, rowEnd) + } + }; + return Promise.resolve(resultSubset); + } + + copyResults(selection: Slick.Range[], includeHeaders?: boolean): void { + this.copyResultsAsync(selection, includeHeaders); + } + + private async copyResultsAsync(selection: Slick.Range[], includeHeaders?: boolean): Promise { + try { + let results = await getResultsString(this, selection, includeHeaders); + this._clipboardService.writeText(results); + } catch (error) { + this._notificationService.error(localize('copyFailed', "Copy failed with error {0}", getErrorMessage(error))); + } + } + + getEolString(): string { + return getEolString(this._textResourcePropertiesService, this.documentUri); + } + shouldIncludeHeaders(includeHeaders: boolean): boolean { + return shouldIncludeHeaders(includeHeaders, this._configurationService); + } + shouldRemoveNewLines(): boolean { + return shouldRemoveNewLines(this._configurationService); + } + + getColumnHeaders(range: Slick.Range): string[] { + let headers: string[] = this.resultSet.columnInfo.slice(range.fromCell, range.toCell + 1).map((info, i) => { + return info.columnName; + }); + return headers; + } + + get canSerialize(): boolean { + return false; + } + + serializeResults(format: SaveFormat, selection: Slick.Range[]): Thenable { + throw new Error('Method not implemented.'); + } +} + +function createResultSet(source: IDataResource): azdata.ResultSetSummary { + let columnInfo: azdata.IDbColumn[] = source.schema.fields.map(field => { + let column = new SimpleDbColumn(field.name); + if (field.type) { + switch (field.type) { + case 'xml': + column.isXml = true; + break; + case 'json': + column.isJson = true; + break; + default: + // Only handling a few cases for now + break; + } + } + return column; + }); + let summary: azdata.ResultSetSummary = { + batchId: 0, + id: 0, + complete: true, + rowCount: source.data.length, + columnInfo: columnInfo + }; + return summary; +} + +class SimpleDbColumn implements azdata.IDbColumn { + + constructor(columnName: string) { + this.columnName = columnName; + } + allowDBNull?: boolean; + baseCatalogName: string; + baseColumnName: string; + baseSchemaName: string; + baseServerName: string; + baseTableName: string; + columnName: string; + columnOrdinal?: number; + columnSize?: number; + isAliased?: boolean; + isAutoIncrement?: boolean; + isExpression?: boolean; + isHidden?: boolean; + isIdentity?: boolean; + isKey?: boolean; + isBytes?: boolean; + isChars?: boolean; + isSqlVariant?: boolean; + isUdt?: boolean; + dataType: string; + isXml?: boolean; + isJson?: boolean; + isLong?: boolean; + isReadOnly?: boolean; + isUnique?: boolean; + numericPrecision?: number; + numericScale?: number; + udtAssemblyQualifiedName: string; + dataTypeName: string; +} diff --git a/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts index a72a67f2ad..9c09cac652 100644 --- a/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts +++ b/src/sql/workbench/parts/notebook/outputs/markdownOutput.component.ts @@ -66,6 +66,9 @@ export class MarkdownOutputComponent extends AngularDisposable implements IMimeC @Input() set cellModel(value: ICellModel) { this._cellModel = value; + if (this._initialized) { + this.updatePreview(); + } } public get isTrusted(): boolean { diff --git a/src/sql/workbench/parts/notebook/outputs/tableRenderers.ts b/src/sql/workbench/parts/notebook/outputs/tableRenderers.ts index e19d463f23..b776a46f93 100644 --- a/src/sql/workbench/parts/notebook/outputs/tableRenderers.ts +++ b/src/sql/workbench/parts/notebook/outputs/tableRenderers.ts @@ -85,7 +85,7 @@ export function renderDataResource( } // SlickGrid requires columns and data to be in a very specific format; this code was adapted from tableInsight.component.ts -function transformData(rows: any[], columns: Slick.Column[]): { [key: string]: string }[] { +export function transformData(rows: any[], columns: Slick.Column[]): { [key: string]: string }[] { return rows.map(row => { let dataWithSchema = {}; Object.keys(row).forEach((val, index) => { @@ -101,7 +101,7 @@ function transformData(rows: any[], columns: Slick.Column[]): { [key: strin }); } -function transformColumns(columns: string[]): Slick.Column[] { +export function transformColumns(columns: string[]): Slick.Column[] { return columns.map((col, index) => { return >{ name: col, diff --git a/src/sql/workbench/parts/query/browser/actions.ts b/src/sql/workbench/parts/query/browser/actions.ts index 21c617c4ce..8a88a82038 100644 --- a/src/sql/workbench/parts/query/browser/actions.ts +++ b/src/sql/workbench/parts/query/browser/actions.ts @@ -9,7 +9,6 @@ import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService import { ITree } from 'vs/base/parts/tree/browser/tree'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import QueryRunner from 'sql/platform/query/common/queryRunner'; import { SaveFormat } from 'sql/workbench/parts/grid/common/interfaces'; import { Table } from 'sql/base/browser/ui/table/table'; import { GridTableState } from 'sql/workbench/parts/query/electron-browser/gridPanel'; @@ -17,16 +16,18 @@ import { QueryEditor } from './queryEditor'; import { CellSelectionModel } from 'sql/base/browser/ui/table/plugins/cellSelectionModel.plugin'; import { isWindows } from 'vs/base/common/platform'; import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; +import { IGridDataProvider } from 'sql/platform/query/common/gridDataProvider'; +import { INotificationService } from 'vs/platform/notification/common/notification'; export interface IGridActionContext { - cell: { row: number; cell: number; }; - selection: Slick.Range[]; - runner: QueryRunner; + gridDataProvider: IGridDataProvider; + table: Table; + tableState: GridTableState; + cell?: { row: number; cell: number; }; + selection?: Slick.Range[]; + selectionModel?: CellSelectionModel; batchId: number; resultId: number; - table: Table; - selectionModel: CellSelectionModel; - tableState: GridTableState; } export interface IMessagesActionContext { @@ -64,19 +65,17 @@ export class SaveResultAction extends Action { label: string, icon: string, private format: SaveFormat, - private accountForNumberColumn = true + @INotificationService private notificationService: INotificationService ) { super(id, label, icon); } - public run(context: IGridActionContext): Promise { - if (this.accountForNumberColumn) { - context.runner.serializeResults(context.batchId, context.resultId, this.format, - mapForNumberColumn(context.selection)); - } else { - context.runner.serializeResults(context.batchId, context.resultId, this.format, context.selection); + public async run(context: IGridActionContext): Promise { + if (!context.gridDataProvider.canSerialize) { + this.notificationService.warn(localize('saveToFileNotSupported', "Save to file is not supported by the backing data source")); } - return Promise.resolve(true); + await context.gridDataProvider.serializeResults(this.format, mapForNumberColumn(context.selection)); + return true; } } @@ -98,11 +97,11 @@ export class CopyResultAction extends Action { public run(context: IGridActionContext): Promise { if (this.accountForNumberColumn) { - context.runner.copyResults( + context.gridDataProvider.copyResults( mapForNumberColumn(context.selection), - context.batchId, context.resultId, this.copyHeader); + this.copyHeader); } else { - context.runner.copyResults(context.selection, context.batchId, context.resultId, this.copyHeader); + context.gridDataProvider.copyResults(context.selection, this.copyHeader); } return Promise.resolve(true); } diff --git a/src/sql/workbench/parts/query/browser/queryResultsEditor.ts b/src/sql/workbench/parts/query/browser/queryResultsEditor.ts index ad1a99c5dd..4d3dd8e768 100644 --- a/src/sql/workbench/parts/query/browser/queryResultsEditor.ts +++ b/src/sql/workbench/parts/query/browser/queryResultsEditor.ts @@ -59,7 +59,7 @@ export class BareResultsGridInfo extends BareFontInfo { } } -function getBareResultsGridInfoStyles(info: BareResultsGridInfo): string { +export function getBareResultsGridInfoStyles(info: BareResultsGridInfo): string { let content = ''; if (info.fontFamily) { content += `font-family: ${info.fontFamily};`; diff --git a/src/sql/workbench/parts/query/electron-browser/gridPanel.ts b/src/sql/workbench/parts/query/electron-browser/gridPanel.ts index b55a95ab3f..cf7f7fdb69 100644 --- a/src/sql/workbench/parts/query/electron-browser/gridPanel.ts +++ b/src/sql/workbench/parts/query/electron-browser/gridPanel.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { attachTableStyler } from 'sql/platform/theme/common/styler'; -import QueryRunner from 'sql/platform/query/common/queryRunner'; +import QueryRunner, { QueryGridDataProvider } from 'sql/platform/query/common/queryRunner'; import { VirtualizedCollection, AsyncDataProvider } from 'sql/base/browser/ui/table/asyncDataView'; import { Table } from 'sql/base/browser/ui/table/table'; import { ScrollableSplitView, IView } from 'sql/base/browser/ui/scrollableSplitview/scrollableSplitview'; @@ -39,6 +39,8 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic import { IAction } from 'vs/base/common/actions'; import { ScrollbarVisibility } from 'vs/base/common/scrollable'; import { ILogService } from 'vs/platform/log/common/log'; +import { localize } from 'vs/nls'; +import { IGridDataProvider } from 'sql/platform/query/common/gridDataProvider'; import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format'; import { CancellationToken } from 'vs/base/common/cancellation'; @@ -362,7 +364,12 @@ export class GridPanel { } } -class GridTable extends Disposable implements IView { +export interface IDataSet { + rowCount: number; + columnInfo: azdata.IDbColumn[]; +} + +export abstract class GridTableBase extends Disposable implements IView { private table: Table; private actionBar: ActionBar; private container = document.createElement('div'); @@ -390,24 +397,19 @@ class GridTable extends Disposable implements IView { public isOnlyTable: boolean = true; - public get resultSet(): azdata.ResultSetSummary { - return this._resultSet; - } - // this handles if the row count is small, like 4-5 rows - private get maxSize(): number { + protected get maxSize(): number { return ((this.resultSet.rowCount) * this.rowHeight) + HEADER_HEIGHT + ESTIMATED_SCROLL_BAR_HEIGHT; } constructor( - private runner: QueryRunner, - private _resultSet: azdata.ResultSetSummary, state: GridTableState, - @IContextMenuService private contextMenuService: IContextMenuService, - @IInstantiationService private instantiationService: IInstantiationService, - @IEditorService private editorService: IEditorService, - @IUntitledEditorService private untitledEditorService: IUntitledEditorService, - @IConfigurationService private configurationService: IConfigurationService + protected _resultSet: azdata.ResultSetSummary, + protected contextMenuService: IContextMenuService, + protected instantiationService: IInstantiationService, + protected editorService: IEditorService, + protected untitledEditorService: IUntitledEditorService, + protected configurationService: IConfigurationService ) { super(); let config = this.configurationService.getValue<{ rowHeight: number }>('resultsGrid'); @@ -423,7 +425,7 @@ class GridTable extends Disposable implements IView { return >{ id: i.toString(), name: c.columnName === 'Microsoft SQL Server 2005 XML Showplan' - ? 'XML Showplan' + ? localize('xmlShowplan', "XML Showplan") : escape(c.columnName), field: i.toString(), formatter: isLinked ? hyperLinkFormatter : textFormatter, @@ -432,6 +434,12 @@ class GridTable extends Disposable implements IView { }); } + abstract get gridDataProvider(): IGridDataProvider; + + public get resultSet(): azdata.ResultSetSummary { + return this._resultSet; + } + public onAdd() { this.visible = true; let collection = new VirtualizedCollection( @@ -505,28 +513,27 @@ class GridTable extends Disposable implements IView { this.table.style(this.styles); } - let actions = this.getCurrentActions(); - let actionBarContainer = document.createElement('div'); actionBarContainer.style.width = ACTIONBAR_WIDTH + 'px'; actionBarContainer.style.display = 'inline-block'; actionBarContainer.style.height = '100%'; actionBarContainer.style.verticalAlign = 'top'; this.container.appendChild(actionBarContainer); + let context: IGridActionContext = { + gridDataProvider: this.gridDataProvider, + table: this.table, + tableState: this.state, + batchId: this.resultSet.batchId, + resultId: this.resultSet.id + }; this.actionBar = new ActionBar(actionBarContainer, { - orientation: ActionsOrientation.VERTICAL, context: { - runner: this.runner, - batchId: this.resultSet.batchId, - resultId: this.resultSet.id, - table: this.table, - tableState: this.state - } + orientation: ActionsOrientation.VERTICAL, context: context }); // update context before we run an action this.selectionModel.onSelectedRangesChanged.subscribe(e => { this.actionBar.context = this.generateContext(); }); - this.actionBar.push(actions, { icon: true, label: false }); + this.rebuildActionBar(); this.selectionModel.onSelectedRangesChanged.subscribe(e => { if (this.state) { @@ -605,7 +612,7 @@ class GridTable extends Disposable implements IView { let column = this.resultSet.columnInfo[event.cell.cell - 1]; // handle if a showplan link was clicked if (column && (column.isXml || column.isJson)) { - this.runner.getQueryRows(event.cell.row, 1, this.resultSet.batchId, this.resultSet.id).then(async d => { + this.gridDataProvider.getRowData(event.cell.row, 1).then(async d => { let value = d.resultSubset.rows[0][event.cell.cell - 1]; let content = value.displayValue; @@ -631,9 +638,7 @@ class GridTable extends Disposable implements IView { return { cell, selection, - runner: this.runner, - batchId: this.resultSet.batchId, - resultId: this.resultSet.id, + gridDataProvider: this.gridDataProvider, table: this.table, tableState: this.state, selectionModel: this.selectionModel @@ -646,28 +651,9 @@ class GridTable extends Disposable implements IView { this.actionBar.push(actions, { icon: true, label: false }); } - private getCurrentActions(): IAction[] { + protected abstract getCurrentActions(): IAction[]; - let actions = []; - - if (this.state.canBeMaximized) { - if (this.state.maximized) { - actions.splice(1, 0, new RestoreTableAction()); - } else { - actions.splice(1, 0, new MaximizeTableAction()); - } - } - - actions.push( - new SaveResultAction(SaveResultAction.SAVECSV_ID, SaveResultAction.SAVECSV_LABEL, SaveResultAction.SAVECSV_ICON, SaveFormat.CSV), - new SaveResultAction(SaveResultAction.SAVEEXCEL_ID, SaveResultAction.SAVEEXCEL_LABEL, SaveResultAction.SAVEEXCEL_ICON, SaveFormat.EXCEL), - new SaveResultAction(SaveResultAction.SAVEJSON_ID, SaveResultAction.SAVEJSON_LABEL, SaveResultAction.SAVEJSON_ICON, SaveFormat.JSON), - new SaveResultAction(SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML), - this.instantiationService.createInstance(ChartDataAction) - ); - - return actions; - } + protected abstract getContextActions(): IAction[]; public layout(size?: number): void { if (!this.table) { @@ -692,7 +678,7 @@ class GridTable extends Disposable implements IView { } private loadData(offset: number, count: number): Thenable { - return this.runner.getQueryRows(offset, count, this.resultSet.batchId, this.resultSet.id).then(response => { + return this.gridDataProvider.getRowData(offset, count).then(response => { if (!response.resultSubset) { return []; } @@ -716,17 +702,19 @@ class GridTable extends Disposable implements IView { this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, getActions: () => { - let actions = [ + let actions: IAction[] = [ new SelectAllGridAction(), - new Separator(), - new SaveResultAction(SaveResultAction.SAVECSV_ID, SaveResultAction.SAVECSV_LABEL, SaveResultAction.SAVECSV_ICON, SaveFormat.CSV), - new SaveResultAction(SaveResultAction.SAVEEXCEL_ID, SaveResultAction.SAVEEXCEL_LABEL, SaveResultAction.SAVEEXCEL_ICON, SaveFormat.EXCEL), - new SaveResultAction(SaveResultAction.SAVEJSON_ID, SaveResultAction.SAVEJSON_LABEL, SaveResultAction.SAVEJSON_ICON, SaveFormat.JSON), - new SaveResultAction(SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML), - new Separator(), + new Separator() + ]; + let contributedActions: IAction[] = this.getContextActions(); + if (contributedActions && contributedActions.length > 0) { + actions.push(...contributedActions); + actions.push(new Separator()); + } + actions.push( new CopyResultAction(CopyResultAction.COPY_ID, CopyResultAction.COPY_LABEL, false), new CopyResultAction(CopyResultAction.COPYWITHHEADERS_ID, CopyResultAction.COPYWITHHEADERS_LABEL, true) - ]; + ); if (this.state.canBeMaximized) { if (this.state.maximized) { @@ -787,3 +775,56 @@ class GridTable extends Disposable implements IView { super.dispose(); } } + +class GridTable extends GridTableBase { + private _gridDataProvider: IGridDataProvider; + constructor( + runner: QueryRunner, + resultSet: azdata.ResultSetSummary, + state: GridTableState, + @IContextMenuService contextMenuService: IContextMenuService, + @IInstantiationService instantiationService: IInstantiationService, + @IEditorService editorService: IEditorService, + @IUntitledEditorService untitledEditorService: IUntitledEditorService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(state, resultSet, contextMenuService, instantiationService, editorService, untitledEditorService, configurationService); + this._gridDataProvider = this.instantiationService.createInstance(QueryGridDataProvider, runner, resultSet.batchId, resultSet.id); + } + + get gridDataProvider(): IGridDataProvider { + return this._gridDataProvider; + } + + protected getCurrentActions(): IAction[] { + + let actions = []; + + if (this.state.canBeMaximized) { + if (this.state.maximized) { + actions.splice(1, 0, new RestoreTableAction()); + } else { + actions.splice(1, 0, new MaximizeTableAction()); + } + } + + actions.push( + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVECSV_ID, SaveResultAction.SAVECSV_LABEL, SaveResultAction.SAVECSV_ICON, SaveFormat.CSV), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEEXCEL_ID, SaveResultAction.SAVEEXCEL_LABEL, SaveResultAction.SAVEEXCEL_ICON, SaveFormat.EXCEL), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEJSON_ID, SaveResultAction.SAVEJSON_LABEL, SaveResultAction.SAVEJSON_ICON, SaveFormat.JSON), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML), + this.instantiationService.createInstance(ChartDataAction) + ); + + return actions; + } + + protected getContextActions(): IAction[] { + return [ + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVECSV_ID, SaveResultAction.SAVECSV_LABEL, SaveResultAction.SAVECSV_ICON, SaveFormat.CSV), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEEXCEL_ID, SaveResultAction.SAVEEXCEL_LABEL, SaveResultAction.SAVEEXCEL_ICON, SaveFormat.EXCEL), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEJSON_ID, SaveResultAction.SAVEJSON_LABEL, SaveResultAction.SAVEJSON_ICON, SaveFormat.JSON), + this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML), + ]; + } +} diff --git a/src/sql/workbench/services/notebook/common/notebookServiceImpl.ts b/src/sql/workbench/services/notebook/common/notebookServiceImpl.ts index b9eab3af04..afe7130d48 100644 --- a/src/sql/workbench/services/notebook/common/notebookServiceImpl.ts +++ b/src/sql/workbench/services/notebook/common/notebookServiceImpl.ts @@ -195,7 +195,8 @@ export class NotebookService extends Disposable implements INotebookService { if (this._configurationService) { this.updateNotebookThemes(); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(OVERRIDE_EDITOR_THEMING_SETTING)) { + if (e.affectsConfiguration(OVERRIDE_EDITOR_THEMING_SETTING) + || e.affectsConfiguration('resultsGrid')) { this.updateNotebookThemes(); } })); @@ -229,7 +230,7 @@ export class NotebookService extends Disposable implements INotebookService { this._themeParticipant.dispose(); } this._overrideEditorThemeSetting = overrideEditorSetting; - this._themeParticipant = registerNotebookThemes(overrideEditorSetting); + this._themeParticipant = registerNotebookThemes(overrideEditorSetting, this._configurationService); } }