From 991aa836599f276dc5aa49efff657bb445e5362a Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Fri, 13 Mar 2026 18:21:01 -0700 Subject: [PATCH] Use simple-datatables for data grid control instead of detailed html table elements. Adds handling for large data sets, column ordering. Add ability to select row. Refactoring and cleanup within resultsViewer.ts --- src/Client/extension.ts | 12 +- src/Client/features/copilot.ts | 4 +- src/Client/features/queryDocuments.ts | 12 +- src/Client/features/resultsViewer.ts | 727 ++++++++++++++++---------- src/Client/package.json | 59 ++- 5 files changed, 512 insertions(+), 302 deletions(-) diff --git a/src/Client/extension.ts b/src/Client/extension.ts index 72b02fa..cf7b431 100644 --- a/src/Client/extension.ts +++ b/src/Client/extension.ts @@ -91,12 +91,12 @@ export async function activate(context: ExtensionContext) // Track Kusto session state - keep views visible while in a Kusto session const updateKustoContext = () => { - // Check if any Kusto documents are open OR if chart panel exists + // Check if any Kusto documents are open OR if singleton results view exists const hasKustoDocument = vscode.workspace.textDocuments.some(doc => doc.languageId === 'kusto'); - const hasChartPanel = resultsViewer.hasChartPanel(); - const isKustoActive = hasKustoDocument || hasChartPanel; + const hasSingletonView = resultsViewer.hasSingletonResultsView(); + const isKustoActive = hasKustoDocument || hasSingletonView; vscode.commands.executeCommand('setContext', 'kusto.hasActiveDocument', isKustoActive); - vscode.commands.executeCommand('setContext', 'kusto.hasChartPanel', hasChartPanel); + vscode.commands.executeCommand('setContext', 'kusto.hasSingletonView', hasSingletonView); // Track whether the active editor is showing a read-only entity definition const activeEditor = vscode.window.activeTextEditor; @@ -104,9 +104,9 @@ export async function activate(context: ExtensionContext) vscode.commands.executeCommand('setContext', 'kusto.isEntityDefinition', isEntityDef); }; - // Command to notify when chart panel state changes (triggers context update) + // Command to notify when singleton view state changes (triggers context update) context.subscriptions.push( - vscode.commands.registerCommand('kusto.chartPanelStateChanged', () => { + vscode.commands.registerCommand('kusto.singletonViewStateChanged', () => { updateKustoContext(); }) ); diff --git a/src/Client/features/copilot.ts b/src/Client/features/copilot.ts index 3a9e8bb..892aae6 100644 --- a/src/Client/features/copilot.ts +++ b/src/Client/features/copilot.ts @@ -7,7 +7,7 @@ import * as conn from './connections'; import * as server from './server'; import { ENTITY_DEFINITION_SCHEME } from './entityDefinitionProvider'; import { resultTableToMarkdown } from './markdown'; -import { displayResultsPanel, displaySingletonResultView, ResultViewMode } from './resultsViewer'; +import { displayResultsInPanel, displayResultsInSingletonView, ResultViewMode } from './resultsViewer'; const COPILOT_PARTICIPANT_ID = 'kusto'; const MAX_SCHEMA_CHARS = 30000; // Approximate limit to stay within token limits @@ -411,7 +411,7 @@ async function runQuery(input: { query: string; cluster?: string; database?: str } if (input.showResults) { - await displaySingletonResultView(languageClient, result.data, 'all', true); + await displayResultsInSingletonView(languageClient, result.data, 'all', true); } return resultTableToMarkdown(result.data.tables[0]!); diff --git a/src/Client/features/queryDocuments.ts b/src/Client/features/queryDocuments.ts index b482a61..4cfe01f 100644 --- a/src/Client/features/queryDocuments.ts +++ b/src/Client/features/queryDocuments.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; import { setDocumentConnection, ensureServer, getDocumentConnection } from './connections'; import * as server from './server'; -import { displayResultsPanel, displayError, displaySingletonResultView, ResultViewMode } from './resultsViewer'; +import { displayResultsInPanel, displayErrorInPanel, displayResultsInSingletonView, ResultViewMode } from './resultsViewer'; import * as resultsCache from './resultsCache'; import { getClipboardContext, clearClipboardContext, copyToClipboard } from './clipboard'; import { ENTITY_DEFINITION_SCHEME } from './entityDefinitionProvider'; @@ -139,7 +139,7 @@ async function runQuery(client: LanguageClient, queryRange?: server.SelectionRan if (runResult && runResult.error) { // display error and highlight error range - await displayError(runResult.error); + await displayErrorInPanel(runResult.error); if (runResult.error.range) { const r = runResult.error.range; @@ -153,8 +153,8 @@ async function runQuery(client: LanguageClient, queryRange?: server.SelectionRan await resultsCache.addToCache(uri, queryText, runResult.data); // Display result tables and chart from ResultData - await displayResultsPanel(client, runResult.data, 'data'); - await displaySingletonResultView(client, runResult.data, 'chart', true); + await displayResultsInPanel(client, runResult.data, 'data'); + await displayResultsInSingletonView(client, runResult.data, 'chart', true); } // Refresh CodeLens to show/hide Results lens @@ -192,8 +192,8 @@ async function showResults(client: LanguageClient, uri: string, line: number, ch )); const cachedData = await resultsCache.getFromCache(uri, queryText); if (cachedData) { - await displayResultsPanel(client, cachedData, 'data'); - await displaySingletonResultView(client, cachedData, 'chart', true); + await displayResultsInPanel(client, cachedData, 'data'); + await displayResultsInSingletonView(client, cachedData, 'chart', true); } } catch (error) { vscode.window.showErrorMessage(`Failed to show results: ${error}`); diff --git a/src/Client/features/resultsViewer.ts b/src/Client/features/resultsViewer.ts index 92f05dc..25ffa55 100644 --- a/src/Client/features/resultsViewer.ts +++ b/src/Client/features/resultsViewer.ts @@ -2,8 +2,22 @@ // Licensed under the MIT license. /* -* This module manages the custom editor for .kqr files, which contain saved query results -* and displays both a chart and tabular result data. +* This module manages query result display in three contexts: +* +* 1. Results Panel — A WebviewView in VS Code's bottom panel area, showing +* live query results. At most one exists. ("Panel" in our terminology.) +* +* 2. Singleton Result Viewer — A WebviewPanel in the editor area, used for +* live query results displayed beside the active editor. At most one +* exists and is not backed by a document. +* +* 3. Document Result Viewer — A WebviewPanel (custom editor) in the editor +* area for .kqr files. Document-backed; multiple can be open at once. +* +* Terminology note: VS Code's API names are the reverse of ours — the bottom +* panel uses `WebviewView` while the editor-area viewers use `WebviewPanel`. +* We use "panel" to mean the bottom panel and "viewer" for the editor area, +* which is more intuitive even though it doesn't match the VS Code type names. */ import * as vscode from 'vscode'; @@ -13,10 +27,10 @@ import { copyToClipboard, ClipboardItem, formatCfHtml } from './clipboard'; import { resultDataToMarkdown } from './markdown'; import { resultDataToHtml, DataAsHtml, HtmlTable } from './html'; -// ─── Bottom panel WebviewView state ───────────────────────────────────────── +// ─── Results Panel (bottom panel WebviewView) state ───────────────────────── -/** The bottom-panel results WebviewView, if resolved. */ -let resultsView: vscode.WebviewView | undefined; +/** The bottom-panel results WebviewView (`WebviewView`, not `WebviewPanel` — see module doc). */ +let resultsPanel: vscode.WebviewView | undefined; /** Last result data shown in the bottom panel (for copy/save/chart commands). */ let lastPanelResultData: server.ResultData | undefined; @@ -27,17 +41,17 @@ let lastPanelTableNames: string[] = []; /** Active tab index in the panel view. */ let panelActiveTabIndex = 0; -/** The view type used for the custom result editor. */ -const resultEditorViewType = 'kusto.resultEditor'; +/** The view type used for the custom results viewer. */ +const resultViewerViewType = 'kusto.resultViewer'; /** The language client, set during activation. */ let languageClient: LanguageClient; -/** Set of all chart webview panels (singleton + chart editor tabs). */ -const chartWebviews = new Set(); +/** Set of all result webviews (singleton + results viewer tabs). */ +const resultWebviews = new Set(); -/** The most recently focused chart webview panel. */ -let activeChartWebview: vscode.WebviewPanel | undefined; +/** The most recently focused result webview. */ +let activeResultWebview: vscode.WebviewPanel | undefined; /** Known chart kinds for the edit panel dropdown (must match server-side ChartKind constants). */ const chartKinds = [ @@ -65,35 +79,35 @@ const axisTypes = ['Linear', 'Log']; */ export type ResultViewMode = 'chart' | 'data' | 'all'; -/** Per-editor state for result editor webview panels. */ -interface ResultEditorState { +/** Per-viewer state for results viewer webviews. */ +interface ResultViewerState { resultData: server.ResultData; tableNames: string[]; activeView: string; // 'chart', 'table-0', 'table-1', etc. chartOptionsOverride?: server.ChartOptions; } -/** Map from webview panel to its editor state. */ -const editorStates = new Map(); +/** Map from webview to its viewer state. */ +const viewerStates = new Map(); /** - * Registers a chart webview panel for copy command targeting. + * Registers a result webview for copy command targeting. * Tracks focus and removes on dispose. */ -export function registerChartWebview(panel: vscode.WebviewPanel): void { - chartWebviews.add(panel); - activeChartWebview = panel; +export function registerResultWebview(webview: vscode.WebviewPanel): void { + resultWebviews.add(webview); + activeResultWebview = webview; - panel.onDidChangeViewState(() => { - if (panel.active) { - activeChartWebview = panel; + webview.onDidChangeViewState(() => { + if (webview.active) { + activeResultWebview = webview; } }); - panel.onDidDispose(() => { - chartWebviews.delete(panel); - if (activeChartWebview === panel) { - activeChartWebview = undefined; + webview.onDidDispose(() => { + resultWebviews.delete(webview); + if (activeResultWebview === webview) { + activeResultWebview = undefined; } }); } @@ -107,8 +121,8 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien context.subscriptions.push( vscode.window.registerCustomEditorProvider( - resultEditorViewType, - new ResultEditorProvider(), + resultViewerViewType, + new ResultsViewProvider(), { supportsMultipleEditorsPerDocument: false } ) ); @@ -116,13 +130,13 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien // Register the bottom-panel WebviewView provider vscode.window.registerWebviewViewProvider('kusto.resultsView', { resolveWebviewView(webviewView) { - resultsView = webviewView; + resultsPanel = webviewView; webviewView.webview.options = { enableScripts: true, enableForms: false }; webviewView.onDidDispose(() => { - resultsView = undefined; + resultsPanel = undefined; }); webviewView.webview.onDidReceiveMessage((message) => { if (message.command === 'viewChanged' && typeof message.viewId === 'string') { @@ -130,10 +144,10 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien if (match) { panelActiveTabIndex = parseInt(match[1]!, 10); } - sendPanelExpression(); + sendExpressionToResultsPanel(); } if (message.command === 'requestExpression') { - sendPanelExpression(); + sendExpressionToResultsPanel(); } if (message.command === 'copyText' && typeof message.text === 'string') { vscode.env.clipboard.writeText(message.text); @@ -149,23 +163,24 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien }); // Open the results view on start up when in panel mode - if (getResultsDisplay() === 'panel') { + if (getResultsViewDisplayLocation() === 'panel') { vscode.commands.executeCommand('kusto.resultsView.focus'); } // Register chart copy commands that target whichever chart webview is active context.subscriptions.push( vscode.commands.registerCommand('kusto.copyChartLight', () => { - activeChartWebview?.webview.postMessage({ command: 'copyChartLight' }); + activeResultWebview?.webview.postMessage({ command: 'copyChartLight' }); }), vscode.commands.registerCommand('kusto.copyChartDark', () => { - activeChartWebview?.webview.postMessage({ command: 'copyChartDark' }); + activeResultWebview?.webview.postMessage({ command: 'copyChartDark' }); }), vscode.commands.registerCommand('kusto.toggleChartEditor', () => { - activeChartWebview?.webview.postMessage({ command: 'toggleEditPanel' }); + activeResultWebview?.webview.postMessage({ command: 'toggleEditPanel' }); }), - vscode.commands.registerCommand('kusto.saveChart', () => saveChartFromPanel()), - vscode.commands.registerCommand('kusto.moveChartToMain', () => moveChartToMain()) + vscode.commands.registerCommand('kusto.saveSingletonResults', () => saveCurrentResults('singleton')), + vscode.commands.registerCommand('kusto.moveViewToMain', () => moveResultViewToMain()), + vscode.commands.registerCommand('kusto.toggleSearch', () => toggleSearch()) ); // Register results-related commands (previously in resultsPanel) @@ -173,11 +188,23 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien vscode.commands.registerCommand('kusto.copyData', () => copyData()), vscode.commands.registerCommand('kusto.copyCell', () => copyCell()), vscode.commands.registerCommand('kusto.copyTableAsExpression', () => copyTableAsExpression()), - vscode.commands.registerCommand('kusto.saveResults', () => saveResultsFromSingleton()), - vscode.commands.registerCommand('kusto.chartResults', () => chartResultsFromPanel()) + vscode.commands.registerCommand('kusto.savePanelResults', () => saveCurrentResults('panel')), + vscode.commands.registerCommand('kusto.chartPanelResults', () => openChartFromResultsPanel()) ); } +/** + * Sends a toggleSearch message to the active results webview. + * This toggles the search input box for the data tabs. + */ +function toggleSearch(): void { + if (activeResultWebview?.active) { + activeResultWebview.webview.postMessage({ command: 'toggleSearch' }); + } else if (resultsPanel) { + resultsPanel.webview.postMessage({ command: 'toggleSearch' }); + } +} + /** * Determines if VS Code is currently using a dark color theme. */ @@ -276,8 +303,8 @@ export async function saveResults(source: { data: server.ResultData }): Promise< return { uri: finalUri, alreadyOpen: false }; } -/** Script injected into chart webview HTML to handle copy commands. */ -const chartMessageHandlerScript = ` +/** Base script injected into all result webviews for core message handling. */ +const webviewMessageHandlerScript = ` `; + +/** Chart-specific script for Plotly copy commands. Only injected when a chart is present. */ +const chartCopyScript = ` +`; /** - * Injects the chart message handler script into chart HTML content. + * Injects webview message handler scripts into result HTML content. + * Always injects the base handler; only includes chart copy script when hasChart is true. * Also adds data-vscode-context to suppress default context menu items. */ -export function injectChartMessageHandler(html: string): string { +export function injectMessageHandlerScripts(html: string, hasChart: boolean): string { // Add data-vscode-context to suppress default Cut/Copy/Paste context menu items let result = html; const contextAttr = ` data-vscode-context='{"preventDefaultContextMenuItems": true}'`; @@ -435,21 +476,25 @@ export function injectChartMessageHandler(html: string): string { } } + const scripts = hasChart + ? webviewMessageHandlerScript + chartCopyScript + : webviewMessageHandlerScript; + if (result.includes('')) { - return result.replace('', chartMessageHandlerScript + ''); + return result.replace('', scripts + ''); } if (result.includes('')) { - return result.replace('', chartMessageHandlerScript + ''); + return result.replace('', scripts + ''); } - return result + chartMessageHandlerScript; + return result + scripts; } /** - * Custom editor provider for .kqr files. - * The file contains ResultData JSON (tables + chart options). - * Shows both data tables and chart with a toggle tab bar. + * Custom text editor provider for .kqr files. + * The file contains ResultData JSON (tables + chart options + query). + * Can show chart, data tables and query in different tabs. */ -export class ResultEditorProvider implements vscode.CustomTextEditorProvider { +export class ResultsViewProvider implements vscode.CustomTextEditorProvider { async resolveCustomTextEditor( document: vscode.TextDocument, @@ -460,16 +505,16 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { enableScripts: true }; - // Track this editor webview for copy commands - registerChartWebview(webviewPanel); + // Track this results viewer webview for copy commands + registerResultWebview(webviewPanel); // Update context key when this panel gains/loses focus const updateChartContext = () => { if (webviewPanel.active) { - const state = editorStates.get(webviewPanel); + const state = viewerStates.get(webviewPanel); const hasChart = !!state?.resultData?.chartOptions; - vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', hasChart); - vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', state?.activeView === 'chart'); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerHasChart', hasChart); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerChartActive', state?.activeView === 'chart'); } }; webviewPanel.onDidChangeViewState(() => updateChartContext()); @@ -485,18 +530,18 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Listen for messages from the webview webviewPanel.webview.onDidReceiveMessage(async (message) => { if (message.command === 'viewChanged' && typeof message.viewId === 'string') { - const state = editorStates.get(webviewPanel); + const state = viewerStates.get(webviewPanel); if (state) { state.activeView = message.viewId; } - vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', message.viewId === 'chart'); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerChartActive', message.viewId === 'chart'); if (message.viewId.startsWith('table-')) { - sendExpressionToEditorPanel(webviewPanel); + sendExpressionToResultsView(webviewPanel); } return; } if (message.command === 'requestExpression') { - sendExpressionToEditorPanel(webviewPanel); + sendExpressionToResultsView(webviewPanel); return; } if (message.command === 'copyText' && typeof message.text === 'string') { @@ -504,7 +549,7 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { return; } if (message.command === 'chartOptionsChanged' && message.chartOptions) { - const state = editorStates.get(webviewPanel); + const state = viewerStates.get(webviewPanel); if (!state) { return; } state.chartOptionsOverride = message.chartOptions as server.ChartOptions; if (chartOptionsTimer) { clearTimeout(chartOptionsTimer); } @@ -551,7 +596,7 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Render from the document content await this.updateWebview(document, webviewPanel); - sendExpressionToEditorPanel(webviewPanel); + sendExpressionToResultsView(webviewPanel); // Re-render when the document content changes (e.g. external edit) const changeSubscription = vscode.workspace.onDidChangeTextDocument(async e => { @@ -570,9 +615,9 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { webviewPanel.onDidDispose(() => { if (chartOptionsTimer) { clearTimeout(chartOptionsTimer); } - editorStates.delete(webviewPanel); - vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', false); - vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', false); + viewerStates.delete(webviewPanel); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerHasChart', false); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerChartActive', false); changeSubscription.dispose(); themeSubscription.dispose(); }); @@ -609,9 +654,9 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Update context key for editor title actions if (webviewPanel.active) { - vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', hasChart); - const activeView = editorStates.get(webviewPanel)?.activeView ?? (hasChart ? 'chart' : 'table-0'); - vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', activeView === 'chart'); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerHasChart', hasChart); + const activeView = viewerStates.get(webviewPanel)?.activeView ?? (hasChart ? 'chart' : 'table-0'); + vscode.commands.executeCommand('setContext', 'kusto.resultViewerChartActive', activeView === 'chart'); } if (!hasTable && !hasChart) { @@ -619,11 +664,11 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { return; } - // Track editor state for copy commands + // Track viewer state for copy commands const tableNames = (dataResult?.tables ?? []).map(t => t.name); const firstActiveView = hasChart ? 'chart' : 'table-0'; - const existingState = editorStates.get(webviewPanel); - editorStates.set(webviewPanel, { + const existingState = viewerStates.get(webviewPanel); + viewerStates.set(webviewPanel, { resultData, tableNames, activeView: existingState?.activeView ?? firstActiveView, @@ -632,12 +677,12 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { const chartOptions = existingState?.chartOptionsOverride ?? resultData.chartOptions; const columnNames = resultData.tables[0]?.columns?.map(c => c.name) ?? []; - const html = this.buildDualViewHtml(dataResult, chartResult?.html, hasChart, 'all', chartOptions, columnNames, - resultData.query, resultData.cluster, resultData.database); - webviewPanel.webview.html = injectChartMessageHandler(html); + const html = this.BuildMultiTabbedHtml(dataResult, chartResult?.html, hasChart, 'all', chartOptions, columnNames, + resultData.query, resultData.cluster, resultData.database, resultData.tables); + webviewPanel.webview.html = injectMessageHandlerScripts(html, hasChart); } - private async updateChartOnly(state: ResultEditorState, webviewPanel: vscode.WebviewPanel): Promise { + private async updateChartOnly(state: ResultViewerState, webviewPanel: vscode.WebviewPanel): Promise { const chartOptions = state.chartOptionsOverride ?? state.resultData.chartOptions; if (!chartOptions) { return; } const modifiedData: server.ResultData = { @@ -654,7 +699,10 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { } } - buildDualViewHtml( + /* + * Builds the HTML for a multi-tabbed view showing chart, data tables, and query, with toggle buttons. + */ + BuildMultiTabbedHtml( dataResult: DataAsHtml | null, chartHtml: string | undefined, hasChart: boolean, @@ -663,7 +711,8 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { columnNames?: string[], queryText?: string, cluster?: string, - database?: string + database?: string, + resultTables?: server.ResultTable[] ): string { const tables = dataResult?.tables ?? []; @@ -678,13 +727,19 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { (showQuery ? 1 : 0); const showTabs = visibleTabCount > 1; - // Build individual table divs + // Build individual table divs (empty containers — filled by Simple-DataTables) const tableContents = showTables - ? tables.map((t, i) => - `
${t.html}
` + ? tables.map((_t, i) => + `
` ).join('') : ''; + // Embed raw table data as JSON for client-side rendering + const tableDataJson = showTables && resultTables + ? JSON.stringify(resultTables.map(t => ({ columns: t.columns, rows: t.rows }))) + .replace(/<\//g, '<\\/') + : '[]'; + // Extract the chart body content from the full HTML const chartContent = chartHtml ? this.extractBody(chartHtml) @@ -734,6 +789,8 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { ${chartHead} + +