diff --git a/build/esbuild/build.ts b/build/esbuild/build.ts index 2ffacf0601..c2bc379e9e 100644 --- a/build/esbuild/build.ts +++ b/build/esbuild/build.ts @@ -379,6 +379,11 @@ async function buildAll() { path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'integrations', 'index.tsx'), path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'integrations', 'index.js'), { target: 'web', watch: watchAll } + ), + build( + path.join(extensionFolder, 'src', 'webviews', 'webview-side', 'selectInputSettings', 'index.tsx'), + path.join(extensionFolder, 'dist', 'webviews', 'webview-side', 'selectInputSettings', 'index.js'), + { target: 'web', watch: watchAll } ) ); diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 5e0a1465c9..501714e1db 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -235,6 +235,24 @@ export type LocalizedMessages = { integrationsRequiredField: string; integrationsOptionalField: string; integrationsUnnamedIntegration: string; + // Select input settings strings + selectInputSettingsTitle: string; + allowMultipleValues: string; + allowEmptyValue: string; + valueSourceTitle: string; + fromOptions: string; + fromOptionsDescription: string; + addOptionPlaceholder: string; + addButton: string; + fromVariable: string; + fromVariableDescription: string; + variablePlaceholder: string; + optionNameLabel: string; + variableNameLabel: string; + removeOptionAriaLabel: string; + saveButton: string; + cancelButton: string; + failedToSave: string; }; // Map all messages to specific payloads export class IInteractiveWindowMapping { diff --git a/src/notebooks/deepnote/converters/inputConverters.ts b/src/notebooks/deepnote/converters/inputConverters.ts index 29ec1b9766..f2386a2fba 100644 --- a/src/notebooks/deepnote/converters/inputConverters.ts +++ b/src/notebooks/deepnote/converters/inputConverters.ts @@ -1,6 +1,7 @@ import { NotebookCellData, NotebookCellKind } from 'vscode'; import { z } from 'zod'; +import { logger } from '../../../platform/logging'; import type { BlockConverter } from './blockConverter'; import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; import { @@ -14,57 +15,69 @@ import { DeepnoteFileInputMetadataSchema, DeepnoteButtonMetadataSchema } from '../deepnoteSchemas'; -import { parseJsonWithFallback } from '../dataConversionUtils'; import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; +import { formatInputBlockCellContent } from '../inputBlockContentFormatter'; export abstract class BaseInputBlockConverter implements BlockConverter { abstract schema(): T; abstract getSupportedType(): string; abstract defaultConfig(): z.infer; - applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { + /** + * Helper method to update block metadata with common logic. + * Clears block.content, parses schema, deletes DEEPNOTE_VSCODE_RAW_CONTENT_KEY, + * and merges metadata with updates. + * + * If metadata is missing or invalid, applies default config. + * Otherwise, preserves existing metadata and only applies updates. + */ + protected updateBlockMetadata(block: DeepnoteBlock, updates: Partial>): void { block.content = ''; - const config = this.schema().safeParse(parseJsonWithFallback(cell.value)); + if (block.metadata != null) { + delete block.metadata[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]; + } + + // Check if existing metadata is valid + const existingMetadata = this.schema().safeParse(block.metadata); + const hasValidMetadata = + existingMetadata.success && block.metadata != null && Object.keys(block.metadata).length > 0; - if (config.success !== true) { + if (hasValidMetadata) { + // Preserve existing metadata and only apply updates block.metadata = { ...(block.metadata ?? {}), - [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: cell.value + ...updates + }; + } else { + // Apply defaults when metadata is missing or invalid + block.metadata = { + ...this.defaultConfig(), + ...updates }; - return; - } - - if (block.metadata != null) { - delete block.metadata[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]; } + } - block.metadata = { - ...(block.metadata ?? {}), - ...config.data - }; + applyChangesToBlock(block: DeepnoteBlock, _cell: NotebookCellData): void { + // Default implementation: preserve existing metadata + // Readonly blocks (select, checkbox, date, date-range, button) use this default behavior + // Editable blocks override this method to update specific metadata fields + this.updateBlockMetadata(block, {}); } canConvert(blockType: string): boolean { - return blockType.toLowerCase() === this.getSupportedType(); + return this.getSupportedTypes().includes(blockType.toLowerCase()); } convertToCell(block: DeepnoteBlock): NotebookCellData { - const deepnoteJupyterRawContentResult = z.string().safeParse(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); const deepnoteMetadataResult = this.schema().safeParse(block.metadata); if (deepnoteMetadataResult.error != null) { - console.error('Error parsing deepnote input metadata:', deepnoteMetadataResult.error); - console.debug('Metadata:', JSON.stringify(block.metadata)); + logger.error('Error parsing deepnote input metadata', deepnoteMetadataResult.error); } - const configStr = deepnoteJupyterRawContentResult.success - ? deepnoteJupyterRawContentResult.data - : deepnoteMetadataResult.success - ? JSON.stringify(deepnoteMetadataResult.data, null, 2) - : JSON.stringify(this.defaultConfig(), null, 2); - - const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + // Default fallback: empty plaintext cell; subclasses render content/language + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'plaintext'); return cell; } @@ -86,6 +99,21 @@ export class InputTextBlockConverter extends BaseInputBlockConverter { @@ -100,6 +128,21 @@ export class InputTextareaBlockConverter extends BaseInputBlockConverter { @@ -114,6 +157,15 @@ export class InputSelectBlockConverter extends BaseInputBlockConverter { @@ -128,6 +180,30 @@ export class InputSliderBlockConverter extends BaseInputBlockConverter { @@ -142,6 +218,15 @@ export class InputCheckboxBlockConverter extends BaseInputBlockConverter { @@ -156,6 +241,15 @@ export class InputDateBlockConverter extends BaseInputBlockConverter { @@ -170,6 +264,15 @@ export class InputDateRangeBlockConverter extends BaseInputBlockConverter { @@ -184,6 +287,21 @@ export class InputFileBlockConverter extends BaseInputBlockConverter { @@ -198,4 +316,13 @@ export class ButtonBlockConverter extends BaseInputBlockConverter { }); suite('convertToCell', () => { - test('converts input-text block with metadata to JSON cell', () => { + test('converts input-text block with metadata to plaintext cell with value', () => { const block: DeepnoteBlock = { blockGroup: '92f21410c8c54ac0be7e4d2a544552ee', content: '', @@ -41,13 +41,8 @@ suite('InputTextBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_input_label, 'some display name'); - assert.strictEqual(parsed.deepnote_variable_name, 'input_1'); - assert.strictEqual(parsed.deepnote_variable_value, 'some text input'); - assert.strictEqual(parsed.deepnote_variable_default_value, 'some default value'); + assert.strictEqual(cell.languageId, 'plaintext'); + assert.strictEqual(cell.value, 'some text input'); }); test('handles missing metadata with default config', () => { @@ -62,23 +57,17 @@ suite('InputTextBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_input_label, ''); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_variable_value, ''); - assert.isNull(parsed.deepnote_variable_default_value); + assert.strictEqual(cell.languageId, 'plaintext'); + assert.strictEqual(cell.value, ''); }); - test('uses raw content when available', () => { - const rawContent = '{"deepnote_variable_name": "custom"}'; + test('handles missing variable value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', metadata: { - [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: rawContent + deepnote_input_label: 'some label' }, sortingKey: 'a0', type: 'input-text' @@ -86,58 +75,74 @@ suite('InputTextBlockConverter', () => { const cell = converter.convertToCell(block); - assert.strictEqual(cell.value, rawContent); + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'plaintext'); + assert.strictEqual(cell.value, ''); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON to block metadata', () => { + test('applies text value from cell to block metadata', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: 'old content', id: 'block-123', + metadata: { + deepnote_input_label: 'existing label', + deepnote_variable_name: 'input_1', + deepnote_variable_value: 'old value' + }, sortingKey: 'a0', type: 'input-text' }; - const cellValue = JSON.stringify( - { - deepnote_input_label: 'new label', - deepnote_variable_name: 'new_var', - deepnote_variable_value: 'new value', - deepnote_variable_default_value: 'new default' - }, - null, - 2 - ); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'new text value', 'plaintext'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.deepnote_input_label, 'new label'); - assert.strictEqual(block.metadata?.deepnote_variable_name, 'new_var'); - assert.strictEqual(block.metadata?.deepnote_variable_value, 'new value'); - assert.strictEqual(block.metadata?.deepnote_variable_default_value, 'new default'); + assert.deepStrictEqual(block.metadata, { + deepnote_variable_value: 'new text value', + // Other metadata should be preserved + deepnote_input_label: 'existing label', + deepnote_variable_name: 'input_1' + }); }); - test('handles invalid JSON by storing in raw content key', () => { + test('handles empty value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: 'old content', id: 'block-123', + metadata: { + deepnote_variable_value: 'old value' + }, sortingKey: 'a0', type: 'input-text' }; - const invalidJson = '{invalid json}'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'plaintext'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.metadata?.deepnote_variable_value, ''); }); - test('clears raw content key when valid JSON is applied', () => { + test('preserves whitespace in value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-text' + }; + const cell = new NotebookCellData(NotebookCellKind.Code, ' text with spaces \n', 'plaintext'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.deepnote_variable_value, ' text with spaces \n'); + }); + + test('clears raw content key when variable name is applied', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', @@ -148,10 +153,7 @@ suite('InputTextBlockConverter', () => { sortingKey: 'a0', type: 'input-text' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'var1' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'var1', 'plaintext'); converter.applyChangesToBlock(block, cell); @@ -167,8 +169,7 @@ suite('InputTextBlockConverter', () => { sortingKey: 'a0', type: 'input-text' }; - const cellValue = JSON.stringify({ deepnote_variable_name: 'var' }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'var', 'plaintext'); converter.applyChangesToBlock(block, cell); @@ -188,7 +189,7 @@ suite('InputTextareaBlockConverter', () => { }); suite('convertToCell', () => { - test('converts input-textarea block with multiline value to JSON cell', () => { + test('converts input-textarea block to plaintext cell with value', () => { const block: DeepnoteBlock = { blockGroup: '2b5f9340349f4baaa5a3237331214352', content: '', @@ -204,11 +205,8 @@ suite('InputTextareaBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_2'); - assert.strictEqual(parsed.deepnote_variable_value, 'some multiline\ntext input'); + assert.strictEqual(cell.languageId, 'plaintext'); + assert.strictEqual(cell.value, 'some multiline\ntext input'); }); test('handles missing metadata with default config', () => { @@ -222,36 +220,33 @@ suite('InputTextareaBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_variable_value, ''); - assert.strictEqual(parsed.deepnote_input_label, ''); + assert.strictEqual(cell.value, ''); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with multiline value to block metadata', () => { + test('applies text value from cell to block metadata', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'input_2' + }, sortingKey: 'a0', type: 'input-textarea' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'textarea_var', - deepnote_variable_value: 'line1\nline2\nline3' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'new multiline\ntext value', 'plaintext'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.deepnote_variable_name, 'textarea_var'); - assert.strictEqual(block.metadata?.deepnote_variable_value, 'line1\nline2\nline3'); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'new multiline\ntext value'); + // Variable name should be preserved + assert.strictEqual(block.metadata?.deepnote_variable_name, 'input_2'); }); - test('handles invalid JSON', () => { + test('handles empty value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', @@ -259,12 +254,11 @@ suite('InputTextareaBlockConverter', () => { sortingKey: 'a0', type: 'input-textarea' }; - const invalidJson = 'not json'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '', 'plaintext'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.metadata?.deepnote_variable_value, ''); }); }); }); @@ -277,7 +271,7 @@ suite('InputSelectBlockConverter', () => { }); suite('convertToCell', () => { - test('converts input-select block with single value', () => { + test('converts input-select block to Python cell with quoted value', () => { const block: DeepnoteBlock = { blockGroup: 'ba248341bdd94b93a234777968bfedcf', content: '', @@ -298,141 +292,117 @@ suite('InputSelectBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_3'); - assert.strictEqual(parsed.deepnote_variable_value, 'Option 1'); - assert.deepStrictEqual(parsed.deepnote_variable_options, ['Option 1', 'Option 2']); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '"Option 1"'); }); - test('converts input-select block with multiple values', () => { + test('handles missing metadata with default config', () => { const block: DeepnoteBlock = { - blockGroup: '9f77387639cd432bb913890dea32b6c3', + blockGroup: 'test-group', content: '', - id: '748548e64442416bb9f3a9c5ec22c4de', - metadata: { - deepnote_input_label: 'some select display name', - deepnote_variable_name: 'input_4', - deepnote_variable_value: ['Option 1'], - deepnote_variable_options: ['Option 1', 'Option 2'], - deepnote_variable_select_type: 'from-options', - deepnote_allow_multiple_values: true, - deepnote_variable_custom_options: ['Option 1', 'Option 2'], - deepnote_variable_selected_variable: '' - }, - sortingKey: 'y', + id: 'block-123', + sortingKey: 'a0', type: 'input-select' }; const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_allow_multiple_values, true); - assert.deepStrictEqual(parsed.deepnote_variable_value, ['Option 1']); + assert.strictEqual(cell.value, 'None'); }); - test('converts input-select block with allow empty values', () => { + test('escapes quotes in single select value', () => { const block: DeepnoteBlock = { - blockGroup: '146c4af1efb2448fa2d3b7cfbd30da77', + blockGroup: 'test-group', content: '', - id: 'a3521dd942d2407693b0202b55c935a7', + id: 'block-123', metadata: { - deepnote_input_label: 'allows empty value', - deepnote_variable_name: 'input_5', - deepnote_variable_value: 'Option 1', - deepnote_variable_options: ['Option 1', 'Option 2'], - deepnote_allow_empty_values: true, - deepnote_variable_select_type: 'from-options', - deepnote_variable_default_value: '', - deepnote_variable_custom_options: ['Option 1', 'Option 2'], - deepnote_variable_selected_variable: '' + deepnote_variable_value: 'Option with "quotes"' }, - sortingKey: 'yU', + sortingKey: 'a0', type: 'input-select' }; const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_allow_empty_values, true); - assert.strictEqual(parsed.deepnote_variable_default_value, ''); + assert.strictEqual(cell.value, '"Option with \\"quotes\\""'); }); - test('handles missing metadata with default config', () => { + test('escapes backslashes in single select value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_value: 'Path\\to\\file' + }, sortingKey: 'a0', type: 'input-select' }; const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_variable_value, 'Option 1'); + assert.strictEqual(cell.value, '"Path\\\\to\\\\file"'); }); - }); - suite('applyChangesToBlock', () => { - test('applies valid JSON with single value', () => { + test('escapes quotes in multi-select array values', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_value: ['Option "A"', 'Option "B"'] + }, sortingKey: 'a0', type: 'input-select' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'select_var', - deepnote_variable_value: 'Option A', - deepnote_variable_options: ['Option A', 'Option B'] - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); - converter.applyChangesToBlock(block, cell); + const cell = converter.convertToCell(block); - assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.deepnote_variable_value, 'Option A'); - assert.deepStrictEqual(block.metadata?.deepnote_variable_options, ['Option A', 'Option B']); + assert.strictEqual(cell.value, '["Option \\"A\\"", "Option \\"B\\""]'); }); - test('applies valid JSON with array value', () => { + test('handles empty array for multi-select', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_value: [] + }, sortingKey: 'a0', type: 'input-select' }; - const cellValue = JSON.stringify({ - deepnote_variable_value: ['Option 1', 'Option 2'], - deepnote_allow_multiple_values: true - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); - converter.applyChangesToBlock(block, cell); + const cell = converter.convertToCell(block); - assert.deepStrictEqual(block.metadata?.deepnote_variable_value, ['Option 1', 'Option 2']); - assert.strictEqual(block.metadata?.deepnote_allow_multiple_values, true); + assert.strictEqual(cell.value, '[]'); }); + }); - test('handles invalid JSON', () => { + suite('applyChangesToBlock', () => { + test('preserves existing metadata (select blocks are readonly)', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'input_3', + deepnote_variable_options: ['Option A', 'Option B'], + deepnote_variable_value: 'Option A' + }, sortingKey: 'a0', type: 'input-select' }; - const invalidJson = '{broken'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + // Cell content is ignored since select blocks are readonly + const cell = new NotebookCellData(NotebookCellKind.Code, '"Option B"', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.content, ''); + // Value should be preserved from metadata, not parsed from cell + assert.strictEqual(block.metadata?.deepnote_variable_value, 'Option A'); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'input_3'); + assert.deepStrictEqual(block.metadata?.deepnote_variable_options, ['Option A', 'Option B']); }); }); }); @@ -454,10 +424,10 @@ suite('InputSliderBlockConverter', () => { deepnote_input_label: 'slider input value', deepnote_slider_step: 1, deepnote_variable_name: 'input_6', - deepnote_variable_value: '5', + deepnote_variable_value: 5, deepnote_slider_max_value: 10, deepnote_slider_min_value: 0, - deepnote_variable_default_value: '5' + deepnote_variable_default_value: 5 }, sortingKey: 'yj', type: 'input-slider' @@ -466,14 +436,8 @@ suite('InputSliderBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_6'); - assert.strictEqual(parsed.deepnote_variable_value, '5'); - assert.strictEqual(parsed.deepnote_slider_min_value, 0); - assert.strictEqual(parsed.deepnote_slider_max_value, 10); - assert.strictEqual(parsed.deepnote_slider_step, 1); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '5'); }); test('converts input-slider block with custom step size', () => { @@ -485,7 +449,7 @@ suite('InputSliderBlockConverter', () => { deepnote_input_label: 'step size 2', deepnote_slider_step: 2, deepnote_variable_name: 'input_7', - deepnote_variable_value: '6', + deepnote_variable_value: 6, deepnote_slider_max_value: 10, deepnote_slider_min_value: 4 }, @@ -495,10 +459,7 @@ suite('InputSliderBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_slider_step, 2); - assert.strictEqual(parsed.deepnote_slider_min_value, 4); - assert.strictEqual(parsed.deepnote_variable_value, '6'); + assert.strictEqual(cell.value, '6'); }); test('handles missing metadata with default config', () => { @@ -512,35 +473,33 @@ suite('InputSliderBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_slider_min_value, 0); - assert.strictEqual(parsed.deepnote_slider_max_value, 10); + assert.strictEqual(cell.value, ''); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with slider configuration', () => { + test('applies numeric value from cell', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'slider1', + deepnote_slider_min_value: 0, + deepnote_slider_max_value: 100, + deepnote_slider_step: 5 + }, sortingKey: 'a0', type: 'input-slider' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'slider1', - deepnote_variable_value: '7', - deepnote_slider_min_value: 0, - deepnote_slider_max_value: 100, - deepnote_slider_step: 5 - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '7', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); assert.strictEqual(block.metadata?.deepnote_variable_value, '7'); + // Other metadata should be preserved + assert.strictEqual(block.metadata?.deepnote_variable_name, 'slider1'); assert.strictEqual(block.metadata?.deepnote_slider_min_value, 0); assert.strictEqual(block.metadata?.deepnote_slider_max_value, 100); assert.strictEqual(block.metadata?.deepnote_slider_step, 5); @@ -551,34 +510,37 @@ suite('InputSliderBlockConverter', () => { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'slider1' + }, sortingKey: 'a0', type: 'input-slider' }; - const cellValue = JSON.stringify({ - deepnote_variable_value: 42 - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '42', 'python'); converter.applyChangesToBlock(block, cell); - // Numeric values fail string schema validation, so stored in raw content - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], cellValue); + assert.strictEqual(block.metadata?.deepnote_variable_value, '42'); }); - test('handles invalid JSON', () => { + test('handles invalid numeric value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'slider1', + deepnote_variable_value: '5' + }, sortingKey: 'a0', type: 'input-slider' }; - const invalidJson = 'invalid'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'invalid', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + // Should fall back to existing value (string per metadata contract) + assert.strictEqual(block.metadata?.deepnote_variable_value, '5'); }); }); }); @@ -591,7 +553,7 @@ suite('InputCheckboxBlockConverter', () => { }); suite('convertToCell', () => { - test('converts input-checkbox block to JSON cell', () => { + test('converts input-checkbox block to Python cell with boolean value', () => { const block: DeepnoteBlock = { blockGroup: '5dd57f6bb90b49ebb954f6247b26427d', content: '', @@ -608,11 +570,8 @@ suite('InputCheckboxBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_8'); - assert.strictEqual(parsed.deepnote_variable_value, false); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, 'False'); }); test('handles checkbox with true value', () => { @@ -630,8 +589,7 @@ suite('InputCheckboxBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_value, true); + assert.strictEqual(cell.value, 'True'); }); test('handles missing metadata with default config', () => { @@ -645,67 +603,53 @@ suite('InputCheckboxBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_variable_value, false); + assert.strictEqual(cell.value, 'False'); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with boolean value', () => { + test('preserves existing metadata (checkbox blocks are readonly)', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'checkbox1', + deepnote_variable_value: false + }, sortingKey: 'a0', type: 'input-checkbox' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'checkbox1', - deepnote_variable_value: true - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + // Cell content is ignored since checkbox blocks are readonly + const cell = new NotebookCellData(NotebookCellKind.Code, 'True', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.deepnote_variable_value, true); - }); - - test('applies false value', () => { - const block: DeepnoteBlock = { - blockGroup: 'test-group', - content: '', - id: 'block-123', - sortingKey: 'a0', - type: 'input-checkbox' - }; - const cellValue = JSON.stringify({ - deepnote_variable_value: false, - deepnote_variable_default_value: true - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); - - converter.applyChangesToBlock(block, cell); - + // Value should be preserved from metadata, not parsed from cell assert.strictEqual(block.metadata?.deepnote_variable_value, false); - assert.strictEqual(block.metadata?.deepnote_variable_default_value, true); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'checkbox1'); }); - test('handles invalid JSON', () => { + test('preserves default value', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'checkbox1', + deepnote_variable_value: true, + deepnote_variable_default_value: true + }, sortingKey: 'a0', type: 'input-checkbox' }; - const invalidJson = '{]'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'False', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.metadata?.deepnote_variable_value, true); + assert.strictEqual(block.metadata?.deepnote_variable_default_value, true); }); }); }); @@ -718,7 +662,7 @@ suite('InputDateBlockConverter', () => { }); suite('convertToCell', () => { - test('converts input-date block to JSON cell', () => { + test('converts input-date block to Python cell with quoted date', () => { const block: DeepnoteBlock = { blockGroup: 'e84010446b844a86a1f6bbe5d89dc798', content: '', @@ -736,12 +680,8 @@ suite('InputDateBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_9'); - assert.strictEqual(parsed.deepnote_variable_value, '2025-10-13T00:00:00.000Z'); - assert.strictEqual(parsed.deepnote_input_date_version, 2); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '"2025-10-13T00:00:00.000Z"'); }); test('handles missing metadata with default config', () => { @@ -755,51 +695,35 @@ suite('InputDateBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - // Default value should be an ISO date string - assert.match(parsed.deepnote_variable_value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.strictEqual(cell.value, '""'); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with date value', () => { + test('preserves existing metadata (date blocks are readonly)', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'date1', + deepnote_input_date_version: 2, + deepnote_variable_value: '2025-01-01T00:00:00.000Z' + }, sortingKey: 'a0', type: 'input-date' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'date1', - deepnote_variable_value: '2025-12-31T00:00:00.000Z', - deepnote_input_date_version: 2 - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + // Cell content is ignored since date blocks are readonly + const cell = new NotebookCellData(NotebookCellKind.Code, '"2025-12-31T00:00:00.000Z"', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.strictEqual(block.metadata?.deepnote_variable_value, '2025-12-31T00:00:00.000Z'); + // Value should be preserved from metadata, not parsed from cell + assert.strictEqual(block.metadata?.deepnote_variable_value, '2025-01-01T00:00:00.000Z'); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'date1'); assert.strictEqual(block.metadata?.deepnote_input_date_version, 2); }); - - test('handles invalid JSON', () => { - const block: DeepnoteBlock = { - blockGroup: 'test-group', - content: '', - id: 'block-123', - sortingKey: 'a0', - type: 'input-date' - }; - const invalidJson = 'not valid'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); - - converter.applyChangesToBlock(block, cell); - - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); - }); }); }); @@ -827,11 +751,8 @@ suite('InputDateRangeBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, 'input_10'); - assert.deepStrictEqual(parsed.deepnote_variable_value, ['2025-10-06', '2025-10-16']); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '("2025-10-06", "2025-10-16")'); }); test('converts input-date-range block with relative date', () => { @@ -850,9 +771,8 @@ suite('InputDateRangeBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_input_label, 'relative past 3 months'); - assert.strictEqual(parsed.deepnote_variable_value, 'past3months'); + // Relative dates are not formatted as tuples + assert.strictEqual(cell.value, ''); }); test('handles missing metadata with default config', () => { @@ -866,65 +786,32 @@ suite('InputDateRangeBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.strictEqual(parsed.deepnote_variable_value, ''); + assert.strictEqual(cell.value, ''); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with date range array', () => { + test('preserves existing metadata (date range blocks are readonly)', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'range1', + deepnote_variable_value: ['2025-06-01', '2025-06-30'] + }, sortingKey: 'a0', type: 'input-date-range' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'range1', - deepnote_variable_value: ['2025-01-01', '2025-12-31'] - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + // Cell content is ignored since date range blocks are readonly + const cell = new NotebookCellData(NotebookCellKind.Code, '("2025-01-01", "2025-12-31")', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); - assert.deepStrictEqual(block.metadata?.deepnote_variable_value, ['2025-01-01', '2025-12-31']); - }); - - test('applies valid JSON with relative date string', () => { - const block: DeepnoteBlock = { - blockGroup: 'test-group', - content: '', - id: 'block-123', - sortingKey: 'a0', - type: 'input-date-range' - }; - const cellValue = JSON.stringify({ - deepnote_variable_value: 'past7days' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); - - converter.applyChangesToBlock(block, cell); - - assert.strictEqual(block.metadata?.deepnote_variable_value, 'past7days'); - }); - - test('handles invalid JSON', () => { - const block: DeepnoteBlock = { - blockGroup: 'test-group', - content: '', - id: 'block-123', - sortingKey: 'a0', - type: 'input-date-range' - }; - const invalidJson = '{{bad}}'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); - - converter.applyChangesToBlock(block, cell); - - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + // Value should be preserved from metadata, not parsed from cell + assert.deepStrictEqual(block.metadata?.deepnote_variable_value, ['2025-06-01', '2025-06-30']); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'range1'); }); }); }); @@ -937,7 +824,7 @@ suite('InputFileBlockConverter', () => { }); suite('convertToCell', () => { - test('converts input-file block to JSON cell', () => { + test('converts input-file block to Python cell with quoted file path', () => { const block: DeepnoteBlock = { blockGroup: '651f4f5db96b43d5a6a1a492935fa08d', content: '', @@ -945,7 +832,7 @@ suite('InputFileBlockConverter', () => { metadata: { deepnote_input_label: 'csv file input', deepnote_variable_name: 'input_12', - deepnote_variable_value: '', + deepnote_variable_value: 'data.csv', deepnote_allowed_file_extensions: '.csv' }, sortingKey: 'yyj', @@ -955,12 +842,8 @@ suite('InputFileBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_input_label, 'csv file input'); - assert.strictEqual(parsed.deepnote_variable_name, 'input_12'); - assert.strictEqual(parsed.deepnote_allowed_file_extensions, '.csv'); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '"data.csv"'); }); test('handles missing metadata with default config', () => { @@ -974,47 +857,84 @@ suite('InputFileBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_variable_name, ''); - assert.isNull(parsed.deepnote_allowed_file_extensions); + assert.strictEqual(cell.value, ''); + }); + + test('escapes backslashes in Windows file paths', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + deepnote_variable_value: 'C:\\Users\\Documents\\file.txt' + }, + sortingKey: 'a0', + type: 'input-file' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.value, '"C:\\\\Users\\\\Documents\\\\file.txt"'); + }); + + test('escapes quotes in file paths', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + deepnote_variable_value: 'file "with quotes".txt' + }, + sortingKey: 'a0', + type: 'input-file' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.value, '"file \\"with quotes\\".txt"'); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with file extension', () => { + test('applies file path from cell', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'file1', + deepnote_allowed_file_extensions: '.pdf,.docx' + }, sortingKey: 'a0', type: 'input-file' }; - const cellValue = JSON.stringify({ - deepnote_variable_name: 'file1', - deepnote_allowed_file_extensions: '.pdf,.docx' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '"document.pdf"', 'python'); converter.applyChangesToBlock(block, cell); assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'document.pdf'); + // Other metadata should be preserved + assert.strictEqual(block.metadata?.deepnote_variable_name, 'file1'); assert.strictEqual(block.metadata?.deepnote_allowed_file_extensions, '.pdf,.docx'); }); - test('handles invalid JSON', () => { + test('handles unquoted file path', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_variable_name: 'file1' + }, sortingKey: 'a0', type: 'input-file' }; - const invalidJson = 'bad json'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, 'file.txt', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'file.txt'); }); }); }); @@ -1027,7 +947,7 @@ suite('ButtonBlockConverter', () => { }); suite('convertToCell', () => { - test('converts button block with set_variable behavior', () => { + test('converts button block to Python cell with comment', () => { const block: DeepnoteBlock = { blockGroup: '22e563550e734e75b35252e4975c3110', content: '', @@ -1045,15 +965,11 @@ suite('ButtonBlockConverter', () => { const cell = converter.convertToCell(block); assert.strictEqual(cell.kind, NotebookCellKind.Code); - assert.strictEqual(cell.languageId, 'json'); - - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_button_title, 'Run'); - assert.strictEqual(parsed.deepnote_button_behavior, 'set_variable'); - assert.strictEqual(parsed.deepnote_button_color_scheme, 'blue'); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '# Buttons only work in Deepnote apps'); }); - test('converts button block with run behavior', () => { + test('converts button block with run behavior to Python cell with comment', () => { const block: DeepnoteBlock = { blockGroup: '2a1e97120eb24494adff278264625a4f', content: '', @@ -1070,9 +986,9 @@ suite('ButtonBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_button_title, 'Run notebook button'); - assert.strictEqual(parsed.deepnote_button_behavior, 'run'); + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '# Buttons only work in Deepnote apps'); }); test('handles missing metadata with default config', () => { @@ -1086,27 +1002,27 @@ suite('ButtonBlockConverter', () => { const cell = converter.convertToCell(block); - const parsed = JSON.parse(cell.value); - assert.strictEqual(parsed.deepnote_button_title, 'Run'); - assert.strictEqual(parsed.deepnote_button_behavior, 'set_variable'); + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'python'); + assert.strictEqual(cell.value, '# Buttons only work in Deepnote apps'); }); }); suite('applyChangesToBlock', () => { - test('applies valid JSON with button configuration', () => { + test('preserves existing metadata and ignores cell content', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + deepnote_button_title: 'Click Me', + deepnote_button_behavior: 'run', + deepnote_button_color_scheme: 'red' + }, sortingKey: 'a0', type: 'button' }; - const cellValue = JSON.stringify({ - deepnote_button_title: 'Click Me', - deepnote_button_behavior: 'run', - deepnote_button_color_scheme: 'red' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '# Buttons only work in Deepnote apps', 'python'); converter.applyChangesToBlock(block, cell); @@ -1116,7 +1032,7 @@ suite('ButtonBlockConverter', () => { assert.strictEqual(block.metadata?.deepnote_button_color_scheme, 'red'); }); - test('applies different color schemes', () => { + test('applies default config when metadata is missing', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', @@ -1124,30 +1040,33 @@ suite('ButtonBlockConverter', () => { sortingKey: 'a0', type: 'button' }; - const cellValue = JSON.stringify({ - deepnote_button_color_scheme: 'green' - }); - const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '# Buttons only work in Deepnote apps', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.deepnote_button_color_scheme, 'green'); + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_button_title, 'Run'); + assert.strictEqual(block.metadata?.deepnote_button_behavior, 'set_variable'); + assert.strictEqual(block.metadata?.deepnote_button_color_scheme, 'blue'); }); - test('handles invalid JSON', () => { + test('removes raw content key from metadata', () => { const block: DeepnoteBlock = { blockGroup: 'test-group', content: '', id: 'block-123', + metadata: { + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: 'some raw content', + deepnote_button_title: 'Test' + }, sortingKey: 'a0', type: 'button' }; - const invalidJson = '}{'; - const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + const cell = new NotebookCellData(NotebookCellKind.Code, '# Buttons only work in Deepnote apps', 'python'); converter.applyChangesToBlock(block, cell); - assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], undefined); }); }); }); diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 546a26f349..c707e309c8 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -2,10 +2,12 @@ import { injectable, inject } from 'inversify'; import { workspace } from 'vscode'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IExtensionContext } from '../../platform/common/types'; +import { ILogger } from '../../platform/logging/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; import { IIntegrationManager } from './integrations/types'; +import { DeepnoteInputBlockEditProtection } from './deepnoteInputBlockEditProtection'; /** * Service responsible for activating and configuring Deepnote notebook support in VS Code. @@ -19,10 +21,13 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic private serializer: DeepnoteNotebookSerializer; + private editProtection: DeepnoteInputBlockEditProtection; + constructor( @inject(IExtensionContext) private extensionContext: IExtensionContext, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, - @inject(IIntegrationManager) integrationManager: IIntegrationManager + @inject(IIntegrationManager) integrationManager: IIntegrationManager, + @inject(ILogger) private readonly logger: ILogger ) { this.integrationManager = integrationManager; } @@ -34,8 +39,10 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager); this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager); + this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.extensionContext.subscriptions.push(workspace.registerNotebookSerializer('deepnote', this.serializer)); + this.extensionContext.subscriptions.push(this.editProtection); this.explorerView.activate(); this.integrationManager.activate(); diff --git a/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts b/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts index c7ee287329..d1f6780a30 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.unit.test.ts @@ -3,13 +3,26 @@ import { assert } from 'chai'; import { DeepnoteActivationService } from './deepnoteActivationService'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { IExtensionContext } from '../../platform/common/types'; +import { ILogger } from '../../platform/logging/types'; import { IIntegrationManager } from './integrations/types'; +function createMockLogger(): ILogger { + return { + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + ci: () => undefined + } as ILogger; +} + suite('DeepnoteActivationService', () => { let activationService: DeepnoteActivationService; let mockExtensionContext: IExtensionContext; let manager: DeepnoteNotebookManager; let mockIntegrationManager: IIntegrationManager; + let mockLogger: ILogger; setup(() => { mockExtensionContext = { @@ -22,7 +35,13 @@ suite('DeepnoteActivationService', () => { return; } }; - activationService = new DeepnoteActivationService(mockExtensionContext, manager, mockIntegrationManager); + mockLogger = createMockLogger(); + activationService = new DeepnoteActivationService( + mockExtensionContext, + manager, + mockIntegrationManager, + mockLogger + ); }); suite('constructor', () => { @@ -92,8 +111,10 @@ suite('DeepnoteActivationService', () => { return; } }; - const service1 = new DeepnoteActivationService(context1, manager1, mockIntegrationManager1); - const service2 = new DeepnoteActivationService(context2, manager2, mockIntegrationManager2); + const mockLogger1 = createMockLogger(); + const mockLogger2 = createMockLogger(); + const service1 = new DeepnoteActivationService(context1, manager1, mockIntegrationManager1, mockLogger1); + const service2 = new DeepnoteActivationService(context2, manager2, mockIntegrationManager2, mockLogger2); // Verify each service has its own context assert.strictEqual((service1 as any).extensionContext, context1); @@ -128,8 +149,10 @@ suite('DeepnoteActivationService', () => { return; } }; - new DeepnoteActivationService(context1, manager1, mockIntegrationManager1); - new DeepnoteActivationService(context2, manager2, mockIntegrationManager2); + const mockLogger3 = createMockLogger(); + const mockLogger4 = createMockLogger(); + new DeepnoteActivationService(context1, manager1, mockIntegrationManager1, mockLogger3); + new DeepnoteActivationService(context2, manager2, mockIntegrationManager2, mockLogger4); assert.strictEqual(context1.subscriptions.length, 0); assert.strictEqual(context2.subscriptions.length, 1); diff --git a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts index 9b0bd542a0..9cf039f82f 100644 --- a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts @@ -2,15 +2,31 @@ // Licensed under the MIT License. import { + CancellationToken, Disposable, - notebooks, + EventEmitter, NotebookCell, NotebookCellStatusBarItem, - NotebookCellStatusBarItemProvider + NotebookCellStatusBarItemProvider, + NotebookEdit, + Position, + QuickPickItem, + Range, + ThemeIcon, + WorkspaceEdit, + commands, + l10n, + notebooks, + window, + workspace } from 'vscode'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import type { Pocket } from '../../platform/deepnote/pocket'; +import { formatInputBlockCellContent } from './inputBlockContentFormatter'; +import { SelectInputSettingsWebviewProvider } from './selectInputSettingsWebview'; +import { IExtensionContext } from '../../platform/common/types'; +import { getFilePath } from '../../platform/common/platform/fs-paths'; /** * Provides status bar items for Deepnote input block cells to display their block type. @@ -21,6 +37,14 @@ export class DeepnoteInputBlockCellStatusBarItemProvider implements NotebookCellStatusBarItemProvider, IExtensionSyncActivationService { private readonly disposables: Disposable[] = []; + private readonly _onDidChangeCellStatusBarItems = new EventEmitter(); + private readonly selectInputSettingsWebview: SelectInputSettingsWebviewProvider; + + public readonly onDidChangeCellStatusBarItems = this._onDidChangeCellStatusBarItems.event; + + constructor(@inject(IExtensionContext) extensionContext: IExtensionContext) { + this.selectInputSettingsWebview = new SelectInputSettingsWebviewProvider(extensionContext); + } // List of supported Deepnote input block types private readonly INPUT_BLOCK_TYPES = [ @@ -38,9 +62,158 @@ export class DeepnoteInputBlockCellStatusBarItemProvider 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 command to update input block variable name + this.disposables.push( + commands.registerCommand('deepnote.updateInputBlockVariableName', async (cell?: NotebookCell) => { + if (!cell) { + // Fall back to the active notebook 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.updateVariableName(cell); + }) + ); + + // Register commands for type-specific actions + this.registerTypeSpecificCommands(); + + // Dispose our emitter with the extension + this.disposables.push(this._onDidChangeCellStatusBarItems); } - provideCellStatusBarItems(cell: NotebookCell): NotebookCellStatusBarItem | undefined { + private registerTypeSpecificCommands(): void { + // Select input: choose option(s) + this.disposables.push( + commands.registerCommand('deepnote.selectInputChooseOption', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.selectInputChooseOption(activeCell); + } + }) + ); + + // Slider: set min value + this.disposables.push( + commands.registerCommand('deepnote.sliderSetMin', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.sliderSetMin(activeCell); + } + }) + ); + + // Slider: set max value + this.disposables.push( + commands.registerCommand('deepnote.sliderSetMax', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.sliderSetMax(activeCell); + } + }) + ); + + // Slider: set step value + this.disposables.push( + commands.registerCommand('deepnote.sliderSetStep', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.sliderSetStep(activeCell); + } + }) + ); + + // File input: choose file + this.disposables.push( + commands.registerCommand('deepnote.fileInputChooseFile', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.fileInputChooseFile(activeCell); + } + }) + ); + + // Select input: configure settings + this.disposables.push( + commands.registerCommand('deepnote.selectInputSettings', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.selectInputSettings(activeCell); + } + }) + ); + + // Checkbox: toggle value + this.disposables.push( + commands.registerCommand('deepnote.checkboxToggle', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.checkboxToggle(activeCell); + } + }) + ); + + // Date input: choose date + this.disposables.push( + commands.registerCommand('deepnote.dateInputChooseDate', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.dateInputChooseDate(activeCell); + } + }) + ); + + // Date range: choose start date + this.disposables.push( + commands.registerCommand('deepnote.dateRangeChooseStart', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.dateRangeChooseStart(activeCell); + } + }) + ); + + // Date range: choose end date + this.disposables.push( + commands.registerCommand('deepnote.dateRangeChooseEnd', async (cell?: NotebookCell) => { + const activeCell = cell || this.getActiveCell(); + if (activeCell) { + await this.dateRangeChooseEnd(activeCell); + } + }) + ); + } + + private getActiveCell(): NotebookCell | undefined { + const activeEditor = window.activeNotebookEditor; + if (activeEditor && activeEditor.selection) { + return activeEditor.notebook.cellAt(activeEditor.selection.start); + } + return undefined; + } + + provideCellStatusBarItems(cell: NotebookCell, token: CancellationToken): NotebookCellStatusBarItem[] | undefined { + if (token.isCancellationRequested) { + return undefined; + } + // Check if this cell is a Deepnote input block // Get the block type from the __deepnotePocket metadata field const pocket = cell.metadata?.__deepnotePocket as Pocket | undefined; @@ -50,17 +223,445 @@ export class DeepnoteInputBlockCellStatusBarItemProvider return undefined; } + const items: NotebookCellStatusBarItem[] = []; + const formattedName = this.formatBlockTypeName(blockType); - // Create a status bar item showing the block type - // Using alignment value 2 (NotebookCellStatusBarAlignment.Right) - const statusBarItem: NotebookCellStatusBarItem = { - text: formattedName, - alignment: 2, // NotebookCellStatusBarAlignment.Right - tooltip: `Deepnote ${formattedName}` + // Extract additional metadata for display + const metadata = cell.metadata as Record | undefined; + const label = metadata?.deepnote_input_label as string | undefined; + const buttonTitle = metadata?.deepnote_button_title as string | undefined; + + // Build status bar text with additional info + let statusText = formattedName; + if (label) { + statusText += `: ${label}`; + } else if (buttonTitle) { + statusText += `: ${buttonTitle}`; + } + + // Build detailed tooltip + const tooltipLines = [l10n.t('Deepnote {0}', formattedName)]; + if (label) { + tooltipLines.push(l10n.t('Label: {0}', label)); + } + if (buttonTitle) { + tooltipLines.push(l10n.t('Title: {0}', buttonTitle)); + } + + // Add type-specific metadata to tooltip + this.addTypeSpecificTooltip(tooltipLines, blockType, metadata); + + // Create a status bar item showing the block type and metadata on the left + items.push({ + text: statusText, + alignment: 1, // NotebookCellStatusBarAlignment.Left + priority: 100, + tooltip: tooltipLines.join('\n') + }); + + // Add variable name status bar item (clickable) + items.push(this.createVariableStatusBarItem(cell)); + + // Add type-specific status bar items + this.addTypeSpecificStatusBarItems(items, blockType, cell, metadata); + + return items; + } + + /** + * Adds type-specific status bar items based on the block type + */ + private addTypeSpecificStatusBarItems( + items: NotebookCellStatusBarItem[], + blockType: string, + cell: NotebookCell, + metadata: Record | undefined + ): void { + if (!metadata) { + return; + } + + switch (blockType) { + case 'input-select': + this.addSelectInputStatusBarItems(items, cell, metadata); + break; + + case 'input-slider': + this.addSliderInputStatusBarItems(items, cell, metadata); + break; + + case 'input-checkbox': + this.addCheckboxInputStatusBarItems(items, cell, metadata); + break; + + case 'input-date': + this.addDateInputStatusBarItems(items, cell, metadata); + break; + + case 'input-date-range': + this.addDateRangeInputStatusBarItems(items, cell, metadata); + break; + + case 'input-file': + this.addFileInputStatusBarItems(items, cell, metadata); + break; + + // input-text, input-textarea, and button don't have additional buttons + } + } + + private addSelectInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + metadata: Record + ): void { + const selectType = metadata.deepnote_variable_select_type as string | undefined; + const sourceVariable = metadata.deepnote_variable_selected_variable as string | undefined; + const value = metadata.deepnote_variable_value; + const allowMultiple = metadata.deepnote_allow_multiple_values as boolean | undefined; + + // Show current selection + let selectionText = ''; + if (Array.isArray(value)) { + selectionText = value.length > 0 ? value.join(', ') : l10n.t('None'); + } else if (typeof value === 'string') { + selectionText = value || l10n.t('None'); + } + + items.push({ + text: l10n.t('Selection: {0}', selectionText), + alignment: 1, + priority: 80, + tooltip: allowMultiple + ? l10n.t('Current selection (multi-select)\nClick to change') + : l10n.t('Current selection\nClick to change'), + command: { + title: l10n.t('Choose Option'), + command: 'deepnote.selectInputChooseOption', + arguments: [cell] + } + }); + + // Settings button + items.push({ + text: l10n.t('$(gear) Settings'), + alignment: 1, + priority: 79, + tooltip: l10n.t('Configure select input settings\nClick to open'), + command: { + title: l10n.t('Settings'), + command: 'deepnote.selectInputSettings', + arguments: [cell] + } + }); + + // Show source variable if select type is from-variable + if (selectType === 'from-variable' && sourceVariable) { + items.push({ + text: l10n.t('Source: {0}', sourceVariable), + alignment: 1, + priority: 75, + tooltip: l10n.t('Variable containing options') + }); + } + } + + private addSliderInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + metadata: Record + ): void { + const min = metadata.deepnote_slider_min_value as number | undefined; + const max = metadata.deepnote_slider_max_value as number | undefined; + const step = metadata.deepnote_slider_step as number | undefined; + + items.push({ + text: l10n.t('Min: {0}', min ?? 0), + alignment: 1, + priority: 80, + tooltip: l10n.t('Minimum value\nClick to change'), + command: { + title: l10n.t('Set Min'), + command: 'deepnote.sliderSetMin', + arguments: [cell] + } + }); + + items.push({ + text: l10n.t('Max: {0}', max ?? 10), + alignment: 1, + priority: 79, + tooltip: l10n.t('Maximum value\nClick to change'), + command: { + title: l10n.t('Set Max'), + command: 'deepnote.sliderSetMax', + arguments: [cell] + } + }); + + items.push({ + text: l10n.t('Step: {0}', step ?? 1), + alignment: 1, + priority: 78, + tooltip: l10n.t('Step size\nClick to change'), + command: { + title: l10n.t('Set Step'), + command: 'deepnote.sliderSetStep', + arguments: [cell] + } + }); + } + + private addCheckboxInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + metadata: Record + ): void { + const value = metadata.deepnote_variable_value as boolean | undefined; + const checked = value ?? false; + + items.push({ + text: checked ? l10n.t('$(check) Checked') : l10n.t('$(close) Unchecked'), + alignment: 1, + priority: 80, + tooltip: l10n.t('Click to toggle'), + command: { + title: l10n.t('Toggle'), + command: 'deepnote.checkboxToggle', + arguments: [cell] + } + }); + } + + private addFileInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + _metadata: Record + ): void { + items.push({ + text: l10n.t('$(folder-opened) Choose File'), + alignment: 1, + priority: 80, + tooltip: l10n.t('Choose a file\nClick to browse'), + command: { + title: l10n.t('Choose File'), + command: 'deepnote.fileInputChooseFile', + arguments: [cell] + } + }); + } + + private addDateInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + metadata: Record + ): void { + const value = metadata.deepnote_variable_value as string | undefined; + const dateStr = value ? new Date(value).toLocaleDateString() : l10n.t('Not set'); + + items.push({ + text: l10n.t('Date: {0}', dateStr), + alignment: 1, + priority: 80, + tooltip: l10n.t('Click to choose date'), + command: { + title: l10n.t('Choose Date'), + command: 'deepnote.dateInputChooseDate', + arguments: [cell] + } + }); + } + + private addDateRangeInputStatusBarItems( + items: NotebookCellStatusBarItem[], + cell: NotebookCell, + metadata: Record + ): void { + const value = metadata.deepnote_variable_value; + let startDate = l10n.t('Not set'); + let endDate = l10n.t('Not set'); + + if (Array.isArray(value) && value.length === 2) { + startDate = new Date(value[0]).toLocaleDateString(); + endDate = new Date(value[1]).toLocaleDateString(); + } + + items.push({ + text: l10n.t('Start: {0}', startDate), + alignment: 1, + priority: 80, + tooltip: l10n.t('Click to choose start date'), + command: { + title: l10n.t('Choose Start Date'), + command: 'deepnote.dateRangeChooseStart', + arguments: [cell] + } + }); + + items.push({ + text: l10n.t('End: {0}', endDate), + alignment: 1, + priority: 79, + tooltip: l10n.t('Click to choose end date'), + command: { + title: l10n.t('Choose End Date'), + command: 'deepnote.dateRangeChooseEnd', + arguments: [cell] + } + }); + } + + /** + * Adds type-specific metadata to the tooltip + */ + private addTypeSpecificTooltip( + tooltipLines: string[], + blockType: string, + metadata: Record | undefined + ): void { + if (!metadata) { + return; + } + + switch (blockType) { + case 'input-slider': { + const min = metadata.deepnote_slider_min_value; + const max = metadata.deepnote_slider_max_value; + const step = metadata.deepnote_slider_step; + if (min !== undefined && max !== undefined) { + tooltipLines.push( + l10n.t( + 'Range: {0} - {1}{2}', + String(min), + String(max), + step !== undefined ? l10n.t(' (step: {0})', String(step)) : '' + ) + ); + } + break; + } + + case 'input-select': { + const options = metadata.deepnote_variable_options as string[] | undefined; + if (options && options.length > 0) { + tooltipLines.push( + l10n.t('Options: {0}', `${options.slice(0, 3).join(', ')}${options.length > 3 ? '...' : ''}`) + ); + } + break; + } + + case 'input-file': { + const extensions = metadata.deepnote_allowed_file_extensions as string | undefined; + if (extensions) { + tooltipLines.push(l10n.t('Allowed extensions: {0}', extensions)); + } + break; + } + + case 'button': { + const behavior = metadata.deepnote_button_behavior as string | undefined; + const colorScheme = metadata.deepnote_button_color_scheme as string | undefined; + if (behavior) { + tooltipLines.push(l10n.t('Behavior: {0}', behavior)); + } + if (colorScheme) { + tooltipLines.push(l10n.t('Color: {0}', colorScheme)); + } + break; + } + } + + // Add default value if present + const defaultValue = metadata.deepnote_variable_default_value; + if (defaultValue !== undefined && defaultValue !== null) { + const dv = typeof defaultValue === 'object' ? JSON.stringify(defaultValue) : String(defaultValue); + tooltipLines.push(l10n.t('Default: {0}', dv)); + } + } + + /** + * Creates a status bar item for the variable name with a clickable command + */ + private createVariableStatusBarItem(cell: NotebookCell): NotebookCellStatusBarItem { + const variableName = this.getVariableName(cell); + + const text = variableName ? l10n.t('Variable: {0}', variableName) : l10n.t('$(edit) Set variable name'); + + return { + text, + alignment: 1, // NotebookCellStatusBarAlignment.Left + priority: 90, + tooltip: l10n.t('Variable name for input block\nClick to change'), + command: { + title: l10n.t('Change Variable Name'), + command: 'deepnote.updateInputBlockVariableName', + arguments: [cell] + } }; + } + + /** + * Gets the variable name from cell metadata or cell content + */ + private getVariableName(cell: NotebookCell): string { + const metadata = cell.metadata; + if (metadata && typeof metadata === 'object') { + const variableName = (metadata as Record).deepnote_variable_name; + if (typeof variableName === 'string' && variableName) { + return variableName; + } + } + + return ''; + } + + /** + * Updates the variable name for an input block cell + */ + private async updateVariableName(cell: NotebookCell): Promise { + const currentVariableName = this.getVariableName(cell); + + const newVariableNameInput = await window.showInputBox({ + prompt: l10n.t('Enter variable name for input block'), + value: currentVariableName, + ignoreFocusOut: true, + validateInput: (value) => { + const trimmed = value.trim(); + if (!trimmed) { + return l10n.t('Variable name cannot be empty'); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { + return l10n.t('Variable name must be a valid Python identifier'); + } + return undefined; + } + }); + + const newVariableName = newVariableNameInput?.trim(); + if (newVariableName === undefined || newVariableName === currentVariableName) { + return; + } + + // Update both cell metadata and cell content + const edit = new WorkspaceEdit(); + const updatedMetadata = { + ...cell.metadata, + deepnote_variable_name: newVariableName + }; + + // Update cell metadata + edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, updatedMetadata)]); - return statusBarItem; + const success = await workspace.applyEdit(edit); + if (!success) { + void window.showErrorMessage(l10n.t('Failed to update variable name')); + return; + } + + // Trigger status bar update + this._onDidChangeCellStatusBarItems.fire(); } /** @@ -80,6 +681,526 @@ export class DeepnoteInputBlockCellStatusBarItemProvider .join(' '); } + /** + * Handler for select input: choose option(s) + */ + private async selectInputChooseOption(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + if (!metadata) { + return; + } + + const selectType = metadata.deepnote_variable_select_type as string | undefined; + const allowMultiple = metadata.deepnote_allow_multiple_values as boolean | undefined; + const allowEmpty = metadata.deepnote_allow_empty_values as boolean | undefined; + const currentValue = metadata.deepnote_variable_value; + + // Get options based on select type + let options: string[] = []; + if (selectType === 'from-variable') { + // For from-variable type, we can't easily get the options here + // Show a message to the user + void window.showInformationMessage( + l10n.t('This select input uses options from a variable. Edit the source variable to change options.') + ); + return; + } else { + // from-options type + const optionsArray = metadata.deepnote_variable_options as string[] | undefined; + options = optionsArray || []; + } + + if (options.length === 0) { + void window.showWarningMessage(l10n.t('No options available')); + return; + } + + if (allowMultiple) { + // Multi-select using QuickPick with custom behavior for clear selection + const currentSelection = Array.isArray(currentValue) ? currentValue : []; + + // Create quick pick items (only actual options, not "Clear selection") + const optionItems = options.map((opt) => ({ + label: opt, + picked: currentSelection.includes(opt) + })); + + // Use createQuickPick for more control + const quickPick = window.createQuickPick(); + quickPick.items = optionItems; + quickPick.selectedItems = optionItems.filter((item) => item.picked); + quickPick.canSelectMany = true; + quickPick.placeholder = l10n.t('Select one or more options'); + + // Add "Clear selection" as a button if empty values are allowed + if (allowEmpty) { + const clearButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: l10n.t('Clear selection') + }; + quickPick.buttons = [clearButton]; + + quickPick.onDidTriggerButton((button) => { + if (button === clearButton) { + // Clear selection and close + quickPick.hide(); + void this.updateCellMetadata(cell, { deepnote_variable_value: [] }); + } + }); + } else { + // If empty values are not allowed, ensure at least one item is always selected + // Track the most recent non-empty selection + let lastNonEmptySelection: readonly QuickPickItem[] = optionItems.filter((item) => item.picked); + + quickPick.onDidChangeSelection((selectedItems) => { + if (selectedItems.length === 0) { + // Prevent deselecting the last item - restore most recent selection + quickPick.selectedItems = lastNonEmptySelection; + } else { + // Update the last non-empty selection + lastNonEmptySelection = selectedItems; + } + }); + } + + quickPick.onDidAccept(() => { + const selected = quickPick.selectedItems; + + // If empty values are not allowed, ensure at least one item is selected + if (!allowEmpty && selected.length === 0) { + void window.showWarningMessage(l10n.t('At least one option must be selected')); + return; + } + + const newValue = selected.map((item) => item.label); + void this.updateCellMetadata(cell, { deepnote_variable_value: newValue }); + quickPick.hide(); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + }); + + quickPick.show(); + } else { + // Single select + const quickPickItems = options.map((opt) => ({ + label: opt, + description: typeof currentValue === 'string' && currentValue === opt ? l10n.t('(current)') : undefined + })); + + // Add empty option if allowed + if (allowEmpty) { + quickPickItems.unshift({ + label: l10n.t('$(circle-slash) None'), + description: + currentValue === null || currentValue === undefined || currentValue === '' + ? l10n.t('(current)') + : undefined + }); + } + + const selected = await window.showQuickPick(quickPickItems, { + placeHolder: allowEmpty ? l10n.t('Select an option or none') : l10n.t('Select an option'), + canPickMany: false + }); + + if (selected === undefined) { + return; + } + + // Check if "None" was chosen + const noneLabel = l10n.t('$(circle-slash) None'); + if (selected.label === noneLabel) { + await this.updateCellMetadata(cell, { deepnote_variable_value: null }); + } else { + await this.updateCellMetadata(cell, { deepnote_variable_value: selected.label }); + } + } + } + + /** + * Handler for slider: set min value + */ + private async sliderSetMin(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentMin = (metadata?.deepnote_slider_min_value as number) ?? 0; + + const input = await window.showInputBox({ + prompt: l10n.t('Enter minimum value'), + value: String(currentMin), + validateInput: (value) => { + const num = parseFloat(value); + if (isNaN(num)) { + return l10n.t('Please enter a valid number'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + const newMin = parseFloat(input); + const updates: Record = { deepnote_slider_min_value: newMin }; + const currentMax = metadata?.deepnote_slider_max_value as number | undefined; + if (currentMax !== undefined && newMin > currentMax) { + updates.deepnote_slider_max_value = newMin; + void window.showWarningMessage(l10n.t('Min exceeded max; max adjusted to {0}.', String(newMin))); + } + await this.updateCellMetadata(cell, updates); + } + + /** + * Handler for slider: set max value + */ + private async sliderSetMax(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentMax = (metadata?.deepnote_slider_max_value as number) ?? 10; + + const input = await window.showInputBox({ + prompt: l10n.t('Enter maximum value'), + value: String(currentMax), + validateInput: (value) => { + const num = parseFloat(value); + if (isNaN(num)) { + return l10n.t('Please enter a valid number'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + const newMax = parseFloat(input); + const updates: Record = { deepnote_slider_max_value: newMax }; + const currentMin = metadata?.deepnote_slider_min_value as number | undefined; + if (currentMin !== undefined && newMax < currentMin) { + updates.deepnote_slider_min_value = newMax; + void window.showWarningMessage(l10n.t('Max exceeded min; min adjusted to {0}.', String(newMax))); + } + await this.updateCellMetadata(cell, updates); + } + + /** + * Handler for slider: set step value + */ + private async sliderSetStep(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentStep = (metadata?.deepnote_slider_step as number) ?? 1; + + const input = await window.showInputBox({ + prompt: l10n.t('Enter step size'), + value: String(currentStep), + validateInput: (value) => { + const num = parseFloat(value); + if (isNaN(num)) { + return l10n.t('Please enter a valid number'); + } + if (num <= 0) { + return l10n.t('Step size must be greater than 0'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + const newStep = parseFloat(input); + await this.updateCellMetadata(cell, { deepnote_slider_step: newStep }); + } + + /** + * Handler for checkbox: toggle value + */ + private async checkboxToggle(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentValue = (metadata?.deepnote_variable_value as boolean) ?? false; + + await this.updateCellMetadata(cell, { deepnote_variable_value: !currentValue }); + } + + /** + * Handler for file input: choose file + */ + private async fileInputChooseFile(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const allowedExtensions = metadata?.deepnote_allowed_file_extensions as string | undefined; + + // Parse allowed extensions if provided + const filters: { [name: string]: string[] } = {}; + if (allowedExtensions) { + // Split by comma and clean up + const extensions = allowedExtensions + .split(',') + .map((ext) => ext.trim().replace(/^\./, '')) + .filter((ext) => ext.length > 0); + + if (extensions.length > 0) { + filters[l10n.t('Allowed Files')] = extensions; + } + } + + // Add "All Files" option + filters[l10n.t('All Files')] = ['*']; + + const uris = await window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: Object.keys(filters).length > 1 ? filters : undefined, + openLabel: l10n.t('Select File') + }); + + if (!uris || uris.length === 0) { + return; + } + + // Get the file path (using getFilePath for platform-correct path separators) + const filePath = getFilePath(uris[0]); + + await this.updateCellMetadata(cell, { deepnote_variable_value: filePath }); + } + + /** + * Handler for select input: configure settings + */ + private async selectInputSettings(cell: NotebookCell): Promise { + await this.selectInputSettingsWebview.show(cell); + // The webview will handle saving the settings + // Trigger a status bar refresh after the webview closes + this._onDidChangeCellStatusBarItems.fire(); + } + + /** + * Convert a date value to YYYY-MM-DD format without timezone shifts. + * If the value already matches YYYY-MM-DD, use it directly. + * Otherwise, use local date components to construct the string. + */ + private formatDateToYYYYMMDD(dateValue: string): string { + if (!dateValue) { + return ''; + } + + // If already in YYYY-MM-DD format, use it directly + if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) { + return dateValue; + } + + // Otherwise, construct from local date components + const date = new Date(dateValue); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + /** + * Strictly validate a YYYY-MM-DD date string. + * Returns the normalized string if valid, or null if invalid. + * Rejects out-of-range dates like "2023-02-30". + */ + private validateStrictDate(value: string): string | null { + // 1) Match YYYY-MM-DD format + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + + // 2) Extract year, month, day as integers + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + + // 3) Check month is 1..12 + if (month < 1 || month > 12) { + return null; + } + + // 4) Compute correct days-in-month (accounting for leap years) + const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const maxDay = daysInMonth[month - 1]; + + // 5) Ensure day is within range + if (day < 1 || day > maxDay) { + return null; + } + + // Return the normalized string + return value; + } + + /** + * Handler for date input: choose date + */ + private async dateInputChooseDate(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentValue = metadata?.deepnote_variable_value as string | undefined; + const currentDate = currentValue ? this.formatDateToYYYYMMDD(currentValue) : ''; + + const input = await window.showInputBox({ + prompt: l10n.t('Enter date (YYYY-MM-DD)'), + value: currentDate, + validateInput: (value) => { + if (!value) { + return l10n.t('Date cannot be empty'); + } + if (this.validateStrictDate(value) === null) { + return l10n.t('Invalid date'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + // Store as YYYY-MM-DD format (not full ISO string) + await this.updateCellMetadata(cell, { deepnote_variable_value: input }); + } + + /** + * Handler for date range: choose start date + */ + private async dateRangeChooseStart(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentValue = metadata?.deepnote_variable_value; + let currentStart = ''; + let currentEnd = ''; + + if (Array.isArray(currentValue) && currentValue.length === 2) { + currentStart = this.formatDateToYYYYMMDD(currentValue[0]); + currentEnd = this.formatDateToYYYYMMDD(currentValue[1]); + } + + const input = await window.showInputBox({ + prompt: l10n.t('Enter start date (YYYY-MM-DD)'), + value: currentStart, + validateInput: (value) => { + if (!value) { + return l10n.t('Date cannot be empty'); + } + if (this.validateStrictDate(value) === null) { + return l10n.t('Invalid date'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + // Store as YYYY-MM-DD format (not full ISO string) + let newValue = currentEnd ? [input, currentEnd] : [input, input]; + if (newValue[1] && newValue[0] > newValue[1]) { + newValue = [newValue[1], newValue[0]]; + void window.showWarningMessage(l10n.t('Start date was after end date; the range was adjusted.')); + } + await this.updateCellMetadata(cell, { deepnote_variable_value: newValue }); + } + + /** + * Handler for date range: choose end date + */ + private async dateRangeChooseEnd(cell: NotebookCell): Promise { + const metadata = cell.metadata as Record | undefined; + const currentValue = metadata?.deepnote_variable_value; + let currentStart = ''; + let currentEnd = ''; + + if (Array.isArray(currentValue) && currentValue.length === 2) { + currentStart = this.formatDateToYYYYMMDD(currentValue[0]); + currentEnd = this.formatDateToYYYYMMDD(currentValue[1]); + } + + const input = await window.showInputBox({ + prompt: l10n.t('Enter end date (YYYY-MM-DD)'), + value: currentEnd, + validateInput: (value) => { + if (!value) { + return l10n.t('Date cannot be empty'); + } + if (this.validateStrictDate(value) === null) { + return l10n.t('Invalid date'); + } + return undefined; + } + }); + + if (input === undefined) { + return; + } + + // Store as YYYY-MM-DD format (not full ISO string) + let newValue = currentStart ? [currentStart, input] : [input, input]; + if (newValue[0] > newValue[1]) { + newValue = [newValue[1], newValue[0]]; + void window.showWarningMessage(l10n.t('End date was before start date; the range was adjusted.')); + } + await this.updateCellMetadata(cell, { deepnote_variable_value: newValue }); + } + + /** + * Helper method to update cell metadata and cell content + */ + private async updateCellMetadata(cell: NotebookCell, updates: Record): Promise { + const edit = new WorkspaceEdit(); + const updatedMetadata = { + ...cell.metadata, + ...updates + }; + + // Update cell metadata + edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, updatedMetadata)]); + + // Update cell content if the value changed + if ('deepnote_variable_value' in updates) { + const newCellContent = this.formatCellContent(cell, updatedMetadata); + if (newCellContent !== null) { + const fullRange = new Range( + new Position(0, 0), + new Position( + cell.document.lineCount - 1, + cell.document.lineAt(cell.document.lineCount - 1).text.length + ) + ); + edit.replace(cell.document.uri, fullRange, newCellContent); + } + } + + const success = await workspace.applyEdit(edit); + if (!success) { + void window.showErrorMessage(l10n.t('Failed to update cell metadata')); + return; + } + + // Trigger status bar update + this._onDidChangeCellStatusBarItems.fire(); + } + + /** + * Formats the cell content based on the block type and value + */ + private formatCellContent(_cell: NotebookCell, metadata: Record): string | null { + const pocket = metadata.__deepnotePocket as Pocket | undefined; + const blockType = pocket?.type; + + if (!blockType) { + return null; + } + + // Use shared formatter + return formatInputBlockCellContent(blockType, metadata); + } + dispose(): void { this.disposables.forEach((d) => d.dispose()); } diff --git a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts index 0870f8225f..29989208d3 100644 --- a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts @@ -2,15 +2,28 @@ // Licensed under the MIT License. import { expect } from 'chai'; +import { anything, verify, when } from 'ts-mockito'; + +import { CancellationToken, NotebookCell, NotebookCellKind, NotebookDocument, Uri } from 'vscode'; + +import type { IExtensionContext } from '../../platform/common/types'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnoteInputBlockCellStatusBarProvider'; -import { NotebookCell, NotebookCellKind, NotebookDocument } from 'vscode'; -import { Uri } from 'vscode'; suite('DeepnoteInputBlockCellStatusBarItemProvider', () => { let provider: DeepnoteInputBlockCellStatusBarItemProvider; + let mockExtensionContext: IExtensionContext; + let mockToken: CancellationToken; setup(() => { - provider = new DeepnoteInputBlockCellStatusBarItemProvider(); + mockExtensionContext = { + subscriptions: [] + } as any; + mockToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => undefined }) + } as any; + provider = new DeepnoteInputBlockCellStatusBarItemProvider(mockExtensionContext); }); teardown(() => { @@ -18,13 +31,16 @@ suite('DeepnoteInputBlockCellStatusBarItemProvider', () => { }); function createMockCell(metadata?: Record): NotebookCell { + const notebookUri = Uri.file('/test/notebook.deepnote'); return { index: 0, - notebook: {} as NotebookDocument, + notebook: { + uri: notebookUri + } as NotebookDocument, kind: NotebookCellKind.Code, document: { - uri: Uri.file('/test/notebook.deepnote'), - fileName: '/test/notebook.deepnote', + uri: Uri.file('/test/notebook.deepnote#cell0'), + fileName: '/test/notebook.deepnote#cell0', isUntitled: false, languageId: 'json', version: 1, @@ -34,7 +50,7 @@ suite('DeepnoteInputBlockCellStatusBarItemProvider', () => { save: async () => true, eol: 1, lineCount: 1, - lineAt: () => ({}) as any, + lineAt: () => ({ text: '' }) as any, offsetAt: () => 0, positionAt: () => ({}) as any, validateRange: () => ({}) as any, @@ -47,155 +63,958 @@ suite('DeepnoteInputBlockCellStatusBarItemProvider', () => { } suite('Input Block Type Detection', () => { - test('Should return status bar item for input-text block', () => { + test('Should return status bar items for input-text block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-text' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Text'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); + expect(items?.[0].text).to.equal('Input Text'); + expect(items?.[0].alignment).to.equal(1); // Left }); - test('Should return status bar item for input-textarea block', () => { + test('Should return status bar items for input-textarea block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-textarea' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Textarea'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); + expect(items?.[0].text).to.equal('Input Textarea'); }); - test('Should return status bar item for input-select block', () => { + test('Should return status bar items for input-select block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-select' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Select'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); // Type label, variable, and selection button + expect(items?.[0].text).to.equal('Input Select'); }); - test('Should return status bar item for input-slider block', () => { + test('Should return status bar items for input-slider block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-slider' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Slider'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); // Type label, variable, min, and max buttons + expect(items?.[0].text).to.equal('Input Slider'); }); - test('Should return status bar item for input-checkbox block', () => { + test('Should return status bar items for input-checkbox block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-checkbox' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Checkbox'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); // Type label, variable, and toggle button + expect(items?.[0].text).to.equal('Input Checkbox'); }); - test('Should return status bar item for input-date block', () => { + test('Should return status bar items for input-date block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-date' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Date'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); // Type label, variable, and date button + expect(items?.[0].text).to.equal('Input Date'); }); - test('Should return status bar item for input-date-range block', () => { + test('Should return status bar items for input-date-range block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-date-range' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Date Range'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); // Type label, variable, start, and end buttons + expect(items?.[0].text).to.equal('Input Date Range'); }); - test('Should return status bar item for input-file block', () => { + test('Should return status bar items for input-file block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-file' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input File'); + expect(items).to.not.be.undefined; + expect(items).to.have.lengthOf(3); // Type label, variable, and choose file button + expect(items?.[0].text).to.equal('Input File'); }); - test('Should return status bar item for button block', () => { + test('Should return status bar items for button block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'button' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Button'); + expect(items).to.not.be.undefined; + expect(items).to.have.length.at.least(2); + expect(items?.[0].text).to.equal('Button'); }); }); suite('Non-Input Block Types', () => { test('Should return undefined for code block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'code' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.be.undefined; + expect(items).to.be.undefined; }); test('Should return undefined for sql block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'sql' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.be.undefined; + expect(items).to.be.undefined; }); test('Should return undefined for markdown block', () => { const cell = createMockCell({ __deepnotePocket: { type: 'text-cell-p' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.be.undefined; + expect(items).to.be.undefined; }); test('Should return undefined for cell with no type metadata', () => { const cell = createMockCell({}); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.be.undefined; + expect(items).to.be.undefined; }); test('Should return undefined for cell with undefined metadata', () => { const cell = createMockCell(undefined); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.be.undefined; + expect(items).to.be.undefined; }); }); suite('Status Bar Item Properties', () => { test('Should have correct tooltip for input-text', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-text' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item?.tooltip).to.equal('Deepnote Input Text'); + expect(items?.[0].tooltip).to.equal('Deepnote Input Text'); }); test('Should have correct tooltip for button', () => { const cell = createMockCell({ __deepnotePocket: { type: 'button' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item?.tooltip).to.equal('Deepnote Button'); + expect(items?.[0].tooltip).to.equal('Deepnote Button'); }); test('Should format multi-word block types correctly', () => { const cell = createMockCell({ __deepnotePocket: { type: 'input-date-range' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item?.text).to.equal('Input Date Range'); - expect(item?.tooltip).to.equal('Deepnote Input Date Range'); + expect(items?.[0].text).to.equal('Input Date Range'); + expect(items?.[0].tooltip).to.equal('Deepnote Input Date Range'); }); }); suite('Case Insensitivity', () => { test('Should handle uppercase block type', () => { const cell = createMockCell({ __deepnotePocket: { type: 'INPUT-TEXT' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('INPUT TEXT'); + expect(items).to.not.be.undefined; + expect(items?.[0].text).to.equal('INPUT TEXT'); }); test('Should handle mixed case block type', () => { const cell = createMockCell({ __deepnotePocket: { type: 'Input-Text' } }); - const item = provider.provideCellStatusBarItems(cell); + const items = provider.provideCellStatusBarItems(cell, mockToken); + + expect(items).to.not.be.undefined; + expect(items?.[0].text).to.equal('Input Text'); + }); + }); + + suite('activate', () => { + setup(() => { + resetVSCodeMocks(); + // Ensure all registration methods return proper disposables + when( + mockedVSCodeNamespaces.notebooks.registerNotebookCellStatusBarItemProvider(anything(), anything()) + ).thenReturn({ dispose: () => undefined } as any); + when(mockedVSCodeNamespaces.commands.registerCommand(anything(), anything())).thenReturn({ + dispose: () => undefined + } as any); + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).thenReturn({ + dispose: () => undefined + } as any); + }); + + teardown(() => { + resetVSCodeMocks(); + }); + + test('registers notebook cell status bar provider for deepnote notebooks', () => { + provider.activate(); + + verify( + mockedVSCodeNamespaces.notebooks.registerNotebookCellStatusBarItemProvider('deepnote', provider) + ).once(); + }); + + test('registers deepnote.updateInputBlockVariableName command', () => { + provider.activate(); + + verify( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateInputBlockVariableName', anything()) + ).once(); + }); + + test('updateInputBlockVariableName command handler falls back to active cell when no cell provided', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateInputBlockVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + const cell = createMockCell({ __deepnotePocket: { type: 'input-text' } }); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: { + cellAt: (_index: number) => cell + }, + selection: { start: 0 } + } as any); + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('new_name')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + provider.activate(); + expect(commandHandler).to.not.be.undefined; + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify that the active cell was used + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + }); + + test('updateInputBlockVariableName command handler shows error when no cell and no active editor', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateInputBlockVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + provider.activate(); + expect(commandHandler).to.not.be.undefined; + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); + }); + + test('updateInputBlockVariableName command handler shows error when no cell and no selection', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateInputBlockVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: {}, + selection: undefined + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + provider.activate(); + expect(commandHandler).to.not.be.undefined; + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); + }); + + test('registers workspace.onDidChangeNotebookDocument listener', () => { + provider.activate(); + + verify(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).once(); + }); + + test('fires status bar update event when deepnote notebook changes', () => { + let changeHandler: ((e: any) => void) | undefined; + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).thenCall((handler) => { + changeHandler = handler; + return { dispose: () => undefined }; + }); + + let eventFired = false; + provider.onDidChangeCellStatusBarItems(() => { + eventFired = true; + }); + + provider.activate(); + expect(changeHandler).to.not.be.undefined; + + // Fire the event with a deepnote notebook + changeHandler!({ notebook: { notebookType: 'deepnote' } }); + + expect(eventFired).to.be.true; + }); + }); + + suite('Command Handlers', () => { + setup(() => { + resetVSCodeMocks(); + // Ensure all registration methods return proper disposables + when( + mockedVSCodeNamespaces.notebooks.registerNotebookCellStatusBarItemProvider(anything(), anything()) + ).thenReturn({ dispose: () => undefined } as any); + when(mockedVSCodeNamespaces.commands.registerCommand(anything(), anything())).thenReturn({ + dispose: () => undefined + } as any); + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).thenReturn({ + dispose: () => undefined + } as any); + }); + + teardown(() => { + resetVSCodeMocks(); + }); + + suite('deepnote.updateInputBlockVariableName', () => { + test('should update variable name when valid input is provided', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-text' }, + deepnote_variable_name: 'old_var' + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('new_var')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).updateVariableName(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-text' }, + deepnote_variable_name: 'old_var' + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).updateVariableName(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should not update if variable name is unchanged', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-text' }, + deepnote_variable_name: 'my_var' + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('my_var')); + + await (provider as any).updateVariableName(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should show error if workspace edit fails', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-text' }, + deepnote_variable_name: 'old_var' + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('new_var')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(false)); + + await (provider as any).updateVariableName(cell); + + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + }); + + suite('deepnote.checkboxToggle', () => { + test('should toggle checkbox from false to true', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-checkbox' }, + deepnote_variable_value: false + }); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).checkboxToggle(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('command handler falls back to active cell when no cell provided', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when(mockedVSCodeNamespaces.commands.registerCommand('deepnote.checkboxToggle', anything())).thenCall( + (_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + } + ); + + const cell = createMockCell({ + __deepnotePocket: { type: 'input-checkbox' }, + deepnote_variable_value: false + }); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: { + cellAt: (_index: number) => cell + }, + selection: { start: 0 } + } as any); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + provider.activate(); + expect(commandHandler).to.not.be.undefined; + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify that the active cell was used + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should toggle checkbox from true to false', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-checkbox' }, + deepnote_variable_value: true + }); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).checkboxToggle(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should default to false if value is undefined', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-checkbox' } + }); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).checkboxToggle(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should show error if workspace edit fails', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-checkbox' }, + deepnote_variable_value: false + }); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(false)); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).checkboxToggle(cell); + + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + }); + + suite('deepnote.sliderSetMin', () => { + test('should update slider min value', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_min_value: 0 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('5')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).sliderSetMin(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_min_value: 0 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).sliderSetMin(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + }); + + suite('deepnote.sliderSetMax', () => { + test('should update slider max value', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_max_value: 10 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('20')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).sliderSetMax(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_max_value: 10 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).sliderSetMax(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + }); + + suite('deepnote.sliderSetStep', () => { + test('should update slider step value', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_step: 1 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('0.5')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).sliderSetStep(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-slider' }, + deepnote_slider_step: 1 + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).sliderSetStep(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + }); + + suite('deepnote.selectInputChooseOption', () => { + test('should update single select value', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' }, + deepnote_variable_select_type: 'from-options', + deepnote_variable_options: ['option1', 'option2', 'option3'], + deepnote_variable_value: 'option1', + deepnote_allow_multiple_values: false + }); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'option2' } as any) + ); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).selectInputChooseOption(cell); + + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('command handler falls back to active cell when no cell provided', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.selectInputChooseOption', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' }, + deepnote_variable_select_type: 'from-options', + deepnote_variable_options: ['option1', 'option2'], + deepnote_allow_multiple_values: false + }); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: { + cellAt: (_index: number) => cell + }, + selection: { start: 0 } + } as any); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'option2' } as any) + ); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + provider.activate(); + expect(commandHandler).to.not.be.undefined; + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify that the active cell was used + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); + }); + + test('should not update if user cancels single select', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' }, + deepnote_variable_select_type: 'from-options', + deepnote_variable_options: ['option1', 'option2'], + deepnote_allow_multiple_values: false + }); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + await (provider as any).selectInputChooseOption(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should show info message for from-variable select type', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' }, + deepnote_variable_select_type: 'from-variable', + deepnote_variable_selected_variable: 'my_options' + }); + + await (provider as any).selectInputChooseOption(cell); + + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should show warning if no options available', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' }, + deepnote_variable_select_type: 'from-options', + deepnote_variable_options: [] + }); + + await (provider as any).selectInputChooseOption(cell); + + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + }); + }); + + suite('deepnote.selectInputSettings', () => { + test('should open settings webview and fire status bar update', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-select' } + }); + + // Mock the webview show method + const webview = (provider as any).selectInputSettingsWebview; + let showCalled = false; + webview.show = async () => { + showCalled = true; + }; + + // Track if the event was fired + let eventFired = false; + const disposable = provider.onDidChangeCellStatusBarItems(() => { + eventFired = true; + }); + + try { + await (provider as any).selectInputSettings(cell); + + expect(showCalled).to.be.true; + expect(eventFired).to.be.true; + } finally { + disposable.dispose(); + } + }); + }); + + suite('deepnote.fileInputChooseFile', () => { + test('should update file path when file is selected', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-file' } + }); + + const mockUri = Uri.file('/path/to/file.txt'); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([mockUri])); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).fileInputChooseFile(cell); + + verify(mockedVSCodeNamespaces.window.showOpenDialog(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels file selection', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-file' } + }); + + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).fileInputChooseFile(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should not update if empty array is returned', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-file' } + }); + + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([])); + + await (provider as any).fileInputChooseFile(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + }); + + suite('deepnote.dateInputChooseDate', () => { + test('should update date value when valid date is provided', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date' }, + deepnote_variable_value: '2024-01-01' + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('2024-12-31')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).dateInputChooseDate(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date' } + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).dateInputChooseDate(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('validates empty date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateInputChooseDate(cell); + + expect(validateInput).to.not.be.undefined; + const result = validateInput!(''); + expect(result).to.not.be.undefined; + }); + + test('validates invalid date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateInputChooseDate(cell); + + expect(validateInput).to.not.be.undefined; + const result = validateInput!('invalid-date'); + expect(result).to.not.be.undefined; + }); + }); + + suite('deepnote.dateRangeChooseStart', () => { + test('should update start date in date range', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-01-01', '2024-12-31'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('2024-02-01')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).dateRangeChooseStart(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels start date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-01-01', '2024-12-31'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).dateRangeChooseStart(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should show warning if start date is after end date', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-01-01', '2024-06-30'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('2024-12-31')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).dateRangeChooseStart(cell); + + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('validates empty start date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateRangeChooseStart(cell); + + expect(validateInput).to.not.be.undefined; + const result = validateInput!(''); + expect(result).to.not.be.undefined; + }); + + test('validates invalid start date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateRangeChooseStart(cell); + + expect(validateInput).to.not.be.undefined; + const result = validateInput!('not-a-date'); + expect(result).to.not.be.undefined; + }); + }); + + suite('deepnote.dateRangeChooseEnd', () => { + test('should update end date in date range', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-01-01', '2024-12-31'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('2024-11-30')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).dateRangeChooseEnd(cell); + + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('should not update if user cancels end date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-01-01', '2024-12-31'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + await (provider as any).dateRangeChooseEnd(cell); + + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('should show warning if end date is before start date', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' }, + deepnote_variable_value: ['2024-06-01', '2024-12-31'] + }); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('2024-01-01')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await (provider as any).dateRangeChooseEnd(cell); + + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('validates empty end date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateRangeChooseEnd(cell); + + expect(validateInput).to.not.be.undefined; + const result = validateInput!(''); + expect(result).to.not.be.undefined; + }); + + test('validates invalid end date input', async () => { + const cell = createMockCell({ + __deepnotePocket: { type: 'input-date-range' } + }); + + let validateInput: ((value: string) => string | undefined) | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + validateInput = options.validateInput; + return Promise.resolve(undefined); + }); + + await (provider as any).dateRangeChooseEnd(cell); - expect(item).to.not.be.undefined; - expect(item?.text).to.equal('Input Text'); + expect(validateInput).to.not.be.undefined; + const result = validateInput!('bad-date'); + expect(result).to.not.be.undefined; + }); }); }); }); diff --git a/src/notebooks/deepnote/deepnoteInputBlockEditProtection.ts b/src/notebooks/deepnote/deepnoteInputBlockEditProtection.ts new file mode 100644 index 0000000000..b43dfcdb6c --- /dev/null +++ b/src/notebooks/deepnote/deepnoteInputBlockEditProtection.ts @@ -0,0 +1,179 @@ +import { injectable, inject } from 'inversify'; +import { + Disposable, + NotebookCell, + NotebookCellData, + NotebookDocumentChangeEvent, + NotebookEdit, + NotebookRange, + Position, + Range, + Uri, + workspace, + WorkspaceEdit +} from 'vscode'; +import { ILogger } from '../../platform/logging/types'; +import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter'; + +/** + * Protects readonly input blocks from being edited by reverting changes. + * Also protects the language ID of all input blocks. + * This is needed because VSCode doesn't support the `editable: false` metadata property. + */ +@injectable() +export class DeepnoteInputBlockEditProtection implements Disposable { + private readonly disposables: Disposable[] = []; + + // Input types that should be readonly (controlled via status bar) + private readonly readonlyInputTypes = new Set([ + 'input-select', + 'input-checkbox', + 'input-date', + 'input-date-range', + 'button' + ]); + + // All input block types (for language protection) + private readonly allInputTypes = new Set([ + 'input-text', + 'input-textarea', + 'input-select', + 'input-slider', + 'input-checkbox', + 'input-date', + 'input-date-range', + 'input-file', + 'button' + ]); + + constructor(@inject(ILogger) private readonly logger: ILogger) { + // Listen for notebook document changes + this.disposables.push( + workspace.onDidChangeNotebookDocument((e) => { + void this.handleNotebookChange(e); + }) + ); + } + + private async handleNotebookChange(e: NotebookDocumentChangeEvent): Promise { + // Check if this is a Deepnote notebook + if (e.notebook.notebookType !== 'deepnote') { + return; + } + + // Collect all cells that need language fixes in a single batch + const cellsToFix: Array<{ cell: NotebookCell; blockType: string }> = []; + + // Process content changes (cell edits) + for (const cellChange of e.cellChanges) { + const cell = cellChange.cell; + const blockType = cell.metadata?.__deepnotePocket?.type || cell.metadata?.type; + + if (!blockType || !this.allInputTypes.has(blockType)) { + continue; + } + + // Check if the document (content) changed for readonly blocks + if (cellChange.document && this.readonlyInputTypes.has(blockType)) { + // Revert the change by restoring content from metadata + await this.revertCellContent(cell); + } + } + + // Check all cells in the notebook for language changes + // We need to check all cells because language changes don't reliably appear in cellChanges or contentChanges + for (const cell of e.notebook.getCells()) { + const blockType = cell.metadata?.__deepnotePocket?.type || cell.metadata?.type; + + if (blockType && this.allInputTypes.has(blockType)) { + const expectedLanguage = getInputBlockLanguage(blockType); + // Only add to fix list if language is actually wrong + if (expectedLanguage && cell.document.languageId !== expectedLanguage) { + cellsToFix.push({ cell, blockType }); + } + } + } + + // Apply all language fixes in a single batch to minimize flickering + if (cellsToFix.length > 0) { + await this.protectCellLanguages(cellsToFix); + } + } + + private async revertCellContent(cell: NotebookCell): Promise { + const blockType = cell.metadata?.__deepnotePocket?.type || cell.metadata?.type; + const metadata = cell.metadata; + + // Use shared formatter to get correct content + const correctContent = formatInputBlockCellContent(blockType, metadata); + + // Only revert if content actually changed + if (cell.document.getText() !== correctContent) { + const edit = new WorkspaceEdit(); + const lastLine = Math.max(0, cell.document.lineCount - 1); + const fullRange = new Range( + new Position(0, 0), + new Position(lastLine, cell.document.lineAt(lastLine).text.length) + ); + edit.replace(cell.document.uri, fullRange, correctContent); + const success = await workspace.applyEdit(edit); + if (!success) { + this.logger.error( + `Failed to revert cell content for input block type '${blockType}' at cell index ${ + cell.index + } in notebook ${cell.notebook.uri.toString()}` + ); + } + } + } + + private async protectCellLanguages(cellsToFix: Array<{ cell: NotebookCell; blockType: string }>): Promise { + if (cellsToFix.length === 0) { + return; + } + + // Group cells by notebook to apply edits efficiently + const editsByNotebook = new Map(); + + for (const { cell, blockType } of cellsToFix) { + const expectedLanguage = getInputBlockLanguage(blockType); + + if (!expectedLanguage) { + continue; + } + + const notebookUriStr = cell.notebook.uri.toString(); + if (!editsByNotebook.has(notebookUriStr)) { + editsByNotebook.set(notebookUriStr, { uri: cell.notebook.uri, edits: [] }); + } + + // Add the cell replacement edit + const cellData = new NotebookCellData(cell.kind, cell.document.getText(), expectedLanguage); + cellData.metadata = cell.metadata; + + editsByNotebook + .get(notebookUriStr)! + .edits.push(NotebookEdit.replaceCells(new NotebookRange(cell.index, cell.index + 1), [cellData])); + } + + // Apply all edits in a single workspace edit to minimize flickering + const workspaceEdit = new WorkspaceEdit(); + for (const { uri, edits } of editsByNotebook.values()) { + workspaceEdit.set(uri, edits); + } + + const success = await workspace.applyEdit(workspaceEdit); + if (!success) { + const cellInfo = cellsToFix + .map(({ cell, blockType }) => `cell ${cell.index} (type: ${blockType})`) + .join(', '); + this.logger.error( + `Failed to protect cell languages for ${cellsToFix.length} cell(s): ${cellInfo} in notebook(s)` + ); + } + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts index a9bcc780a4..5c324c8ae9 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts @@ -18,6 +18,7 @@ import { IDisposableRegistry } from '../../platform/common/types'; import { Commands } from '../../platform/common/constants'; import { chainWithPendingUpdates } from '../../kernels/execution/notebookUpdater'; import { WrappedError } from '../../platform/errors/types'; +import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter'; import { DeepnoteBigNumberMetadataSchema, DeepnoteTextInputMetadataSchema, @@ -342,15 +343,17 @@ export class DeepnoteNotebookCommandListener implements IExtensionSyncActivation __deepnotePocket: { type: blockType, ...defaultMetadata - } + }, + ...defaultMetadata }; const result = await chainWithPendingUpdates(document, (edit) => { - const newCell = new NotebookCellData( - NotebookCellKind.Code, - JSON.stringify(defaultMetadata, null, 2), - 'json' - ); + // Use the formatter to get the correct cell content based on block type and metadata + const cellContent = formatInputBlockCellContent(blockType, defaultMetadata); + // Get the correct language mode for this input block type + const languageMode = getInputBlockLanguage(blockType) ?? 'python'; + + const newCell = new NotebookCellData(NotebookCellKind.Code, cellContent, languageMode); newCell.metadata = metadata; const nbEdit = NotebookEdit.insertCells(insertIndex, [newCell]); edit.set(document.uri, [nbEdit]); diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts index 05fd5e37cd..ad4f5364b0 100644 --- a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts @@ -18,6 +18,7 @@ import { getNextDeepnoteVariableName, InputBlockType } from './deepnoteNotebookCommandListener'; +import { formatInputBlockCellContent, getInputBlockLanguage } from './inputBlockContentFormatter'; import { IDisposable } from '../../platform/common/types'; import * as notebookUpdater from '../../kernels/execution/notebookUpdater'; import { createMockedNotebookDocument } from '../../test/datascience/editor-integration/helpers'; @@ -607,20 +608,14 @@ suite('DeepnoteNotebookCommandListener', () => { const newCell = notebookEdit.newCells[0]; assert.equal(newCell.kind, NotebookCellKind.Code, 'Should be a code cell'); - assert.equal(newCell.languageId, 'json', 'Should have json language'); - // Verify cell content is valid JSON with correct structure - const content = JSON.parse(newCell.value); - assert.equal( - content.deepnote_variable_name, - expectedVariableName, - 'Variable name should match expected' - ); + // Verify language mode matches the expected language for this block type + const expectedLanguage = getInputBlockLanguage(blockType); + assert.equal(newCell.languageId, expectedLanguage, `Should have ${expectedLanguage} language`); - // Verify all expected metadata keys are present in content - expectedMetadataKeys.forEach((key) => { - assert.property(content, key, `Content should have ${key} property`); - }); + // Verify cell content is formatted correctly using the formatter + const expectedContent = formatInputBlockCellContent(blockType, newCell.metadata); + assert.equal(newCell.value, expectedContent, 'Cell content should match formatted content'); // Verify metadata structure assert.property(newCell.metadata, '__deepnotePocket', 'Should have __deepnotePocket metadata'); @@ -631,11 +626,21 @@ suite('DeepnoteNotebookCommandListener', () => { 'Metadata should have correct variable name' ); - // Verify metadata keys match content keys + // Verify all expected metadata keys are present in __deepnotePocket expectedMetadataKeys.forEach((key) => { assert.property(newCell.metadata.__deepnotePocket, key, `Metadata should have ${key} property`); }); + // Verify metadata is also at the top level + assert.equal( + newCell.metadata.deepnote_variable_name, + expectedVariableName, + 'Top-level metadata should have correct variable name' + ); + expectedMetadataKeys.forEach((key) => { + assert.property(newCell.metadata, key, `Top-level metadata should have ${key} property`); + }); + // Verify reveal and selection were set assert.isTrue( (editor.revealRange as sinon.SinonStub).calledOnce, diff --git a/src/notebooks/deepnote/inputBlockContentFormatter.ts b/src/notebooks/deepnote/inputBlockContentFormatter.ts new file mode 100644 index 0000000000..8568866b59 --- /dev/null +++ b/src/notebooks/deepnote/inputBlockContentFormatter.ts @@ -0,0 +1,128 @@ +/** + * Utility for formatting input block cell content based on block type and metadata. + * This is the single source of truth for how input block values are displayed in cells. + */ + +/** + * Gets the expected language ID for an input block type. + * This is the single source of truth for input block language modes. + */ +export function getInputBlockLanguage(blockType: string): string | undefined { + const languageMap: Record = { + 'input-text': 'plaintext', + 'input-textarea': 'plaintext', + 'input-select': 'python', + 'input-slider': 'python', + 'input-checkbox': 'python', + 'input-date': 'python', + 'input-date-range': 'python', + 'input-file': 'python', + button: 'python' + }; + return languageMap[blockType]; +} + +/** + * Formats the cell content for an input block based on its type and metadata. + * @param blockType The type of the input block (e.g., 'input-text', 'input-select') + * @param metadata The cell metadata containing the value and other configuration + * @returns The formatted cell content string + */ +export function formatInputBlockCellContent(blockType: string, metadata: Record): string { + switch (blockType) { + case 'input-text': + case 'input-textarea': { + const value = metadata.deepnote_variable_value; + return typeof value === 'string' ? value : ''; + } + + case 'input-select': { + const value = metadata.deepnote_variable_value; + if (Array.isArray(value)) { + // Multi-select: show as array of quoted strings + if (value.length === 0) { + // Empty array for multi-select + return '[]'; + } + return `[${value.map((v) => JSON.stringify(v)).join(', ')}]`; + } else if (typeof value === 'string') { + // Single select: show as quoted string + return JSON.stringify(value); + } else if (value === null || value === undefined) { + // Empty/null value + return 'None'; + } + return ''; + } + + case 'input-slider': { + const value = metadata.deepnote_variable_value; + return typeof value === 'number' ? String(value) : ''; + } + + case 'input-checkbox': { + const value = metadata.deepnote_variable_value ?? false; + return value ? 'True' : 'False'; + } + + case 'input-date': { + const value = metadata.deepnote_variable_value; + if (value) { + const dateStr = formatDateValue(value); + return dateStr ? JSON.stringify(dateStr) : '""'; + } + return '""'; + } + + case 'input-date-range': { + const value = metadata.deepnote_variable_value; + if (Array.isArray(value) && value.length === 2) { + const start = formatDateValue(value[0]); + const end = formatDateValue(value[1]); + if (start || end) { + return `(${JSON.stringify(start)}, ${JSON.stringify(end)})`; + } + } else { + const defaultValue = metadata.deepnote_variable_default_value; + if (Array.isArray(defaultValue) && defaultValue.length === 2) { + const start = formatDateValue(defaultValue[0]); + const end = formatDateValue(defaultValue[1]); + if (start || end) { + return `(${JSON.stringify(start)}, ${JSON.stringify(end)})`; + } + } + } + return ''; + } + + case 'input-file': { + const value = metadata.deepnote_variable_value; + return typeof value === 'string' && value ? JSON.stringify(value) : ''; + } + + case 'button': { + return '# Buttons only work in Deepnote apps'; + } + + default: + return ''; + } +} + +/** + * Helper to format date value (could be string or Date object). + * Converts to YYYY-MM-DD format. + */ +function formatDateValue(val: unknown): string { + if (!val) { + return ''; + } + if (typeof val === 'string') { + return val; + } + if (val instanceof Date) { + // Convert Date to YYYY-MM-DD format + return val.toISOString().split('T')[0]; + } + return String(val); +} diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 1245ea60cb..c94e87ade3 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -430,7 +430,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { - + Deepnote Integrations diff --git a/src/notebooks/deepnote/selectInputSettingsWebview.ts b/src/notebooks/deepnote/selectInputSettingsWebview.ts new file mode 100644 index 0000000000..52c015e94d --- /dev/null +++ b/src/notebooks/deepnote/selectInputSettingsWebview.ts @@ -0,0 +1,326 @@ +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 { SelectInputSettings, SelectInputWebviewMessage } from '../../platform/notebooks/deepnote/types'; +import { WrappedError } from '../../platform/errors/types'; +import { logger } from '../../platform/logging'; + +/** + * Manages the webview panel for select input settings + */ +@injectable() +export class SelectInputSettingsWebviewProvider { + private currentPanel: WebviewPanel | undefined; + private currentPanelId: number = 0; + private readonly disposables: Disposable[] = []; + private currentCell: NotebookCell | undefined; + private resolvePromise: ((settings: SelectInputSettings | null) => void) | undefined; + + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + /** + * Show the select input 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( + 'deepnoteSelectInputSettings', + localize.SelectInputSettings.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: SelectInputWebviewMessage) => { + 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 + // Guard with panel identity to prevent old panels from affecting new ones + 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 + await this.sendLocStrings(); + await this.sendInitialData(); + + // 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: SelectInputSettings = { + allowMultipleValues: (metadata?.deepnote_allow_multiple_values as boolean) ?? false, + allowEmptyValue: (metadata?.deepnote_allow_empty_values as boolean) ?? false, + selectType: (metadata?.deepnote_variable_select_type as 'from-options' | 'from-variable') ?? 'from-options', + options: + (metadata?.deepnote_variable_custom_options as string[]) ?? + (metadata?.deepnote_variable_options as string[]) ?? + [], + selectedVariable: (metadata?.deepnote_variable_selected_variable as string) ?? '' + }; + + await this.currentPanel.webview.postMessage({ + type: 'init', + settings + }); + } + + private async sendLocStrings(): Promise { + if (!this.currentPanel) { + return; + } + + const locStrings: Partial = { + selectInputSettingsTitle: localize.SelectInputSettings.title, + allowMultipleValues: localize.SelectInputSettings.allowMultipleValues, + allowEmptyValue: localize.SelectInputSettings.allowEmptyValue, + valueSourceTitle: localize.SelectInputSettings.valueSourceTitle, + fromOptions: localize.SelectInputSettings.fromOptions, + fromOptionsDescription: localize.SelectInputSettings.fromOptionsDescription, + addOptionPlaceholder: localize.SelectInputSettings.addOptionPlaceholder, + addButton: localize.SelectInputSettings.addButton, + fromVariable: localize.SelectInputSettings.fromVariable, + fromVariableDescription: localize.SelectInputSettings.fromVariableDescription, + variablePlaceholder: localize.SelectInputSettings.variablePlaceholder, + optionNameLabel: localize.SelectInputSettings.optionNameLabel, + variableNameLabel: localize.SelectInputSettings.variableNameLabel, + removeOptionAriaLabel: localize.SelectInputSettings.removeOptionAriaLabel, + saveButton: localize.SelectInputSettings.saveButton, + cancelButton: localize.SelectInputSettings.cancelButton + }; + + await this.currentPanel.webview.postMessage({ + type: 'locInit', + locStrings + }); + } + + private async handleMessage(message: SelectInputWebviewMessage): 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 + // Keep promise pending so user can retry or cancel + // Panel remains open for retry + logger.error('SelectInputSettingsWebview: 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: SelectInputSettings): Promise { + if (!this.currentCell) { + return; + } + + const edit = new WorkspaceEdit(); + const metadata = { ...(this.currentCell.metadata as Record) }; + + metadata.deepnote_allow_multiple_values = settings.allowMultipleValues; + metadata.deepnote_allow_empty_values = settings.allowEmptyValue; + metadata.deepnote_variable_select_type = settings.selectType; + + // Update the options field based on the select type + if (settings.selectType === 'from-options') { + metadata.deepnote_variable_custom_options = settings.options; + metadata.deepnote_variable_options = settings.options; + delete metadata.deepnote_variable_selected_variable; + } else { + metadata.deepnote_variable_selected_variable = settings.selectedVariable; + delete metadata.deepnote_variable_custom_options; + delete metadata.deepnote_variable_options; + } + + // Update cell metadata to preserve outputs and attachments + edit.set(this.currentCell.notebook.uri, [NotebookEdit.updateCellMetadata(this.currentCell.index, metadata)]); + + try { + const success = await workspace.applyEdit(edit); + if (!success) { + const errorMessage = localize.SelectInputSettings.failedToSave; + logger.error(errorMessage); + void window.showErrorMessage(errorMessage); + throw new WrappedError(errorMessage, undefined); + } + } catch (error) { + const errorMessage = localize.SelectInputSettings.failedToSave; + const cause = error instanceof Error ? error : undefined; + const causeMessage = cause?.message || String(error); + // Log the full error with cause for diagnostics + logger.error(`${errorMessage}: ${causeMessage}`, error); + // Show only the localized friendly message to users + void window.showErrorMessage(errorMessage); + // Attach the cause to the thrown error for telemetry/diagnostics + 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', + 'selectInputSettings', + 'index.js' + ) + ); + const codiconUri = webview.asWebviewUri( + Uri.joinPath( + this.extensionContext.extensionUri, + 'dist', + 'webviews', + 'webview-side', + 'react-common', + 'codicon', + 'codicon.css' + ) + ); + + const title = localize.SelectInputSettings.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/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 906ff5d26d..51289fed34 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -37,6 +37,16 @@ suite('SqlCellStatusBarProvider', () => { cancellationToken = tokenSource.token; }); + test('returns undefined when cancellation token is requested', async () => { + const cell = createMockCell('sql', {}); + const tokenSource = new CancellationTokenSource(); + tokenSource.cancel(); + + const result = await provider.provideCellStatusBarItems(cell, tokenSource.token); + + assert.isUndefined(result); + }); + test('returns undefined for non-SQL cells', async () => { const cell = createMockCell('python', {}); @@ -338,6 +348,169 @@ suite('SqlCellStatusBarProvider', () => { verify(mockedVSCodeNamespaces.commands.registerCommand('deepnote.switchSqlIntegration', anything())).once(); }); + test('updateSqlVariableName command handler falls back to active cell when no cell provided', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateSqlVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + const cell = createMockCell('sql', {}); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: { + cellAt: (_index: number) => cell + }, + selection: { start: 0 } + } as any); + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('new_name')); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify that the active cell was used + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); + + test('updateSqlVariableName command handler shows error when no cell and no active editor', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateSqlVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); + }); + + test('updateSqlVariableName command handler shows error when no cell and no selection', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when( + mockedVSCodeNamespaces.commands.registerCommand('deepnote.updateSqlVariableName', anything()) + ).thenCall((_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + }); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: {}, + selection: undefined + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); + }); + + test('switchSqlIntegration command handler falls back to active cell when no cell provided', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when(mockedVSCodeNamespaces.commands.registerCommand('deepnote.switchSqlIntegration', anything())).thenCall( + (_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + } + ); + + const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const cell = createMockCell('sql', {}, notebookMetadata); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: { + cellAt: (_index: number) => cell + }, + selection: { start: 0 } + } as any); + when(activateNotebookManager.getOriginalProject('project-1')).thenReturn({ + project: { integrations: [] } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify that the active cell was used + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); + }); + + test('switchSqlIntegration command handler shows error when no cell and no active editor', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when(mockedVSCodeNamespaces.commands.registerCommand('deepnote.switchSqlIntegration', anything())).thenCall( + (_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + } + ); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + }); + + test('switchSqlIntegration command handler shows error when no cell and no selection', async () => { + let commandHandler: ((cell?: NotebookCell) => Promise) | undefined; + when(mockedVSCodeNamespaces.commands.registerCommand('deepnote.switchSqlIntegration', anything())).thenCall( + (_name, handler) => { + commandHandler = handler; + return { dispose: () => undefined }; + } + ); + + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: {}, + selection: undefined + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + activateProvider.activate(); + assert.isDefined(commandHandler, 'Command handler should be registered'); + + // Invoke the handler without a cell argument + await commandHandler!(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).never(); + }); + test('listens to integration storage changes', () => { const onDidChangeIntegrations = new EventEmitter(); when(activateIntegrationStorage.onDidChangeIntegrations).thenReturn(onDidChangeIntegrations.event); @@ -347,6 +520,25 @@ suite('SqlCellStatusBarProvider', () => { // Verify the listener was registered by checking disposables assert.isTrue(activateDisposables.length > 0); }); + + test('registers workspace.onDidChangeNotebookDocument listener', () => { + const onDidChangeIntegrations = new EventEmitter(); + when(activateIntegrationStorage.onDidChangeIntegrations).thenReturn(onDidChangeIntegrations.event); + + activateProvider.activate(); + + verify(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).once(); + }); + + test('disposes the event emitter', () => { + const onDidChangeIntegrations = new EventEmitter(); + when(activateIntegrationStorage.onDidChangeIntegrations).thenReturn(onDidChangeIntegrations.event); + + activateProvider.activate(); + + // Verify the emitter is added to disposables + assert.isTrue(activateDisposables.length > 0); + }); }); suite('event listeners', () => { @@ -407,6 +599,72 @@ suite('SqlCellStatusBarProvider', () => { 'onDidChangeCellStatusBarItems should fire three times' ); }); + + test('fires onDidChangeCellStatusBarItems when deepnote notebook changes', () => { + const onDidChangeIntegrations = new EventEmitter(); + const onDidChangeNotebookDocument = new EventEmitter(); + when(eventIntegrationStorage.onDidChangeIntegrations).thenReturn(onDidChangeIntegrations.event); + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).thenCall((handler) => { + onDidChangeNotebookDocument.event(handler); + return { + dispose: () => { + return; + } + }; + }); + + eventProvider.activate(); + + const statusBarChangeHandler = createEventHandler( + eventProvider, + 'onDidChangeCellStatusBarItems', + eventDisposables + ); + + // Fire notebook document change event for deepnote notebook + onDidChangeNotebookDocument.fire({ + notebook: { + notebookType: 'deepnote' + } + }); + + assert.strictEqual(statusBarChangeHandler.count, 1, 'onDidChangeCellStatusBarItems should fire once'); + }); + + test('does not fire onDidChangeCellStatusBarItems when non-deepnote notebook changes', () => { + const onDidChangeIntegrations = new EventEmitter(); + const onDidChangeNotebookDocument = new EventEmitter(); + when(eventIntegrationStorage.onDidChangeIntegrations).thenReturn(onDidChangeIntegrations.event); + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument(anything())).thenCall((handler) => { + onDidChangeNotebookDocument.event(handler); + return { + dispose: () => { + return; + } + }; + }); + + eventProvider.activate(); + + const statusBarChangeHandler = createEventHandler( + eventProvider, + 'onDidChangeCellStatusBarItems', + eventDisposables + ); + + // Fire notebook document change event for non-deepnote notebook + onDidChangeNotebookDocument.fire({ + notebook: { + notebookType: 'jupyter-notebook' + } + }); + + assert.strictEqual( + statusBarChangeHandler.count, + 0, + 'onDidChangeCellStatusBarItems should not fire for non-deepnote notebooks' + ); + }); }); suite('updateSqlVariableName command handler', () => { @@ -534,6 +792,20 @@ suite('SqlCellStatusBarProvider', () => { await updateVariableNameHandler(cell); }); + + test('validates input - accepts valid Python identifier', async () => { + const cell = createMockCell('sql', {}); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options) => { + const validationResult = options.validateInput('valid_name'); + assert.isUndefined(validationResult, 'Valid input should return undefined'); + return Promise.resolve('valid_name'); + }); + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenReturn(Promise.resolve(true)); + + await updateVariableNameHandler(cell); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).once(); + }); }); suite('switchSqlIntegration command handler', () => { @@ -718,6 +990,64 @@ suite('SqlCellStatusBarProvider', () => { assert.strictEqual(duckDbItem.label, 'DataFrame SQL (DuckDB)'); }); + test('shows BigQuery type label for BigQuery integrations', async () => { + const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const cell = createMockCell('sql', {}, notebookMetadata); + let quickPickItems: any[] = []; + + when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + project: { + integrations: [ + { + id: 'bigquery-integration', + name: 'My BigQuery', + type: 'big-query' + } + ] + } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items) => { + quickPickItems = items; + return Promise.resolve(undefined); + }); + + await switchIntegrationHandler(cell); + + const bigQueryItem = quickPickItems.find((item) => item.id === 'bigquery-integration'); + assert.isDefined(bigQueryItem, 'BigQuery integration should be in quick pick items'); + assert.strictEqual(bigQueryItem.description, 'BigQuery'); + }); + + test('shows raw type for unknown integration types', async () => { + const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const cell = createMockCell('sql', {}, notebookMetadata); + let quickPickItems: any[] = []; + + when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + project: { + integrations: [ + { + id: 'unknown-integration', + name: 'Unknown DB', + type: 'unknown_type' + } + ] + } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items) => { + quickPickItems = items; + return Promise.resolve(undefined); + }); + + await switchIntegrationHandler(cell); + + const unknownItem = quickPickItems.find((item) => item.id === 'unknown-integration'); + assert.isDefined(unknownItem, 'Unknown integration should be in quick pick items'); + assert.strictEqual(unknownItem.description, 'unknown_type'); + }); + test('marks current integration as selected in quick pick', async () => { const currentIntegrationId = 'current-integration'; const notebookMetadata = { deepnoteProjectId: 'project-1' }; @@ -813,6 +1143,54 @@ suite('SqlCellStatusBarProvider', () => { const duckDbItem = quickPickItems.find((item) => item.id === DATAFRAME_SQL_INTEGRATION_ID); assert.isDefined(duckDbItem, 'DuckDB should still be in the list'); }); + + test('does not update when selected integration is same as current', async () => { + const currentIntegrationId = 'current-integration'; + const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const cell = createMockCell('sql', { sql_integration_id: currentIntegrationId }, notebookMetadata); + + when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + project: { + integrations: [ + { + id: currentIntegrationId, + name: 'Current Integration', + type: 'pgsql' + } + ] + } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ id: currentIntegrationId, label: 'Current Integration' } as any) + ); + + await switchIntegrationHandler(cell); + + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); + + test('does not update when selected item has no id property', async () => { + const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const cell = createMockCell('sql', { sql_integration_id: 'current-integration' }, notebookMetadata); + + when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + project: { + integrations: [] + } + } as any); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + // Return an item without an id property (e.g., a separator) + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ kind: -1 } as any) + ); + + await switchIntegrationHandler(cell); + + verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); + verify(mockedVSCodeNamespaces.workspace.applyEdit(anything())).never(); + }); }); function createMockCell( diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 59e2b63e1f..013b292bfd 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -126,6 +126,7 @@ export const LanguagesSupportedByPythonkernel = [ 'perl', // %%perl 'qsharp', // %%qsharp 'json', // JSON cells for custom block types + 'plaintext', // plaintext cells (e.g., Deepnote input-text blocks) 'raw' // raw cells (no formatting) ]; export const jupyterLanguageToMonacoLanguageMapping = new Map([ diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 5332eb9a63..0e65f409bd 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -900,6 +900,26 @@ export namespace Integrations { export const snowflakeWarehousePlaceholder = l10n.t(''); } +export namespace SelectInputSettings { + export const title = l10n.t('Select Input Settings'); + export const allowMultipleValues = l10n.t('Allow to select multiple values'); + export const allowEmptyValue = l10n.t('Allow empty value'); + export const valueSourceTitle = l10n.t('Value'); + export const fromOptions = l10n.t('From options'); + export const fromOptionsDescription = l10n.t('A set of defined options.'); + export const addOptionPlaceholder = l10n.t('Add option...'); + export const addButton = l10n.t('Add'); + export const fromVariable = l10n.t('From variable'); + export const fromVariableDescription = l10n.t('A list or Series that contains only strings, numbers or booleans.'); + export const variablePlaceholder = l10n.t('Variable name...'); + export const optionNameLabel = l10n.t('Option name'); + export const variableNameLabel = l10n.t('Variable name'); + export const removeOptionAriaLabel = l10n.t('Remove option'); + export const saveButton = l10n.t('Save'); + export const cancelButton = l10n.t('Cancel'); + export const failedToSave = l10n.t('Failed to save select input 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 6036737e2c..ff2a1cd0fe 100644 --- a/src/platform/notebooks/deepnote/types.ts +++ b/src/platform/notebooks/deepnote/types.ts @@ -3,6 +3,26 @@ import { IDisposable, Resource } from '../../common/types'; import { EnvironmentVariables } from '../../common/variables/types'; import { IntegrationConfig } from './integrationTypes'; +/** + * Settings for select input blocks + */ +export interface SelectInputSettings { + allowMultipleValues: boolean; + allowEmptyValue: boolean; + selectType: 'from-options' | 'from-variable'; + options: string[]; + selectedVariable: string; +} + +/** + * Message types for select input settings webview + */ +export type SelectInputWebviewMessage = + | { type: 'init'; settings: SelectInputSettings } + | { type: 'save'; settings: SelectInputSettings } + | { type: 'locInit'; locStrings: Record } + | { type: 'cancel' }; + export const IIntegrationStorage = Symbol('IIntegrationStorage'); export interface IIntegrationStorage extends IDisposable { /** diff --git a/src/webviews/webview-side/selectInputSettings/SelectInputSettingsPanel.tsx b/src/webviews/webview-side/selectInputSettings/SelectInputSettingsPanel.tsx new file mode 100644 index 0000000000..01568a9d99 --- /dev/null +++ b/src/webviews/webview-side/selectInputSettings/SelectInputSettingsPanel.tsx @@ -0,0 +1,255 @@ +import * as React from 'react'; +import { IVsCodeApi } from '../react-common/postOffice'; +import { getLocString, storeLocStrings } from '../react-common/locReactSide'; +import { SelectInputSettings, WebviewMessage } from './types'; + +export interface ISelectInputSettingsPanelProps { + baseTheme: string; + vscodeApi: IVsCodeApi; +} + +export const SelectInputSettingsPanel: React.FC = ({ baseTheme, vscodeApi }) => { + const [settings, setSettings] = React.useState({ + allowMultipleValues: false, + allowEmptyValue: false, + selectType: 'from-options', + options: [], + selectedVariable: '' + }); + + const [newOption, setNewOption] = React.useState(''); + + React.useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case 'init': + setSettings(message.settings); + 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 handleToggle = (field: 'allowMultipleValues' | 'allowEmptyValue') => { + setSettings((prev) => ({ + ...prev, + [field]: !prev[field] + })); + }; + + const handleSelectTypeChange = (selectType: 'from-options' | 'from-variable') => { + setSettings((prev) => ({ + ...prev, + selectType + })); + }; + + const handleAddOption = () => { + const trimmedValue = newOption.trim(); + + // Check if the trimmed value is non-empty + if (!trimmedValue) { + return; + } + + // Add the trimmed value if not duplicate, using functional updater to avoid race conditions + setSettings((prev) => { + // Normalize for comparison (case-insensitive) + const normalizedValue = trimmedValue.toLowerCase(); + + // Check if the normalized value is already present in options + const isDuplicate = prev.options.some((option) => option.toLowerCase() === normalizedValue); + + if (isDuplicate) { + return prev; + } + + return { + ...prev, + options: [...prev.options, trimmedValue] + }; + }); + setNewOption(''); + }; + + const handleRemoveOption = (index: number) => { + setSettings((prev) => ({ + ...prev, + options: prev.options.filter((_, i) => i !== index) + })); + }; + + const handleVariableChange = (e: React.ChangeEvent) => { + setSettings((prev) => ({ + ...prev, + selectedVariable: e.target.value + })); + }; + + const handleSave = () => { + vscodeApi.postMessage({ + type: 'save', + settings + }); + }; + + const handleCancel = () => { + vscodeApi.postMessage({ + type: 'cancel' + }); + }; + + return ( +
+

{getLocString('selectInputSettingsTitle', 'Settings')}

+ +
+
+ + +
+ +
+ + +
+
+ +

{getLocString('valueSourceTitle', 'Value')}

+ +
+ + + +
+ +
+ + +
+
+ ); +}; diff --git a/src/webviews/webview-side/selectInputSettings/index.tsx b/src/webviews/webview-side/selectInputSettings/index.tsx new file mode 100644 index 0000000000..3595441219 --- /dev/null +++ b/src/webviews/webview-side/selectInputSettings/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 { SelectInputSettingsPanel } from './SelectInputSettingsPanel'; + +import '../common/index.css'; +import './selectInputSettings.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/selectInputSettings/selectInputSettings.css b/src/webviews/webview-side/selectInputSettings/selectInputSettings.css new file mode 100644 index 0000000000..1897688ba3 --- /dev/null +++ b/src/webviews/webview-side/selectInputSettings/selectInputSettings.css @@ -0,0 +1,259 @@ +.select-input-settings-panel { + padding: 20px; + max-width: 600px; + margin: 0 auto; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.select-input-settings-panel h1 { + font-size: 24px; + margin-bottom: 20px; +} + +.select-input-settings-panel h2 { + font-size: 18px; + margin-top: 30px; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.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); +} + +.value-source-section { + margin-top: 20px; +} + +.radio-option { + display: flex; + align-items: flex-start; + padding: 15px; + margin-bottom: 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.radio-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.radio-option.selected { + border-color: var(--vscode-focusBorder); + background-color: var(--vscode-list-activeSelectionBackground); +} + +.radio-option input[type='radio'] { + margin-right: 12px; + margin-top: 2px; +} + +.radio-option input[type='radio']:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.radio-content { + flex: 1; +} + +.radio-title { + font-weight: 600; + margin-bottom: 5px; +} + +.radio-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: 10px; +} + +.options-list { + margin-top: 10px; +} + +.option-tag { + display: inline-flex; + align-items: center; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 4px 8px; + margin: 4px 4px 4px 0; + border-radius: 3px; + font-size: 12px; +} + +.option-tag button { + background: none; + border: none; + color: inherit; + margin-left: 6px; + cursor: pointer; + padding: 0; + font-size: 14px; + line-height: 1; +} + +.option-tag button:hover { + opacity: 0.7; +} + +.option-tag button:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.add-option-form { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.add-option-form input { + flex: 1; + padding: 6px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; +} + +.add-option-form button { + padding: 6px 12px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 2px; + cursor: pointer; +} + +.add-option-form button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.variable-input { + 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; + margin-top: 10px; +} + +.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/selectInputSettings/types.ts b/src/webviews/webview-side/selectInputSettings/types.ts new file mode 100644 index 0000000000..5f25b5d3a5 --- /dev/null +++ b/src/webviews/webview-side/selectInputSettings/types.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Re-export types from platform for use in webview +export type { + SelectInputSettings, + SelectInputWebviewMessage as WebviewMessage +} from '../../../platform/notebooks/deepnote/types';