mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-17 01:25:36 -05:00
* Adds functionality to copy table column headers * Minor clean up * Code review changes * Removes unneeded comma
997 lines
37 KiB
TypeScript
997 lines
37 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import 'vs/css!./media/gridPanel';
|
|
|
|
import { ITableStyles, ITableMouseEvent, FilterableColumn } from 'sql/base/browser/ui/table/interfaces';
|
|
import { attachTableFilterStyler, attachTableStyler } from 'sql/platform/theme/common/styler';
|
|
import QueryRunner, { QueryGridDataProvider } from 'sql/workbench/services/query/common/queryRunner';
|
|
import { ResultSetSummary, IColumn, ICellValue } from 'sql/workbench/services/query/common/query';
|
|
import { VirtualizedCollection } from 'sql/base/browser/ui/table/asyncDataView';
|
|
import { Table } from 'sql/base/browser/ui/table/table';
|
|
import { MouseWheelSupport } from 'sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin';
|
|
import { AutoColumnSize } from 'sql/base/browser/ui/table/plugins/autoSizeColumns.plugin';
|
|
import { IGridActionContext, SaveResultAction, CopyResultAction, SelectAllGridAction, MaximizeTableAction, RestoreTableAction, ChartDataAction, VisualizerDataAction, CopyHeadersAction } from 'sql/workbench/contrib/query/browser/actions';
|
|
import { CellSelectionModel } from 'sql/base/browser/ui/table/plugins/cellSelectionModel.plugin';
|
|
import { RowNumberColumn } from 'sql/base/browser/ui/table/plugins/rowNumberColumn.plugin';
|
|
import { escape } from 'sql/base/common/strings';
|
|
import { hyperLinkFormatter, textFormatter } from 'sql/base/browser/ui/table/formatters';
|
|
import { AdditionalKeyBindings } from 'sql/base/browser/ui/table/plugins/additionalKeyBindings.plugin';
|
|
|
|
import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
|
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
|
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
|
import { Emitter, Event } from 'vs/base/common/event';
|
|
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
|
import { isUndefinedOrNull } from 'vs/base/common/types';
|
|
import { Disposable, dispose, DisposableStore } from 'vs/base/common/lifecycle';
|
|
import { range } from 'vs/base/common/arrays';
|
|
import { generateUuid } from 'vs/base/common/uuid';
|
|
import { ActionBar, ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar';
|
|
import { isInDOM, Dimension } from 'vs/base/browser/dom';
|
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
|
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
|
import { IAction, Separator } from 'vs/base/common/actions';
|
|
import { ILogService } from 'vs/platform/log/common/log';
|
|
import { localize } from 'vs/nls';
|
|
import { IGridDataProvider } from 'sql/workbench/services/query/common/gridDataProvider';
|
|
import { CancellationToken } from 'vs/base/common/cancellation';
|
|
import { GridPanelState, GridTableState } from 'sql/workbench/common/editor/query/gridTableState';
|
|
import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
|
|
import { SaveFormat } from 'sql/workbench/services/query/common/resultSerializer';
|
|
import { Progress } from 'vs/platform/progress/common/progress';
|
|
import { ScrollableView, IView } from 'sql/base/browser/ui/scrollableView/scrollableView';
|
|
import { IQueryEditorConfiguration } from 'sql/platform/query/common/query';
|
|
import { Orientation } from 'vs/base/browser/ui/splitview/splitview';
|
|
import { IQueryModelService } from 'sql/workbench/services/query/common/queryModel';
|
|
import { FilterButtonWidth, HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin';
|
|
import { HybridDataProvider } from 'sql/base/browser/ui/table/hybridDataProvider';
|
|
import { INotificationService } from 'vs/platform/notification/common/notification';
|
|
import { alert, status } from 'vs/base/browser/ui/aria/aria';
|
|
import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces';
|
|
import { ExecutionPlanInput } from 'sql/workbench/contrib/executionPlan/common/executionPlanInput';
|
|
import { CopyAction } from 'vs/editor/contrib/clipboard/browser/clipboard';
|
|
import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/browser/format';
|
|
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
|
|
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
|
|
|
|
const ROW_HEIGHT = 29;
|
|
const HEADER_HEIGHT = 26;
|
|
const MIN_GRID_HEIGHT_ROWS = 8;
|
|
const ESTIMATED_SCROLL_BAR_HEIGHT = 15;
|
|
const BOTTOM_PADDING = 15;
|
|
const ACTIONBAR_WIDTH = 36;
|
|
|
|
// minimum height needed to show the full actionbar
|
|
const ACTIONBAR_HEIGHT = 120;
|
|
|
|
// this handles min size if rows is greater than the min grid visible rows
|
|
const MIN_GRID_HEIGHT = (MIN_GRID_HEIGHT_ROWS * ROW_HEIGHT) + HEADER_HEIGHT + ESTIMATED_SCROLL_BAR_HEIGHT;
|
|
|
|
// The regex to check whether a string is a valid JSON string. It is used to determine:
|
|
// 1. whether the cell should be rendered as a hyperlink.
|
|
// 2. when user clicks a cell, whether the cell content should be displayed in a new text editor as json.
|
|
// Based on the requirements, the solution doesn't need to be very accurate, a simple regex is enough since it is more
|
|
// performant than trying to parse the string to object.
|
|
// Regex explaination: after removing the trailing whitespaces and line breaks, the string must start with '[' (to support arrays)
|
|
// or '{', and there must be a '}' or ']' to close it.
|
|
const IsJsonRegex = /^\s*[\{|\[][\S\s]*[\}\]]\s*$/g;
|
|
|
|
export class GridPanel extends Disposable {
|
|
private container = document.createElement('div');
|
|
private scrollableView: ScrollableView;
|
|
private tables: Array<GridTable<any>> = [];
|
|
private tableDisposable = this._register(new DisposableStore());
|
|
private queryRunnerDisposables = this._register(new DisposableStore());
|
|
|
|
private runner: QueryRunner;
|
|
|
|
private maximizedGrid: GridTable<any>;
|
|
private _state: GridPanelState | undefined;
|
|
|
|
constructor(
|
|
@IConfigurationService private readonly configurationService: IConfigurationService,
|
|
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
|
@ILogService private readonly logService: ILogService,
|
|
@IThemeService private readonly themeService: IThemeService,
|
|
) {
|
|
super();
|
|
this.scrollableView = new ScrollableView(this.container);
|
|
this.scrollableView.onDidScroll(e => {
|
|
if (this.state && this.scrollableView.length !== 0) {
|
|
this.state.scrollPosition = e.scrollTop;
|
|
}
|
|
});
|
|
}
|
|
|
|
public render(container: HTMLElement): void {
|
|
this.container.style.width = '100%';
|
|
this.container.style.height = '100%';
|
|
|
|
container.appendChild(this.container);
|
|
}
|
|
|
|
public layout(size: Dimension): void {
|
|
this.scrollableView.layout(size.height, size.width);
|
|
}
|
|
|
|
public focus(): void {
|
|
// will need to add logic to save the focused grid and focus that
|
|
this.tables[0].focus();
|
|
}
|
|
|
|
public set queryRunner(runner: QueryRunner) {
|
|
this.queryRunnerDisposables.clear();
|
|
this.reset();
|
|
this.runner = runner;
|
|
this.queryRunnerDisposables.add(this.runner.onResultSet(this.onResultSet, this));
|
|
this.queryRunnerDisposables.add(this.runner.onResultSetUpdate(this.updateResultSet, this));
|
|
this.queryRunnerDisposables.add(this.runner.onQueryStart(() => {
|
|
status(localize('query.QueryExecutionStarted', "Query execution started."));
|
|
if (this.state) {
|
|
this.state.tableStates = [];
|
|
}
|
|
this.reset();
|
|
}));
|
|
this.queryRunnerDisposables.add(this.runner.onQueryEnd(() => {
|
|
status(localize('query.QueryExecutionEnded', "Query execution completed."));
|
|
}));
|
|
this.queryRunnerDisposables.add(this.runner.onMessage((messages) => {
|
|
if (messages?.find(m => m.isError)) {
|
|
alert(localize('query.QueryErrorOccured', "Error occured while executing the query."));
|
|
}
|
|
}));
|
|
this.addResultSet(this.runner.batchSets.reduce<ResultSetSummary[]>((p, e) => {
|
|
if (this.configurationService.getValue<IQueryEditorConfiguration>('queryEditor').results.streaming) {
|
|
p = p.concat(e.resultSetSummaries ?? []);
|
|
} else {
|
|
p = p.concat(e.resultSetSummaries?.filter(c => c.complete) ?? []);
|
|
}
|
|
return p;
|
|
}, []));
|
|
|
|
if (this.state && this.state.scrollPosition) {
|
|
this.scrollableView.setScrollTop(this.state.scrollPosition);
|
|
}
|
|
}
|
|
|
|
public resetScrollPosition(): void {
|
|
this.scrollableView.setScrollTop(this.state.scrollPosition);
|
|
}
|
|
|
|
private onResultSet(resultSet: ResultSetSummary | ResultSetSummary[]) {
|
|
let resultsToAdd: ResultSetSummary[];
|
|
if (!Array.isArray(resultSet)) {
|
|
resultsToAdd = [resultSet];
|
|
} else {
|
|
resultsToAdd = resultSet.splice(0);
|
|
}
|
|
const sizeChanges = () => {
|
|
this.tables.map(t => {
|
|
t.state.canBeMaximized = this.tables.length > 1;
|
|
});
|
|
|
|
if (this.state && this.state.scrollPosition) {
|
|
this.scrollableView.setScrollTop(this.state.scrollPosition);
|
|
}
|
|
};
|
|
|
|
if (this.configurationService.getValue<IQueryEditorConfiguration>('queryEditor').results.streaming) {
|
|
this.addResultSet(resultsToAdd);
|
|
sizeChanges();
|
|
} else {
|
|
resultsToAdd = resultsToAdd.filter(e => e.complete);
|
|
if (resultsToAdd.length > 0) {
|
|
this.addResultSet(resultsToAdd);
|
|
}
|
|
sizeChanges();
|
|
}
|
|
}
|
|
|
|
private updateResultSet(resultSet: ResultSetSummary | ResultSetSummary[]) {
|
|
let resultsToUpdate: ResultSetSummary[];
|
|
if (!Array.isArray(resultSet)) {
|
|
resultsToUpdate = [resultSet];
|
|
} else {
|
|
resultsToUpdate = resultSet.splice(0);
|
|
}
|
|
|
|
const sizeChanges = () => {
|
|
if (this.state && this.state.scrollPosition) {
|
|
this.scrollableView.setScrollTop(this.state.scrollPosition);
|
|
}
|
|
};
|
|
|
|
if (this.configurationService.getValue<IQueryEditorConfiguration>('queryEditor').results.streaming) {
|
|
for (let set of resultsToUpdate) {
|
|
let table = this.tables.find(t => t.resultSet.batchId === set.batchId && t.resultSet.id === set.id);
|
|
if (table) {
|
|
table.updateResult(set);
|
|
} else {
|
|
this.logService.warn('Got result set update request for non-existant table');
|
|
}
|
|
}
|
|
sizeChanges();
|
|
} else {
|
|
resultsToUpdate = resultsToUpdate.filter(e => e.complete);
|
|
if (resultsToUpdate.length > 0) {
|
|
this.addResultSet(resultsToUpdate);
|
|
}
|
|
sizeChanges();
|
|
}
|
|
}
|
|
|
|
private addResultSet(resultSet: ResultSetSummary[]) {
|
|
const tables: Array<GridTable<any>> = [];
|
|
|
|
for (const set of resultSet) {
|
|
// ensure we aren't adding a resultSet that is already visible
|
|
if (this.tables.find(t => t.resultSet.batchId === set.batchId && t.resultSet.id === set.id)) {
|
|
continue;
|
|
}
|
|
let tableState: GridTableState;
|
|
if (this.state) {
|
|
tableState = this.state.tableStates.find(e => e.batchId === set.batchId && e.resultId === set.id);
|
|
}
|
|
if (!tableState) {
|
|
tableState = new GridTableState(set.id, set.batchId);
|
|
if (this.state) {
|
|
this.state.tableStates.push(tableState);
|
|
}
|
|
}
|
|
const table = this.instantiationService.createInstance(GridTable, this.runner, set, tableState);
|
|
this.tableDisposable.add(tableState.onMaximizedChange(e => {
|
|
if (e) {
|
|
this.maximizeTable(table.id);
|
|
} else {
|
|
this.minimizeTables();
|
|
}
|
|
}));
|
|
this.tableDisposable.add(attachTableStyler(table, this.themeService));
|
|
|
|
tables.push(table);
|
|
}
|
|
|
|
this.tables = this.tables.concat(tables);
|
|
|
|
// turn-off special-case process when only a single table is being displayed
|
|
if (this.tables.length > 1) {
|
|
for (let i = 0; i < this.tables.length; ++i) {
|
|
this.tables[i].isOnlyTable = false;
|
|
}
|
|
}
|
|
|
|
if (isUndefinedOrNull(this.maximizedGrid)) {
|
|
this.scrollableView.addViews(tables);
|
|
}
|
|
}
|
|
|
|
public clear() {
|
|
this.reset();
|
|
this.state = undefined;
|
|
}
|
|
|
|
private reset() {
|
|
this.scrollableView.clear();
|
|
dispose(this.tables);
|
|
this.tableDisposable.clear();
|
|
this.tables = [];
|
|
this.maximizedGrid = undefined;
|
|
}
|
|
|
|
private maximizeTable(tableid: string): void {
|
|
if (!this.tables.find(t => t.id === tableid)) {
|
|
return;
|
|
}
|
|
|
|
for (let i = this.tables.length - 1; i >= 0; i--) {
|
|
if (this.tables[i].id === tableid) {
|
|
const selectedTable = this.tables[i];
|
|
selectedTable.state.maximized = true;
|
|
this.maximizedGrid = selectedTable;
|
|
this.scrollableView.clear();
|
|
this.scrollableView.addViews([selectedTable]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private minimizeTables(): void {
|
|
if (this.maximizedGrid) {
|
|
this.maximizedGrid.state.maximized = false;
|
|
this.maximizedGrid = undefined;
|
|
this.scrollableView.clear();
|
|
this.scrollableView.addViews(this.tables);
|
|
}
|
|
}
|
|
|
|
public set state(val: GridPanelState) {
|
|
this._state = val;
|
|
if (this.state) {
|
|
this.tables.map(t => {
|
|
let state = this.state.tableStates.find(s => s.batchId === t.resultSet.batchId && s.resultId === t.resultSet.id);
|
|
if (!state) {
|
|
this.state.tableStates.push(t.state);
|
|
}
|
|
if (state) {
|
|
t.state = state;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public get state() {
|
|
return this._state;
|
|
}
|
|
|
|
public override dispose() {
|
|
dispose(this.tables);
|
|
this.tables = undefined;
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
export interface IDataSet {
|
|
rowCount: number;
|
|
columnInfo: IColumn[];
|
|
}
|
|
|
|
export interface IGridTableOptions {
|
|
actionOrientation: ActionsOrientation;
|
|
showActionBar?: boolean;
|
|
inMemoryDataProcessing: boolean;
|
|
inMemoryDataCountThreshold?: number;
|
|
}
|
|
|
|
const defaultGridTableOptions: IGridTableOptions = {
|
|
showActionBar: true,
|
|
inMemoryDataProcessing: false,
|
|
actionOrientation: ActionsOrientation.VERTICAL
|
|
};
|
|
|
|
export abstract class GridTableBase<T> extends Disposable implements IView {
|
|
private table: Table<T>;
|
|
private actionBar: ActionBar;
|
|
private container = document.createElement('div');
|
|
private selectionModel = new CellSelectionModel<T>({ hasRowSelector: true });
|
|
private styles: ITableStyles;
|
|
private currentHeight: number;
|
|
private dataProvider: HybridDataProvider<T>;
|
|
private filterPlugin: HeaderFilter<T>;
|
|
private isDisposed: boolean = false;
|
|
|
|
private columns: Slick.Column<T>[];
|
|
|
|
private rowNumberColumn: RowNumberColumn<T>;
|
|
|
|
private _onDidChange = new Emitter<number>();
|
|
public readonly onDidChange: Event<number> = this._onDidChange.event;
|
|
|
|
public id = generateUuid();
|
|
readonly element: HTMLElement = this.container;
|
|
protected tableContainer: HTMLElement;
|
|
|
|
private _state: GridTableState;
|
|
|
|
private visible = false;
|
|
|
|
private rowHeight: number;
|
|
|
|
public isOnlyTable: boolean = true;
|
|
|
|
public providerId: string;
|
|
|
|
// this handles if the row count is small, like 4-5 rows
|
|
protected get maxSize(): number {
|
|
return ((this.resultSet.rowCount) * this.rowHeight) + HEADER_HEIGHT + ESTIMATED_SCROLL_BAR_HEIGHT;
|
|
}
|
|
|
|
public focus(): void {
|
|
if (!this.table.activeCell) {
|
|
this.table.setActiveCell(0, 1);
|
|
this.selectionModel.setSelectedRanges([new Slick.Range(0, 1)]);
|
|
}
|
|
this.table.focus();
|
|
}
|
|
|
|
constructor(
|
|
state: GridTableState,
|
|
protected _resultSet: ResultSetSummary,
|
|
private readonly options: IGridTableOptions,
|
|
@IContextMenuService private readonly contextMenuService: IContextMenuService,
|
|
@IInstantiationService protected readonly instantiationService: IInstantiationService,
|
|
@IEditorService private readonly editorService: IEditorService,
|
|
@IUntitledTextEditorService private readonly untitledEditorService: IUntitledTextEditorService,
|
|
@IConfigurationService protected readonly configurationService: IConfigurationService,
|
|
@IQueryModelService private readonly queryModelService: IQueryModelService,
|
|
@IThemeService private readonly themeService: IThemeService,
|
|
@IContextViewService private readonly contextViewService: IContextViewService,
|
|
@INotificationService private readonly notificationService: INotificationService,
|
|
@IExecutionPlanService private readonly executionPlanService: IExecutionPlanService,
|
|
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
|
|
@IQuickInputService private readonly quickInputService: IQuickInputService
|
|
) {
|
|
super();
|
|
|
|
this.options = { ...defaultGridTableOptions, ...options };
|
|
let config = this.configurationService.getValue<{ rowHeight: number }>('resultsGrid');
|
|
this.rowHeight = config && config.rowHeight ? config.rowHeight : ROW_HEIGHT;
|
|
this.state = state;
|
|
this.container.style.width = '100%';
|
|
this.container.style.height = '100%';
|
|
|
|
this.columns = this.resultSet.columnInfo.map((c, i) => {
|
|
return <Slick.Column<T>>{
|
|
id: i.toString(),
|
|
name: c.columnName === 'Microsoft SQL Server 2005 XML Showplan'
|
|
? localize('xmlShowplan', "XML Showplan")
|
|
: escape(c.columnName),
|
|
field: i.toString(),
|
|
formatter: c.isXml ? hyperLinkFormatter : queryResultTextFormatter,
|
|
width: this.state.columnSizes && this.state.columnSizes[i] ? this.state.columnSizes[i] : undefined
|
|
};
|
|
});
|
|
}
|
|
|
|
abstract get gridDataProvider(): IGridDataProvider;
|
|
|
|
public get resultSet(): ResultSetSummary {
|
|
return this._resultSet;
|
|
}
|
|
|
|
public async onDidInsert() {
|
|
if (this.isDisposed) {
|
|
return;
|
|
}
|
|
if (!this.table) {
|
|
this.build();
|
|
}
|
|
this.visible = true;
|
|
let collection = new VirtualizedCollection(
|
|
50,
|
|
index => this.placeholdGenerator(index),
|
|
this.resultSet.rowCount,
|
|
(offset, count) => this.loadData(offset, count)
|
|
);
|
|
collection.setCollectionChangedCallback((startIndex, count) => {
|
|
this.renderGridDataRowsRange(startIndex, count);
|
|
});
|
|
this.dataProvider.dataRows = collection;
|
|
this.setFilterState();
|
|
this.table.updateRowCount();
|
|
await this.setupState();
|
|
}
|
|
|
|
public onDidRemove() {
|
|
this.visible = false;
|
|
let collection = new VirtualizedCollection(
|
|
50,
|
|
index => this.placeholdGenerator(index),
|
|
0,
|
|
() => Promise.resolve([])
|
|
);
|
|
this.dataProvider.dataRows = collection;
|
|
this.table.updateRowCount();
|
|
}
|
|
|
|
// actionsOrientation controls the orientation (horizontal or vertical) of the actionBar
|
|
private build(): void {
|
|
let actionBarContainer = document.createElement('div');
|
|
|
|
// Create a horizontal actionbar if orientation passed in is HORIZONTAL
|
|
if (this.options.actionOrientation === ActionsOrientation.HORIZONTAL) {
|
|
actionBarContainer.className = 'grid-panel action-bar horizontal';
|
|
this.container.appendChild(actionBarContainer);
|
|
}
|
|
|
|
this.tableContainer = document.createElement('div');
|
|
this.tableContainer.className = 'grid-panel';
|
|
this.tableContainer.style.display = 'inline-block';
|
|
this.tableContainer.style.width = `calc(100% - ${ACTIONBAR_WIDTH}px)`;
|
|
|
|
this.container.appendChild(this.tableContainer);
|
|
|
|
let collection = new VirtualizedCollection(
|
|
50,
|
|
index => this.placeholdGenerator(index),
|
|
0,
|
|
() => Promise.resolve([])
|
|
);
|
|
collection.setCollectionChangedCallback((startIndex, count) => {
|
|
this.renderGridDataRowsRange(startIndex, count);
|
|
});
|
|
this.rowNumberColumn = new RowNumberColumn({ autoCellSelection: false });
|
|
this.columns.unshift(this.rowNumberColumn.getColumnDefinition());
|
|
let tableOptions: Slick.GridOptions<T> = {
|
|
rowHeight: this.rowHeight,
|
|
showRowNumber: true,
|
|
forceFitColumns: false,
|
|
defaultColumnWidth: 120
|
|
};
|
|
this.dataProvider = new HybridDataProvider(collection,
|
|
(offset, count) => { return this.loadData(offset, count); },
|
|
undefined,
|
|
undefined,
|
|
(data: ICellValue) => {
|
|
if (!data || data.isNull) {
|
|
return undefined;
|
|
}
|
|
// If the string only contains whitespaces, it will be treated as empty string to make the filtering easier.
|
|
// Note: this is the display string and does not impact the export/copy features.
|
|
return data.displayValue.trim() === '' ? '' : data.displayValue;
|
|
},
|
|
{
|
|
inMemoryDataProcessing: this.options.inMemoryDataProcessing,
|
|
inMemoryDataCountThreshold: this.options.inMemoryDataCountThreshold
|
|
});
|
|
this.table = this._register(new Table(this.tableContainer, this.accessibilityService, this.quickInputService, { dataProvider: this.dataProvider, columns: this.columns }, tableOptions));
|
|
this.table.setTableTitle(localize('resultsGrid', "Results grid"));
|
|
this.table.setSelectionModel(this.selectionModel);
|
|
this.table.registerPlugin(new MouseWheelSupport());
|
|
const autoSizeOnRender: boolean = !this.state.columnSizes && this.configurationService.getValue('resultsGrid.autoSizeColumns');
|
|
this.table.registerPlugin(new AutoColumnSize({ autoSizeOnRender: autoSizeOnRender, maxWidth: this.configurationService.getValue<number>('resultsGrid.maxColumnWidth'), extraColumnHeaderWidth: FilterButtonWidth }));
|
|
this.table.registerPlugin(this.rowNumberColumn);
|
|
this.table.registerPlugin(new AdditionalKeyBindings());
|
|
this._register(this.dataProvider.onFilterStateChange(() => { this.layout(); }));
|
|
this._register(this.table.onContextMenu(this.contextMenu, this));
|
|
this._register(this.table.onClick(this.onTableClick, this));
|
|
this._register(this.dataProvider.onFilterStateChange(() => {
|
|
const columns = this.table.columns as FilterableColumn<T>[];
|
|
this.state.columnFilters = columns.filter((column) => column.filterValues?.length > 0).map(column => {
|
|
return {
|
|
filterValues: column.filterValues,
|
|
field: column.field
|
|
};
|
|
});
|
|
this.table.rerenderGrid();
|
|
}));
|
|
this._register(this.dataProvider.onSortComplete((args: Slick.OnSortEventArgs<T>) => {
|
|
this.state.sortState = {
|
|
field: args.sortCol.field,
|
|
sortAsc: args.sortAsc
|
|
};
|
|
this.table.rerenderGrid();
|
|
}));
|
|
this.filterPlugin = new HeaderFilter(this.contextViewService, this.notificationService, {
|
|
disabledFilterMessage: localize('resultsGrid.maxRowCountExceeded', "Max row count for filtering/sorting has been exceeded. To update it, navigate to User Settings and change the setting: 'queryEditor.results.inMemoryDataProcessingThreshold'"),
|
|
refreshColumns: !autoSizeOnRender // The auto size columns plugin refreshes the columns so we don't need to refresh twice if both plugins are on.
|
|
});
|
|
this._register(attachTableFilterStyler(this.filterPlugin, this.themeService));
|
|
this.table.registerPlugin(this.filterPlugin);
|
|
if (this.styles) {
|
|
this.table.style(this.styles);
|
|
}
|
|
// If the actionsOrientation passed in is "VERTICAL" (or no actionsOrientation is passed in at all), create a vertical actionBar
|
|
if (this.options.actionOrientation === ActionsOrientation.VERTICAL) {
|
|
actionBarContainer.className = 'grid-panel action-bar vertical';
|
|
actionBarContainer.style.width = ACTIONBAR_WIDTH + 'px';
|
|
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: this.options.actionOrientation, context: context
|
|
});
|
|
// update context before we run an action
|
|
this.selectionModel.onSelectedRangesChanged.subscribe(e => {
|
|
this.actionBar.context = this.generateContext();
|
|
});
|
|
this.rebuildActionBar();
|
|
this.selectionModel.onSelectedRangesChanged.subscribe(async e => {
|
|
if (this.state) {
|
|
this.state.selection = this.selectionModel.getSelectedRanges();
|
|
}
|
|
await this.notifyTableSelectionChanged();
|
|
});
|
|
|
|
this.table.grid.onScroll.subscribe((e, data) => {
|
|
if (!this.visible) {
|
|
// If the grid is not set up yet it can get scroll events resetting the top to 0px,
|
|
// so ignore those events
|
|
return;
|
|
}
|
|
if (this.state && isInDOM(this.container)) {
|
|
this.state.scrollPositionY = data.scrollTop;
|
|
this.state.scrollPositionX = data.scrollLeft;
|
|
}
|
|
});
|
|
|
|
// we need to remove the first column since this is the row number
|
|
this.table.onColumnResize(() => {
|
|
let columnSizes = this.table.grid.getColumns().slice(1).map(v => v.width);
|
|
this.state.columnSizes = columnSizes;
|
|
});
|
|
|
|
this.table.grid.onActiveCellChanged.subscribe(e => {
|
|
if (this.state) {
|
|
this.state.activeCell = this.table.grid.getActiveCell();
|
|
}
|
|
});
|
|
// Add implementation for the copy action to respect the user's global copy command keybinding.
|
|
// 1 is a priority number that is slightly larger than the basic handler's priority 0 to make sure our implementation
|
|
// is executed.
|
|
this._register(CopyAction.addImplementation(1, 'query-result-grid', accessor => {
|
|
const selectedRanges = this.table.getSelectedRanges();
|
|
// Only do copy if the grid is the current active grid.
|
|
if (this.container.contains(document.activeElement) && selectedRanges && selectedRanges.length !== 0) {
|
|
this.instantiationService.createInstance(CopyResultAction, CopyResultAction.COPY_ID, CopyResultAction.COPY_LABEL, false).run(this.generateContext());
|
|
return true;
|
|
}
|
|
return false;
|
|
}));
|
|
}
|
|
|
|
private restoreScrollState() {
|
|
if (this.state.scrollPositionX || this.state.scrollPositionY) {
|
|
this.table.grid.scrollTo(this.state.scrollPositionY);
|
|
this.table.grid.getContainerNode().children[3].scrollLeft = this.state.scrollPositionX;
|
|
}
|
|
}
|
|
|
|
private async setupState() {
|
|
// change actionbar on maximize change
|
|
this._register(this.state.onMaximizedChange(this.rebuildActionBar, this));
|
|
|
|
this._register(this.state.onCanBeMaximizedChange(this.rebuildActionBar, this));
|
|
|
|
this.restoreScrollState();
|
|
|
|
this.rebuildActionBar();
|
|
|
|
// Setting the active cell resets the selection so save it here
|
|
let savedSelection = this.state.selection;
|
|
|
|
if (this.state.activeCell) {
|
|
this.table.setActiveCell(this.state.activeCell.row, this.state.activeCell.cell);
|
|
}
|
|
|
|
if (savedSelection) {
|
|
this.selectionModel.setSelectedRanges(savedSelection);
|
|
}
|
|
|
|
if (this.state.sortState) {
|
|
const sortAsc = this.state.sortState.sortAsc;
|
|
const sortCol = this.columns.find((column) => column.field === this.state.sortState.field);
|
|
this.table.grid.setSortColumn(sortCol.id, sortAsc);
|
|
await this.dataProvider.sort({
|
|
multiColumnSort: false,
|
|
grid: this.table.grid,
|
|
sortAsc: sortAsc,
|
|
sortCol: sortCol
|
|
});
|
|
}
|
|
|
|
if (this.state.columnFilters) {
|
|
this.columns.forEach(column => {
|
|
const idx = this.state.columnFilters.findIndex(filter => filter.field === column.field);
|
|
if (idx !== -1) {
|
|
(<FilterableColumn<T>>column).filterValues = this.state.columnFilters[idx].filterValues;
|
|
}
|
|
});
|
|
await this.dataProvider.filter(this.columns);
|
|
}
|
|
}
|
|
|
|
public get state(): GridTableState {
|
|
return this._state;
|
|
}
|
|
|
|
public set state(val: GridTableState) {
|
|
this._state = val;
|
|
}
|
|
|
|
private async getRowData(start: number, length: number): Promise<ICellValue[][]> {
|
|
let subset;
|
|
if (this.dataProvider.isDataInMemory) {
|
|
// handle the scenario when the data is sorted/filtered,
|
|
// we need to use the data that is being displayed
|
|
const data = await this.dataProvider.getRangeAsync(start, length);
|
|
subset = data.map(item => Object.keys(item).map(key => item[key]));
|
|
} else {
|
|
subset = (await this.gridDataProvider.getRowData(start, length)).rows;
|
|
}
|
|
return subset;
|
|
}
|
|
|
|
private async notifyTableSelectionChanged() {
|
|
const selectedCells = [];
|
|
for (const range of this.state.selection) {
|
|
const subset = await this.getRowData(range.fromRow, range.toRow - range.fromRow + 1);
|
|
subset.forEach(row => {
|
|
// start with range.fromCell -1 because we have row number column which is not available in the actual data
|
|
for (let i = range.fromCell - 1; i < range.toCell; i++) {
|
|
selectedCells.push(row[i]);
|
|
}
|
|
});
|
|
}
|
|
this.queryModelService.notifyCellSelectionChanged(selectedCells);
|
|
}
|
|
|
|
private async onTableClick(event: ITableMouseEvent) {
|
|
// account for not having the number column
|
|
const column = this.resultSet.columnInfo[event.cell.cell - 1];
|
|
// handle if a showplan link was clicked
|
|
if (column) {
|
|
const subset = await this.getRowData(event.cell.row, 1);
|
|
const value = subset[0][event.cell.cell - 1];
|
|
const isJson = isJsonCell(value);
|
|
if (column.isXml || isJson) {
|
|
const result = await this.executionPlanService.isExecutionPlan(this.providerId, value.displayValue);
|
|
if (result.isExecutionPlan) {
|
|
const executionPlanGraphInfo = {
|
|
graphFileContent: value.displayValue,
|
|
graphFileType: result.queryExecutionPlanFileExtension
|
|
};
|
|
|
|
const executionPlanInput = this.instantiationService.createInstance(ExecutionPlanInput, undefined, executionPlanGraphInfo);
|
|
await this.editorService.openEditor(executionPlanInput);
|
|
}
|
|
else {
|
|
const content = value.displayValue;
|
|
const input = this.untitledEditorService.create({ languageId: column.isXml ? 'xml' : 'json', initialValue: content });
|
|
await input.resolve();
|
|
await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, input.textEditorModel, FormattingMode.Explicit, Progress.None, CancellationToken.None);
|
|
input.setDirty(false);
|
|
await this.editorService.openEditor(input);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public updateResult(resultSet: ResultSetSummary) {
|
|
this._resultSet = resultSet;
|
|
if (this.table && this.visible) {
|
|
this.dataProvider.length = resultSet.rowCount;
|
|
this.setFilterState();
|
|
this.table.updateRowCount();
|
|
}
|
|
this._onDidChange.fire(undefined);
|
|
}
|
|
|
|
private setFilterState(): void {
|
|
const rowCount = this.table.getData().getLength();
|
|
this.filterPlugin.enabled = this.options.inMemoryDataProcessing
|
|
&& (this.options.inMemoryDataCountThreshold === undefined || this.options.inMemoryDataCountThreshold >= rowCount);
|
|
}
|
|
|
|
private generateContext(cell?: Slick.Cell): IGridActionContext {
|
|
const selection = this.selectionModel.getSelectedRanges();
|
|
return <IGridActionContext>{
|
|
cell,
|
|
selection,
|
|
gridDataProvider: this.gridDataProvider,
|
|
table: this.table,
|
|
tableState: this.state,
|
|
selectionModel: this.selectionModel
|
|
};
|
|
}
|
|
|
|
private rebuildActionBar() {
|
|
let actions = this.getCurrentActions();
|
|
this.actionBar.clear();
|
|
if (this.options.showActionBar) {
|
|
this.actionBar.push(actions, { icon: true, label: false });
|
|
}
|
|
}
|
|
|
|
public get showActionBar(): boolean {
|
|
return this.options.showActionBar;
|
|
}
|
|
|
|
public set showActionBar(v: boolean) {
|
|
if (this.options.showActionBar !== v) {
|
|
this.options.showActionBar = v;
|
|
this.rebuildActionBar();
|
|
}
|
|
}
|
|
|
|
protected abstract getCurrentActions(): IAction[];
|
|
|
|
protected abstract getContextActions(): IAction[];
|
|
|
|
// The actionsOrientation passed in controls the actionBar orientation
|
|
public layout(size?: number): void {
|
|
if (!size) {
|
|
size = this.currentHeight;
|
|
} else {
|
|
this.currentHeight = size;
|
|
}
|
|
// Table is always called with Orientation as VERTICAL
|
|
this.table?.layout(size, Orientation.VERTICAL);
|
|
}
|
|
|
|
public get minimumSize(): number {
|
|
// clamp between ensuring we can show the actionbar, while also making sure we don't take too much space
|
|
// if there is only one table then allow a minimum size of ROW_HEIGHT
|
|
return this.isOnlyTable ? ROW_HEIGHT : Math.max(Math.min(this.maxSize, MIN_GRID_HEIGHT), ACTIONBAR_HEIGHT + BOTTOM_PADDING);
|
|
}
|
|
|
|
public get maximumSize(): number {
|
|
return Math.max(this.maxSize, ACTIONBAR_HEIGHT + BOTTOM_PADDING);
|
|
}
|
|
|
|
private loadData(offset: number, count: number): Thenable<T[]> {
|
|
return this.gridDataProvider.getRowData(offset, count).then(response => {
|
|
if (!response) {
|
|
return [];
|
|
}
|
|
return response.rows.map(r => {
|
|
let dataWithSchema = {};
|
|
// skip the first column since its a number column
|
|
for (let i = 1; i < this.columns.length; i++) {
|
|
dataWithSchema[this.columns[i].field] = {
|
|
displayValue: r[i - 1].displayValue,
|
|
ariaLabel: escape(r[i - 1].displayValue),
|
|
isNull: r[i - 1].isNull,
|
|
invariantCultureDisplayValue: r[i - 1].invariantCultureDisplayValue
|
|
};
|
|
}
|
|
return dataWithSchema as T;
|
|
});
|
|
});
|
|
}
|
|
|
|
private contextMenu(e: ITableMouseEvent): void {
|
|
const { cell } = e;
|
|
this.contextMenuService.showContextMenu({
|
|
getAnchor: () => e.anchor,
|
|
getActions: () => {
|
|
let actions: IAction[] = [
|
|
new SelectAllGridAction(),
|
|
new Separator()
|
|
];
|
|
let contributedActions: IAction[] = this.getContextActions();
|
|
if (contributedActions && contributedActions.length > 0) {
|
|
actions.push(...contributedActions);
|
|
actions.push(new Separator());
|
|
}
|
|
actions.push(
|
|
this.instantiationService.createInstance(CopyResultAction, CopyResultAction.COPY_ID, CopyResultAction.COPY_LABEL, false),
|
|
this.instantiationService.createInstance(CopyResultAction, CopyResultAction.COPYWITHHEADERS_ID, CopyResultAction.COPYWITHHEADERS_LABEL, true),
|
|
this.instantiationService.createInstance(CopyHeadersAction)
|
|
);
|
|
|
|
if (this.state.canBeMaximized) {
|
|
if (this.state.maximized) {
|
|
actions.splice(1, 0, new RestoreTableAction());
|
|
} else {
|
|
actions.splice(1, 0, new MaximizeTableAction());
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
},
|
|
getActionsContext: () => {
|
|
return this.generateContext(cell);
|
|
}
|
|
});
|
|
}
|
|
|
|
private placeholdGenerator(index: number): any {
|
|
return {};
|
|
}
|
|
|
|
private renderGridDataRowsRange(startIndex: number, count: number): void {
|
|
this.invalidateRange(startIndex, startIndex + count);
|
|
}
|
|
|
|
private invalidateRange(start: number, end: number): void {
|
|
let refreshedRows = range(start, end);
|
|
if (this.table) {
|
|
this.table.invalidateRows(refreshedRows, true);
|
|
}
|
|
}
|
|
|
|
public style(styles: ITableStyles) {
|
|
if (this.table) {
|
|
this.table.style(styles);
|
|
} else {
|
|
this.styles = styles;
|
|
}
|
|
}
|
|
|
|
public override dispose() {
|
|
this.isDisposed = true;
|
|
this.container.remove();
|
|
if (this.table) {
|
|
this.table.dispose();
|
|
}
|
|
if (this.actionBar) {
|
|
this.actionBar.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class GridTable<T> extends GridTableBase<T> {
|
|
private _gridDataProvider: IGridDataProvider;
|
|
constructor(
|
|
private _runner: QueryRunner,
|
|
resultSet: ResultSetSummary,
|
|
state: GridTableState,
|
|
@IContextMenuService contextMenuService: IContextMenuService,
|
|
@IInstantiationService instantiationService: IInstantiationService,
|
|
@IContextKeyService private contextKeyService: IContextKeyService,
|
|
@IEditorService editorService: IEditorService,
|
|
@IUntitledTextEditorService untitledEditorService: IUntitledTextEditorService,
|
|
@IConfigurationService configurationService: IConfigurationService,
|
|
@IQueryModelService queryModelService: IQueryModelService,
|
|
@IThemeService themeService: IThemeService,
|
|
@IContextViewService contextViewService: IContextViewService,
|
|
@INotificationService notificationService: INotificationService,
|
|
@IExecutionPlanService executionPlanService: IExecutionPlanService,
|
|
@IAccessibilityService accessibilityService: IAccessibilityService,
|
|
@IQuickInputService quickInputService: IQuickInputService
|
|
) {
|
|
super(state, resultSet, {
|
|
actionOrientation: ActionsOrientation.VERTICAL,
|
|
inMemoryDataProcessing: true,
|
|
showActionBar: true,
|
|
inMemoryDataCountThreshold: configurationService.getValue<IQueryEditorConfiguration>('queryEditor').results.inMemoryDataProcessingThreshold,
|
|
}, contextMenuService, instantiationService, editorService, untitledEditorService, configurationService, queryModelService, themeService, contextViewService, notificationService, executionPlanService, accessibilityService, quickInputService);
|
|
this._gridDataProvider = this.instantiationService.createInstance(QueryGridDataProvider, this._runner, resultSet.batchId, resultSet.id);
|
|
this.providerId = this._runner.getProviderId();
|
|
}
|
|
|
|
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.SAVEMARKDOWN_ID, SaveResultAction.SAVEMARKDOWN_LABEL, SaveResultAction.SAVEMARKDOWN_ICON, SaveFormat.MARKDOWN),
|
|
this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML),
|
|
this.instantiationService.createInstance(ChartDataAction)
|
|
);
|
|
|
|
if (this.contextKeyService.getContextKeyValue('showVisualizer')) {
|
|
actions.push(this.instantiationService.createInstance(VisualizerDataAction, this._runner));
|
|
}
|
|
|
|
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.SAVEMARKDOWN_ID, SaveResultAction.SAVEMARKDOWN_LABEL, SaveResultAction.SAVEMARKDOWN_ICON, SaveFormat.MARKDOWN),
|
|
this.instantiationService.createInstance(SaveResultAction, SaveResultAction.SAVEXML_ID, SaveResultAction.SAVEXML_LABEL, SaveResultAction.SAVEXML_ICON, SaveFormat.XML),
|
|
];
|
|
}
|
|
}
|
|
|
|
function isJsonCell(value: ICellValue): boolean {
|
|
return !!(value && !value.isNull && value.displayValue?.match(IsJsonRegex));
|
|
}
|
|
|
|
function queryResultTextFormatter(row: number | undefined, cell: any | undefined, value: ICellValue, columnDef: any | undefined, dataContext: any | undefined): string {
|
|
if (isJsonCell(value)) {
|
|
return hyperLinkFormatter(row, cell, value, columnDef, dataContext);
|
|
} else {
|
|
return textFormatter(row, cell, value, columnDef, dataContext);
|
|
}
|
|
}
|