diff --git a/src/Client/README.md b/src/Client/README.md index 4ab7e45..88c606d 100644 --- a/src/Client/README.md +++ b/src/Client/README.md @@ -1,7 +1,8 @@ # Kusto Explorer (VS Code Extension) -Edit, run and chart Kusto queries (KQL). -Explore databases and query results. +- Edit, run and chart Kusto queries (KQL) +- Explore databases and query results +- Consult copilot to help create, run and diagnose your queries ## Get Started - Open or create a `.kql` file in VS Code @@ -14,6 +15,11 @@ Explore databases and query results. - Add another query to the kql document, rinse and repeat - Save your results and charts as a .kqr file to share with others or re-open later +## Kusto Connections (in explorer panel) +- Keep a list of Kusto servers you use to run queries +- Select a server and database to be the default database for your query document +- Explore the entities available in each database + ## Query Editor (.kql documents) - Syntax and semantic coloring of query text - Autocompletion (Intellisense) @@ -22,19 +28,25 @@ Explore databases and query results. - Goto definition and find all references for functions, tables, columns, and more - Code actions and quick fixes for common issues and refactorings - Copy colorized query text to the clipboard for pasting into other documents -- Have mutliple independent queries in the same document, separated by a blank line. +- Have multiple independent queries in the same document, separated by a blank line. ## Results Panel (bottom panel) - Copy contents of cells or entire table to clipboard -- Drag and drop a table into your document as a KQL datatable expression +- Drag and drop a table into your document as a KQL `datatable` expression - Chart your data (it will open in a results viewer tab) +- Save the data as a `.kqr` file (Kusto Query Results) + +## Chart Panel (with documents) +- Edit the chart options to customize the chart type, axes, legend and more +- Copy the chart as an image to clipboard for either light-mode or dark-mode pasting +- Save chart and data as a `.kqr` file (Kusto Query Result) ## Results Viewer (.kqr documents) -- Shows both result data and chart in the same view -- Copy the chart as an image to clipboard for either light-mode or dark-mode pasting. -- Copy the data to clipboard just like results panel. +- Shows chart, data and query in one view +- Add a chart if you don't have one yet - Edit the chart options to customize the chart type, axes, legend and more -- Save to a .kqr file to share with others or re-open later +- Copy the chart as an image to clipboard for either light-mode or dark-mode pasting +- Copy the data to clipboard just like results panel ## Requirements - VS Code 1.9.0 or higher \ No newline at end of file diff --git a/src/Client/extension.ts b/src/Client/extension.ts index 43dde63..72b02fa 100644 --- a/src/Client/extension.ts +++ b/src/Client/extension.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { workspace, ExtensionContext, window } from 'vscode'; import * as conn from './features/connectionsPanel' import * as connections from './features/connections' -import * as documentPanels from './features/documentPanels' +import * as queryDocuments from './features/queryDocuments' import * as resultsViewer from './features/resultsViewer' import * as copilot from './features/copilot' import * as connectionStatusBar from './features/connectionStatusBar' @@ -131,7 +131,7 @@ export async function activate(context: ExtensionContext) connectionStatusBar.activate(context); // activate query execution features - documentPanels.activate(context, client); + queryDocuments.activate(context, client); // activate chart file editor (.kchart) resultsViewer.activate(context, client); diff --git a/src/Client/features/copilot.ts b/src/Client/features/copilot.ts index 573a9d0..3a9e8bb 100644 --- a/src/Client/features/copilot.ts +++ b/src/Client/features/copilot.ts @@ -7,8 +7,7 @@ import * as conn from './connections'; import * as server from './server'; import { ENTITY_DEFINITION_SCHEME } from './entityDefinitionProvider'; import { resultTableToMarkdown } from './markdown'; -import * as resultsPanel from './resultsPanel'; -import { displayChart } from './resultsViewer'; +import { displayResultsPanel, displaySingletonResultView, ResultViewMode } from './resultsViewer'; const COPILOT_PARTICIPANT_ID = 'kusto'; const MAX_SCHEMA_CHARS = 30000; // Approximate limit to stay within token limits @@ -412,8 +411,7 @@ async function runQuery(input: { query: string; cluster?: string; database?: str } if (input.showResults) { - await resultsPanel.displayResults(languageClient, result.data); - await displayChart(languageClient, result.data); + await displaySingletonResultView(languageClient, result.data, 'all', true); } return resultTableToMarkdown(result.data.tables[0]!); diff --git a/src/Client/features/documentPanels.ts b/src/Client/features/queryDocuments.ts similarity index 98% rename from src/Client/features/documentPanels.ts rename to src/Client/features/queryDocuments.ts index ceec831..b482a61 100644 --- a/src/Client/features/documentPanels.ts +++ b/src/Client/features/queryDocuments.ts @@ -5,8 +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 * as resultsPanel from './resultsPanel'; -import { displayChart } from './resultsViewer'; +import { displayResultsPanel, displayError, displaySingletonResultView, ResultViewMode } from './resultsViewer'; import * as resultsCache from './resultsCache'; import { getClipboardContext, clearClipboardContext, copyToClipboard } from './clipboard'; import { ENTITY_DEFINITION_SCHEME } from './entityDefinitionProvider'; @@ -29,9 +28,6 @@ let codeLensProvider: KustoCodeLensProvider; */ export function activate(context: vscode.ExtensionContext, client: LanguageClient): void { - // Activate results panel - resultsPanel.activate(context, client); - // Register query-related commands context.subscriptions.push( vscode.commands.registerCommand('kusto.runQuery', (startLine?: number, startChar?: number, endLine?: number, endChar?: number) => runQuery(client, rangeFromArgs(startLine, startChar, endLine, endChar))), @@ -143,8 +139,7 @@ async function runQuery(client: LanguageClient, queryRange?: server.SelectionRan if (runResult && runResult.error) { // display error and highlight error range - await resultsPanel.displayError(runResult.error); - await displayChart(client, undefined); + await displayError(runResult.error); if (runResult.error.range) { const r = runResult.error.range; @@ -158,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 resultsPanel.displayResults(client, runResult.data); - await displayChart(client, runResult.data); + await displayResultsPanel(client, runResult.data, 'data'); + await displaySingletonResultView(client, runResult.data, 'chart', true); } // Refresh CodeLens to show/hide Results lens @@ -197,8 +192,8 @@ async function showResults(client: LanguageClient, uri: string, line: number, ch )); const cachedData = await resultsCache.getFromCache(uri, queryText); if (cachedData) { - await resultsPanel.displayResults(client, cachedData); - await displayChart(client, cachedData); + await displayResultsPanel(client, cachedData, 'data'); + await displaySingletonResultView(client, cachedData, 'chart', true); } } catch (error) { vscode.window.showErrorMessage(`Failed to show results: ${error}`); diff --git a/src/Client/features/resultsPanel.ts b/src/Client/features/resultsPanel.ts deleted file mode 100644 index c5b29f2..0000000 --- a/src/Client/features/resultsPanel.ts +++ /dev/null @@ -1,501 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/* - This module manages the results webview, which displays query results in the "Results" tab of "The Panel" -*/ - -import * as vscode from 'vscode'; -import { LanguageClient } from 'vscode-languageclient/node'; -import * as server from './server'; -import { copyToClipboard, formatCfHtml } from './clipboard'; -import { saveResults, copyCellFromEditor, copyDataFromEditor, copyTableAsExpressionFromEditor } from './resultsViewer'; -import { displayChart } from './resultsViewer'; -import { resultDataToMarkdown } from './markdown'; -import { resultDataToHtml, HtmlTable } from './html'; - -let resultsView: vscode.WebviewView | undefined; -let lastResultData: server.ResultData | undefined; -let lastTableNames: string[] = []; -let activeTabIndex = 0; -let languageClient: LanguageClient | undefined; - -/** - * Activates the results panel webview and registers associated commands. - * @param context The extension context - * @param client The language client for LSP communication - */ -export function activate(context: vscode.ExtensionContext, client: LanguageClient): void { - - languageClient = client; - - // Register the results view webview provider - vscode.window.registerWebviewViewProvider('kusto.resultsView', { - resolveWebviewView(webviewView) { - resultsView = webviewView; - webviewView.webview.options = { - enableScripts: true, - // Prevent the view from being disposed when hidden (e.g., when chart panel has focus) - enableForms: false - }; - // Prevent disposal when hidden - webviewView.onDidDispose(() => { - resultsView = undefined; - }); - // Listen for messages from the results webview - webviewView.webview.onDidReceiveMessage((message) => { - if (message.command === 'tabChanged' && typeof message.index === 'number') { - activeTabIndex = message.index; - // Refresh the cached expression for the newly active tab - sendExpressionToWebview(); - } - if (message.command === 'requestExpression') { - sendExpressionToWebview(); - } - if (message.command === 'copyText' && typeof message.text === 'string') { - vscode.env.clipboard.writeText(message.text); - } - }); - webviewView.webview.html = 'no results'; - } - }, { - webviewOptions: { - retainContextWhenHidden: true // Keep the view alive even when hidden - } - }); - - // Open the results view on start up - vscode.commands.executeCommand('kusto.resultsView.focus'); - - // Register results-related commands - context.subscriptions.push( - vscode.commands.registerCommand('kusto.copyData', () => copyData()), - vscode.commands.registerCommand('kusto.copyCell', () => copyCell()), - vscode.commands.registerCommand('kusto.copyTableAsExpression', () => copyTableAsExpression(client)), - vscode.commands.registerCommand('kusto.saveResults', () => saveResultsFromPanel(client)), - vscode.commands.registerCommand('kusto.chartResults', () => chartResults(client)) - ); -} - -/** - * Fetches data HTML from the server and displays it in the results view. - * @param client The language client for LSP communication - * @param resultData The ResultData object, or undefined to clear - */ -export async function displayResults( - client: LanguageClient, - resultData?: server.ResultData -): Promise -{ - const data = resultData - ? resultDataToHtml(resultData) - : null; - if (data && data.tables.length > 0) { - lastResultData = resultData; - lastTableNames = data.tables.map(t => t.name); - activeTabIndex = 0; - const html = buildTabbedHtml(data.tables); - const totalRows = data.tables.reduce((sum, t) => sum + t.rowCount, 0); - await showResultsHtml(html, totalRows, data.hasChart); - // Eagerly fetch and send the datatable expression to the webview for drag-and-drop - sendExpressionToWebview(); - } else { - await showResultsHtml('no results', undefined, false); - } -} - -/** - * Saves the current results panel data to a .kqr file, - * closes any open chart panel, and opens the saved file. - */ -async function saveResultsFromPanel(client: LanguageClient): Promise { - if (!lastResultData) { - vscode.window.showWarningMessage('No result data available to save.'); - return; - } - - const result = await saveResults({ data: lastResultData }); - if (result) { - // Close the chart panel if open - await displayChart(client, undefined); - // Open/reveal the saved file in the main editor group - await vscode.commands.executeCommand('vscode.openWith', result.uri, 'kusto.resultEditor', vscode.ViewColumn.One); - } -} - -async function chartResults(client: LanguageClient): Promise { - if (!lastResultData) { - vscode.window.showWarningMessage('No result data available to chart.'); - return; - } - const chartData: server.ResultData = { - ...lastResultData, - chartOptions: lastResultData.chartOptions ?? { kind: 'ColumnChart' } - }; - await displayChart(client, chartData); -} - -export async function displayError(error: server.QueryDiagnostic): Promise { - //var htmlMessage = `

\u274C${escapeHtml(message)}

`; - var htmlMessage = `
\u274C
${escapeHtml(error.message)}
${escapeHtml(error.details || '')}
`; - await showResultsHtml(htmlMessage, undefined, false, true); -} - -/** CSS styles for the tabbed results view. */ -const tabStyles = ` -`; - -/** - * Wraps multiple HTML table strings in a tabbed layout. - * If there is only one table, returns it without tabs. - */ -function buildTabbedHtml(tables: HtmlTable[]): string { - if (tables.length === 0) { - return 'no results'; - } - - if (tables.length === 1) { - return tables[0]!.html; - } - - const tabButtons = tables.map((t, i) => - `` - ).join('\n'); - - const tabContents = tables.map((t, i) => - `
${t.html}
` - ).join('\n'); - - return ` -${tabStyles} - -
- ${tabButtons} -
-${tabContents} - -`; -} - -/** - * Escapes HTML special characters in a string. - */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -/** - * Displays query results in the results view. - * @param dataHtml The HTML content to display - * @param rowCount Optional row count for badge - * @param hasChart Whether a chart is being displayed (affects show() behavior) - * @param hasError Whether an error occurred (affects badge display) - */ -async function showResultsHtml(dataHtml: string, rowCount?: number, hasChart?: boolean, hasError?: boolean): Promise -{ - // Ensure the view is visible (this triggers resolveWebviewView if not already called) - if (!resultsView) - { - await vscode.commands.executeCommand('kusto.resultsView.focus'); - } - - if (!resultsView) - { - return; // Still not available - } - - try - { - resultsView.webview.html = injectMessageHandler(dataHtml); - - // Update badge - if (rowCount) - { - resultsView.badge = { - tooltip: `${rowCount} rows`, - value: rowCount - }; - } - else if (hasError) - { - resultsView.badge = { - tooltip: 'Error', - value: 1 - }; - } - else - { - resultsView.badge = undefined; - } - - // Only call show() if there's no chart - let the chart panel and results view coexist - // The retainContextWhenHidden option will keep the view alive - if (!hasChart) - { - resultsView.show(true); // show but preserve focus on editor - } - } - catch (error) - { - // Results view was disposed, try to recreate it - await vscode.commands.executeCommand('kusto.resultsView.focus'); - - if (resultsView) - { - try - { - resultsView.webview.html = injectMessageHandler(dataHtml); - - if (rowCount) - { - resultsView.badge = { - tooltip: `${rowCount} rows`, - value: rowCount - }; - } - - if (!hasChart) - { - resultsView.show(true); - } - } catch (retryError) - { - vscode.window.showErrorMessage(`Failed to display results: ${retryError}`); - } - } - } -} - -/** - * Copies the table cell under the cursor in the results view to the clipboard. - */ -async function copyCell(): Promise { - if (copyCellFromEditor()) { - return; - } - if (!resultsView) { - return; - } - - resultsView.webview.postMessage({ command: 'copyCell' }); -} - -/** - * Copies the active result table as a KQL datatable expression to the clipboard. - * @param client The language client for LSP communication - */ -async function copyTableAsExpression(client: LanguageClient): Promise { - if (await copyTableAsExpressionFromEditor()) { - return; - } - - if (!lastResultData) { - return; - } - - try { - const tableName = lastTableNames[activeTabIndex]; - const result = await server.getDataAsExpression(client, lastResultData, tableName); - if (result?.expression) { - await vscode.env.clipboard.writeText(result.expression); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to copy as expression: ${error}`); - } -} - -/** - * Copies the results view content (as rich HTML + markdown) to the clipboard. - * Uses the cached ResultData. - */ -async function copyData(): Promise { - if (await copyDataFromEditor()) { - return; - } - - if (!languageClient || !lastResultData) { - return; - } - - const tableName = lastTableNames[activeTabIndex]; - - const htmlResult = resultDataToHtml(lastResultData, tableName); - - const html = htmlResult?.tables[0]?.html; - const markdown = resultDataToMarkdown(lastResultData, tableName); - - if (html) { - copyToClipboard([ - { format: 'HTML Format', data: formatCfHtml(html), encoding: 'utf8' }, - { format: 'Text', data: markdown || html, encoding: 'text' }, - ]); - } else if (markdown) { - vscode.env.clipboard.writeText(markdown); - } -} - -/** Script injected into webview HTML to handle messages from the extension. */ -const webviewMessageHandlerScript = ` -`; - -/** - * Fetches the datatable expression for the current result/tab and sends it - * to the webview so it is available immediately on dragstart. - */ -async function sendExpressionToWebview(): Promise { - if (!resultsView || !languageClient || !lastResultData) { - return; - } - try { - const tableName = lastTableNames[activeTabIndex]; - const result = await server.getDataAsExpression(languageClient, lastResultData, tableName); - if (result?.expression && resultsView) { - resultsView.webview.postMessage({ command: 'setExpression', expression: result.expression }); - } - } catch { - // Ignore — drag will just not work until expression is available - } -} - -/** - * Injects the message handler script into webview HTML content. - * Also adds data-vscode-context to enable webview context menus. - */ -function injectMessageHandler(html: string): string { - // Add data-vscode-context to the body tag to enable webview context menus - let result = html; - const contextAttr = ` data-vscode-context='{\"webviewSection\": \"results\"}'`; - if (result.includes('', ''); - if (result.includes('')) { - result = result.replace('', ''); - } - } - - // Insert script before or append at the end - if (result.includes('')) { - return result.replace('', webviewMessageHandlerScript + ''); - } - if (result.includes('')) { - return result.replace('', webviewMessageHandlerScript + ''); - } - return result + webviewMessageHandlerScript; -} diff --git a/src/Client/features/resultsViewer.ts b/src/Client/features/resultsViewer.ts index a13221b..92f05dc 100644 --- a/src/Client/features/resultsViewer.ts +++ b/src/Client/features/resultsViewer.ts @@ -13,6 +13,20 @@ import { copyToClipboard, ClipboardItem, formatCfHtml } from './clipboard'; import { resultDataToMarkdown } from './markdown'; import { resultDataToHtml, DataAsHtml, HtmlTable } from './html'; +// ─── Bottom panel WebviewView state ───────────────────────────────────────── + +/** The bottom-panel results WebviewView, if resolved. */ +let resultsView: vscode.WebviewView | undefined; + +/** Last result data shown in the bottom panel (for copy/save/chart commands). */ +let lastPanelResultData: server.ResultData | undefined; + +/** Table names from the last panel result. */ +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'; @@ -43,6 +57,14 @@ const legendOptions = ['Visible', 'Hidden']; /** Known axis type options (must match server-side ChartAxis constants). */ const axisTypes = ['Linear', 'Log']; +/** + * Controls which content sections are shown in a result view. + * - 'chart': Only the chart, no tabs. + * - 'data': Only data tables. Tabs shown only if multiple tables. + * - 'all': Chart, data tables, and query tabs — all visible. + */ +export type ResultViewMode = 'chart' | 'data' | 'all'; + /** Per-editor state for result editor webview panels. */ interface ResultEditorState { resultData: server.ResultData; @@ -91,7 +113,47 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien ) ); - // Register copy commands that target whichever chart webview is active + // Register the bottom-panel WebviewView provider + vscode.window.registerWebviewViewProvider('kusto.resultsView', { + resolveWebviewView(webviewView) { + resultsView = webviewView; + webviewView.webview.options = { + enableScripts: true, + enableForms: false + }; + webviewView.onDidDispose(() => { + resultsView = undefined; + }); + webviewView.webview.onDidReceiveMessage((message) => { + if (message.command === 'viewChanged' && typeof message.viewId === 'string') { + const match = message.viewId.match(/^table-(\d+)$/); + if (match) { + panelActiveTabIndex = parseInt(match[1]!, 10); + } + sendPanelExpression(); + } + if (message.command === 'requestExpression') { + sendPanelExpression(); + } + if (message.command === 'copyText' && typeof message.text === 'string') { + vscode.env.clipboard.writeText(message.text); + } + handleChartWebviewMessage(message); + }); + webviewView.webview.html = 'no results'; + } + }, { + webviewOptions: { + retainContextWhenHidden: true + } + }); + + // Open the results view on start up when in panel mode + if (getResultsDisplay() === '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' }); @@ -105,6 +167,15 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien vscode.commands.registerCommand('kusto.saveChart', () => saveChartFromPanel()), vscode.commands.registerCommand('kusto.moveChartToMain', () => moveChartToMain()) ); + + // Register results-related commands (previously in resultsPanel) + context.subscriptions.push( + 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()) + ); } /** @@ -419,6 +490,13 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { state.activeView = message.viewId; } vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', message.viewId === 'chart'); + if (message.viewId.startsWith('table-')) { + sendExpressionToEditorPanel(webviewPanel); + } + return; + } + if (message.command === 'requestExpression') { + sendExpressionToEditorPanel(webviewPanel); return; } if (message.command === 'copyText' && typeof message.text === 'string') { @@ -473,6 +551,7 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Render from the document content await this.updateWebview(document, webviewPanel); + sendExpressionToEditorPanel(webviewPanel); // Re-render when the document content changes (e.g. external edit) const changeSubscription = vscode.workspace.onDidChangeTextDocument(async e => { @@ -548,12 +627,12 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { resultData, tableNames, activeView: existingState?.activeView ?? firstActiveView, - chartOptionsOverride: existingState?.chartOptionsOverride + ...(existingState?.chartOptionsOverride && { chartOptionsOverride: existingState.chartOptionsOverride }) }); const chartOptions = existingState?.chartOptionsOverride ?? resultData.chartOptions; const columnNames = resultData.tables[0]?.columns?.map(c => c.name) ?? []; - const html = this.buildDualViewHtml(dataResult, chartResult?.html, hasChart, chartOptions, columnNames, + const html = this.buildDualViewHtml(dataResult, chartResult?.html, hasChart, 'all', chartOptions, columnNames, resultData.query, resultData.cluster, resultData.database); webviewPanel.webview.html = injectChartMessageHandler(html); } @@ -579,6 +658,7 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { dataResult: DataAsHtml | null, chartHtml: string | undefined, hasChart: boolean, + mode: ResultViewMode, chartOptions?: server.ChartOptions, columnNames?: string[], queryText?: string, @@ -587,10 +667,23 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { ): string { const tables = dataResult?.tables ?? []; + const showChart = hasChart && (mode === 'chart' || mode === 'all'); + const showTables = mode === 'data' || mode === 'all'; + const showQuery = !!queryText && mode === 'all'; + + // Determine whether to show the tab bar + const visibleTabCount = + (showChart ? 1 : 0) + + (showTables ? tables.length : 0) + + (showQuery ? 1 : 0); + const showTabs = visibleTabCount > 1; + // Build individual table divs - const tableContents = tables.map((t, i) => - `
${t.html}
` - ).join(''); + const tableContents = showTables + ? tables.map((t, i) => + `
${t.html}
` + ).join('') + : ''; // Extract the chart body content from the full HTML const chartContent = chartHtml @@ -598,33 +691,43 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { : ''; // Extract chart head content (scripts like Plotly) - const chartHead = chartHtml + const chartHead = chartHtml && showChart ? this.extractHead(chartHtml) : ''; - // Build the toggle buttons - const chartButton = hasChart - ? `` - : ''; + // Determine first active view + const firstActiveView = showChart ? 'chart' : 'table-0'; - let tableButtons: string; - if (tables.length === 1) { - tableButtons = `Data`; - } else { - tableButtons = tables.map((t, i) => - `${this.escapeHtml(t.name)} (${t.rowCount})` - ).join(''); - } + // Build the toggle buttons (only used when showTabs is true) + let tabButtons = ''; + if (showTabs) { + const chartButton = showChart + ? `` + : ''; - const hasQuery = !!queryText; - const queryButton = hasQuery - ? `` - : ''; + let tableButtonsHtml = ''; + if (showTables) { + if (tables.length === 1) { + tableButtonsHtml = `Data`; + } else { + tableButtonsHtml = tables.map((t, i) => + `${this.escapeHtml(t.name)} (${t.rowCount})` + ).join(''); + } + } - const firstActiveView = hasChart ? 'chart' : 'table-0'; + const queryButton = showQuery + ? `` + : ''; + + tabButtons = chartButton + tableButtonsHtml + queryButton; + } // Build chart options edit panel - const editPanelHtml = hasChart ? this.buildEditPanelHtml(chartOptions, columnNames ?? []) : ''; + const editPanelHtml = showChart ? this.buildEditPanelHtml(chartOptions, columnNames ?? []) : ''; + + // When only a single item is visible, mark it active and use full height + const mainAreaHeight = showTabs ? 'calc(100vh - 33px)' : '100vh'; return ` @@ -669,7 +772,7 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { .view-toggle .spacer { flex: 1; } .main-area { display: flex; - height: calc(100vh - 33px); + height: ${mainAreaHeight}; } .content-area { flex: 1; @@ -834,16 +937,14 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { -
- ${chartButton} - ${tableButtons} - ${queryButton} -
+ ${showTabs ? `
+ ${tabButtons} +
` : ''}
- ${hasChart ? `
${chartContent}
` : ''} + ${showChart ? `
${chartContent}
` : ''} ${tableContents} - ${hasQuery ? `
+ ${showQuery ? `
${cluster ? `Cluster: ${this.escapeHtml(cluster)}` : ''} ${database ? `Database: ${this.escapeHtml(database)}` : ''} @@ -863,13 +964,44 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Track whether the user wants the edit panel open var editPanelUserVisible = false; + // Drag & drop: cache the KQL datatable expression for the active table + var cachedExpression = ''; + + function makeTablesDraggable() { + var activeContent = document.querySelector('.view-content.active'); + if (activeContent) { + activeContent.querySelectorAll('table').forEach(function(tbl) { + tbl.setAttribute('draggable', 'true'); + }); + } + } + makeTablesDraggable(); + + document.addEventListener('dragstart', function(e) { + var tbl = e.target.closest ? e.target.closest('table') : null; + if (!tbl) { return; } + if (cachedExpression) { + e.dataTransfer.setData('text/plain', cachedExpression); + e.dataTransfer.effectAllowed = 'copy'; + } else { + if (window._vscodeApi) { + window._vscodeApi.postMessage({ command: 'requestExpression' }); + } + e.preventDefault(); + } + }); + function switchView(viewId) { + cachedExpression = ''; document.querySelectorAll('.view-content').forEach(function(el) { el.classList.remove('active'); }); document.querySelectorAll('.view-toggle button[data-view]').forEach(function(el) { el.classList.remove('active'); }); var target = document.getElementById(viewId); if (target) target.classList.add('active'); var btn = document.querySelector('.view-toggle button[data-view="' + viewId + '"]'); if (btn) btn.classList.add('active'); + if (viewId.startsWith('table-')) { + makeTablesDraggable(); + } // Hide/restore edit panel based on view and user preference var editPanel = document.getElementById('edit-panel'); if (editPanel) { @@ -1016,6 +1148,10 @@ export class ResultEditorProvider implements vscode.CustomTextEditorProvider { } return; } + if (msg && msg.command === 'setExpression' && typeof msg.expression === 'string') { + cachedExpression = msg.expression; + return; + } if (msg && msg.command === 'updateChart' && msg.chartBodyHtml) { var chartDiv = document.getElementById('chart'); if (chartDiv) { @@ -1214,21 +1350,186 @@ export function hasChartPanel(): boolean { } /** - * Fetches chart and table HTML, then displays the full dual-view in the singleton chart panel. - * Pass undefined to close the panel. + * Returns the configured results display mode. */ -export async function displayChart( +function getResultsDisplay(): 'panel' | 'beside' { + return vscode.workspace.getConfiguration('kusto.results').get('display', 'panel') === 'beside' + ? 'beside' + : 'panel'; +} + +/** + * Displays query results in the bottom panel view. + */ +export async function displayResultsPanel( client: LanguageClient, - resultData?: server.ResultData + resultData: server.ResultData | undefined, + mode: ResultViewMode +): Promise { + if (!resultData?.tables?.length) { + clearPanelView(); + return; + } + + await displayResultsInPanel(client, resultData, mode); +} + +/** + * Displays a query error in the bottom panel and closes any singleton beside panel. + */ +export async function displayError(error: server.QueryDiagnostic): Promise { + disposeSingletonPanel(); + + const htmlMessage = `
\u274C
${escapeHtmlStatic(error.message)}
${escapeHtmlStatic(error.details || '')}
`; + + await showPanelHtml(htmlMessage, undefined, true); +} + +function escapeHtmlStatic(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// \u2500\u2500\u2500 Bottom panel display \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + +/** + * Displays results in the bottom panel WebviewView using the full dual-view HTML. + */ +async function displayResultsInPanel(client: LanguageClient, resultData: server.ResultData, mode: ResultViewMode): Promise { + lastPanelResultData = resultData; + panelActiveTabIndex = 0; + + const darkMode = isDarkMode(); + + const [dataResult, chartResult] = await Promise.all([ + Promise.resolve(resultDataToHtml(resultData)), + (mode === 'chart' || mode === 'all') && resultData.chartOptions + ? server.getChartAsHtml(client, resultData, darkMode) + : Promise.resolve(null) + ]); + + const hasChart = !!chartResult?.html; + const hasTable = !!dataResult?.tables?.length; + lastPanelTableNames = (dataResult?.tables ?? []).map(t => t.name); + + if (!hasTable && !hasChart) { + await showPanelHtml('no results'); + return; + } + + const chartOptions = resultData.chartOptions; + const columnNames = resultData.tables[0]?.columns?.map(c => c.name) ?? []; + const html = htmlBuilder.buildDualViewHtml(dataResult, chartResult?.html, hasChart, mode, chartOptions, columnNames, + resultData.query, resultData.cluster, resultData.database); + + const totalRows = (dataResult?.tables ?? []).reduce((sum, t) => sum + t.rowCount, 0); + await showPanelHtml(injectChartMessageHandler(html), totalRows); + sendPanelExpression(); +} + +/** + * Shows HTML content in the bottom panel WebviewView. + * In panel mode, focuses the view. In beside mode, updates silently. + */ +async function showPanelHtml(html: string, rowCount?: number, hasError?: boolean): Promise { + const isBesideMode = getResultsDisplay() === 'beside'; + + if (!resultsView) { + // In beside mode, don't force the panel open — just update if it exists + if (isBesideMode) { + return; + } + await vscode.commands.executeCommand('kusto.resultsView.focus'); + } + + if (!resultsView) { + return; + } + + try { + resultsView.webview.html = html; + + if (rowCount) { + resultsView.badge = { tooltip: `${rowCount} rows`, value: rowCount }; + } else if (hasError) { + resultsView.badge = { tooltip: 'Error', value: 1 }; + } else { + resultsView.badge = undefined; + } + + // Only auto-show the panel in panel mode + if (!isBesideMode) { + resultsView.show(true); + } + } catch { + if (isBesideMode) { + return; + } + await vscode.commands.executeCommand('kusto.resultsView.focus'); + if (resultsView) { + try { + resultsView.webview.html = html; + if (rowCount) { + resultsView.badge = { tooltip: `${rowCount} rows`, value: rowCount }; + } + resultsView.show(true); + } catch (retryError) { + vscode.window.showErrorMessage(`Failed to display results: ${retryError}`); + } + } + } +} + +/** Clears the panel view content. */ +function clearPanelView(): void { + if (resultsView) { + resultsView.webview.html = 'no results'; + resultsView.badge = undefined; + } + lastPanelResultData = undefined; + lastPanelTableNames = []; +} + +/** + * Sends the datatable expression to the bottom panel for drag-and-drop. + */ +async function sendPanelExpression(): Promise { + if (!resultsView || !languageClient || !lastPanelResultData) { + return; + } + try { + const tableName = lastPanelTableNames[panelActiveTabIndex]; + const result = await server.getDataAsExpression(languageClient, lastPanelResultData, tableName); + if (result?.expression && resultsView) { + resultsView.webview.postMessage({ command: 'setExpression', expression: result.expression }); + } + } catch { + // Ignore + } +} + + +// --- Singleton beside display ---------------------------------------------------------------- + +/** + * Shows/hides the singleton result panel. + * @param beside If true, opens in a beside column; if false, opens in the main editor column. + */ +export async function displaySingletonResultView( + client: LanguageClient, + resultData: server.ResultData | undefined, + mode: ResultViewMode, + beside: boolean ): Promise { if (!resultData?.tables?.length) { disposeSingletonPanel(); return; } - // Only open/update the singleton panel if there are chart options (render command). - // If no chart options and the panel is already open, close it. - if (!resultData.chartOptions) { + if (mode === 'chart' && !resultData.chartOptions) { disposeSingletonPanel(); return; } @@ -1249,10 +1550,10 @@ export async function displayChart( const hasChart = !!chartResult?.html; const columnNames = resultData.tables[0]?.columns?.map(c => c.name) ?? []; - const html = htmlBuilder.buildDualViewHtml(dataResult, chartResult?.html, hasChart, chartOptions, columnNames, + const html = htmlBuilder.buildDualViewHtml(dataResult, chartResult?.html, hasChart, mode, chartOptions, columnNames, resultData.query, resultData.cluster, resultData.database); - showSingletonPanel(injectChartMessageHandler(html), resultData, (dataResult?.tables ?? []).map(t => t.name)); + showSingletonPanel(injectChartMessageHandler(html), resultData, (dataResult?.tables ?? []).map(t => t.name), beside, mode); } function getChartViewColumn(): vscode.ViewColumn { @@ -1273,13 +1574,22 @@ function moveChartToMain(): void { } } -function showSingletonPanel(html: string, resultData: server.ResultData, tableNames: string[]): void { - const viewColumn = getChartViewColumn(); +function singletonTitleForMode(mode: ResultViewMode): string { + switch (mode) { + case 'chart': return 'Chart'; + case 'data': return 'Data'; + case 'all': return 'Results'; + } +} + +function showSingletonPanel(html: string, resultData: server.ResultData, tableNames: string[], beside: boolean, mode: ResultViewMode): void { + const viewColumn = beside ? vscode.ViewColumn.Beside : vscode.ViewColumn.One; + const title = singletonTitleForMode(mode); if (!singletonPanel) { singletonPanel = vscode.window.createWebviewPanel( 'kusto', - 'Results', + title, { viewColumn, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: true } ); @@ -1301,6 +1611,13 @@ function showSingletonPanel(html: string, resultData: server.ResultData, tableNa const state = editorStates.get(singletonPanel!); if (state) { state.activeView = message.viewId; } vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', message.viewId === 'chart'); + if (message.viewId.startsWith('table-')) { + sendExpressionToEditorPanel(singletonPanel!); + } + return; + } + if (message.command === 'requestExpression') { + sendExpressionToEditorPanel(singletonPanel!); return; } if (message.command === 'copyText' && typeof message.text === 'string') { @@ -1346,8 +1663,10 @@ function showSingletonPanel(html: string, resultData: server.ResultData, tableNa vscode.commands.executeCommand('setContext', 'kusto.resultEditorChartActive', hasChart); + singletonPanel.title = title; singletonPanel.webview.html = html; singletonPanel.reveal(viewColumn, true); + sendExpressionToEditorPanel(singletonPanel); } async function updateSingletonChart(): Promise { @@ -1419,6 +1738,24 @@ function getActiveTableName(state: ResultEditorState): string | undefined { return state.tableNames[0]; } +/** + * Fetches the datatable expression for the active table in a result editor + * webview and posts it so it is available for drag-and-drop. + */ +async function sendExpressionToEditorPanel(panel: vscode.WebviewPanel): Promise { + const state = editorStates.get(panel); + if (!state) { return; } + try { + const tableName = getActiveTableName(state); + const result = await server.getDataAsExpression(languageClient, state.resultData, tableName); + if (result?.expression) { + panel.webview.postMessage({ command: 'setExpression', expression: result.expression }); + } + } catch { + // Ignore — drag will just not work until expression is available + } +} + /** * Copies the table cell under the cursor in the active result editor. * Returns true if handled. @@ -1481,3 +1818,101 @@ export async function copyTableAsExpressionFromEditor(): Promise { } return true; } + +// ─── Results commands (copy/save/chart from either panel or singleton) ────────── + +/** + * Copies a table cell from the results. Delegates to editor copy if a result editor is active. + */ +function copyCell(): void { + if (copyCellFromEditor()) { + return; + } + // Panel mode: post message to the bottom panel webview + if (resultsView) { + resultsView.webview.postMessage({ command: 'copyCell' }); + } +} + +/** + * Copies the active table data as HTML + markdown. + */ +async function copyData(): Promise { + if (await copyDataFromEditor()) { + return; + } + + // Fall back to panel data + if (!languageClient || !lastPanelResultData) { + return; + } + + const tableName = lastPanelTableNames[panelActiveTabIndex]; + const htmlResult = resultDataToHtml(lastPanelResultData, tableName); + const html = htmlResult?.tables[0]?.html; + const markdown = resultDataToMarkdown(lastPanelResultData, tableName); + + if (html) { + copyToClipboard([ + { format: 'HTML Format', data: formatCfHtml(html), encoding: 'utf8' }, + { format: 'Text', data: markdown || html, encoding: 'text' }, + ]); + } else if (markdown) { + vscode.env.clipboard.writeText(markdown); + } +} + +/** + * Copies the active table as a KQL datatable expression. + */ +async function copyTableAsExpression(): Promise { + if (await copyTableAsExpressionFromEditor()) { + return; + } + + if (!lastPanelResultData) { + return; + } + + try { + const tableName = lastPanelTableNames[panelActiveTabIndex]; + const result = await server.getDataAsExpression(languageClient, lastPanelResultData, tableName); + if (result?.expression) { + await vscode.env.clipboard.writeText(result.expression); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to copy as expression: ${error}`); + } +} + +/** + * Saves results from the singleton panel or bottom panel to a .kqr file. + */ +async function saveResultsFromSingleton(): Promise { + const data = singletonResultData ?? lastPanelResultData; + if (!data) { + vscode.window.showWarningMessage('No result data available to save.'); + return; + } + + const result = await saveResults({ data }); + if (result) { + disposeSingletonPanel(); + await vscode.commands.executeCommand('vscode.openWith', result.uri, 'kusto.resultEditor', vscode.ViewColumn.One); + } +} + +/** + * Charts the current results from the bottom panel by opening/updating the beside chart panel. + */ +async function chartResultsFromPanel(): Promise { + if (!lastPanelResultData) { + vscode.window.showWarningMessage('No result data available to chart.'); + return; + } + const chartData: server.ResultData = { + ...lastPanelResultData, + chartOptions: lastPanelResultData.chartOptions ?? { kind: 'ColumnChart' } + }; + await displaySingletonResultView(languageClient, chartData, 'chart', true); +} diff --git a/src/Client/features/schemaCache.ts b/src/Client/features/schemaCache.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/Client/package.json b/src/Client/package.json index 1ded4a1..364004e 100644 --- a/src/Client/package.json +++ b/src/Client/package.json @@ -391,22 +391,22 @@ "webview/context": [ { "command": "kusto.copyCell", - "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible)", + "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible && !queryVisible)", "group": "9_cutcopypaste@1" }, { "command": "kusto.copyData", - "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible)", + "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible && !queryVisible)", "group": "9_cutcopypaste@2" }, { "command": "kusto.copyTableAsExpression", - "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible)", + "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto.resultEditor' && !chartVisible && !queryVisible) || (webviewId == 'kusto' && !chartVisible && !queryVisible)", "group": "9_cutcopypaste@3" }, { "command": "kusto.saveResults", - "when": "webviewId == 'kusto.resultsView'" + "when": "webviewId == 'kusto.resultsView' || (webviewId == 'kusto' && !chartVisible)" }, { "command": "kusto.chartResults", @@ -414,15 +414,15 @@ }, { "command": "kusto.copyChartLight", - "when": "webviewId == 'kusto' || (webviewId == 'kusto.resultEditor' && chartVisible)" + "when": "(webviewId == 'kusto' && chartVisible) || (webviewId == 'kusto.resultEditor' && chartVisible)" }, { "command": "kusto.copyChartDark", - "when": "webviewId == 'kusto' || (webviewId == 'kusto.resultEditor' && chartVisible)" + "when": "(webviewId == 'kusto' && chartVisible) || (webviewId == 'kusto.resultEditor' && chartVisible)" }, { "command": "kusto.saveChart", - "when": "webviewId == 'kusto'" + "when": "webviewId == 'kusto' && chartVisible" } ] }, @@ -460,13 +460,27 @@ "explorer": [ { "id": "kusto.connections", - "name": "Connections", + "name": "Kusto Connections", "icon": "$(database)", "when": "kusto.hasActiveDocument" } ] }, "configuration": [ + { + "title": "Results", + "properties": { + "kusto.results.display": { + "type": "string", + "enum": [ + "panel", + "beside" + ], + "default": "panel", + "markdownDescription": "Where query results are displayed.\n- `panel`: In the Results tab of the bottom panel\n- `beside`: In an editor column beside your query" + } + } + }, { "title": "Connections", "properties": {