diff --git a/cspell.json b/cspell.json index 33845480f2..3f8516fbb9 100644 --- a/cspell.json +++ b/cspell.json @@ -24,6 +24,7 @@ "blockgroup", "channeldef", "dataframe", + "datascience", "deepnote", "deepnoteserver", "dntk", diff --git a/package.json b/package.json index 00bfe71b07..58b5b8212d 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,72 @@ "category": "Deepnote", "icon": "$(notebook)" }, + { + "command": "deepnote.addSqlBlock", + "title": "%deepnote.commands.addSqlBlock.title%", + "category": "Deepnote", + "icon": "$(database)" + }, + { + "command": "deepnote.addBigNumberChartBlock", + "title": "%deepnote.commands.addBigNumberChartBlock.title%", + "category": "Deepnote", + "icon": "$(graph)" + }, + { + "command": "deepnote.addInputTextBlock", + "title": "%deepnote.commands.addInputTextBlock.title%", + "category": "Deepnote", + "icon": "$(symbol-text)" + }, + { + "command": "deepnote.addInputTextareaBlock", + "title": "%deepnote.commands.addInputTextareaBlock.title%", + "category": "Deepnote", + "icon": "$(note)" + }, + { + "command": "deepnote.addInputSelectBlock", + "title": "%deepnote.commands.addInputSelectBlock.title%", + "category": "Deepnote", + "icon": "$(list-selection)" + }, + { + "command": "deepnote.addInputSliderBlock", + "title": "%deepnote.commands.addInputSliderBlock.title%", + "category": "Deepnote", + "icon": "$(settings-more-action)" + }, + { + "command": "deepnote.addInputCheckboxBlock", + "title": "%deepnote.commands.addInputCheckboxBlock.title%", + "category": "Deepnote", + "icon": "$(check)" + }, + { + "command": "deepnote.addInputDateBlock", + "title": "%deepnote.commands.addInputDateBlock.title%", + "category": "Deepnote", + "icon": "$(calendar)" + }, + { + "command": "deepnote.addInputDateRangeBlock", + "title": "%deepnote.commands.addInputDateRangeBlock.title%", + "category": "Deepnote", + "icon": "$(calendar)" + }, + { + "command": "deepnote.addInputFileBlock", + "title": "%deepnote.commands.addInputFileBlock.title%", + "category": "Deepnote", + "icon": "$(file)" + }, + { + "command": "deepnote.addButtonBlock", + "title": "%deepnote.commands.addButtonBlock.title%", + "category": "Deepnote", + "icon": "$(add)" + }, { "command": "dataScience.ClearCache", "title": "%jupyter.command.dataScience.clearCache.title%", @@ -736,6 +802,11 @@ "group": "navigation@0", "when": "notebookType == 'deepnote'" }, + { + "command": "deepnote.addSqlBlock", + "group": "navigation@1", + "when": "notebookType == 'deepnote'" + }, { "command": "jupyter.restartkernel", "group": "navigation/execute@5", diff --git a/package.nls.json b/package.nls.json index f323d4cfec..d24ff31dc2 100644 --- a/package.nls.json +++ b/package.nls.json @@ -253,6 +253,17 @@ "deepnote.commands.newProject.title": "New Project", "deepnote.commands.importNotebook.title": "Import Notebook", "deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook", + "deepnote.commands.addSqlBlock.title": "Add SQL Block", + "deepnote.commands.addBigNumberChartBlock.title": "Add Big Number Chart Block", + "deepnote.commands.addInputTextBlock.title": "Add Input Text Block", + "deepnote.commands.addInputTextareaBlock.title": "Add Input Textarea Block", + "deepnote.commands.addInputSelectBlock.title": "Add Input Select Block", + "deepnote.commands.addInputSliderBlock.title": "Add Input Slider Block", + "deepnote.commands.addInputCheckboxBlock.title": "Add Input Checkbox Block", + "deepnote.commands.addInputDateBlock.title": "Add Input Date Block", + "deepnote.commands.addInputDateRangeBlock.title": "Add Input Date Range Block", + "deepnote.commands.addInputFileBlock.title": "Add Input File Block", + "deepnote.commands.addButtonBlock.title": "Add Button Block", "deepnote.views.explorer.name": "Explorer", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", "deepnote.command.selectNotebook.title": "Select Notebook" diff --git a/src/commands.ts b/src/commands.ts index eaf0b65930..106539fbd8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -185,4 +185,15 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.InstallPythonExtensionViaKernelPicker]: []; [DSCommands.InstallPythonViaKernelPicker]: []; [DSCommands.ContinueEditSessionInCodespace]: []; + [DSCommands.AddSqlBlock]: []; + [DSCommands.AddBigNumberChartBlock]: []; + [DSCommands.AddInputTextBlock]: []; + [DSCommands.AddInputTextareaBlock]: []; + [DSCommands.AddInputSelectBlock]: []; + [DSCommands.AddInputSliderBlock]: []; + [DSCommands.AddInputCheckboxBlock]: []; + [DSCommands.AddInputDateBlock]: []; + [DSCommands.AddInputDateRangeBlock]: []; + [DSCommands.AddInputFileBlock]: []; + [DSCommands.AddButtonBlock]: []; } diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts new file mode 100644 index 0000000000..da121db36a --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.ts @@ -0,0 +1,307 @@ +import { injectable, inject } from 'inversify'; +import { + commands, + window, + NotebookCellData, + NotebookCellKind, + NotebookEdit, + NotebookRange, + NotebookCell, + NotebookEditorRevealType, + l10n +} from 'vscode'; +import z from 'zod'; + +import { logger } from '../../platform/logging'; +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import { Commands } from '../../platform/common/constants'; +import { chainWithPendingUpdates } from '../../kernels/execution/notebookUpdater'; +import { + DeepnoteBigNumberMetadataSchema, + DeepnoteTextInputMetadataSchema, + DeepnoteTextareaInputMetadataSchema, + DeepnoteSelectInputMetadataSchema, + DeepnoteSliderInputMetadataSchema, + DeepnoteCheckboxInputMetadataSchema, + DeepnoteDateInputMetadataSchema, + DeepnoteDateRangeInputMetadataSchema, + DeepnoteFileInputMetadataSchema, + DeepnoteButtonMetadataSchema, + DeepnoteSqlMetadata +} from './deepnoteSchemas'; + +export type InputBlockType = + | 'input-text' + | 'input-textarea' + | 'input-select' + | 'input-slider' + | 'input-checkbox' + | 'input-date' + | 'input-date-range' + | 'input-file' + | 'button'; + +export function getInputBlockMetadata(blockType: InputBlockType, variableName: string) { + const defaultInput = { + deepnote_variable_name: variableName + }; + + switch (blockType) { + case 'input-text': + return DeepnoteTextInputMetadataSchema.parse(defaultInput); + case 'input-textarea': + return DeepnoteTextareaInputMetadataSchema.parse(defaultInput); + case 'input-select': + return DeepnoteSelectInputMetadataSchema.parse(defaultInput); + case 'input-slider': + return DeepnoteSliderInputMetadataSchema.parse(defaultInput); + case 'input-checkbox': + return DeepnoteCheckboxInputMetadataSchema.parse(defaultInput); + case 'input-date': + return DeepnoteDateInputMetadataSchema.parse(defaultInput); + case 'input-date-range': + return DeepnoteDateRangeInputMetadataSchema.parse(defaultInput); + case 'input-file': + return DeepnoteFileInputMetadataSchema.parse(defaultInput); + case 'button': + return DeepnoteButtonMetadataSchema.parse(defaultInput); + default: { + const exhaustiveCheck: never = blockType; + throw new Error(`Unhandled block type: ${exhaustiveCheck satisfies never}`); + } + } +} + +export function safeParseDeepnoteVariableNameFromContentJson(content: string): string | undefined { + try { + const variableNameResult = z.string().safeParse(JSON.parse(content)['deepnote_variable_name']); + return variableNameResult.success ? variableNameResult.data : undefined; + } catch (error) { + logger.error('Error parsing deepnote variable name from content JSON', error); + return undefined; + } +} + +export function getNextDeepnoteVariableName(cells: NotebookCell[], prefix: 'df' | 'query' | 'input'): string { + const deepnoteVariableNames = cells.reduce((acc, cell) => { + const contentValue = safeParseDeepnoteVariableNameFromContentJson(cell.document.getText()); + + if (contentValue != null) { + acc.push(contentValue); + } + + const parsedMetadataValue = z.string().safeParse(cell.metadata['deepnote_variable_name']); + if (parsedMetadataValue.success) { + acc.push(parsedMetadataValue.data); + } + + const parsedPocketMetadataValue = z.string().safeParse(cell.metadata.__deepnotePocket?.deepnote_variable_name); + + if (parsedPocketMetadataValue.success) { + acc.push(parsedPocketMetadataValue.data); + } + + return acc; + }, []); + + const maxDeepnoteVariableNamesSuffixNumber = + deepnoteVariableNames.reduce((acc, name) => { + if (!name.startsWith(`${prefix}_`)) { + return acc; + } + + const m = name.match(/_(\d+)$/); + if (m == null) { + return acc; + } + + const suffixNumber = parseInt(m[1]); + + if (isNaN(suffixNumber)) { + return acc; + } + + return acc == null || suffixNumber > acc ? suffixNumber : acc; + }, null) ?? 0; + + return `${prefix}_${maxDeepnoteVariableNamesSuffixNumber + 1}`; +} + +/** + * Service responsible for registering and handling Deepnote-specific notebook commands. + */ +@injectable() +export class DeepnoteNotebookCommandListener implements IExtensionSyncActivationService { + constructor(@inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry) {} + + /** + * Activates the service by registering Deepnote-specific commands. + */ + public activate(): void { + this.registerCommands(); + } + + private registerCommands(): void { + this.disposableRegistry.push(commands.registerCommand(Commands.AddSqlBlock, () => this.addSqlBlock())); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddBigNumberChartBlock, () => this.addBigNumberChartBlock()) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputTextBlock, () => this.addInputBlock('input-text')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputTextareaBlock, () => this.addInputBlock('input-textarea')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputSelectBlock, () => this.addInputBlock('input-select')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputSliderBlock, () => this.addInputBlock('input-slider')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputCheckboxBlock, () => this.addInputBlock('input-checkbox')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputDateBlock, () => this.addInputBlock('input-date')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputDateRangeBlock, () => this.addInputBlock('input-date-range')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddInputFileBlock, () => this.addInputBlock('input-file')) + ); + this.disposableRegistry.push( + commands.registerCommand(Commands.AddButtonBlock, () => this.addInputBlock('button')) + ); + } + + public async addSqlBlock(): Promise { + const editor = window.activeNotebookEditor; + if (!editor) { + throw new Error(l10n.t('No active notebook editor found')); + } + const document = editor.notebook; + const selection = editor.selection; + const cells = editor.notebook.getCells(); + const deepnoteVariableName = getNextDeepnoteVariableName(cells, 'df'); + + const defaultMetadata: DeepnoteSqlMetadata = { + deepnote_variable_name: deepnoteVariableName, + deepnote_return_variable_type: 'dataframe', + sql_integration_id: 'deepnote-dataframe-sql' + }; + + // Determine the index where to insert the new cell (below current selection or at the end) + const insertIndex = selection ? selection.end : document.cellCount; + + const result = await chainWithPendingUpdates(document, (edit) => { + // Create a SQL cell with SQL language for syntax highlighting + // This matches the SqlBlockConverter representation + const newCell = new NotebookCellData(NotebookCellKind.Code, '', 'sql'); + newCell.metadata = { + __deepnotePocket: { + type: 'sql', + ...defaultMetadata + }, + ...defaultMetadata + }; + const nbEdit = NotebookEdit.insertCells(insertIndex, [newCell]); + edit.set(document.uri, [nbEdit]); + }); + if (result !== true) { + throw new Error(l10n.t('Failed to insert SQL block')); + } + + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); + editor.revealRange(notebookRange, NotebookEditorRevealType.Default); + editor.selection = notebookRange; + // Enter edit mode on the new cell + await commands.executeCommand('notebook.cell.edit'); + } + + public async addBigNumberChartBlock(): Promise { + const editor = window.activeNotebookEditor; + if (!editor) { + throw new Error(l10n.t('No active notebook editor found')); + } + const document = editor.notebook; + const selection = editor.selection; + + // Determine the index where to insert the new cell (below current selection or at the end) + const insertIndex = selection ? selection.end : document.cellCount; + + // Initialize empty metadata from the zod schema + const bigNumberMetadata = DeepnoteBigNumberMetadataSchema.parse({}); + + const metadata = { + __deepnotePocket: { + type: 'big-number' + } + }; + + const result = await chainWithPendingUpdates(document, (edit) => { + const newCell = new NotebookCellData( + NotebookCellKind.Code, + JSON.stringify(bigNumberMetadata, null, 2), + 'json' + ); + newCell.metadata = metadata; + const nbEdit = NotebookEdit.insertCells(insertIndex, [newCell]); + edit.set(document.uri, [nbEdit]); + }); + if (result !== true) { + throw new Error(l10n.t('Failed to insert big number chart block')); + } + + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); + editor.revealRange(notebookRange, NotebookEditorRevealType.Default); + editor.selection = notebookRange; + // Enter edit mode on the new cell + await commands.executeCommand('notebook.cell.edit'); + } + + public async addInputBlock(blockType: InputBlockType): Promise { + const editor = window.activeNotebookEditor; + if (!editor) { + throw new Error(l10n.t('No active notebook editor found')); + } + const document = editor.notebook; + const selection = editor.selection; + const cells = editor.notebook.getCells(); + const deepnoteVariableName = getNextDeepnoteVariableName(cells, 'input'); + + // Determine the index where to insert the new cell (below current selection or at the end) + const insertIndex = selection ? selection.end : document.cellCount; + + // Get the appropriate schema and parse default metadata based on block type + const defaultMetadata = getInputBlockMetadata(blockType, deepnoteVariableName); + + const metadata = { + __deepnotePocket: { + type: blockType, + ...defaultMetadata + } + }; + + const result = await chainWithPendingUpdates(document, (edit) => { + const newCell = new NotebookCellData( + NotebookCellKind.Code, + JSON.stringify(defaultMetadata, null, 2), + 'json' + ); + newCell.metadata = metadata; + const nbEdit = NotebookEdit.insertCells(insertIndex, [newCell]); + edit.set(document.uri, [nbEdit]); + }); + if (result !== true) { + throw new Error(l10n.t('Failed to insert input block')); + } + + const notebookRange = new NotebookRange(insertIndex, insertIndex + 1); + editor.revealRange(notebookRange, NotebookEditorRevealType.Default); + editor.selection = notebookRange; + // Enter edit mode on the new cell + await commands.executeCommand('notebook.cell.edit'); + } +} diff --git a/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts new file mode 100644 index 0000000000..49404b0739 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookCommandListener.unit.test.ts @@ -0,0 +1,979 @@ +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { + NotebookCell, + NotebookDocument, + NotebookEditor, + NotebookRange, + NotebookCellKind, + NotebookCellData, + WorkspaceEdit, + commands, + window, + Uri +} from 'vscode'; + +import { + DeepnoteNotebookCommandListener, + getNextDeepnoteVariableName, + InputBlockType +} from './deepnoteNotebookCommandListener'; +import { IDisposable } from '../../platform/common/types'; +import * as notebookUpdater from '../../kernels/execution/notebookUpdater'; +import { createMockedNotebookDocument } from '../../test/datascience/editor-integration/helpers'; + +suite('DeepnoteNotebookCommandListener', () => { + let commandListener: DeepnoteNotebookCommandListener; + let disposables: IDisposable[]; + + setup(() => { + disposables = []; + commandListener = new DeepnoteNotebookCommandListener(disposables); + }); + + teardown(() => { + disposables.forEach((d) => d?.dispose()); + }); + + suite('activate', () => { + test('should register commands when activated', () => { + assert.isEmpty(disposables, 'Disposables should be empty'); + + commandListener.activate(); + + // Verify that at least one command was registered (AddSqlBlock) + assert.isAtLeast(disposables.length, 1, 'Should register at least one command'); + }); + + test('should handle activation without errors', () => { + assert.doesNotThrow(() => { + commandListener.activate(); + }, 'activate() should not throw errors'); + }); + + test('should register disposable command handlers', () => { + commandListener.activate(); + + // Verify disposables were registered + assert.isAtLeast(disposables.length, 1, 'Should register command disposables'); + + // Verify all registered items are disposable (filter out null/undefined first) + const validDisposables = disposables.filter((d) => d != null); + validDisposables.forEach((d) => { + assert.isDefined(d.dispose, 'Each registered item should have a dispose method'); + }); + }); + }); + + suite('command registration', () => { + test('should not register duplicate commands on multiple activations', () => { + commandListener.activate(); + const firstActivationCount = disposables.length; + + // Create new instance and activate again + const disposables2: IDisposable[] = []; + const commandListener2 = new DeepnoteNotebookCommandListener(disposables2); + commandListener2.activate(); + + // Both should register the same number of commands + assert.equal( + disposables2.length, + firstActivationCount, + 'Both activations should register the same number of commands' + ); + + disposables2.forEach((d) => d?.dispose()); + }); + }); + + suite('getNextDeepnoteVariableName', () => { + /** + * Helper function to create a mock NotebookCell + */ + function createMockCell(content: string, metadata?: Record): NotebookCell { + return { + document: { + getText: () => content + }, + metadata: metadata || {} + } as NotebookCell; + } + + const TEST_INPUTS: Array<{ + description: string; + cells: NotebookCell[]; + prefix: 'df' | 'query' | 'input'; + expected: string; + }> = [ + // Tests with 'input' prefix + { + description: 'should return input_1 for empty cells array', + cells: [], + prefix: 'input', + expected: 'input_1' + }, + { + description: 'should return input_1 when no variable names exist', + cells: [createMockCell('{ "some_other_field": "value" }'), createMockCell('{ "data": "test" }')], + prefix: 'input', + expected: 'input_1' + }, + { + description: 'should return input_2 when input_1 exists in content JSON', + cells: [createMockCell('{ "deepnote_variable_name": "input_1" }')], + prefix: 'input', + expected: 'input_2' + }, + { + description: 'should return input_3 when input_1 and input_2 exist', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_1" }'), + createMockCell('{ "deepnote_variable_name": "input_2" }') + ], + prefix: 'input', + expected: 'input_3' + }, + { + description: 'should return input_6 when max suffix is input_5', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_1" }'), + createMockCell('{ "deepnote_variable_name": "input_5" }'), + createMockCell('{ "deepnote_variable_name": "input_3" }') + ], + prefix: 'input', + expected: 'input_6' + }, + { + description: 'should return input_1 when variable names have no numeric suffix', + cells: [ + createMockCell('{ "deepnote_variable_name": "my_variable" }'), + createMockCell('{ "deepnote_variable_name": "another_var" }') + ], + prefix: 'input', + expected: 'input_1' + }, + { + description: 'should return input_11 when input_10 exists', + cells: [createMockCell('{ "deepnote_variable_name": "input_10" }')], + prefix: 'input', + expected: 'input_11' + }, + { + description: 'should extract variable name from metadata', + cells: [ + createMockCell('{}', { + __deepnotePocket: { deepnote_variable_name: 'input_7' } + }) + ], + prefix: 'input', + expected: 'input_8' + }, + { + description: 'should handle both content and metadata variable names', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_2" }'), + createMockCell('{}', { + __deepnotePocket: { deepnote_variable_name: 'input_5' } + }), + createMockCell('{ "deepnote_variable_name": "input_3" }') + ], + prefix: 'input', + expected: 'input_6' + }, + { + description: 'should handle mixed variable names with and without numbers', + cells: [ + createMockCell('{ "deepnote_variable_name": "my_custom_input" }'), + createMockCell('{ "deepnote_variable_name": "input_4" }'), + createMockCell('{ "deepnote_variable_name": "another_variable" }') + ], + prefix: 'input', + expected: 'input_5' + }, + { + description: 'should handle invalid JSON gracefully', + cells: [createMockCell('not valid json'), createMockCell('{ "deepnote_variable_name": "input_3" }')], + prefix: 'input', + expected: 'input_4' + }, + { + description: 'should handle cells with both content and metadata, preferring the highest', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_2" }', { + __deepnotePocket: { deepnote_variable_name: 'input_8' } + }) + ], + prefix: 'input', + expected: 'input_9' + }, + { + description: 'should handle non-numeric suffixes in variable names', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_abc" }'), + createMockCell('{ "deepnote_variable_name": "input_5" }') + ], + prefix: 'input', + expected: 'input_6' + }, + { + description: 'should return input_1 when only zero-suffixed names exist', + cells: [createMockCell('{ "deepnote_variable_name": "input_0" }')], + prefix: 'input', + expected: 'input_1' + }, + { + description: 'should handle large numbers correctly', + cells: [createMockCell('{ "deepnote_variable_name": "input_999" }')], + prefix: 'input', + expected: 'input_1000' + }, + + // Tests with 'df' prefix + { + description: 'should return df_1 for empty cells array with df prefix', + cells: [], + prefix: 'df', + expected: 'df_1' + }, + { + description: 'should return df_2 when df_1 exists', + cells: [createMockCell('{ "deepnote_variable_name": "df_1" }')], + prefix: 'df', + expected: 'df_2' + }, + { + description: 'should return df_5 when df_4 exists and ignore input_ variables', + cells: [ + createMockCell('{ "deepnote_variable_name": "df_4" }'), + createMockCell('{ "deepnote_variable_name": "input_10" }'), + createMockCell('{ "deepnote_variable_name": "query_7" }') + ], + prefix: 'df', + expected: 'df_5' + }, + { + description: 'should return df_1 when only input_ variables exist', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_5" }'), + createMockCell('{ "deepnote_variable_name": "input_10" }') + ], + prefix: 'df', + expected: 'df_1' + }, + + // Tests with 'query' prefix + { + description: 'should return query_1 for empty cells array with query prefix', + cells: [], + prefix: 'query', + expected: 'query_1' + }, + { + description: 'should return query_3 when query_1 and query_2 exist', + cells: [ + createMockCell('{ "deepnote_variable_name": "query_1" }'), + createMockCell('{ "deepnote_variable_name": "query_2" }') + ], + prefix: 'query', + expected: 'query_3' + }, + { + description: 'should return query_8 when max suffix is query_7 and ignore other prefixes', + cells: [ + createMockCell('{ "deepnote_variable_name": "query_7" }'), + createMockCell('{ "deepnote_variable_name": "df_100" }'), + createMockCell('{ "deepnote_variable_name": "input_50" }') + ], + prefix: 'query', + expected: 'query_8' + }, + + // Mixed prefix tests + { + description: 'should only count matching prefix when multiple prefixes exist', + cells: [ + createMockCell('{ "deepnote_variable_name": "input_5" }'), + createMockCell('{ "deepnote_variable_name": "df_3" }'), + createMockCell('{ "deepnote_variable_name": "query_2" }'), + createMockCell('{ "deepnote_variable_name": "input_8" }') + ], + prefix: 'input', + expected: 'input_9' + }, + { + description: 'should handle metadata with different prefix', + cells: [ + createMockCell('{}', { + __deepnotePocket: { deepnote_variable_name: 'df_15' } + }), + createMockCell('{ "deepnote_variable_name": "df_20" }') + ], + prefix: 'df', + expected: 'df_21' + } + ]; + + TEST_INPUTS.forEach(({ description, cells, prefix, expected }) => { + test(description, () => { + const result = getNextDeepnoteVariableName(cells, prefix); + assert.equal(result, expected); + }); + }); + }); + + suite('addBlock', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + /** + * Helper to create mock NotebookCell with metadata + */ + function createMockCell(content: string, metadata?: Record): NotebookCellData { + const cell = new NotebookCellData(NotebookCellKind.Code, content, 'json'); + if (metadata != null) { + cell.metadata = metadata; + } + return cell; + } + + /** + * Helper to create mock NotebookEditor and NotebookDocument + */ + function createMockEditor( + cellDataArray: NotebookCellData[], + selection?: NotebookRange + ): { + editor: NotebookEditor; + document: NotebookDocument; + } { + const uri = Uri.file('/test/notebook.ipynb'); + const document = createMockedNotebookDocument(cellDataArray, {}, uri); + + const editorSelection = + selection != null ? selection : new NotebookRange(0, cellDataArray.length > 0 ? 1 : 0); + + const editor: NotebookEditor = { + notebook: document, + selection: editorSelection, + selections: [editorSelection], + visibleRanges: [], + revealRange: sandbox.stub() + }; + + return { editor, document }; + } + + function mockNotebookUpdateAndExecute(editor: NotebookEditor) { + Object.defineProperty(window, 'activeNotebookEditor', { + value: editor, + configurable: true, + writable: true + }); + + let capturedNotebookEdits: any[] | null = null; + + // Mock chainWithPendingUpdates to capture the edit and resolve immediately + const chainStub = sandbox + .stub(notebookUpdater, 'chainWithPendingUpdates') + .callsFake((_doc: NotebookDocument, callback: (edit: WorkspaceEdit) => void) => { + const edit = new WorkspaceEdit(); + // Stub the set method to capture the notebook edits + sandbox.stub(edit, 'set').callsFake((_uri, edits) => { + capturedNotebookEdits = edits as any[]; + }); + callback(edit); + return Promise.resolve(true); + }); + + // Mock commands.executeCommand + const executeCommandStub = sandbox.stub().resolves(); + Object.defineProperty(commands, 'executeCommand', { + value: executeCommandStub, + configurable: true, + writable: true + }); + + return { + chainStub, + executeCommandStub, + getCapturedNotebookEdits: () => capturedNotebookEdits + }; + } + + const TEST_INPUTS: Array<{ + description: string; + blockType: InputBlockType; + existingCells: NotebookCellData[]; + selection?: NotebookRange; + expectedInsertIndex: number; + expectedVariableName: string; + expectedMetadataKeys: string[]; + }> = [ + { + description: 'should add input-text block at the end when no selection exists', + blockType: 'input-text', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should add input-text block after selection when selection exists', + blockType: 'input-text', + existingCells: [createMockCell('{}')], + selection: new NotebookRange(0, 1), + expectedInsertIndex: 1, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should add input-textarea block with correct metadata', + blockType: 'input-textarea', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should add input-select block with correct metadata', + blockType: 'input-select', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: [ + 'deepnote_variable_name', + 'deepnote_input_label', + 'deepnote_variable_value', + 'deepnote_variable_options' + ] + }, + { + description: 'should add input-slider block with correct metadata', + blockType: 'input-slider', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: [ + 'deepnote_variable_name', + 'deepnote_input_label', + 'deepnote_variable_value', + 'deepnote_slider_min_value', + 'deepnote_slider_max_value', + 'deepnote_slider_step' + ] + }, + { + description: 'should add input-checkbox block with correct metadata', + blockType: 'input-checkbox', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should add input-date block with correct metadata', + blockType: 'input-date', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: [ + 'deepnote_variable_name', + 'deepnote_input_label', + 'deepnote_variable_value', + 'deepnote_input_date_version' + ] + }, + { + description: 'should add input-date-range block with correct metadata', + blockType: 'input-date-range', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should add input-file block with correct metadata', + blockType: 'input-file', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: [ + 'deepnote_variable_name', + 'deepnote_input_label', + 'deepnote_variable_value', + 'deepnote_allowed_file_extensions' + ] + }, + { + description: 'should add button block with correct metadata', + blockType: 'button', + existingCells: [], + selection: undefined, + expectedInsertIndex: 0, + expectedVariableName: 'input_1', + expectedMetadataKeys: [ + 'deepnote_variable_name', + 'deepnote_button_title', + 'deepnote_button_behavior', + 'deepnote_button_color_scheme' + ] + }, + { + description: 'should generate correct variable name when existing inputs exist', + blockType: 'input-text', + existingCells: [ + createMockCell('{ "deepnote_variable_name": "input_1" }'), + createMockCell('{ "deepnote_variable_name": "input_2" }') + ], + selection: new NotebookRange(1, 2), + expectedInsertIndex: 2, + expectedVariableName: 'input_3', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should insert at selection.end when selection is in the middle', + blockType: 'input-text', + existingCells: [createMockCell('{}'), createMockCell('{}'), createMockCell('{}')], + selection: new NotebookRange(1, 2), + expectedInsertIndex: 2, + expectedVariableName: 'input_1', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + }, + { + description: 'should handle large variable numbers correctly', + blockType: 'input-text', + existingCells: [createMockCell('{ "deepnote_variable_name": "input_99" }')], + selection: undefined, + expectedInsertIndex: 1, + expectedVariableName: 'input_100', + expectedMetadataKeys: ['deepnote_variable_name', 'deepnote_input_label', 'deepnote_variable_value'] + } + ]; + + TEST_INPUTS.forEach( + ({ + description, + blockType, + existingCells, + selection, + expectedInsertIndex, + expectedVariableName, + expectedMetadataKeys + }) => { + test(description, async () => { + // Setup mocks + const { editor, document } = createMockEditor(existingCells, selection); + + const { chainStub, executeCommandStub, getCapturedNotebookEdits } = + mockNotebookUpdateAndExecute(editor); + + // Call the method and await it + await commandListener.addInputBlock(blockType); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + assert.equal(chainStub.firstCall.args[0], document, 'Should be called with correct document'); + + // Verify the edits were captured + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + assert.isDefined(capturedNotebookEdits, 'Notebook edits should be defined'); + + // Verify cell was inserted at correct index + // TypeScript type narrowing issue - we've already asserted it's not null + const editsArray = capturedNotebookEdits!; + assert.equal(editsArray.length, 1, 'Should have one notebook edit'); + + const notebookEdit = editsArray[0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + + 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 all expected metadata keys are present in content + expectedMetadataKeys.forEach((key) => { + assert.property(content, key, `Content should have ${key} property`); + }); + + // Verify metadata structure + assert.property(newCell.metadata, '__deepnotePocket', 'Should have __deepnotePocket metadata'); + assert.equal(newCell.metadata.__deepnotePocket.type, blockType, 'Should have correct block type'); + assert.equal( + newCell.metadata.__deepnotePocket.deepnote_variable_name, + expectedVariableName, + 'Metadata should have correct variable name' + ); + + // Verify metadata keys match content keys + expectedMetadataKeys.forEach((key) => { + assert.property(newCell.metadata.__deepnotePocket, key, `Metadata should have ${key} property`); + }); + + // Verify reveal and selection were set + assert.isTrue( + (editor.revealRange as sinon.SinonStub).calledOnce, + 'Should reveal the new cell range' + ); + const revealCall = (editor.revealRange as sinon.SinonStub).firstCall; + assert.equal(revealCall.args[0].start, expectedInsertIndex, 'Should reveal correct range start'); + assert.equal(revealCall.args[0].end, expectedInsertIndex + 1, 'Should reveal correct range end'); + + // Verify notebook.cell.edit command was executed + assert.isTrue( + executeCommandStub.calledWith('notebook.cell.edit'), + 'Should execute notebook.cell.edit command' + ); + }); + } + ); + + test('should do nothing when no active editor exists', async () => { + // Setup: no active editor + Object.defineProperty(window, 'activeNotebookEditor', { + value: undefined, + configurable: true, + writable: true + }); + + const chainStub = sandbox.stub(notebookUpdater, 'chainWithPendingUpdates'); + + // Call the method + await assert.isRejected( + commandListener.addInputBlock('input-text'), + Error, + 'No active notebook editor found' + ); + + // Verify chainWithPendingUpdates was NOT called + assert.isFalse(chainStub.called, 'chainWithPendingUpdates should not be called when no editor exists'); + }); + + test('should handle errors in chainWithPendingUpdates gracefully', async () => { + // Setup mocks + const { editor } = createMockEditor([]); + Object.defineProperty(window, 'activeNotebookEditor', { + value: editor, + configurable: true, + writable: true + }); + + // Mock chainWithPendingUpdates to reject + const chainStub = sandbox.stub(notebookUpdater, 'chainWithPendingUpdates').rejects(new Error('Test error')); + + // Call the method - should not throw + await assert.isRejected(commandListener.addInputBlock('input-text'), Error, 'Test error'); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called'); + }); + + suite('addSqlBlock', () => { + test('should add SQL block at the end when no selection exists', async () => { + // Setup mocks + const { editor, document } = createMockEditor([], undefined); + const { chainStub, executeCommandStub, getCapturedNotebookEdits } = + mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addSqlBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + assert.equal(chainStub.firstCall.args[0], document, 'Should be called with correct document'); + + // Verify the edits were captured + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + assert.isDefined(capturedNotebookEdits, 'Notebook edits should be defined'); + + const editsArray = capturedNotebookEdits!; + assert.equal(editsArray.length, 1, 'Should have one notebook edit'); + + const notebookEdit = editsArray[0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + + const newCell = notebookEdit.newCells[0]; + assert.equal(newCell.kind, NotebookCellKind.Code, 'Should be a code cell'); + assert.equal(newCell.languageId, 'sql', 'Should have sql language'); + assert.equal(newCell.value, '', 'Should have empty content'); + + // Verify metadata structure + assert.property(newCell.metadata, '__deepnotePocket', 'Should have __deepnotePocket metadata'); + assert.equal(newCell.metadata.__deepnotePocket.type, 'sql', 'Should have sql type'); + assert.equal(newCell.metadata.deepnote_variable_name, 'df_1', 'Should have correct variable name'); + assert.equal( + newCell.metadata.deepnote_return_variable_type, + 'dataframe', + 'Should have dataframe return type' + ); + assert.equal( + newCell.metadata.sql_integration_id, + 'deepnote-dataframe-sql', + 'Should have correct sql integration id' + ); + + // Verify reveal and selection were set + assert.isTrue((editor.revealRange as sinon.SinonStub).calledOnce, 'Should reveal the new cell range'); + const revealCall = (editor.revealRange as sinon.SinonStub).firstCall; + assert.equal(revealCall.args[0].start, 0, 'Should reveal correct range start'); + assert.equal(revealCall.args[0].end, 1, 'Should reveal correct range end'); + assert.equal(revealCall.args[1], 0, 'Should use NotebookEditorRevealType.Default (value 0)'); + + // Verify notebook.cell.edit command was executed + assert.isTrue( + executeCommandStub.calledWith('notebook.cell.edit'), + 'Should execute notebook.cell.edit command' + ); + }); + + test('should add SQL block after selection when selection exists', async () => { + // Setup mocks + const existingCells = [createMockCell('{}'), createMockCell('{}')]; + const selection = new NotebookRange(1, 2); + const { editor } = createMockEditor(existingCells, selection); + const { chainStub, getCapturedNotebookEdits } = mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addSqlBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + + // Verify a cell was inserted + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + const notebookEdit = capturedNotebookEdits![0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + assert.equal(notebookEdit.newCells[0].languageId, 'sql', 'Should be SQL cell'); + }); + + test('should generate correct variable name when existing df variables exist', async () => { + // Setup mocks with existing df variables + const existingCells = [ + createMockCell('{ "deepnote_variable_name": "df_1" }'), + createMockCell('{ "deepnote_variable_name": "df_2" }') + ]; + const { editor } = createMockEditor(existingCells, undefined); + const { getCapturedNotebookEdits } = mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addSqlBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + const notebookEdit = capturedNotebookEdits![0] as any; + const newCell = notebookEdit.newCells[0]; + + // Verify variable name is df_3 + assert.equal(newCell.metadata.deepnote_variable_name, 'df_3', 'Should generate next variable name'); + }); + + test('should ignore input variables when generating df variable name', async () => { + // Setup mocks with input variables (should not affect df numbering) + const existingCells = [ + createMockCell('{ "deepnote_variable_name": "input_10" }'), + createMockCell('{ "deepnote_variable_name": "df_2" }') + ]; + const { editor } = createMockEditor(existingCells, undefined); + const { getCapturedNotebookEdits } = mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addSqlBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + const notebookEdit = capturedNotebookEdits![0] as any; + const newCell = notebookEdit.newCells[0]; + + // Verify variable name is df_3 (not affected by input_10) + assert.equal(newCell.metadata.deepnote_variable_name, 'df_3', 'Should only consider df variables'); + }); + + test('should throw error when no active editor exists', async () => { + // Setup: no active editor + Object.defineProperty(window, 'activeNotebookEditor', { + value: undefined, + configurable: true, + writable: true + }); + + // Call the method and expect rejection + await assert.isRejected(commandListener.addSqlBlock(), Error, 'No active notebook editor found'); + }); + + test('should throw error when chainWithPendingUpdates fails', async () => { + // Setup mocks + const { editor } = createMockEditor([], undefined); + Object.defineProperty(window, 'activeNotebookEditor', { + value: editor, + configurable: true, + writable: true + }); + + // Mock chainWithPendingUpdates to return false + sandbox.stub(notebookUpdater, 'chainWithPendingUpdates').resolves(false); + + // Call the method and expect rejection + await assert.isRejected(commandListener.addSqlBlock(), Error, 'Failed to insert SQL block'); + }); + }); + + suite('addBigNumberChartBlock', () => { + test('should add big number block at the end when no selection exists', async () => { + // Setup mocks + const { editor, document } = createMockEditor([], undefined); + const { chainStub, executeCommandStub, getCapturedNotebookEdits } = + mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addBigNumberChartBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + assert.equal(chainStub.firstCall.args[0], document, 'Should be called with correct document'); + + // Verify the edits were captured + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + assert.isDefined(capturedNotebookEdits, 'Notebook edits should be defined'); + + const editsArray = capturedNotebookEdits!; + assert.equal(editsArray.length, 1, 'Should have one notebook edit'); + + const notebookEdit = editsArray[0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + + 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 + const content = JSON.parse(newCell.value); + assert.isObject(content, 'Content should be an object'); + + // Verify metadata structure + assert.property(newCell.metadata, '__deepnotePocket', 'Should have __deepnotePocket metadata'); + assert.equal(newCell.metadata.__deepnotePocket.type, 'big-number', 'Should have big-number type'); + + // Verify reveal and selection were set + assert.isTrue((editor.revealRange as sinon.SinonStub).calledOnce, 'Should reveal the new cell range'); + const revealCall = (editor.revealRange as sinon.SinonStub).firstCall; + assert.equal(revealCall.args[0].start, 0, 'Should reveal correct range start'); + assert.equal(revealCall.args[0].end, 1, 'Should reveal correct range end'); + assert.equal(revealCall.args[1], 0, 'Should use NotebookEditorRevealType.Default (value 0)'); + + // Verify notebook.cell.edit command was executed + assert.isTrue( + executeCommandStub.calledWith('notebook.cell.edit'), + 'Should execute notebook.cell.edit command' + ); + }); + + test('should add big number block after selection when selection exists', async () => { + // Setup mocks + const existingCells = [createMockCell('{}'), createMockCell('{}')]; + const selection = new NotebookRange(0, 1); + const { editor } = createMockEditor(existingCells, selection); + const { chainStub, getCapturedNotebookEdits } = mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addBigNumberChartBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + + // Verify a cell was inserted + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + const notebookEdit = capturedNotebookEdits![0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + assert.equal(notebookEdit.newCells[0].languageId, 'json', 'Should be JSON cell'); + }); + + test('should insert at correct position in the middle of notebook', async () => { + // Setup mocks + const existingCells = [createMockCell('{}'), createMockCell('{}'), createMockCell('{}')]; + const selection = new NotebookRange(1, 2); + const { editor } = createMockEditor(existingCells, selection); + const { chainStub, getCapturedNotebookEdits } = mockNotebookUpdateAndExecute(editor); + + // Call the method + await commandListener.addBigNumberChartBlock(); + + const capturedNotebookEdits = getCapturedNotebookEdits(); + + // Verify chainWithPendingUpdates was called + assert.isTrue(chainStub.calledOnce, 'chainWithPendingUpdates should be called once'); + + // Verify a cell was inserted + assert.isNotNull(capturedNotebookEdits, 'Notebook edits should be captured'); + const notebookEdit = capturedNotebookEdits![0] as any; + assert.equal(notebookEdit.newCells.length, 1, 'Should insert one cell'); + assert.equal(notebookEdit.newCells[0].languageId, 'json', 'Should be JSON cell'); + }); + + test('should throw error when no active editor exists', async () => { + // Setup: no active editor + Object.defineProperty(window, 'activeNotebookEditor', { + value: undefined, + configurable: true, + writable: true + }); + + // Call the method and expect rejection + await assert.isRejected( + commandListener.addBigNumberChartBlock(), + Error, + 'No active notebook editor found' + ); + }); + + test('should throw error when chainWithPendingUpdates fails', async () => { + // Setup mocks + const { editor } = createMockEditor([], undefined); + Object.defineProperty(window, 'activeNotebookEditor', { + value: editor, + configurable: true, + writable: true + }); + + // Mock chainWithPendingUpdates to return false + sandbox.stub(notebookUpdater, 'chainWithPendingUpdates').resolves(false); + + // Call the method and expect rejection + await assert.isRejected( + commandListener.addBigNumberChartBlock(), + Error, + 'Failed to insert big number chart block' + ); + }); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts index 70bab382ff..7c8712b467 100644 --- a/src/notebooks/deepnote/deepnoteSchemas.ts +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -43,6 +43,21 @@ export const DeepnoteBigNumberMetadataSchema = z.object({ .transform((val) => val ?? false) }); +export const DeepnoteSqlMetadataSchema = z.object({ + deepnote_variable_name: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_return_variable_type: z + .enum(['dataframe', 'query_preview']) + .nullish() + .transform((val) => val ?? 'dataframe'), + sql_integration_id: z + .string() + .nullish() + .transform((val) => val ?? '') +}); + // Base schema with common fields for all input types const DeepnoteBaseInputMetadataSchema = z.object({ deepnote_variable_name: z @@ -213,3 +228,4 @@ export const DeepnoteButtonMetadataSchema = DeepnoteBaseInputMetadataSchema.exte export type DeepnoteChartBigNumberOutput = z.infer; export type DeepnoteBigNumberMetadata = z.infer; +export type DeepnoteSqlMetadata = z.infer; diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 651bc92147..333213be4c 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -66,6 +66,7 @@ import { DeepnoteKernelAutoSelector } from './deepnote/deepnoteKernelAutoSelecto import { DeepnoteServerProvider } from '../kernels/deepnote/deepnoteServerProvider.node'; import { DeepnoteInitNotebookRunner, IDeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; +import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; import { SqlIntegrationStartupCodeProvider } from './deepnote/integrations/sqlIntegrationStartupCodeProvider'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; @@ -141,6 +142,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteActivationService ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteNotebookCommandListener + ); serviceManager.addSingleton(IDeepnoteNotebookManager, DeepnoteNotebookManager); serviceManager.addSingleton(IIntegrationStorage, IntegrationStorage); serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 04752612eb..f3f8442749 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -38,6 +38,7 @@ import { INotebookEditorProvider, INotebookPythonEnvironmentService } from './ty import { DeepnoteActivationService } from './deepnote/deepnoteActivationService'; import { DeepnoteNotebookManager } from './deepnote/deepnoteNotebookManager'; import { IDeepnoteNotebookManager } from './types'; +import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { IntegrationStorage } from '../platform/notebooks/deepnote/integrationStorage'; import { IntegrationDetector } from './deepnote/integrations/integrationDetector'; import { IntegrationManager } from './deepnote/integrations/integrationManager'; @@ -103,6 +104,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteActivationService ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteNotebookCommandListener + ); serviceManager.addSingleton(IDeepnoteNotebookManager, DeepnoteNotebookManager); serviceManager.addSingleton(IIntegrationStorage, IntegrationStorage); serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 40638000ff..9e9cb2b2a5 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -224,6 +224,17 @@ export namespace Commands { export const OpenDeepnoteFile = 'deepnote.openFile'; export const RevealInDeepnoteExplorer = 'deepnote.revealInExplorer'; export const ManageIntegrations = 'deepnote.manageIntegrations'; + export const AddSqlBlock = 'deepnote.addSqlBlock'; + export const AddBigNumberChartBlock = 'deepnote.addBigNumberChartBlock'; + export const AddInputTextBlock = 'deepnote.addInputTextBlock'; + export const AddInputTextareaBlock = 'deepnote.addInputTextareaBlock'; + export const AddInputSelectBlock = 'deepnote.addInputSelectBlock'; + export const AddInputSliderBlock = 'deepnote.addInputSliderBlock'; + export const AddInputCheckboxBlock = 'deepnote.addInputCheckboxBlock'; + export const AddInputDateBlock = 'deepnote.addInputDateBlock'; + export const AddInputDateRangeBlock = 'deepnote.addInputDateRangeBlock'; + export const AddInputFileBlock = 'deepnote.addInputFileBlock'; + export const AddButtonBlock = 'deepnote.addButtonBlock'; export const ExportAsPythonScript = 'jupyter.exportAsPythonScript'; export const ExportToHTML = 'jupyter.exportToHTML'; export const ExportToPDF = 'jupyter.exportToPDF'; diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index 82c23b87a4..962d75bd0e 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -2406,6 +2406,29 @@ export namespace vscMockExtHostedTypes { } } + /** + * Notebook editor reveal type. + */ + export enum NotebookEditorRevealType { + /** + * The range will be revealed with as little scrolling as possible. + */ + Default = 0, + /** + * The range will always be revealed in the center of the viewport. + */ + InCenter = 1, + /** + * If the range is outside the viewport, it will be revealed in the center of the viewport. + * Otherwise, it will be revealed with as little scrolling as possible. + */ + InCenterIfOutsideViewport = 2, + /** + * The range will always be revealed at the top of the viewport. + */ + AtTop = 3 + } + /** * A notebook range represents an ordered pair of two cell indices. * It is guaranteed that start is less than or equal to end. diff --git a/src/test/vscode-mock.ts b/src/test/vscode-mock.ts index 723b0bce1b..f4664be019 100644 --- a/src/test/vscode-mock.ts +++ b/src/test/vscode-mock.ts @@ -195,3 +195,4 @@ mockedVSCode.QuickPickItemKind = vscodeMocks.vscMockExtHostedTypes.QuickPickItem mockedVSCode.NotebookCellOutput = vscodeMocks.vscMockExtHostedTypes.NotebookCellOutput; (mockedVSCode as any).NotebookCellOutputItem = vscodeMocks.vscMockExtHostedTypes.NotebookCellOutputItem; (mockedVSCode as any).NotebookCellExecutionState = vscodeMocks.vscMockExtHostedTypes.NotebookCellExecutionState; +(mockedVSCode as any).NotebookEditorRevealType = vscodeMocks.vscMockExtHostedTypes.NotebookEditorRevealType; diff --git a/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx index a8fcaa3203..ae949884ec 100644 --- a/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx +++ b/src/webviews/webview-side/chart-big-number-renderer/ChartBigNumberOutputRenderer.tsx @@ -24,7 +24,7 @@ export function ChartBigNumberOutputRenderer({ return 'NaN'; } - return formatValue(parsedValue, metadata.deepnote_big_number_format ?? 'number'); + return formatValue(parsedValue, metadata.deepnote_big_number_format); }, [output.value, metadata.deepnote_big_number_format]); const comparisonValue = useMemo(() => {