diff --git a/src/KustoLanguageServer/Charting/ChartOptions.cs b/src/KustoLanguageServer/Charting/ChartOptions.cs index 31481e9..e2c7e36 100644 --- a/src/KustoLanguageServer/Charting/ChartOptions.cs +++ b/src/KustoLanguageServer/Charting/ChartOptions.cs @@ -79,11 +79,25 @@ public static ChartOptions FromChartVisualizationOptions(ChartVisualizationOptio Legend = options.Legend.ToString(), XAxis = options.XAxis.ToString(), YAxis = options.YAxis.ToString(), - Xmin = options.Xmin, - Xmax = options.Xmax, - Ymin = options.Ymin, - Ymax = options.Ymax, + Xmin = ConvertNanToNull(options.Xmin), + Xmax = ConvertNanToNull(options.Xmax), + Ymin = ConvertNanToNull(options.Ymin), + Ymax = ConvertNanToNull(options.Ymax), Accumulate = options.Accumulate }; } + + private static object? ConvertNanToNull(object? value) + { + return value switch + { + double d when double.IsNaN(d) => null, + double d when double.IsPositiveInfinity(d) => double.MaxValue, + double d when double.IsNegativeInfinity(d) => double.MinValue, + float f when float.IsNaN(f) => null, + float f when float.IsPositiveInfinity(f) => float.MaxValue, + float f when float.IsNegativeInfinity(f) => float.MinValue, + _ => value + }; + } } diff --git a/src/VsCodeExtension/extension.ts b/src/VsCodeExtension/extension.ts index 3b74464..f82d97a 100644 --- a/src/VsCodeExtension/extension.ts +++ b/src/VsCodeExtension/extension.ts @@ -8,7 +8,7 @@ import * as conn from './features/connectionsPanel' import * as connections from './features/connections' import * as documentPanels from './features/documentPanels' import * as chartPanel from './features/chartPanel' -import * as charts from './features/results' +import * as charts from './features/resultsEditor' import * as copilot from './features/copilot' import * as connectionStatusBar from './features/connectionStatusBar' import * as clientStorage from './features/clientStorage' diff --git a/src/VsCodeExtension/features/chartPanel.ts b/src/VsCodeExtension/features/chartPanel.ts index 92ff4ae..5b4ef73 100644 --- a/src/VsCodeExtension/features/chartPanel.ts +++ b/src/VsCodeExtension/features/chartPanel.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; import * as server from './server'; -import { isDarkMode, injectChartMessageHandler, handleChartWebviewMessage, registerChartWebview, saveResults } from './results'; +import { isDarkMode, injectChartMessageHandler, handleChartWebviewMessage, registerChartWebview, saveResults } from './resultsEditor'; let chartPanel: vscode.WebviewPanel | undefined; let currentResultData: server.ResultData | undefined; diff --git a/src/VsCodeExtension/features/results.ts b/src/VsCodeExtension/features/resultsEditor.ts similarity index 51% rename from src/VsCodeExtension/features/results.ts rename to src/VsCodeExtension/features/resultsEditor.ts index b1b6ec0..5bb9707 100644 --- a/src/VsCodeExtension/features/results.ts +++ b/src/VsCodeExtension/features/resultsEditor.ts @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. // 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. +*/ + import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; import * as server from './server'; @@ -20,11 +25,30 @@ const chartWebviews = new Set(); /** The most recently focused chart webview panel. */ let activeChartWebview: vscode.WebviewPanel | undefined; +/** Known chart kinds for the edit panel dropdown (must match server-side ChartKind constants). */ +const chartKinds = [ + 'AreaChart', 'BarChart', 'Card', 'ColumnChart', 'Graph', + 'LineChart', 'PieChart', 'PivotChart', 'Plotly', 'Sankey', + 'ScatterChart', 'StackedAreaChart', '3DChart', + 'TimeLadderChart', 'TimeLineChart', 'TimeLineWithAnomalyChart', + 'TimePivot', 'TreeMap' +]; + +/** Known chart modes (must match server-side ChartMode constants). */ +const chartModes = ['Default', 'Unstacked', 'Stacked', 'Stacked100']; + +/** Known legend options (must match server-side ChartLegendMode constants). */ +const legendOptions = ['Visible', 'Hidden']; + +/** Known axis type options (must match server-side ChartAxis constants). */ +const axisTypes = ['Linear', 'Log']; + /** Per-editor state for result editor webview panels. */ interface ResultEditorState { resultData: server.ResultData; tableNames: string[]; activeView: string; // 'chart', 'table-0', 'table-1', etc. + chartOptionsOverride?: server.ChartOptions; } /** Map from webview panel to its editor state. */ @@ -74,6 +98,9 @@ export function activate(context: vscode.ExtensionContext, client: LanguageClien }), vscode.commands.registerCommand('kusto.copyChartDark', () => { activeChartWebview?.webview.postMessage({ command: 'copyChartDark' }); + }), + vscode.commands.registerCommand('kusto.toggleChartEditor', () => { + activeChartWebview?.webview.postMessage({ command: 'toggleEditPanel' }); }) ); } @@ -182,6 +209,8 @@ const chartMessageHandlerScript = ` (function() { const vscodeApi = (typeof acquireVsCodeApi === 'function') ? acquireVsCodeApi() : null; if (!vscodeApi) return; + // Expose for edit panel script + window._vscodeApi = vscodeApi; // Track the element under the cursor when context menu opens (for copyCell) let lastContextTarget = null; @@ -361,6 +390,20 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Track this editor webview for copy commands registerChartWebview(webviewPanel); + // Update context key when this panel gains/loses focus + const updateChartContext = () => { + if (webviewPanel.active) { + const state = editorStates.get(webviewPanel); + const hasChart = !!state?.resultData?.chartOptions; + vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', hasChart); + } + }; + webviewPanel.onDidChangeViewState(() => updateChartContext()); + + // Debounce timer for chart options updates + let chartOptionsTimer: ReturnType | undefined; + let ignoringSelfEdit = false; + // Listen for messages from the webview webviewPanel.webview.onDidReceiveMessage(async (message) => { if (message.command === 'viewChanged' && typeof message.viewId === 'string') { @@ -374,6 +417,28 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { vscode.env.clipboard.writeText(message.text); return; } + if (message.command === 'chartOptionsChanged' && message.chartOptions) { + const state = editorStates.get(webviewPanel); + if (!state) { return; } + state.chartOptionsOverride = message.chartOptions as server.ChartOptions; + if (chartOptionsTimer) { clearTimeout(chartOptionsTimer); } + chartOptionsTimer = setTimeout(async () => { + await this.updateChartOnly(state, webviewPanel); + // Persist updated chart options to the backing file + const updatedData: server.ResultData = { ...state.resultData, chartOptions: state.chartOptionsOverride! }; + const content = JSON.stringify(updatedData, null, 2); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, fullRange, content); + ignoringSelfEdit = true; + await vscode.workspace.applyEdit(edit); + ignoringSelfEdit = false; + }, 600); + return; + } handleChartWebviewMessage(message); }); @@ -383,6 +448,9 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { // Re-render when the document content changes (e.g. external edit) const changeSubscription = vscode.workspace.onDidChangeTextDocument(async e => { if (e.document.uri.toString() === document.uri.toString()) { + if (ignoringSelfEdit) { + return; + } await this.updateWebview(document, webviewPanel); } }); @@ -393,7 +461,9 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { }); webviewPanel.onDidDispose(() => { + if (chartOptionsTimer) { clearTimeout(chartOptionsTimer); } editorStates.delete(webviewPanel); + vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', false); changeSubscription.dispose(); themeSubscription.dispose(); }); @@ -428,6 +498,11 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { const hasChart = !!chartResult?.html; const hasTable = !!dataResult?.tables?.length; + // Update context key for editor title actions + if (webviewPanel.active) { + vscode.commands.executeCommand('setContext', 'kusto.resultEditorHasChart', hasChart); + } + if (!hasTable && !hasChart) { webviewPanel.webview.html = '

Failed to render results.

'; return; @@ -443,14 +518,35 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { activeView: existingState?.activeView ?? firstActiveView }); - const html = this.buildDualViewHtml(dataResult, chartResult?.html, hasChart); + 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); webviewPanel.webview.html = injectChartMessageHandler(html); } + private async updateChartOnly(state: ResultEditorState, webviewPanel: vscode.WebviewPanel): Promise { + const chartOptions = state.chartOptionsOverride ?? state.resultData.chartOptions; + if (!chartOptions) { return; } + const modifiedData: server.ResultData = { + ...state.resultData, + chartOptions + }; + const darkMode = isDarkMode(); + const chartResult = await server.getChartAsHtml(languageClient, modifiedData, darkMode); + if (chartResult?.html) { + webviewPanel.webview.postMessage({ + command: 'updateChart', + chartBodyHtml: this.extractBody(chartResult.html) + }); + } + } + private buildDualViewHtml( dataResult: DataAsHtml | null, chartHtml: string | undefined, - hasChart: boolean + hasChart: boolean, + chartOptions?: server.ChartOptions, + columnNames?: string[] ): string { const tables = dataResult?.tables ?? []; @@ -485,6 +581,9 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { const firstActiveView = hasChart ? 'chart' : 'table-0'; + // Build chart options edit panel + const editPanelHtml = hasChart ? this.buildEditPanelHtml(chartOptions, columnNames ?? []) : ''; + return ` @@ -501,6 +600,7 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { } .view-toggle { display: flex; + align-items: center; border-bottom: 1px solid var(--vscode-panel-border, #444); background: var(--vscode-editor-background); position: sticky; @@ -524,9 +624,134 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { border-bottom-color: var(--vscode-focusBorder, #007acc); color: var(--vscode-foreground); } - .view-content { display: none; height: calc(100vh - 33px); overflow: auto; } + .view-toggle .spacer { flex: 1; } + .main-area { + display: flex; + height: calc(100vh - 33px); + } + .content-area { + flex: 1; + overflow: hidden; + min-width: 0; + } + .view-content { display: none; height: 100%; overflow: auto; } .view-content.active { display: block; } #chart-view { padding: 0; } + /* Edit panel */ + .edit-panel { + display: none; + width: 280px; + min-width: 280px; + border-left: 1px solid var(--vscode-panel-border, #444); + background: var(--vscode-sideBar-background, var(--vscode-editor-background)); + overflow-y: auto; + padding: 12px; + box-sizing: border-box; + } + .edit-panel.visible { display: block; } + .edit-panel h3 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-foreground); + opacity: 0.8; + } + .edit-panel .field { margin-bottom: 10px; } + .edit-panel label { + display: block; + margin-bottom: 3px; + font-size: 11px; + color: var(--vscode-descriptionForeground, var(--vscode-foreground)); + } + .edit-panel select, + .edit-panel input[type="text"] { + width: 100%; + padding: 4px 6px; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-input-border, #555); + border-radius: 2px; + font-family: inherit; + font-size: inherit; + box-sizing: border-box; + } + .edit-panel select:focus, + .edit-panel input[type="text"]:focus { + outline: 1px solid var(--vscode-focusBorder, #007acc); + outline-offset: -1px; + } + .edit-panel .checkbox-field { + display: flex; + align-items: center; + gap: 6px; + } + .edit-panel .checkbox-field label { + display: inline; + margin-bottom: 0; + } + .edit-panel .column-picker { + display: flex; + gap: 4px; + } + .edit-panel .column-picker select { + flex: 1; + min-width: 0; + } + .edit-panel .column-picker button { + padding: 2px 8px; + cursor: pointer; + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + border: none; + border-radius: 2px; + font-size: inherit; + } + .edit-panel .column-picker button:hover { + background: var(--vscode-button-hoverBackground, #1177bb); + } + .edit-panel .column-list { + list-style: none; + padding: 0; + margin: 4px 0 0 0; + max-height: 120px; + overflow-y: auto; + border: 1px solid var(--vscode-input-border, #555); + border-radius: 2px; + background: var(--vscode-input-background, #3c3c3c); + } + .edit-panel .column-list:empty { + display: none; + } + .edit-panel .column-list li { + display: flex; + align-items: center; + padding: 2px 4px; + gap: 2px; + } + .edit-panel .column-list li:hover { + background: var(--vscode-list-hoverBackground, #2a2d2e); + } + .edit-panel .column-list li span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .edit-panel .column-list li button { + background: none; + border: none; + color: var(--vscode-foreground, #ccc); + cursor: pointer; + padding: 0 2px; + font-size: 14px; + line-height: 1; + opacity: 0.7; + } + .edit-panel .column-list li button:hover { + opacity: 1; + } /* Table styles */ table { border-collapse: collapse; width: 100%; } th, td { @@ -549,8 +774,13 @@ class ResultEditorProvider implements vscode.CustomTextEditorProvider { ${chartButton} ${tableButtons} - ${hasChart ? `
${chartContent}
` : ''} - ${tableContents} +
+
+ ${hasChart ? `
${chartContent}
` : ''} + ${tableContents} +
+ ${editPanelHtml} +
`; } + private buildEditPanelHtml(chartOptions: server.ChartOptions | undefined, columnNames: string[]): string { + const opts = chartOptions ?? { kind: 'ColumnChart' }; + + // Ensure the current kind is always present in the dropdown + const allKinds = chartKinds.includes(opts.kind) ? chartKinds : [opts.kind, ...chartKinds]; + const kindOptions = allKinds.map(k => + `` + ).join(''); + + // Ensure the current mode is always present in the dropdown + const currentMode = opts.mode ?? ''; + const allModes = !currentMode || chartModes.includes(currentMode) ? chartModes : [currentMode, ...chartModes]; + const modeOptions = ['', ...allModes].map(m => + `` + ).join(''); + + const legendChecked = (opts.legend ?? 'Visible') !== 'Hidden' ? ' checked' : ''; + + const xColOptions = ['', ...columnNames].map(c => + `` + ).join(''); + + const currentXAxis = opts.xAxis ?? ''; + const allAxisTypes = currentXAxis && !axisTypes.includes(currentXAxis) ? [currentXAxis, ...axisTypes] : axisTypes; + const xAxisOptions = ['', ...allAxisTypes].map(a => + `` + ).join(''); + + const currentYAxis = opts.yAxis ?? ''; + const allYAxisTypes = currentYAxis && !axisTypes.includes(currentYAxis) ? [currentYAxis, ...axisTypes] : axisTypes; + const yAxisOptions = ['', ...allYAxisTypes].map(a => + `` + ).join(''); + + const allColOptions = columnNames.map(c => + `` + ).join(''); + + const yColumnsItems = (opts.yColumns ?? []).map(c => + `
  • ${this.escapeHtml(c)}
  • ` + ).join(''); + + const seriesItems = (opts.series ?? []).map(c => + `
  • ${this.escapeHtml(c)}
  • ` + ).join(''); + + return `
    +

    Chart Options

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
      ${yColumnsItems}
    +
    +
    + +
    + + +
    +
      ${seriesItems}
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    `; + } + private buildTabbedTableHtml(tables: HtmlTable[]): string { if (tables.length === 1) { return tables[0]!.html; diff --git a/src/VsCodeExtension/features/resultsPanel.ts b/src/VsCodeExtension/features/resultsPanel.ts index 707ca6a..19e2630 100644 --- a/src/VsCodeExtension/features/resultsPanel.ts +++ b/src/VsCodeExtension/features/resultsPanel.ts @@ -1,11 +1,15 @@ // 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 './results'; +import { saveResults, copyCellFromEditor, copyDataFromEditor, copyTableAsExpressionFromEditor } from './resultsEditor'; import * as chartPanel from './chartPanel'; import { resultDataToMarkdown } from './markdown'; import { resultDataToHtml, HtmlTable } from './html'; diff --git a/src/VsCodeExtension/package.json b/src/VsCodeExtension/package.json index bcceb75..2bccae9 100644 --- a/src/VsCodeExtension/package.json +++ b/src/VsCodeExtension/package.json @@ -201,6 +201,11 @@ "command": "kusto.moveChartToMain", "title": "Move to Main Editor", "icon": "$(move)" + }, + { + "command": "kusto.toggleChartEditor", + "title": "Edit Chart Options", + "icon": "$(edit)" } ], "menus": { @@ -318,6 +323,11 @@ "command": "kusto.moveChartToMain", "when": "activeWebviewPanelId == kusto", "group": "navigation@3" + }, + { + "command": "kusto.toggleChartEditor", + "when": "activeWebviewPanelId == kusto.resultEditor && kusto.resultEditorHasChart", + "group": "navigation@3" } ], "editor/context": [