diff --git a/build/esbuild/build.ts b/build/esbuild/build.ts index c2bc379e9e..c8e93a5cdc 100644 --- a/build/esbuild/build.ts +++ b/build/esbuild/build.ts @@ -384,6 +384,11 @@ async function buildAll() { path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'selectInputSettings', 'index.tsx'), path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'selectInputSettings', 'index.js'), { target: 'web', watch: watchAll } + ), + build( + path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'bigNumberComparisonSettings', 'index.tsx'), + path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'bigNumberComparisonSettings', 'index.js'), + { target: 'web', watch: watchAll } ) ); diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 9cd884dc5b..5ef6c37248 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -470,6 +470,20 @@ export type LocalizedMessages = { saveButton: string; cancelButton: string; failedToSave: string; + // Big number comparison settings strings + bigNumberComparisonTitle: string; + enableComparison: string; + comparisonTypeLabel: string; + percentageChange: string; + absoluteValue: string; + comparisonValueLabel: string; + comparisonValuePlaceholder: string; + comparisonTitleLabel: string; + comparisonTitlePlaceholder: string; + comparisonTitleHelp: string; + comparisonValueHelp: string; + comparisonFormatLabel: string; + comparisonFormatHelp: string; }; // Map all messages to specific payloads export class IInteractiveWindowMapping { diff --git a/src/notebooks/deepnote/bigNumberComparisonSettingsWebview.ts b/src/notebooks/deepnote/bigNumberComparisonSettingsWebview.ts new file mode 100644 index 0000000000..0907352e09 --- /dev/null +++ b/src/notebooks/deepnote/bigNumberComparisonSettingsWebview.ts @@ -0,0 +1,314 @@ +import { + CancellationToken, + Disposable, + NotebookCell, + NotebookEdit, + Uri, + ViewColumn, + WebviewPanel, + window, + workspace, + WorkspaceEdit +} from 'vscode'; +import { inject, injectable } from 'inversify'; + +import { IExtensionContext } from '../../platform/common/types'; +import { LocalizedMessages } from '../../messageTypes'; +import * as localize from '../../platform/common/utils/localize'; +import { + BigNumberComparisonSettings, + BigNumberComparisonWebviewMessage +} from '../../platform/notebooks/deepnote/types'; +import { WrappedError } from '../../platform/errors/types'; +import { logger } from '../../platform/logging'; + +/** + * Manages the webview panel for big number comparison settings + */ +@injectable() +export class BigNumberComparisonSettingsWebviewProvider { + private currentPanel: WebviewPanel | undefined; + private currentPanelId: number = 0; + private readonly disposables: Disposable[] = []; + private currentCell: NotebookCell | undefined; + private resolvePromise: ((settings: BigNumberComparisonSettings | null) => void) | undefined; + + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + /** + * Show the big number comparison settings webview + */ + public async show(cell: NotebookCell, token?: CancellationToken): Promise { + this.currentCell = cell; + + const column = window.activeTextEditor ? window.activeTextEditor.viewColumn : ViewColumn.One; + + // If we already have a panel, cancel any outstanding operation before disposing + if (this.currentPanel) { + // Cancel the previous operation by resolving with null + if (this.resolvePromise) { + this.resolvePromise(null); + this.resolvePromise = undefined; + } + // Now dispose the old panel + this.currentPanel.dispose(); + } + + // Increment panel ID to track this specific panel instance + this.currentPanelId++; + const panelId = this.currentPanelId; + + // Create a new panel + this.currentPanel = window.createWebviewPanel( + 'deepnoteBigNumberComparisonSettings', + localize.BigNumberComparison.title, + column || ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionContext.extensionUri] + } + ); + + // Set the webview's initial html content + this.currentPanel.webview.html = this.getWebviewContent(); + + // Handle messages from the webview + this.currentPanel.webview.onDidReceiveMessage( + async (message: BigNumberComparisonWebviewMessage) => { + await this.handleMessage(message); + }, + null, + this.disposables + ); + + // Handle cancellation token if provided + let cancellationDisposable: Disposable | undefined; + if (token) { + cancellationDisposable = token.onCancellationRequested(() => { + // Only handle cancellation if this is still the current panel + if (this.currentPanelId === panelId) { + if (this.resolvePromise) { + this.resolvePromise(null); + this.resolvePromise = undefined; + } + this.currentPanel?.dispose(); + } + }); + } + + // Reset when the current panel is closed + this.currentPanel.onDidDispose( + () => { + // Only handle disposal if this is still the current panel + if (this.currentPanelId === panelId) { + this.currentPanel = undefined; + this.currentCell = undefined; + if (this.resolvePromise) { + this.resolvePromise(null); + this.resolvePromise = undefined; + } + // Clean up cancellation listener + cancellationDisposable?.dispose(); + this.disposables.forEach((d) => d.dispose()); + this.disposables.length = 0; + } + }, + null, + this.disposables + ); + + // Send initial data after a small delay to ensure webview is ready + // This is necessary because postMessage can fail if sent before the webview is fully loaded + setTimeout(async () => { + await this.sendLocStrings(); + await this.sendInitialData(); + }, 100); + + // Return a promise that resolves when the user saves or cancels + return new Promise((resolve) => { + this.resolvePromise = resolve; + }); + } + + private async sendInitialData(): Promise { + if (!this.currentPanel || !this.currentCell) { + return; + } + + const metadata = this.currentCell.metadata as Record | undefined; + + const settings: BigNumberComparisonSettings = { + enabled: (metadata?.deepnote_big_number_comparison_enabled as boolean) ?? false, + comparisonType: + (metadata?.deepnote_big_number_comparison_type as 'percentage-change' | 'absolute-value' | '') ?? '', + comparisonValue: (metadata?.deepnote_big_number_comparison_value as string) ?? '', + comparisonTitle: (metadata?.deepnote_big_number_comparison_title as string) ?? '', + comparisonFormat: (metadata?.deepnote_big_number_comparison_format as string) ?? '' + }; + + await this.currentPanel.webview.postMessage({ + type: 'init', + settings + }); + } + + private async sendLocStrings(): Promise { + if (!this.currentPanel) { + return; + } + + const locStrings: Partial = { + bigNumberComparisonTitle: localize.BigNumberComparison.title, + enableComparison: localize.BigNumberComparison.enableComparison, + comparisonTypeLabel: localize.BigNumberComparison.comparisonTypeLabel, + percentageChange: localize.BigNumberComparison.percentageChange, + absoluteValue: localize.BigNumberComparison.absoluteValue, + comparisonValueLabel: localize.BigNumberComparison.comparisonValueLabel, + comparisonValuePlaceholder: localize.BigNumberComparison.comparisonValuePlaceholder, + comparisonTitleLabel: localize.BigNumberComparison.comparisonTitleLabel, + comparisonTitlePlaceholder: localize.BigNumberComparison.comparisonTitlePlaceholder, + comparisonTitleHelp: localize.BigNumberComparison.comparisonTitleHelp, + comparisonFormatLabel: localize.BigNumberComparison.comparisonFormatLabel, + comparisonFormatHelp: localize.BigNumberComparison.comparisonFormatHelp, + saveButton: localize.BigNumberComparison.saveButton, + cancelButton: localize.BigNumberComparison.cancelButton + }; + + await this.currentPanel.webview.postMessage({ + type: 'locInit', + locStrings + }); + } + + private async handleMessage(message: BigNumberComparisonWebviewMessage): Promise { + switch (message.type) { + case 'save': + if (this.currentCell) { + try { + await this.saveSettings(message.settings); + if (this.resolvePromise) { + this.resolvePromise(message.settings); + this.resolvePromise = undefined; + } + this.currentPanel?.dispose(); + } catch (error) { + // Error is already shown to user in saveSettings + logger.error('BigNumberComparisonSettingsWebview: Failed to save settings', error); + } + } + break; + + case 'cancel': + if (this.resolvePromise) { + this.resolvePromise(null); + this.resolvePromise = undefined; + } + this.currentPanel?.dispose(); + break; + + case 'init': + case 'locInit': + // These messages are sent from extension to webview, not handled here + break; + } + } + + private async saveSettings(settings: BigNumberComparisonSettings): Promise { + if (!this.currentCell) { + return; + } + + const edit = new WorkspaceEdit(); + const metadata = { ...(this.currentCell.metadata as Record) }; + + metadata.deepnote_big_number_comparison_enabled = settings.enabled; + metadata.deepnote_big_number_comparison_type = settings.comparisonType; + metadata.deepnote_big_number_comparison_value = settings.comparisonValue; + metadata.deepnote_big_number_comparison_title = settings.comparisonTitle; + metadata.deepnote_big_number_comparison_format = settings.comparisonFormat; + + // Update cell metadata + edit.set(this.currentCell.notebook.uri, [NotebookEdit.updateCellMetadata(this.currentCell.index, metadata)]); + + try { + const success = await workspace.applyEdit(edit); + if (!success) { + const errorMessage = localize.BigNumberComparison.failedToSave; + logger.error(errorMessage); + void window.showErrorMessage(errorMessage); + throw new WrappedError(errorMessage, undefined); + } + } catch (error) { + const errorMessage = localize.BigNumberComparison.failedToSave; + const cause = error instanceof Error ? error : undefined; + const causeMessage = cause?.message || String(error); + logger.error(`${errorMessage}: ${causeMessage}`, error); + void window.showErrorMessage(errorMessage); + throw new WrappedError(errorMessage, cause); + } + } + + private getWebviewContent(): string { + if (!this.currentPanel) { + return ''; + } + + const webview = this.currentPanel.webview; + const nonce = this.getNonce(); + + // Get URIs for the React app + const scriptUri = webview.asWebviewUri( + Uri.joinPath( + this.extensionContext.extensionUri, + 'dist', + 'webviews', + 'webview-side', + 'bigNumberComparisonSettings', + 'index.js' + ) + ); + const codiconUri = webview.asWebviewUri( + Uri.joinPath( + this.extensionContext.extensionUri, + 'dist', + 'webviews', + 'webview-side', + 'react-common', + 'codicon', + 'codicon.css' + ) + ); + + const title = localize.BigNumberComparison.title; + + return ` + + + + + + + ${title} + + +
+ + +`; + } + + private getNonce(): string { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; + } + + public dispose(): void { + this.currentPanel?.dispose(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts index 949bd8e57f..ed31366eee 100644 --- a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts +++ b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.ts @@ -1,10 +1,8 @@ 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({}); @@ -13,25 +11,31 @@ 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; - } + // Parse the cell value as the value expression + const valueExpression = cell.value.trim(); if (block.metadata != null) { delete block.metadata[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]; } - block.metadata = { - ...(block.metadata ?? {}), - ...config.data - }; + // Check if existing metadata is valid + const existingMetadata = DeepnoteBigNumberMetadataSchema.safeParse(block.metadata); + const hasValidMetadata = + existingMetadata.success && block.metadata != null && Object.keys(block.metadata).length > 0; + + if (hasValidMetadata) { + // Preserve existing metadata and only update the value + block.metadata = { + ...(block.metadata ?? {}), + deepnote_big_number_value: valueExpression + }; + } else { + // Apply defaults when metadata is missing or invalid + block.metadata = { + ...DEFAULT_BIG_NUMBER_CONFIG, + deepnote_big_number_value: valueExpression + }; + } } canConvert(blockType: string): boolean { @@ -39,7 +43,6 @@ export class ChartBigNumberBlockConverter implements BlockConverter { } 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) { @@ -47,13 +50,12 @@ export class ChartBigNumberBlockConverter implements BlockConverter { 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); + // Show the value expression as cell content + const valueExpression = deepnoteBigNumberMetadataResult.success + ? deepnoteBigNumberMetadataResult.data.deepnote_big_number_value + : DEFAULT_BIG_NUMBER_CONFIG.deepnote_big_number_value; - const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, valueExpression, 'python'); return cell; } diff --git a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts index 120efd3420..efe76072c4 100644 --- a/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/chartBigNumberBlockConverter.unit.test.ts @@ -50,19 +50,8 @@ suite('ChartBigNumberBlockConverter', () => { 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 - }); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, 'b'); }); test('converts absolute change comparison block to cell', () => { @@ -103,19 +92,8 @@ suite('ChartBigNumberBlockConverter', () => { 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 - }); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, 'b'); }); test('converts absolute value comparison block to cell', () => { @@ -156,19 +134,8 @@ suite('ChartBigNumberBlockConverter', () => { 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 - }); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, 'b'); }); test('converts disabled comparison block to cell', () => { @@ -209,31 +176,17 @@ suite('ChartBigNumberBlockConverter', () => { 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 - }); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, 'b'); }); - test('prefers raw content when DEEPNOTE_VSCODE_RAW_CONTENT_KEY exists', () => { + test('uses default value when metadata is invalid', () => { 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"}' + invalid_field: 'invalid value' }, sortingKey: 'a0', type: 'big-number' @@ -242,21 +195,16 @@ suite('ChartBigNumberBlockConverter', () => { 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"}' - ); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, ''); }); - test('uses default config when metadata is invalid', () => { + test('uses default value when metadata is empty', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', - metadata: { - invalid_field: 'invalid value' - }, + metadata: {}, sortingKey: 'a0', type: 'big-number' }; @@ -264,137 +212,165 @@ suite('ChartBigNumberBlockConverter', () => { 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 - }); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, ''); }); + }); - test('uses default config when metadata is empty', () => { + suite('applyChangesToBlock', () => { + test('applies value expression to block metadata', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', - content: '', + content: 'old content', id: 'block-123', - metadata: {}, + metadata: { + existing: 'value', + deepnote_big_number_title: 'old title', + deepnote_big_number_value: 'old_value', + deepnote_big_number_format: 'currency' + }, sortingKey: 'a0', type: 'big-number' }; + const cell = new NotebookCellData(NotebookCellKind.Code, 'new_value', 'python'); - const cell = converter.convertToCell(block); - - assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); + converter.applyChangesToBlock(block, cell); - 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 - }); + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_big_number_value, 'new_value'); + // Other metadata should be preserved + assert.strictEqual(block.metadata?.existing, 'value'); + assert.strictEqual(block.metadata?.deepnote_big_number_title, 'old title'); + assert.strictEqual(block.metadata?.deepnote_big_number_format, 'currency'); }); - }); - suite('applyChangesToBlock', () => { - test('applies valid JSON config to block metadata', () => { + test('removes DEEPNOTE_VSCODE_RAW_CONTENT_KEY when value is applied', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: 'old content', id: 'block-123', - metadata: { existing: 'value' }, + metadata: { + existing: 'value', + deepnote_big_number_value: 'old_value', + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: 'old raw content' + }, 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'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'new_value', 'python'); 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 - }); + assert.strictEqual(block.metadata?.deepnote_big_number_value, 'new_value'); + assert.strictEqual(block.metadata?.existing, 'value'); + assert.doesNotHaveAnyKeys(block.metadata, [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); }); - test('stores invalid JSON as raw content', () => { + test('preserves comparison metadata when updating value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: 'old content', id: 'block-123', - metadata: { existing: 'value' }, + metadata: { + deepnote_big_number_value: 'old_value', + deepnote_big_number_title: 'My Number', + deepnote_big_number_format: 'number', + deepnote_big_number_comparison_enabled: true, + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_value: 'baseline_value', + deepnote_big_number_comparison_title: 'vs baseline', + deepnote_big_number_comparison_format: 'percent' + }, sortingKey: 'a0', type: 'big-number' }; - const cell = new NotebookCellData(NotebookCellKind.Code, 'invalid json {', 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'new_value', 'python'); 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'); + assert.strictEqual(block.metadata?.deepnote_big_number_value, 'new_value'); + // Comparison metadata should be preserved + assert.strictEqual(block.metadata?.deepnote_big_number_comparison_enabled, true); + assert.strictEqual(block.metadata?.deepnote_big_number_comparison_type, 'percentage-change'); + assert.strictEqual(block.metadata?.deepnote_big_number_comparison_value, 'baseline_value'); + assert.strictEqual(block.metadata?.deepnote_big_number_comparison_title, 'vs baseline'); + assert.strictEqual(block.metadata?.deepnote_big_number_comparison_format, 'percent'); }); - test('removes DEEPNOTE_VSCODE_RAW_CONTENT_KEY when valid config is applied', () => { - const block: DeepnoteBlock = { + test('round-trip: block with comparison → cell → block preserves comparison metadata', () => { + // Start with a block that has comparison enabled + const originalBlock: DeepnoteBlock = { blockGroup: 'test-group', - content: 'old content', + content: '', id: 'block-123', metadata: { - existing: 'value', - [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: 'old raw content' + deepnote_big_number_value: 'revenue', + deepnote_big_number_title: 'Revenue', + deepnote_big_number_format: 'currency', + deepnote_big_number_comparison_enabled: true, + deepnote_big_number_comparison_type: 'percentage-change', + deepnote_big_number_comparison_value: 'last_month_revenue', + deepnote_big_number_comparison_title: 'vs last month', + deepnote_big_number_comparison_format: 'percent' }, 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); + // Convert block to cell (simulating loading from file) + const cell = converter.convertToCell(originalBlock); - 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]); + // Manually add metadata like DeepnoteDataConverter does + cell.metadata = { + ...originalBlock.metadata, + id: originalBlock.id, + type: originalBlock.type, + sortingKey: originalBlock.sortingKey, + blockGroup: originalBlock.blockGroup + }; + + // Move pocket fields + const pocket = { + type: cell.metadata.type, + sortingKey: cell.metadata.sortingKey, + blockGroup: cell.metadata.blockGroup + }; + delete cell.metadata.type; + delete cell.metadata.sortingKey; + delete cell.metadata.blockGroup; + cell.metadata.__deepnotePocket = pocket; + + // Now convert cell back to block (simulating execution) + const reconstructedBlock: DeepnoteBlock = { + blockGroup: pocket.blockGroup || 'default-group', + content: cell.value, + id: cell.metadata.id as string, + metadata: { ...cell.metadata }, + sortingKey: pocket.sortingKey || 'a0', + type: pocket.type || 'code' + }; + + // Remove pocket and id from metadata + if (reconstructedBlock.metadata) { + delete reconstructedBlock.metadata.__deepnotePocket; + delete reconstructedBlock.metadata.id; + } + + // Apply changes from cell (simulating user editing the value) + converter.applyChangesToBlock(reconstructedBlock, cell); + + // Verify all comparison metadata is preserved + assert.ok(reconstructedBlock.metadata, 'Block metadata should exist'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const metadata = reconstructedBlock.metadata!; + assert.strictEqual(metadata.deepnote_big_number_value, 'revenue'); + assert.strictEqual(metadata.deepnote_big_number_comparison_enabled, true); + assert.strictEqual(metadata.deepnote_big_number_comparison_type, 'percentage-change'); + assert.strictEqual(metadata.deepnote_big_number_comparison_value, 'last_month_revenue'); + assert.strictEqual(metadata.deepnote_big_number_comparison_title, 'vs last month'); + assert.strictEqual(metadata.deepnote_big_number_comparison_format, 'percent'); }); test('handles empty content', () => { @@ -402,61 +378,76 @@ suite('ChartBigNumberBlockConverter', () => { blockGroup: 'test-group', content: 'old content', id: 'block-123', - metadata: { existing: 'value' }, + metadata: { + existing: 'value', + deepnote_big_number_value: 'old_value' + }, sortingKey: 'a0', type: 'big-number' }; - const cell = new NotebookCellData(NotebookCellKind.Code, '', 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], ''); + assert.strictEqual(block.metadata?.deepnote_big_number_value, ''); assert.strictEqual(block.metadata?.existing, 'value'); }); - test('does not modify other block properties', () => { + test('applies defaults when metadata is missing', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: 'old content', - executionCount: 5, id: 'block-123', - metadata: { custom: 'value' }, - outputs: [], + metadata: {}, 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'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'my_value', 'python'); converter.applyChangesToBlock(block, cell); - assert.deepStrictEqual(block, { + assert.strictEqual(block.content, ''); + assert.deepStrictEqual(block.metadata, { + deepnote_big_number_title: '', + deepnote_big_number_value: 'my_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('does not modify other block properties', () => { + const block: DeepnoteBlock = { blockGroup: 'test-group', - content: '', + content: 'old 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: '' + deepnote_big_number_title: 'title', + deepnote_big_number_value: 'old_value' }, outputs: [], sortingKey: 'a0', type: 'big-number' - }); + }; + const cell = new NotebookCellData(NotebookCellKind.Code, 'new_value', 'python'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.blockGroup, 'test-group'); + assert.strictEqual(block.content, ''); + assert.strictEqual(block.executionCount, 5); + assert.strictEqual(block.id, 'block-123'); + assert.strictEqual(block.sortingKey, 'a0'); + assert.strictEqual(block.type, 'big-number'); + assert.deepStrictEqual(block.outputs, []); + assert.strictEqual(block.metadata?.deepnote_big_number_value, 'new_value'); + assert.strictEqual(block.metadata?.custom, 'value'); }); }); }); diff --git a/src/notebooks/deepnote/deepnoteBigNumberCellStatusBarProvider.ts b/src/notebooks/deepnote/deepnoteBigNumberCellStatusBarProvider.ts new file mode 100644 index 0000000000..e75ca3bace --- /dev/null +++ b/src/notebooks/deepnote/deepnoteBigNumberCellStatusBarProvider.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CancellationToken, + Disposable, + EventEmitter, + NotebookCell, + NotebookCellStatusBarItem, + NotebookCellStatusBarItemProvider, + NotebookEdit, + QuickPickItem, + WorkspaceEdit, + commands, + l10n, + notebooks, + window, + workspace +} from 'vscode'; +import { inject, injectable } from 'inversify'; + +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IExtensionContext } from '../../platform/common/types'; +import type { Pocket } from '../../platform/deepnote/pocket'; +import { BigNumberComparisonSettingsWebviewProvider } from './bigNumberComparisonSettingsWebview'; + +/** + * Provides status bar items for Deepnote big number block cells. + */ +@injectable() +export class DeepnoteBigNumberCellStatusBarProvider + implements NotebookCellStatusBarItemProvider, IExtensionSyncActivationService +{ + private readonly disposables: Disposable[] = []; + private readonly _onDidChangeCellStatusBarItems = new EventEmitter(); + private readonly comparisonSettingsWebview: BigNumberComparisonSettingsWebviewProvider; + + public readonly onDidChangeCellStatusBarItems = this._onDidChangeCellStatusBarItems.event; + + constructor(@inject(IExtensionContext) extensionContext: IExtensionContext) { + this.comparisonSettingsWebview = new BigNumberComparisonSettingsWebviewProvider(extensionContext); + } + + activate(): void { + // Register the status bar item provider for Deepnote notebooks + this.disposables.push(notebooks.registerNotebookCellStatusBarItemProvider('deepnote', this)); + + // Listen for notebook changes to update status bar + this.disposables.push( + workspace.onDidChangeNotebookDocument((e) => { + if (e.notebook.notebookType === 'deepnote') { + this._onDidChangeCellStatusBarItems.fire(); + } + }) + ); + + // Register commands + this.registerCommands(); + + // Dispose our emitter with the extension + this.disposables.push(this._onDidChangeCellStatusBarItems); + } + + private registerCommands(): void { + // Command to update big number title + this.disposables.push( + commands.registerCommand('deepnote.updateBigNumberTitle', async (cell?: NotebookCell) => { + if (!cell) { + const activeEditor = window.activeNotebookEditor; + if (activeEditor && activeEditor.selection) { + cell = activeEditor.notebook.cellAt(activeEditor.selection.start); + } + } + + if (!cell) { + void window.showErrorMessage(l10n.t('No active notebook cell')); + return; + } + + await this.updateTitle(cell); + }) + ); + + // Command to update big number format + this.disposables.push( + commands.registerCommand('deepnote.updateBigNumberFormat', async (cell?: NotebookCell) => { + if (!cell) { + const activeEditor = window.activeNotebookEditor; + if (activeEditor && activeEditor.selection) { + cell = activeEditor.notebook.cellAt(activeEditor.selection.start); + } + } + + if (!cell) { + void window.showErrorMessage(l10n.t('No active notebook cell')); + return; + } + + await this.updateFormat(cell); + }) + ); + + // Command to configure comparison settings + this.disposables.push( + commands.registerCommand('deepnote.configureBigNumberComparison', async (cell?: NotebookCell) => { + if (!cell) { + const activeEditor = window.activeNotebookEditor; + if (activeEditor && activeEditor.selection) { + cell = activeEditor.notebook.cellAt(activeEditor.selection.start); + } + } + + if (!cell) { + void window.showErrorMessage(l10n.t('No active notebook cell')); + return; + } + + await this.configureComparison(cell); + }) + ); + } + + provideCellStatusBarItems(cell: NotebookCell, token: CancellationToken): NotebookCellStatusBarItem[] | undefined { + if (token.isCancellationRequested) { + return undefined; + } + + // Check if this cell is a big number block + const pocket = cell.metadata?.__deepnotePocket as Pocket | undefined; + const blockType = pocket?.type; + + if (blockType?.toLowerCase() !== 'big-number') { + return undefined; + } + + const items: NotebookCellStatusBarItem[] = []; + const metadata = cell.metadata as Record | undefined; + + // 1. Block type indicator + items.push({ + text: 'Big Number', + alignment: 1, // NotebookCellStatusBarAlignment.Left + priority: 100, + tooltip: this.buildTooltip(metadata) + }); + + // 2. Title editor + const title = (metadata?.deepnote_big_number_title as string) || ''; + const titleText = title ? `$(edit) ${title}` : '$(edit) Set title'; + items.push({ + text: titleText, + alignment: 1, + priority: 95, + tooltip: l10n.t('Click to edit title'), + command: { + title: l10n.t('Edit Title'), + command: 'deepnote.updateBigNumberTitle', + arguments: [cell] + } + }); + + // 3. Format selector + const format = (metadata?.deepnote_big_number_format as string) || 'number'; + const formatLabel = this.getFormatLabel(format); + items.push({ + text: formatLabel, + alignment: 1, + priority: 90, + tooltip: l10n.t('Click to change format'), + command: { + title: l10n.t('Change Format'), + command: 'deepnote.updateBigNumberFormat', + arguments: [cell] + } + }); + + // 4. Comparison button + const comparisonEnabled = (metadata?.deepnote_big_number_comparison_enabled as boolean) ?? false; + const comparisonType = (metadata?.deepnote_big_number_comparison_type as string) || ''; + const comparisonValue = (metadata?.deepnote_big_number_comparison_value as string) || ''; + + let comparisonText: string; + if (comparisonEnabled && comparisonType && comparisonValue) { + const comparisonTypeLabel = comparisonType === 'percentage-change' ? '% change' : 'vs'; + const comparisonTitle = (metadata?.deepnote_big_number_comparison_title as string) || ''; + comparisonText = `Comparison: ${comparisonTypeLabel} ${ + comparisonTitle ? `${comparisonTitle} (${comparisonValue})` : comparisonValue + }`; + } else { + comparisonText = 'Set up comparison'; + } + + items.push({ + text: comparisonText, + alignment: 1, + priority: 85, + tooltip: l10n.t('Click to configure comparison'), + command: { + title: l10n.t('Configure Comparison'), + command: 'deepnote.configureBigNumberComparison', + arguments: [cell] + } + }); + + // 5. Hint text on the right + items.push({ + text: l10n.t('Expression to show'), + alignment: 2, // NotebookCellStatusBarAlignment.Right + priority: 100 + }); + + return items; + } + + private buildTooltip(metadata: Record | undefined): string { + const lines: string[] = ['Deepnote Big Number']; + + const title = metadata?.deepnote_big_number_title as string; + if (title) { + lines.push(l10n.t('Title: {0}', title)); + } + + const format = (metadata?.deepnote_big_number_format as string) || 'number'; + lines.push(l10n.t('Format: {0}', this.getFormatLabel(format))); + + const comparisonEnabled = (metadata?.deepnote_big_number_comparison_enabled as boolean) ?? false; + if (comparisonEnabled) { + lines.push(l10n.t('Comparison: Enabled')); + } + + return lines.join('\n'); + } + + private getFormatLabel(format: string): string { + switch (format) { + case 'currency': + return 'Currency'; + case 'percent': + return 'Percent'; + case 'number': + default: + return 'Number'; + } + } + + private async updateTitle(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentTitle = (metadata?.deepnote_big_number_title as string) || ''; + + const newTitle = await window.showInputBox({ + prompt: l10n.t('Enter title for big number. You can use {{var}} syntax to reference variables.'), + value: currentTitle, + placeHolder: l10n.t('e.g., Total Revenue') + }); + + if (newTitle === undefined) { + return; + } + + await this.updateCellMetadata(cell, { deepnote_big_number_title: newTitle }); + } + + private async updateFormat(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentFormat = (metadata?.deepnote_big_number_format as string) || 'number'; + + const formatOptions: QuickPickItem[] = [ + { label: 'Number', description: 'Display as a number', picked: currentFormat === 'number' }, + { label: 'Currency', description: 'Display as currency', picked: currentFormat === 'currency' }, + { label: 'Percent', description: 'Display as percentage', picked: currentFormat === 'percent' } + ]; + + const selected = await window.showQuickPick(formatOptions, { + placeHolder: l10n.t('Select format for big number') + }); + + if (!selected) { + return; + } + + const formatValue = selected.label.toLowerCase(); + await this.updateCellMetadata(cell, { deepnote_big_number_format: formatValue }); + } + + private async configureComparison(cell: NotebookCell): Promise { + const settings = await this.comparisonSettingsWebview.show(cell); + if (settings) { + this._onDidChangeCellStatusBarItems.fire(); + } + } + + private async updateCellMetadata(cell: NotebookCell, updates: Record): Promise { + const edit = new WorkspaceEdit(); + const updatedMetadata = { + ...cell.metadata, + ...updates + }; + + edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, updatedMetadata)]); + + const success = await workspace.applyEdit(edit); + if (!success) { + void window.showErrorMessage(l10n.t('Failed to update big number settings')); + return; + } + + this._onDidChangeCellStatusBarItems.fire(); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + this.comparisonSettingsWebview.dispose(); + } +} diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index c316bc97ae..4a38612de5 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -72,6 +72,7 @@ import { DeepnoteInitNotebookRunner, IDeepnoteInitNotebookRunner } from './deepn import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; +import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNumberCellStatusBarProvider'; import { SqlIntegrationStartupCodeProvider } from './deepnote/integrations/sqlIntegrationStartupCodeProvider'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; @@ -191,6 +192,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteInputBlockCellStatusBarItemProvider ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteBigNumberCellStatusBarProvider + ); // File export/import serviceManager.addSingleton(IFileConverter, FileConverter); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index f3f8442749..d3fe5ac534 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -50,6 +50,7 @@ import { IIntegrationWebviewProvider } from './deepnote/integrations/types'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; +import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNumberCellStatusBarProvider'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { @@ -117,6 +118,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteInputBlockCellStatusBarItemProvider ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteBigNumberCellStatusBarProvider + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index d127170b55..4dd0fe58ab 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -1167,6 +1167,25 @@ export namespace SelectInputSettings { export const failedToSave = l10n.t('Failed to save select input settings'); } +export namespace BigNumberComparison { + export const title = l10n.t('Big Number Comparison Settings'); + export const enableComparison = l10n.t('Enable comparison'); + export const comparisonTypeLabel = l10n.t('Comparison type'); + export const percentageChange = l10n.t('Percentage change'); + export const absoluteValue = l10n.t('Absolute value'); + export const comparisonValueLabel = l10n.t('Comparison value variable'); + export const comparisonValuePlaceholder = l10n.t('e.g., last_month_revenue'); + export const comparisonTitleLabel = l10n.t('Comparison title (optional)'); + export const comparisonTitlePlaceholder = l10n.t('e.g., vs last month'); + export const comparisonTitleHelp = l10n.t('You can use {{var}} syntax to reference variables'); + export const comparisonValueHelp = l10n.t('Enter a variable name (not a literal value)'); + export const comparisonFormatLabel = l10n.t('Comparison format (optional)'); + export const comparisonFormatHelp = l10n.t('Leave empty to use the same format as the main value'); + export const saveButton = l10n.t('Save'); + export const cancelButton = l10n.t('Cancel'); + export const failedToSave = l10n.t('Failed to save big number comparison settings'); +} + export namespace Deprecated { export const SHOW_DEPRECATED_FEATURE_PROMPT_FORMAT_ON_SAVE = l10n.t({ message: "The setting 'python.formatting.formatOnSave' is deprecated, please use 'editor.formatOnSave'.", diff --git a/src/platform/notebooks/deepnote/types.ts b/src/platform/notebooks/deepnote/types.ts index cb51200e37..6ae06bc731 100644 --- a/src/platform/notebooks/deepnote/types.ts +++ b/src/platform/notebooks/deepnote/types.ts @@ -24,6 +24,26 @@ export type SelectInputWebviewMessage = | { type: 'locInit'; locStrings: Record } | { type: 'cancel' }; +/** + * Settings for big number comparison + */ +export interface BigNumberComparisonSettings { + enabled: boolean; + comparisonType: 'percentage-change' | 'absolute-value' | ''; + comparisonValue: string; + comparisonTitle: string; + comparisonFormat: string; +} + +/** + * Message types for big number comparison settings webview + */ +export type BigNumberComparisonWebviewMessage = + | { type: 'init'; settings: BigNumberComparisonSettings } + | { type: 'save'; settings: BigNumberComparisonSettings } + | { type: 'locInit'; locStrings: Record } + | { type: 'cancel' }; + export const IIntegrationStorage = Symbol('IIntegrationStorage'); export interface IIntegrationStorage extends IDisposable { /** diff --git a/src/webviews/webview-side/bigNumberComparisonSettings/BigNumberComparisonSettingsPanel.tsx b/src/webviews/webview-side/bigNumberComparisonSettings/BigNumberComparisonSettingsPanel.tsx new file mode 100644 index 0000000000..0ee9dd06d1 --- /dev/null +++ b/src/webviews/webview-side/bigNumberComparisonSettings/BigNumberComparisonSettingsPanel.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import { IVsCodeApi } from '../react-common/postOffice'; +import { getLocString, storeLocStrings } from '../react-common/locReactSide'; +import { BigNumberComparisonSettings, WebviewMessage } from './types'; + +export interface IBigNumberComparisonSettingsPanelProps { + baseTheme: string; + vscodeApi: IVsCodeApi; +} + +export const BigNumberComparisonSettingsPanel: React.FC = ({ + baseTheme, + vscodeApi +}) => { + const [settings, setSettings] = React.useState({ + enabled: false, + comparisonType: '', + comparisonValue: '', + comparisonTitle: '', + comparisonFormat: '' + }); + const [initialized, setInitialized] = React.useState(false); + + React.useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case 'init': + setSettings(message.settings); + setInitialized(true); + break; + + case 'locInit': + storeLocStrings(message.locStrings); + break; + + case 'save': + case 'cancel': + // These messages are sent from webview to extension, not handled here + break; + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + const handleToggleEnabled = () => { + setSettings((prev) => ({ + ...prev, + enabled: !prev.enabled + })); + }; + + const handleComparisonTypeChange = (comparisonType: 'percentage-change' | 'absolute-value') => { + setSettings((prev) => ({ + ...prev, + comparisonType + })); + }; + + const handleComparisonValueChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSettings((prev) => ({ + ...prev, + comparisonValue: value + })); + }; + + const handleComparisonTitleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSettings((prev) => ({ + ...prev, + comparisonTitle: value + })); + }; + + const handleComparisonFormatChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSettings((prev) => ({ + ...prev, + comparisonFormat: value + })); + }; + + const handleSave = () => { + vscodeApi.postMessage({ + type: 'save', + settings + }); + }; + + const handleCancel = () => { + vscodeApi.postMessage({ + type: 'cancel' + }); + }; + + if (!initialized) { + return
Loading...
; + } + + return ( +
+

{getLocString('bigNumberComparisonTitle', 'Big Number Comparison Settings')}

+ +
+
+ + +
+
+ + {settings.enabled && ( + <> +
+ +
+ + +
+
+ +
+ + +
{getLocString('comparisonValueHelp', 'Enter a variable name')}
+
+ +
+ + +
+ {getLocString('comparisonTitleHelp', 'You can use {{var}} syntax to reference variables')} +
+
+ +
+ + +
+ {getLocString( + 'comparisonFormatHelp', + 'Leave empty to use the same format as the main value' + )} +
+
+ + )} + +
+ + +
+
+ ); +}; diff --git a/src/webviews/webview-side/bigNumberComparisonSettings/bigNumberComparisonSettings.css b/src/webviews/webview-side/bigNumberComparisonSettings/bigNumberComparisonSettings.css new file mode 100644 index 0000000000..92d7adfe85 --- /dev/null +++ b/src/webviews/webview-side/bigNumberComparisonSettings/bigNumberComparisonSettings.css @@ -0,0 +1,178 @@ +.big-number-comparison-settings-panel { + padding: 20px; + max-width: 600px; + margin: 0 auto; +} + +.big-number-comparison-settings-panel h1 { + font-size: 24px; + margin-bottom: 20px; +} + +.settings-section { + margin-bottom: 30px; +} + +.toggle-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; +} + +.toggle-option label { + font-size: 14px; + cursor: pointer; +} + +.toggle-switch { + position: relative; + width: 44px; + height: 24px; +} + +.toggle-switch input:focus-visible + .toggle-slider { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + transition: 0.2s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ''; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: var(--vscode-input-foreground); + transition: 0.2s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); + background-color: var(--vscode-button-foreground); +} + +.form-section { + margin-bottom: 20px; +} + +.form-section label { + display: block; + font-size: 14px; + margin-bottom: 8px; + font-weight: 500; +} + +.form-section input[type='text'], +.form-section select { + width: 100%; + padding: 6px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + font-size: 13px; +} + +.form-section input[type='text']:focus, +.form-section select:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.radio-group { + display: flex; + gap: 20px; + margin-top: 8px; +} + +.radio-option-inline { + display: flex; + align-items: center; + cursor: pointer; +} + +.radio-option-inline input[type='radio'] { + margin-right: 8px; + cursor: pointer; +} + +.radio-option-inline input[type='radio']:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.radio-option-inline span { + font-size: 13px; +} + +.help-text { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 6px; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--vscode-panel-border); +} + +.actions button { + padding: 8px 16px; + border: none; + border-radius: 2px; + cursor: pointer; + font-size: 13px; +} + +.actions button:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.btn-primary { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.btn-primary:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.btn-secondary { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.btn-secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + diff --git a/src/webviews/webview-side/bigNumberComparisonSettings/index.tsx b/src/webviews/webview-side/bigNumberComparisonSettings/index.tsx new file mode 100644 index 0000000000..bf9a3921f6 --- /dev/null +++ b/src/webviews/webview-side/bigNumberComparisonSettings/index.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import { IVsCodeApi } from '../react-common/postOffice'; +import { detectBaseTheme } from '../react-common/themeDetector'; +import { BigNumberComparisonSettingsPanel } from './BigNumberComparisonSettingsPanel'; + +import '../common/index.css'; +import './bigNumberComparisonSettings.css'; + +// This special function talks to vscode from a web panel +declare function acquireVsCodeApi(): IVsCodeApi; + +const baseTheme = detectBaseTheme(); +const vscodeApi = acquireVsCodeApi(); + +ReactDOM.render( + , + document.getElementById('root') as HTMLElement +); diff --git a/src/webviews/webview-side/bigNumberComparisonSettings/types.ts b/src/webviews/webview-side/bigNumberComparisonSettings/types.ts new file mode 100644 index 0000000000..9497aefe03 --- /dev/null +++ b/src/webviews/webview-side/bigNumberComparisonSettings/types.ts @@ -0,0 +1,13 @@ +export interface BigNumberComparisonSettings { + enabled: boolean; + comparisonType: 'percentage-change' | 'absolute-value' | ''; + comparisonValue: string; + comparisonTitle: string; + comparisonFormat: string; +} + +export type WebviewMessage = + | { type: 'init'; settings: BigNumberComparisonSettings } + | { type: 'save'; settings: BigNumberComparisonSettings } + | { type: 'locInit'; locStrings: Record } + | { type: 'cancel' };