diff --git a/build/esbuild/build.ts b/build/esbuild/build.ts index 7b826f8589..2ffacf0601 100644 --- a/build/esbuild/build.ts +++ b/build/esbuild/build.ts @@ -331,6 +331,18 @@ async function buildAll() { path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'dataframeRenderer', 'dataframeRenderer.js'), { target: 'web', watch: isWatchMode } ), + build( + path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'chart-big-number-renderer', 'index.ts'), + path.join( + extensionFolder, + 'dist', + 'webviews', + 'webview-side', + 'chartBigNumberRenderer', + 'chartBigNumberRenderer.js' + ), + { target: 'web', watch: isWatchMode } + ), build( path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'vega-renderer', 'index.ts'), path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'vegaRenderer', 'vegaRenderer.js'), diff --git a/package-lock.json b/package-lock.json index 15c748e392..ce70e55104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,8 @@ "vscode-tas-client": "^0.1.84", "ws": "^6.2.3", "zeromq": "^6.5.0", - "zeromqold": "npm:zeromq@^6.0.0-beta.6" + "zeromqold": "npm:zeromq@^6.0.0-beta.6", + "zod": "^4.1.12" }, "devDependencies": { "@actions/core": "^1.11.1", diff --git a/package.json b/package.json index 39437d90cd..00bfe71b07 100644 --- a/package.json +++ b/package.json @@ -1848,6 +1848,15 @@ ], "requiresMessaging": "optional" }, + { + "id": "deepnote-chart-big-number-renderer", + "displayName": "Deepnote Chart Big Number Renderer", + "entrypoint": "./dist/webviews/webview-side/chartBigNumberRenderer/chartBigNumberRenderer.js", + "mimeTypes": [ + "application/vnd.deepnote.chart.big-number+json" + ], + "requiresMessaging": "optional" + }, { "id": "deepnote-vega-renderer", "displayName": "Deepnote Vega Chart Renderer", @@ -2235,7 +2244,8 @@ "vscode-tas-client": "^0.1.84", "ws": "^6.2.3", "zeromq": "^6.5.0", - "zeromqold": "npm:zeromq@^6.0.0-beta.6" + "zeromqold": "npm:zeromq@^6.0.0-beta.6", + "zod": "^4.1.12" }, "devDependencies": { "@actions/core": "^1.11.1", diff --git a/src/kernels/execution/cellExecution.ts b/src/kernels/execution/cellExecution.ts index 8e8a2d79f1..5bedc16970 100644 --- a/src/kernels/execution/cellExecution.ts +++ b/src/kernels/execution/cellExecution.ts @@ -4,6 +4,7 @@ import type * as KernelMessage from '@jupyterlab/services/lib/kernel/messages'; import { NotebookCell, NotebookCellExecution, workspace, NotebookCellOutput } from 'vscode'; +import { createPythonCode } from '@deepnote/blocks'; import type { Kernel } from '@jupyterlab/services'; import { CellExecutionCreator } from './cellExecutionCreator'; import { analyzeKernelErrors, createOutputWithErrorMessageForDisplay } from '../../platform/errors/errorUtils'; @@ -32,7 +33,6 @@ import { KernelError } from '../errors/kernelError'; import { getCachedSysPrefix } from '../../platform/interpreter/helpers'; import { getCellMetadata } from '../../platform/common/utils'; import { NotebookCellExecutionState, notebookCellExecutions } from '../../platform/notebooks/cellExecutionStateService'; -import { createPythonCode } from '@deepnote/blocks'; import { DeepnoteDataConverter } from '../../notebooks/deepnote/deepnoteDataConverter'; /** diff --git a/src/kernels/execution/cellExecutionMessageHandler.ts b/src/kernels/execution/cellExecutionMessageHandler.ts index 98ca0cf922..cce4cadedf 100644 --- a/src/kernels/execution/cellExecutionMessageHandler.ts +++ b/src/kernels/execution/cellExecutionMessageHandler.ts @@ -24,6 +24,7 @@ import { window, extensions } from 'vscode'; +import { coerce, SemVer } from 'semver'; import type { Kernel } from '@jupyterlab/services'; import { CellExecutionCreator } from './cellExecutionCreator'; @@ -45,7 +46,7 @@ import { handleTensorBoardDisplayDataOutput } from './executionHelpers'; import { Identifiers, RendererExtension, WIDGET_MIMETYPE } from '../../platform/common/constants'; import { CellOutputDisplayIdTracker } from './cellDisplayIdTracker'; import { createDeferred } from '../../platform/common/utils/async'; -import { coerce, SemVer } from 'semver'; +import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants'; // Helper interface for the set_next_input execute reply payload interface ISetNextInputPayload { @@ -1181,6 +1182,15 @@ export class CellExecutionMessageHandler implements IDisposable { const output = translateCellDisplayOutput( new NotebookCellOutput(outputToBeUpdated.outputItems, outputToBeUpdated.outputContainer.metadata) ); + + const data = msg.content.data; + // deepnote-toolkit returns the text/plain mime type for big number outputs + // and for the custom renderer to be used, we need to return the application/vnd.deepnote.chart.big-number+json mime type + if (outputToBeUpdated.cell.metadata['__deepnotePocket']?.['type'] === 'big-number') { + data[CHART_BIG_NUMBER_MIME_TYPE] = data['text/plain']; + delete data['text/plain']; + } + const newOutput = cellOutputToVSCCellOutput( { ...output, diff --git a/src/kernels/execution/helpers.ts b/src/kernels/execution/helpers.ts index c18fbd15e3..78ab6f08ee 100644 --- a/src/kernels/execution/helpers.ts +++ b/src/kernels/execution/helpers.ts @@ -2,30 +2,32 @@ // Licensed under the MIT License. import type * as nbformat from '@jupyterlab/nbformat'; -import { NotebookCellOutput, NotebookCellOutputItem, NotebookCell, Position, Range } from 'vscode'; +import { NotebookCell, NotebookCellOutput, NotebookCellOutputItem, Position, Range } from 'vscode'; // eslint-disable-next-line @typescript-eslint/no-require-imports import type { KernelMessage } from '@jupyterlab/services'; import fastDeepEqual from 'fast-deep-equal'; -import * as path from '../../platform/vscode-path/path'; -import * as uriPath from '../../platform/vscode-path/resources'; +import { Pocket } from '../../platform/deepnote/pocket'; import { PYTHON_LANGUAGE } from '../../platform/common/constants'; import { concatMultilineString, splitMultilineString } from '../../platform/common/utils'; +import { StopWatch } from '../../platform/common/utils/stopWatch'; +import { base64ToUint8Array, uint8ArrayToBase64 } from '../../platform/common/utils/string'; +import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants'; +import { getExtensionSpecificStack } from '../../platform/errors/errors'; +import { createOutputWithErrorMessageForDisplay } from '../../platform/errors/errorUtils'; +import { getCachedEnvironment, getVersion } from '../../platform/interpreter/helpers'; import { logger } from '../../platform/logging'; +import type { NotebookCellExecutionState } from '../../platform/notebooks/cellExecutionStateService'; +import * as path from '../../platform/vscode-path/path'; +import * as uriPath from '../../platform/vscode-path/resources'; import { sendTelemetryEvent, Telemetry } from '../../telemetry'; -import { createOutputWithErrorMessageForDisplay } from '../../platform/errors/errorUtils'; -import { CellExecutionCreator } from './cellExecutionCreator'; -import { IKernelController, KernelConnectionMetadata } from '../types'; import { - isPythonKernelConnection, getInterpreterFromKernelConnectionMetadata, - kernelConnectionMetadataHasKernelModel, - getKernelRegistrationInfo + getKernelRegistrationInfo, + isPythonKernelConnection, + kernelConnectionMetadataHasKernelModel } from '../helpers'; -import { StopWatch } from '../../platform/common/utils/stopWatch'; -import { getExtensionSpecificStack } from '../../platform/errors/errors'; -import { getCachedEnvironment, getVersion } from '../../platform/interpreter/helpers'; -import { base64ToUint8Array, uint8ArrayToBase64 } from '../../platform/common/utils/string'; -import type { NotebookCellExecutionState } from '../../platform/notebooks/cellExecutionStateService'; +import { IKernelController, KernelConnectionMetadata } from '../types'; +import { CellExecutionCreator } from './cellExecutionCreator'; export enum CellOutputMimeTypes { error = 'application/vnd.code.notebook.error', @@ -260,6 +262,9 @@ function translateDisplayDataOutput( } } */ + const deepnotePocket = cellMetadata?.__deepnotePocket as Pocket | undefined; + const deepnoteBlockType = deepnotePocket?.type; + const metadata = getOutputMetadata(output, cellIndex, cellId, cellMetadata); // If we have SVG or PNG, then add special metadata to indicate whether to display `open plot` if ('image/svg+xml' in output.data || 'image/png' in output.data) { @@ -269,7 +274,9 @@ function translateDisplayDataOutput( if (output.data) { // eslint-disable-next-line no-restricted-syntax for (const key in output.data) { - items.push(convertJupyterOutputToBuffer(key, output.data[key])); + // TODO - remove this once this is handled in the deepnote-toolkit + let effectiveKey = deepnoteBlockType === 'big-number' ? CHART_BIG_NUMBER_MIME_TYPE : key; + items.push(convertJupyterOutputToBuffer(effectiveKey, output.data[key] ?? output.data[effectiveKey])); } } diff --git a/src/notebooks/deepnote/converters/blockConverter.ts b/src/notebooks/deepnote/converters/blockConverter.ts index 58aa8d52ec..7db2535769 100644 --- a/src/notebooks/deepnote/converters/blockConverter.ts +++ b/src/notebooks/deepnote/converters/blockConverter.ts @@ -1,6 +1,6 @@ import type { NotebookCellData } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; export interface BlockConverter { applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void; diff --git a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts new file mode 100644 index 0000000000..949bd8e57f --- /dev/null +++ b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts @@ -0,0 +1,64 @@ +import { NotebookCellData, NotebookCellKind } from 'vscode'; +import { z } from 'zod'; + +import type { BlockConverter } from './blockConverter'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; +import { DeepnoteBigNumberMetadataSchema } from '../deepnoteSchemas'; +import { parseJsonWithFallback } from '../dataConversionUtils'; +import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; + +const DEFAULT_BIG_NUMBER_CONFIG = DeepnoteBigNumberMetadataSchema.parse({}); + +export class ChartBigNumberBlockConverter implements BlockConverter { + applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { + block.content = ''; + + const config = DeepnoteBigNumberMetadataSchema.safeParse(parseJsonWithFallback(cell.value)); + + if (config.success !== true) { + block.metadata = { + ...block.metadata, + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: cell.value + }; + + return; + } + + if (block.metadata != null) { + delete block.metadata[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]; + } + + block.metadata = { + ...(block.metadata ?? {}), + ...config.data + }; + } + + canConvert(blockType: string): boolean { + return blockType.toLowerCase() === 'big-number'; + } + + convertToCell(block: DeepnoteBlock): NotebookCellData { + const deepnoteJupyterRawContentResult = z.string().safeParse(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); + const deepnoteBigNumberMetadataResult = DeepnoteBigNumberMetadataSchema.safeParse(block.metadata); + + if (deepnoteBigNumberMetadataResult.error != null) { + console.error('Error parsing deepnote big number metadata:', deepnoteBigNumberMetadataResult.error); + console.debug('Metadata:', JSON.stringify(block.metadata)); + } + + const configStr = deepnoteJupyterRawContentResult.success + ? deepnoteJupyterRawContentResult.data + : deepnoteBigNumberMetadataResult.success + ? JSON.stringify(deepnoteBigNumberMetadataResult.data, null, 2) + : JSON.stringify(DEFAULT_BIG_NUMBER_CONFIG, null, 2); + + const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + + return cell; + } + + getSupportedTypes(): string[] { + return ['big-number']; + } +} diff --git a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts new file mode 100644 index 0000000000..120efd3420 --- /dev/null +++ b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts @@ -0,0 +1,462 @@ +import { assert } from 'chai'; +import { NotebookCellData, NotebookCellKind } from 'vscode'; + +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; +import { ChartBigNumberBlockConverter } from './chartBigNumberBlockConverter'; +import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; + +suite('ChartBigNumberBlockConverter', () => { + let converter: ChartBigNumberBlockConverter; + + setup(() => { + converter = new ChartBigNumberBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts percentage change comparison block to cell', () => { + const block: DeepnoteBlock = { + blockGroup: '30b63388a2ad4cf19e9aa3888220a98f', + content: '', + id: '59ae10ae7fee437d828601fae86a955b', + metadata: { + execution_start: 1759913029303, + execution_millis: 0, + execution_context_id: '6ba1d348-b911-4d71-a61c-ea2c18c6479a', + deepnote_big_number_title: 'test title', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }, + sortingKey: 'x', + type: 'big-number', + executionCount: 9, + outputs: [ + { + output_type: 'execute_result', + execution_count: 9, + data: { + 'text/plain': + '{"comparisonTitle": "vs a", "comparisonValue": "10", "title": "percentage change", "value": "30"}' + }, + metadata: {} + } + ] + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: 'test title', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }); + }); + + test('converts absolute change comparison block to cell', () => { + const block: DeepnoteBlock = { + blockGroup: '8a409554b54241e89f4aa001506ce335', + content: '', + id: '8b3c525d4c974405a6d4da77b193023e', + metadata: { + allow_embed: false, + execution_start: 1759939140215, + execution_millis: 1, + execution_context_id: 'f493c0ab-aea3-4cdd-84da-77865f369ba8', + deepnote_big_number_title: 'absolute change 2', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'absolute-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }, + sortingKey: 'y', + type: 'big-number', + executionCount: 9, + outputs: [ + { + output_type: 'execute_result', + execution_count: 9, + data: { + 'text/plain': + '{"comparisonTitle": "vs a", "comparisonValue": "10", "title": "absolute change", "value": "30"}' + }, + metadata: {} + } + ] + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: 'absolute change 2', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'absolute-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }); + }); + + test('converts absolute value comparison block to cell', () => { + const block: DeepnoteBlock = { + blockGroup: '22df6f01c5c44cc081e3af4dceb397e7', + content: '', + id: 'f7016119e6554cfc8ab423ae4cc981b1', + metadata: { + allow_embed: false, + execution_start: 1759939160631, + execution_millis: 0, + execution_context_id: 'f493c0ab-aea3-4cdd-84da-77865f369ba8', + deepnote_big_number_title: 'absolute change 2', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'absolute-value', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }, + sortingKey: 'yU', + type: 'big-number', + executionCount: 18, + outputs: [ + { + output_type: 'execute_result', + execution_count: 18, + data: { + 'text/plain': + '{"comparisonTitle": "vs a", "comparisonValue": "10", "title": "absolute value", "value": "30"}' + }, + metadata: {} + } + ] + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: 'absolute change 2', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'absolute-value', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }); + }); + + test('converts disabled comparison block to cell', () => { + const block: DeepnoteBlock = { + blockGroup: '769a4e758a7b4e0b88d2e74bc82d75ca', + content: '', + id: '53cc14203e6243fe915b411a88b36845', + metadata: { + allow_embed: false, + execution_start: 1759939184252, + execution_millis: 1, + execution_context_id: 'f493c0ab-aea3-4cdd-84da-77865f369ba8', + deepnote_big_number_title: 'some title', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'plain', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: false + }, + sortingKey: 'yj', + type: 'big-number', + executionCount: 33, + outputs: [ + { + output_type: 'execute_result', + execution_count: 33, + data: { + 'text/plain': + '{"comparisonTitle": "vs a", "comparisonValue": "10", "title": "Some title", "value": "30"}' + }, + metadata: {} + } + ] + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: 'some title', + deepnote_big_number_value: 'b', + deepnote_big_number_format: 'plain', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs a', + deepnote_big_number_comparison_value: 'a', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: false + }); + }); + + test('prefers raw content when DEEPNOTE_VSCODE_RAW_CONTENT_KEY exists', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + deepnote_big_number_title: 'metadata title', + deepnote_big_number_value: 'metadata value', + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: + '{"deepnote_big_number_title": "raw title", "deepnote_big_number_value": "raw value"}' + }, + sortingKey: 'a0', + type: 'big-number' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + assert.strictEqual( + cell.value, + '{"deepnote_big_number_title": "raw title", "deepnote_big_number_value": "raw value"}' + ); + }); + + test('uses default config when metadata is invalid', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + invalid_field: 'invalid value' + }, + sortingKey: 'a0', + type: 'big-number' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: '', + deepnote_big_number_value: '', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: '', + deepnote_big_number_comparison_title: '', + deepnote_big_number_comparison_value: '', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: false + }); + }); + + test('uses default config when metadata is empty', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: {}, + sortingKey: 'a0', + type: 'big-number' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const config = JSON.parse(cell.value); + assert.deepStrictEqual(config, { + deepnote_big_number_title: '', + deepnote_big_number_value: '', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: '', + deepnote_big_number_comparison_title: '', + deepnote_big_number_comparison_value: '', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: false + }); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON config to block metadata', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + metadata: { existing: 'value' }, + sortingKey: 'a0', + type: 'big-number' + }; + const configStr = JSON.stringify( + { + deepnote_big_number_title: 'new title', + deepnote_big_number_value: 'new value', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs old', + deepnote_big_number_comparison_value: 'old value', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }, + null, + 2 + ); + const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.deepStrictEqual(block.metadata, { + existing: 'value', + deepnote_big_number_title: 'new title', + deepnote_big_number_value: 'new value', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_title: 'vs old', + deepnote_big_number_comparison_value: 'old value', + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_enabled: true + }); + }); + + test('stores invalid JSON as raw content', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + metadata: { existing: 'value' }, + sortingKey: 'a0', + type: 'big-number' + }; + const cell = new NotebookCellData(NotebookCellKind.Code, 'invalid json {', 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], 'invalid json {'); + assert.strictEqual(block.metadata?.existing, 'value'); + }); + + test('removes DEEPNOTE_VSCODE_RAW_CONTENT_KEY when valid config is applied', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + metadata: { + existing: 'value', + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: 'old raw content' + }, + sortingKey: 'a0', + type: 'big-number' + }; + const configStr = JSON.stringify( + { + deepnote_big_number_title: 'new title' + }, + null, + 2 + ); + const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_big_number_title, 'new title'); + assert.strictEqual(block.metadata?.existing, 'value'); + assert.doesNotHaveAnyKeys(block.metadata, [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); + }); + + test('handles empty content', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + metadata: { existing: 'value' }, + sortingKey: 'a0', + type: 'big-number' + }; + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], ''); + assert.strictEqual(block.metadata?.existing, 'value'); + }); + + test('does not modify other block properties', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + executionCount: 5, + id: 'block-123', + metadata: { custom: 'value' }, + outputs: [], + sortingKey: 'a0', + type: 'big-number' + }; + const configStr = JSON.stringify( + { + deepnote_big_number_title: 'new title' + }, + null, + 2 + ); + const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.deepStrictEqual(block, { + blockGroup: 'test-group', + content: '', + executionCount: 5, + id: 'block-123', + metadata: { + custom: 'value', + deepnote_big_number_title: 'new title', + deepnote_big_number_comparison_enabled: false, + deepnote_big_number_comparison_format: '', + deepnote_big_number_comparison_title: '', + deepnote_big_number_comparison_type: '', + deepnote_big_number_comparison_value: '', + deepnote_big_number_format: 'number', + deepnote_big_number_value: '' + }, + outputs: [], + sortingKey: 'a0', + type: 'big-number' + }); + }); + }); +}); diff --git a/src/notebooks/deepnote/converters/codeBlockConverter.ts b/src/notebooks/deepnote/converters/codeBlockConverter.ts index ab412b5d94..bac2bf3095 100644 --- a/src/notebooks/deepnote/converters/codeBlockConverter.ts +++ b/src/notebooks/deepnote/converters/codeBlockConverter.ts @@ -1,7 +1,7 @@ import { NotebookCellData, NotebookCellKind } from 'vscode'; import type { BlockConverter } from './blockConverter'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; export class CodeBlockConverter implements BlockConverter { applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { diff --git a/src/notebooks/deepnote/converters/codeBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/codeBlockConverter.unit.test.ts index b25038378d..4c682c4a72 100644 --- a/src/notebooks/deepnote/converters/codeBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/codeBlockConverter.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { NotebookCellData, NotebookCellKind } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { CodeBlockConverter } from './codeBlockConverter'; suite('CodeBlockConverter', () => { diff --git a/src/notebooks/deepnote/converters/constants.ts b/src/notebooks/deepnote/converters/constants.ts new file mode 100644 index 0000000000..514120d3d3 --- /dev/null +++ b/src/notebooks/deepnote/converters/constants.ts @@ -0,0 +1 @@ +export const DEEPNOTE_VSCODE_RAW_CONTENT_KEY = 'deepnote_vscode_raw_content'; diff --git a/src/notebooks/deepnote/converters/markdownBlockConverter.ts b/src/notebooks/deepnote/converters/markdownBlockConverter.ts index fca485cb4a..703e1d1648 100644 --- a/src/notebooks/deepnote/converters/markdownBlockConverter.ts +++ b/src/notebooks/deepnote/converters/markdownBlockConverter.ts @@ -1,7 +1,7 @@ import { NotebookCellData, NotebookCellKind } from 'vscode'; import type { BlockConverter } from './blockConverter'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; export class MarkdownBlockConverter implements BlockConverter { applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { diff --git a/src/notebooks/deepnote/converters/markdownBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/markdownBlockConverter.unit.test.ts index f8acf8a247..0267bed98a 100644 --- a/src/notebooks/deepnote/converters/markdownBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/markdownBlockConverter.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { NotebookCellData, NotebookCellKind } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { MarkdownBlockConverter } from './markdownBlockConverter'; suite('MarkdownBlockConverter', () => { diff --git a/src/notebooks/deepnote/converters/sqlBlockConverter.ts b/src/notebooks/deepnote/converters/sqlBlockConverter.ts index 26aef54a58..cd946b8930 100644 --- a/src/notebooks/deepnote/converters/sqlBlockConverter.ts +++ b/src/notebooks/deepnote/converters/sqlBlockConverter.ts @@ -1,7 +1,7 @@ import { NotebookCellData, NotebookCellKind } from 'vscode'; import type { BlockConverter } from './blockConverter'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; /** * Converter for SQL blocks. diff --git a/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts index ae60c7ecb4..6d65729625 100644 --- a/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/sqlBlockConverter.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { NotebookCellData, NotebookCellKind } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { SqlBlockConverter } from './sqlBlockConverter'; import dedent from 'dedent'; diff --git a/src/notebooks/deepnote/converters/textBlockConverter.ts b/src/notebooks/deepnote/converters/textBlockConverter.ts index 1f591ed634..767c052275 100644 --- a/src/notebooks/deepnote/converters/textBlockConverter.ts +++ b/src/notebooks/deepnote/converters/textBlockConverter.ts @@ -2,7 +2,7 @@ import { createMarkdown, stripMarkdown } from '@deepnote/blocks'; import { NotebookCellData, NotebookCellKind } from 'vscode'; import type { BlockConverter } from './blockConverter'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; export class TextBlockConverter implements BlockConverter { protected static readonly textBlockTypes = [ diff --git a/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts index 22fbb7cb28..99bafaf35c 100644 --- a/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { NotebookCellData, NotebookCellKind } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { TextBlockConverter } from './textBlockConverter'; suite('TextBlockConverter', () => { diff --git a/src/notebooks/deepnote/converters/visualizationBlockConverter.ts b/src/notebooks/deepnote/converters/visualizationBlockConverter.ts index e3c7baac82..4ddb5b0177 100644 --- a/src/notebooks/deepnote/converters/visualizationBlockConverter.ts +++ b/src/notebooks/deepnote/converters/visualizationBlockConverter.ts @@ -1,7 +1,7 @@ import { NotebookCellData, NotebookCellKind } from 'vscode'; import type { BlockConverter } from './blockConverter'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; type DataframeFilter = { column: string; diff --git a/src/notebooks/deepnote/converters/visualizationBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/visualizationBlockConverter.unit.test.ts index db0936e11b..66fc0e5359 100644 --- a/src/notebooks/deepnote/converters/visualizationBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/visualizationBlockConverter.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { NotebookCellData, NotebookCellKind } from 'vscode'; -import type { DeepnoteBlock } from '../deepnoteTypes'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { VisualizationBlockConverter } from './visualizationBlockConverter'; suite('VisualizationBlockConverter', () => { diff --git a/src/notebooks/deepnote/dataConversionUtils.ts b/src/notebooks/deepnote/dataConversionUtils.ts index a3a82a14e6..1b30484770 100644 --- a/src/notebooks/deepnote/dataConversionUtils.ts +++ b/src/notebooks/deepnote/dataConversionUtils.ts @@ -2,6 +2,14 @@ * Utility functions for Deepnote block ID and sorting key generation */ +export function parseJsonWithFallback(value: string, fallback?: unknown): unknown | null { + try { + return JSON.parse(value); + } catch (error) { + return fallback ?? null; + } +} + /** * Generate a random hex ID for blocks (32 character hex string) */ diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index e37a564b49..4c5508d2db 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -1,11 +1,11 @@ import { NotebookCellData, NotebookCellKind, NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; -import type { DeepnoteBlock, DeepnoteOutput } from './deepnoteTypes'; import { generateBlockId, generateSortingKey } from './dataConversionUtils'; +import type { DeepnoteBlock, DeepnoteOutput } from '../../platform/deepnote/deepnoteTypes'; import { ConverterRegistry } from './converters/converterRegistry'; import { BlockConverter } from './converters/blockConverter'; import { CodeBlockConverter } from './converters/codeBlockConverter'; -import { addPocketToCellMetadata, createBlockFromPocket } from './pocket'; +import { addPocketToCellMetadata, createBlockFromPocket } from '../../platform/deepnote/pocket'; import { MarkdownBlockConverter } from './converters/markdownBlockConverter'; import { VisualizationBlockConverter } from './converters/visualizationBlockConverter'; import { compile as convertVegaLiteSpecToVega } from 'vega-lite'; @@ -14,6 +14,8 @@ import { SqlBlockConverter } from './converters/sqlBlockConverter'; import { TextBlockConverter } from './converters/textBlockConverter'; import type { Field } from 'vega-lite/build/src/channeldef'; import type { LayerSpec, TopLevel } from 'vega-lite/build/src/spec'; +import { ChartBigNumberBlockConverter } from './converters/chartBigNumberBlockConverter'; +import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants'; /** * Utility class for converting between Deepnote block structures and VS Code notebook cells. @@ -25,6 +27,7 @@ export class DeepnoteDataConverter { constructor() { this.registry.register(new CodeBlockConverter()); this.registry.register(new MarkdownBlockConverter()); + this.registry.register(new ChartBigNumberBlockConverter()); this.registry.register(new SqlBlockConverter()); this.registry.register(new TextBlockConverter()); this.registry.register(new VisualizationBlockConverter()); @@ -76,7 +79,13 @@ export class DeepnoteDataConverter { // Only set outputs if the block has them (including empty arrays) // This preserves round-trip fidelity if (block.outputs !== undefined) { - cell.outputs = this.transformOutputsForVsCode(block.outputs, index, block.id, block.metadata); + cell.outputs = this.transformOutputsForVsCode( + block.outputs, + index, + block.id, + block.type, + block.metadata + ); } return cell; @@ -251,6 +260,7 @@ export class DeepnoteDataConverter { outputs: DeepnoteOutput[], cellIndex: number, cellId: string, + blockType: DeepnoteBlock['type'], blockMetadata?: Record ): NotebookCellOutput[] { return outputs.map((output) => { @@ -361,9 +371,24 @@ export class DeepnoteDataConverter { ); } - // Plain text as fallback (always last) if (data['text/plain']) { - items.push(NotebookCellOutputItem.text(data['text/plain'] as string)); + let mimeType = 'text/plain'; + // deepnote-toolkit returns the text/plain mime type for big number outputs + // and for the custom renderer to be used, we need to return the application/vnd.deepnote.chart.big-number+json mime type + if (blockType === 'big-number' && !(CHART_BIG_NUMBER_MIME_TYPE in data)) { + mimeType = CHART_BIG_NUMBER_MIME_TYPE; + } + items.push(NotebookCellOutputItem.text(data['text/plain'] as string, mimeType)); + } + + // Deepnote chart big number + if (data[CHART_BIG_NUMBER_MIME_TYPE]) { + items.push( + NotebookCellOutputItem.text( + data[CHART_BIG_NUMBER_MIME_TYPE] as string, + CHART_BIG_NUMBER_MIME_TYPE + ) + ); } } diff --git a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts index 8546f27032..2c0efc065d 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.unit.test.ts @@ -2,7 +2,7 @@ import { assert } from 'chai'; import { NotebookCellKind, type NotebookCellData } from 'vscode'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; -import type { DeepnoteBlock, DeepnoteOutput } from './deepnoteTypes'; +import type { DeepnoteBlock, DeepnoteOutput } from '../../platform/deepnote/deepnoteTypes'; suite('DeepnoteDataConverter', () => { let converter: DeepnoteDataConverter; diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index a5dc600f85..55a7564ab4 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts @@ -10,7 +10,7 @@ import { import { logger } from '../../platform/logging'; import { IDeepnoteNotebookManager } from '../types'; -import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { IKernelProvider } from '../../kernels/types'; import { getDisplayPath } from '../../platform/common/platform/fs-paths'; diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index a74f79713f..f185156250 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -41,7 +41,7 @@ import { disposeAsync } from '../../platform/common/utils'; import { IDeepnoteInitNotebookRunner } from './deepnoteInitNotebookRunner.node'; import { IDeepnoteNotebookManager } from '../types'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; -import { DeepnoteProject } from './deepnoteTypes'; +import { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; import { IKernelProvider, IKernel } from '../../kernels/types'; import { DeepnoteKernelError } from '../../platform/errors/deepnoteKernelErrors'; import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 3c324fb663..ef68f19abf 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -1,7 +1,7 @@ import { injectable } from 'inversify'; import { IDeepnoteNotebookManager } from '../types'; -import type { DeepnoteProject } from './deepnoteTypes'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; /** * Centralized manager for tracking Deepnote notebook selections and project state. diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index 302558c290..c300ae0157 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; -import type { DeepnoteProject } from './deepnoteTypes'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; suite('DeepnoteNotebookManager', () => { let manager: DeepnoteNotebookManager; diff --git a/src/notebooks/deepnote/deepnoteRequirementsHelper.node.ts b/src/notebooks/deepnote/deepnoteRequirementsHelper.node.ts index 5cae9097ae..ecbab61648 100644 --- a/src/notebooks/deepnote/deepnoteRequirementsHelper.node.ts +++ b/src/notebooks/deepnote/deepnoteRequirementsHelper.node.ts @@ -2,7 +2,7 @@ import { inject, injectable } from 'inversify'; import { workspace, CancellationToken, window, Uri, l10n } from 'vscode'; import * as fs from 'fs'; -import type { DeepnoteProject } from './deepnoteTypes'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; import { ILogger } from '../../platform/logging/types'; import { IPersistentStateFactory } from '../../platform/common/types'; diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts new file mode 100644 index 0000000000..34da622882 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +export const DeepnoteChartBigNumberOutputSchema = z.object({ + title: z.string().nullish(), + value: z.string().nullish(), + + comparisonTitle: z.string().nullish(), + comparisonValue: z.string().nullish() +}); + +export const DeepnoteBigNumberMetadataSchema = z.object({ + deepnote_big_number_title: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_value: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_format: z + .string() + .nullish() + .transform((val) => val ?? 'number'), + deepnote_big_number_comparison_type: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_comparison_title: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_comparison_value: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_comparison_format: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_big_number_comparison_enabled: z + .boolean() + .nullish() + .transform((val) => val ?? false) +}); + +export type DeepnoteChartBigNumberOutput = z.infer; +export type DeepnoteBigNumberMetadata = z.infer; diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index 4b4008a4a6..6cdd2209fd 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -1,13 +1,13 @@ -import { injectable, inject } from 'inversify'; -import { l10n, type CancellationToken, type NotebookData, type NotebookSerializer, workspace } from 'vscode'; +import { inject, injectable } from 'inversify'; import * as yaml from 'js-yaml'; +import { l10n, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; import { logger } from '../../platform/logging'; import { IDeepnoteNotebookManager } from '../types'; -import type { DeepnoteProject } from './deepnoteTypes'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; -export { DeepnoteProject, DeepnoteNotebook, DeepnoteOutput } from './deepnoteTypes'; +export { DeepnoteBlock, DeepnoteNotebook, DeepnoteOutput, DeepnoteFile } from '../../platform/deepnote/deepnoteTypes'; /** * Serializer for converting between Deepnote YAML files and VS Code notebook format. diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index d910b6fc2e..ade9d2280e 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -3,7 +3,7 @@ import { assert } from 'chai'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; -import type { DeepnoteProject } from './deepnoteTypes'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; suite('DeepnoteNotebookSerializer', () => { let serializer: DeepnoteNotebookSerializer; diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts index 7955ffd477..d44a00e955 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts @@ -12,7 +12,7 @@ import { import * as yaml from 'js-yaml'; import { DeepnoteTreeItem, DeepnoteTreeItemType, DeepnoteTreeItemContext } from './deepnoteTreeItem'; -import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; /** * Tree data provider for the Deepnote explorer view. diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts index eb659d6c32..52a5a49560 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts @@ -2,7 +2,7 @@ import { assert } from 'chai'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem'; -import type { DeepnoteProject } from './deepnoteTypes'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; suite('DeepnoteTreeDataProvider', () => { let provider: DeepnoteTreeDataProvider; diff --git a/src/notebooks/deepnote/deepnoteTreeItem.ts b/src/notebooks/deepnote/deepnoteTreeItem.ts index cda1128920..8bda4a1b4f 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -1,5 +1,5 @@ import { TreeItem, TreeItemCollapsibleState, Uri, ThemeIcon } from 'vscode'; -import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; /** * Represents different types of items in the Deepnote tree view diff --git a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts index 17ece84167..dd4e8883e1 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts @@ -2,7 +2,7 @@ import { assert } from 'chai'; import { TreeItemCollapsibleState, ThemeIcon } from 'vscode'; import { DeepnoteTreeItem, DeepnoteTreeItemType, DeepnoteTreeItemContext } from './deepnoteTreeItem'; -import type { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes'; +import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; suite('DeepnoteTreeItem', () => { const mockProject: DeepnoteProject = { diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index 21f5c92a34..4392ed7445 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -4,7 +4,7 @@ import { NotebookDocument, NotebookEditor, Uri, type Event } from 'vscode'; import { Resource } from '../platform/common/types'; import type { EnvironmentPath } from '@vscode/python-extension'; -import { DeepnoteProject } from './deepnote/deepnoteTypes'; +import { DeepnoteProject } from '../platform/deepnote/deepnoteTypes'; export interface IEmbedNotebookEditorProvider { findNotebookEditor(resource: Resource): NotebookEditor | undefined; diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 33c0dac054..40638000ff 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -125,7 +125,7 @@ export const LanguagesSupportedByPythonkernel = [ 'sql', // %%sql 'perl', // %%perl 'qsharp', // %%qsharp - 'JSON', //JSON cells for custom block types + 'json', // JSON cells for custom block types 'raw' // raw cells (no formatting) ]; export const jupyterLanguageToMonacoLanguageMapping = new Map([ diff --git a/src/platform/deepnote/deepnoteConstants.ts b/src/platform/deepnote/deepnoteConstants.ts new file mode 100644 index 0000000000..1200071b17 --- /dev/null +++ b/src/platform/deepnote/deepnoteConstants.ts @@ -0,0 +1 @@ +export const CHART_BIG_NUMBER_MIME_TYPE = 'application/vnd.deepnote.chart.big-number+json'; diff --git a/src/notebooks/deepnote/deepnoteTypes.ts b/src/platform/deepnote/deepnoteTypes.ts similarity index 100% rename from src/notebooks/deepnote/deepnoteTypes.ts rename to src/platform/deepnote/deepnoteTypes.ts diff --git a/src/notebooks/deepnote/pocket.ts b/src/platform/deepnote/pocket.ts similarity index 96% rename from src/notebooks/deepnote/pocket.ts rename to src/platform/deepnote/pocket.ts index 02e7a3963f..d20a25d27a 100644 --- a/src/notebooks/deepnote/pocket.ts +++ b/src/platform/deepnote/pocket.ts @@ -1,7 +1,7 @@ import type { NotebookCellData } from 'vscode'; import type { DeepnoteBlock } from './deepnoteTypes'; -import { generateBlockId, generateSortingKey } from './dataConversionUtils'; +import { generateBlockId, generateSortingKey } from '../../notebooks/deepnote/dataConversionUtils'; // Note: 'id' is intentionally excluded from this list so it remains at the top level of cell.metadata // The id field is needed at runtime for cell identification during execution diff --git a/src/notebooks/deepnote/pocket.unit.test.ts b/src/platform/deepnote/pocket.unit.test.ts similarity index 100% rename from src/notebooks/deepnote/pocket.unit.test.ts rename to src/platform/deepnote/pocket.unit.test.ts diff --git a/src/webviews/deepnote-utils/format-value.ts b/src/webviews/deepnote-utils/format-value.ts new file mode 100644 index 0000000000..3160352989 --- /dev/null +++ b/src/webviews/deepnote-utils/format-value.ts @@ -0,0 +1,36 @@ +export function formatValue(value: number, format = 'number'): string { + if (format === 'plain') { + return value.toString(); + } + + if (format === 'number') { + return value.toLocaleString(); + } + + if (format === 'percent') { + const percentage = value * 100; + + if (Math.round(percentage) === percentage) { + return `${percentage}%`; + } + + return `${percentage.toFixed(2)}%`; + } + + if (format === 'scientific') { + return value.toExponential(2).toUpperCase(); + } + + if (format === 'currency') { + return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + } + + if (format === 'financial') { + const financialValue = value < 0 ? value * -1 : value; + const formattedValue = financialValue.toLocaleString('en-US', { minimumFractionDigits: 2 }); + + return value >= 0 ? formattedValue : `(${formattedValue})`; + } + + return value.toLocaleString(); +} diff --git a/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx new file mode 100644 index 0000000000..a8fcaa3203 --- /dev/null +++ b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx @@ -0,0 +1,134 @@ +import React, { useMemo } from 'react'; +import { DeepnoteBigNumberMetadata, DeepnoteChartBigNumberOutput } from '../../../notebooks/deepnote/deepnoteSchemas'; +import { formatValue } from '../../deepnote-utils/format-value'; + +export function ChartBigNumberOutputRenderer({ + output, + metadata +}: { + output: DeepnoteChartBigNumberOutput; + metadata: DeepnoteBigNumberMetadata; +}) { + const title = useMemo(() => { + return output.title || 'Title'; + }, [output.title]); + + const value = useMemo(() => { + if (!output.value) { + return 'Value'; + } + + const parsedValue = parseFloat(output.value); + + if (isNaN(parsedValue)) { + return 'NaN'; + } + + return formatValue(parsedValue, metadata.deepnote_big_number_format ?? 'number'); + }, [output.value, metadata.deepnote_big_number_format]); + + const comparisonValue = useMemo(() => { + if (!output.comparisonValue) { + return undefined; + } + + if (!output.value) { + return; + } + + const isFloat = output.value.includes('.') || output.comparisonValue.includes('.'); + + const parsedValue = isFloat ? parseFloat(output.value) : parseInt(output.value, 10); + const parsedComparisonValue = isFloat + ? parseFloat(output.comparisonValue) + : parseInt(output.comparisonValue, 10); + + if (isNaN(parsedValue) || isNaN(parsedComparisonValue)) { + return undefined; + } + + if (metadata.deepnote_big_number_comparison_type === 'percentage-change') { + if (parsedComparisonValue === 0) { + return undefined; + } + + return (parsedValue - parsedComparisonValue) / parsedComparisonValue; + } + + if (metadata.deepnote_big_number_comparison_type === 'absolute-value') { + return parsedComparisonValue; + } + + return parsedValue - parsedComparisonValue; + }, [metadata.deepnote_big_number_comparison_type, output.comparisonValue, output.value]); + + const formattedComparisonValue = useMemo(() => { + if (comparisonValue == null) { + return '-'; + } + + if (metadata.deepnote_big_number_comparison_type === 'percentage-change') { + const roundedPercentage = Math.round(comparisonValue * 100) / 100; + + return formatValue(roundedPercentage, 'percent'); + } + + return formatValue(comparisonValue, metadata.deepnote_big_number_format ?? 'number'); + }, [comparisonValue, metadata.deepnote_big_number_format, metadata.deepnote_big_number_comparison_type]); + + const changeDirection = useMemo(() => { + if (comparisonValue == null) { + return 1; + } + + return comparisonValue >= 0 ? 1 : -1; + }, [comparisonValue]); + + const comparisonClassName = useMemo(() => { + if (metadata.deepnote_big_number_comparison_format === 'off') { + return 'deepnote-comparison-neutral'; + } + + const formatModifier = metadata.deepnote_big_number_comparison_format === 'inverse' ? -1 : 1; + const modifiedDirection = changeDirection * formatModifier; + + if (modifiedDirection < 0) { + return 'deepnote-comparison-negative'; + } + + return 'deepnote-comparison-positive'; + }, [changeDirection, metadata.deepnote_big_number_comparison_format]); + + const showComparison = + metadata.deepnote_big_number_comparison_enabled === true && + metadata.deepnote_big_number_comparison_type != null; + + return ( +
+
+
+
+

{title}

+
+
+

{value}

+
+ {showComparison ? ( +
+
+

+ {formattedComparisonValue} +

+
+ {output.comparisonTitle != null ? ( +
+

{output.comparisonTitle}

+
+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRendererContainer.tsx b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRendererContainer.tsx new file mode 100644 index 0000000000..60561cbdbc --- /dev/null +++ b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRendererContainer.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { + DeepnoteBigNumberMetadataSchema, + DeepnoteChartBigNumberOutputSchema +} from '../../../notebooks/deepnote/deepnoteSchemas'; +import { ChartBigNumberOutputRenderer } from './ChartBigNumberOutputRenderer'; + +export function ChartBigNumberOutputRendererContainer({ + outputText, + outputMetadata +}: { + outputText: string; + outputMetadata: unknown; +}) { + // Remove single quotes from start and end of string if present + const data = JSON.parse(outputText.replace(/^'|'$/g, '')); + const blockMetadata = DeepnoteBigNumberMetadataSchema.parse(outputMetadata); + + const chartBigNumberOutput = DeepnoteChartBigNumberOutputSchema.parse(data); + + return ; +} diff --git a/src/webviews/webview-side/chart-big-number-renderer/ErrorBoundary.tsx b/src/webviews/webview-side/chart-big-number-renderer/ErrorBoundary.tsx new file mode 100644 index 0000000000..304601241a --- /dev/null +++ b/src/webviews/webview-side/chart-big-number-renderer/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { FallbackProps } from 'react-error-boundary'; + +export function ErrorFallback({ error }: FallbackProps) { + return ( +
+
Error rendering big number
+
{error.message}
+
+ Stack trace +
+                    {error.stack}
+                
+
+
+ ); +} diff --git a/src/webviews/webview-side/chart-big-number-renderer/index.ts b/src/webviews/webview-side/chart-big-number-renderer/index.ts new file mode 100644 index 0000000000..4c910917a2 --- /dev/null +++ b/src/webviews/webview-side/chart-big-number-renderer/index.ts @@ -0,0 +1,54 @@ +import './styles.css'; + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { ErrorBoundary } from 'react-error-boundary'; + +import type { ActivationFunction, OutputItem, RendererContext } from 'vscode-notebook-renderer'; + +import { ErrorFallback } from './ErrorBoundary'; + +import { ChartBigNumberOutputRendererContainer } from './ChartBigNumberOutputRendererContainer'; + +export const activate: ActivationFunction = (_context: RendererContext) => { + return { + renderOutputItem(outputItem: OutputItem, element: HTMLElement) { + ReactDOM.render( + React.createElement( + ErrorBoundary, + { + FallbackComponent: ErrorFallback, + onError: (error, info) => { + console.error('Vega renderer error:', error, info); + } + }, + React.createElement(ChartBigNumberOutputRendererContainer, { + outputText: outputItem.text(), + outputMetadata: outputItem.metadata + }) + ), + element + ); + }, + + disposeOutputItem(id?: string) { + // If undefined, all cells are being removed. + if (id == null) { + for (let i = 0; i < document.children.length; i++) { + const child = document.children.item(i); + if (child == null) { + continue; + } + ReactDOM.unmountComponentAtNode(child); + } + return; + } + + const element = document.getElementById(id); + if (element == null) { + return; + } + ReactDOM.unmountComponentAtNode(element); + } + }; +}; diff --git a/src/webviews/webview-side/chart-big-number-renderer/styles.css b/src/webviews/webview-side/chart-big-number-renderer/styles.css new file mode 100644 index 0000000000..86eb50b0e3 --- /dev/null +++ b/src/webviews/webview-side/chart-big-number-renderer/styles.css @@ -0,0 +1,54 @@ +.deepnote-big-number-container { + display: flex; + flex-direction: row; +} + +.deepnote-big-number-card { + display: flex; + flex-shrink: 0; + padding: 16px; + border-radius: 6px; + border: 1px solid var(--vscode-panel-border); + font-size: 13px; +} + +.deepnote-big-number-content { + width: 200px; +} + +.deepnote-big-number-title { + word-wrap: break-word; + margin: 0; +} + +.deepnote-big-number-value { + font-size: 24px; + font-weight: 600; + margin: 0; +} + +.deepnote-big-number-comparison { + display: flex; + column-gap: 0.5rem; +} + +.deepnote-comparison-text { + margin: 0; +} + +.deepnote-comparison-title { + margin: 0; +} + +/* Conditional comparison colors */ +.deepnote-comparison-positive { + color: var(--vscode-charts-green); +} + +.deepnote-comparison-negative { + color: var(--vscode-errorForeground); +} + +.deepnote-comparison-neutral { + color: var(--vscode-foreground); +}