diff --git a/build/esbuild/build.ts b/build/esbuild/build.ts index 4e5bdbb7b9..7b826f8589 100644 --- a/build/esbuild/build.ts +++ b/build/esbuild/build.ts @@ -336,6 +336,18 @@ async function buildAll() { path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'vegaRenderer', 'vegaRenderer.js'), { target: 'web', watch: isWatchMode } ), + build( + path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'sql-metadata-renderer', 'index.ts'), + path.join( + extensionFolder, + 'dist', + 'webviews', + 'webview-side', + 'sqlMetadataRenderer', + 'sqlMetadataRenderer.js' + ), + { target: 'web', watch: isWatchMode } + ), build( path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'variable-view', 'index.tsx'), path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'viewers', 'variableView.js'), diff --git a/package.json b/package.json index d9bffeb461..39437d90cd 100644 --- a/package.json +++ b/package.json @@ -1856,6 +1856,15 @@ "application/vnd.vega.v5+json" ], "requiresMessaging": "optional" + }, + { + "id": "deepnote-sql-metadata-renderer", + "displayName": "Deepnote SQL Metadata Renderer", + "entrypoint": "./dist/webviews/webview-side/sqlMetadataRenderer/sqlMetadataRenderer.js", + "mimeTypes": [ + "application/vnd.deepnote.sql-output-metadata+json" + ], + "requiresMessaging": "optional" } ], "viewsContainers": { diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index 1567e91ab4..e37a564b49 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -220,6 +220,10 @@ export class DeepnoteDataConverter { ); } else if (item.mime === 'application/vnd.vega.v5+json') { data['application/vnd.vega.v5+json'] = JSON.parse(new TextDecoder().decode(item.data)); + } else if (item.mime === 'application/vnd.deepnote.sql-output-metadata+json') { + data['application/vnd.deepnote.sql-output-metadata+json'] = JSON.parse( + new TextDecoder().decode(item.data) + ); } } @@ -305,6 +309,15 @@ export class DeepnoteDataConverter { ); } + if (data['application/vnd.deepnote.sql-output-metadata+json']) { + items.push( + NotebookCellOutputItem.json( + data['application/vnd.deepnote.sql-output-metadata+json'], + 'application/vnd.deepnote.sql-output-metadata+json' + ) + ); + } + if (data['application/vnd.vegalite.v5+json']) { const patchedVegaLiteSpec = produce( data['application/vnd.vegalite.v5+json'] as TopLevel>, diff --git a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts index 8efc7b0734..8546f27032 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts @@ -431,6 +431,48 @@ suite('DeepnoteDataConverter', () => { assert.strictEqual(outputs[0].items[0].mime, 'text/plain'); assert.strictEqual(new TextDecoder().decode(outputs[0].items[0].data), 'fallback text'); }); + + test('converts SQL metadata output', () => { + const sqlMetadata = { + status: 'read_from_cache_success', + cache_created_at: '2024-10-21T10:30:00Z', + compiled_query: 'SELECT * FROM users', + variable_type: 'dataframe', + integration_id: 'postgres-prod', + size_in_bytes: 2621440 + }; + + const deepnoteOutputs: DeepnoteOutput[] = [ + { + output_type: 'execute_result', + execution_count: 1, + data: { + 'application/vnd.deepnote.sql-output-metadata+json': sqlMetadata + } + } + ]; + + const blocks: DeepnoteBlock[] = [ + { + blockGroup: 'test-group', + id: 'block1', + type: 'code', + content: 'SELECT * FROM users', + sortingKey: 'a0', + outputs: deepnoteOutputs + } + ]; + + const cells = converter.convertBlocksToCells(blocks); + const outputs = cells[0].outputs!; + + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].items.length, 1); + assert.strictEqual(outputs[0].items[0].mime, 'application/vnd.deepnote.sql-output-metadata+json'); + + const outputData = JSON.parse(new TextDecoder().decode(outputs[0].items[0].data)); + assert.deepStrictEqual(outputData, sqlMetadata); + }); }); suite('round trip conversion', () => { @@ -468,6 +510,53 @@ suite('DeepnoteDataConverter', () => { assert.deepStrictEqual(roundTripBlocks, originalBlocks); }); + test('SQL metadata output round-trips correctly', () => { + const sqlMetadata = { + status: 'read_from_cache_success', + cache_created_at: '2024-10-21T10:30:00Z', + compiled_query: 'SELECT * FROM users WHERE active = true', + variable_type: 'dataframe', + integration_id: 'postgres-prod', + size_in_bytes: 2621440 + }; + + const originalBlocks: DeepnoteBlock[] = [ + { + blockGroup: 'test-group', + id: 'sql-block', + type: 'code', + content: 'SELECT * FROM users WHERE active = true', + sortingKey: 'a0', + executionCount: 1, + metadata: {}, + outputs: [ + { + output_type: 'execute_result', + execution_count: 1, + data: { + 'application/vnd.deepnote.sql-output-metadata+json': sqlMetadata + } + } + ] + } + ]; + + const cells = converter.convertBlocksToCells(originalBlocks); + const roundTripBlocks = converter.convertCellsToBlocks(cells); + + // The round-trip should preserve the SQL metadata output + assert.strictEqual(roundTripBlocks.length, 1); + assert.strictEqual(roundTripBlocks[0].id, 'sql-block'); + assert.strictEqual(roundTripBlocks[0].outputs?.length, 1); + + const output = roundTripBlocks[0].outputs![0] as { + output_type: string; + data?: Record; + }; + assert.strictEqual(output.output_type, 'execute_result'); + assert.deepStrictEqual(output.data?.['application/vnd.deepnote.sql-output-metadata+json'], sqlMetadata); + }); + test('real deepnote notebook round-trips without losing data', () => { // Inline test data representing a real Deepnote notebook with various block types // blockGroup is an optional field not in the DeepnoteBlock interface, so we cast as any diff --git a/src/webviews/webview-side/sql-metadata-renderer/SqlMetadataRenderer.tsx b/src/webviews/webview-side/sql-metadata-renderer/SqlMetadataRenderer.tsx new file mode 100644 index 0000000000..7c5e3ec341 --- /dev/null +++ b/src/webviews/webview-side/sql-metadata-renderer/SqlMetadataRenderer.tsx @@ -0,0 +1,96 @@ +import React, { memo } from 'react'; + +export interface SqlMetadataRendererProps { + data: { + cache_created_at?: string; + compiled_query?: string; + integration_id?: string; + size_in_bytes?: number; + status: string; + variable_type?: string; + }; +} + +const getStatusMessage = (status: string) => { + switch (status) { + case 'read_from_cache_success': + return { + icon: '✓', + text: 'Query result loaded from cache', + color: 'var(--vscode-testing-iconPassed)' + }; + case 'success_no_cache': + return { + icon: 'ℹ', + text: 'Query executed successfully', + color: 'var(--vscode-notificationsInfoIcon-foreground)' + }; + case 'cache_not_supported_for_query': + return { + icon: 'ℹ', + text: 'Caching not supported for this query type', + color: 'var(--vscode-notificationsInfoIcon-foreground)' + }; + default: + return { + icon: 'ℹ', + text: `Status: ${status}`, + color: 'var(--vscode-foreground)' + }; + } +}; + +const formatBytes = (bytes: number) => { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +export const SqlMetadataRenderer = memo(function SqlMetadataRenderer({ data }: SqlMetadataRendererProps) { + const statusInfo = getStatusMessage(data.status); + + return ( +
+
+ {statusInfo.icon} + {statusInfo.text} +
+ + {data.cache_created_at && ( +
+ Cache created: {new Date(data.cache_created_at).toLocaleString()} +
+ )} + + {data.size_in_bytes !== undefined && ( +
+ Result size: {formatBytes(data.size_in_bytes)} +
+ )} +
+ ); +}); diff --git a/src/webviews/webview-side/sql-metadata-renderer/index.ts b/src/webviews/webview-side/sql-metadata-renderer/index.ts new file mode 100644 index 0000000000..c14afb8de2 --- /dev/null +++ b/src/webviews/webview-side/sql-metadata-renderer/index.ts @@ -0,0 +1,45 @@ +import type { ActivationFunction, OutputItem, RendererContext } from 'vscode-notebook-renderer'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import { SqlMetadataRenderer } from './SqlMetadataRenderer'; + +/** + * Renderer for SQL metadata output (application/vnd.deepnote.sql-output-metadata+json). + * This renderer displays information about SQL query execution, including cache status, + * query size, and other metadata. + */ +export const activate: ActivationFunction = (_context: RendererContext) => { + const roots = new Map(); + + return { + renderOutputItem(outputItem: OutputItem, element: HTMLElement) { + try { + const data = outputItem.json(); + + const root = document.createElement('div'); + element.appendChild(root); + roots.set(outputItem.id, root); + + ReactDOM.render(React.createElement(SqlMetadataRenderer, { data }), root); + } catch (error) { + console.error(`Error rendering SQL metadata: ${error}`); + const errorDiv = document.createElement('div'); + errorDiv.style.padding = '10px'; + errorDiv.style.color = 'var(--vscode-errorForeground)'; + errorDiv.textContent = `Error rendering SQL metadata: ${error}`; + element.appendChild(errorDiv); + } + }, + + disposeOutputItem(id?: string) { + if (id) { + const root = roots.get(id); + if (root) { + ReactDOM.unmountComponentAtNode(root); + roots.delete(id); + } + } + } + }; +};