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); } }