From 7b0fe388be16b164956635c13b069ae0eb64778c Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Wed, 29 Oct 2025 16:26:19 +0200 Subject: [PATCH 1/8] feat: open in Deepnote command --- package.json | 23 ++ src/commands.ts | 1 + src/notebooks/deepnote/importClient.node.ts | 211 ++++++++++++++++++ .../deepnote/importClient.unit.test.ts | 170 ++++++++++++++ .../deepnote/openInDeepnoteHandler.node.ts | 121 ++++++++++ .../openInDeepnoteHandler.node.unit.test.ts | 26 +++ src/notebooks/serviceRegistry.node.ts | 5 + src/platform/common/constants.ts | 1 + 8 files changed, 558 insertions(+) create mode 100644 src/notebooks/deepnote/importClient.node.ts create mode 100644 src/notebooks/deepnote/importClient.unit.test.ts create mode 100644 src/notebooks/deepnote/openInDeepnoteHandler.node.ts create mode 100644 src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts diff --git a/package.json b/package.json index 3d3b59ec40..4f16a8b7cc 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,12 @@ "category": "Deepnote", "icon": "$(plug)" }, + { + "command": "deepnote.openInDeepnote", + "title": "Open in Deepnote", + "category": "Deepnote", + "icon": "$(globe)" + }, { "command": "deepnote.newProject", "title": "New project", @@ -761,6 +767,11 @@ "when": "editorFocus && editorLangId == python && jupyter.hascodecells && !notebookEditorFocused && isWorkspaceTrusted", "command": "jupyter.exportfileasnotebook", "group": "Jupyter3@2" + }, + { + "when": "resourceExtname == .deepnote", + "command": "deepnote.openInDeepnote", + "group": "navigation" } ], "editor.interactiveWindow.context": [ @@ -1386,6 +1397,18 @@ "type": "object", "title": "Deepnote", "properties": { + "deepnote.apiEndpoint": { + "type": "string", + "default": "https://deepnote.com", + "description": "Deepnote API endpoint", + "scope": "application" + }, + "deepnote.disableSSLVerification": { + "type": "boolean", + "default": false, + "description": "Disable SSL certificate verification (for development only)", + "scope": "application" + }, "jupyter.experiments.enabled": { "type": "boolean", "default": true, diff --git a/src/commands.ts b/src/commands.ts index 40192a8f6f..1d59de73a4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -197,4 +197,5 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.AddInputDateRangeBlock]: []; [DSCommands.AddInputFileBlock]: []; [DSCommands.AddButtonBlock]: []; + [DSCommands.OpenInDeepnote]: []; } diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts new file mode 100644 index 0000000000..e3bb34ebaa --- /dev/null +++ b/src/notebooks/deepnote/importClient.node.ts @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { workspace } from 'vscode'; +import { logger } from '../../platform/logging'; +import * as https from 'https'; +import fetch from 'node-fetch'; + +/** + * Response from the import initialization endpoint + */ +export interface InitImportResponse { + importId: string; + uploadUrl: string; + expiresAt: string; +} + +/** + * Error response from the API + */ +export interface ApiError { + message: string; + statusCode: number; +} + +/** + * Maximum file size for uploads (100MB) + */ +export const MAX_FILE_SIZE = 100 * 1024 * 1024; + +/** + * Gets the API endpoint from configuration + */ +function getApiEndpoint(): string { + const config = workspace.getConfiguration('deepnote'); + return config.get('apiEndpoint', 'https://api.deepnote.com'); +} + +/** + * Checks if SSL verification should be disabled + */ +function shouldDisableSSLVerification(): boolean { + const config = workspace.getConfiguration('deepnote'); + return config.get('disableSSLVerification', false); +} + +/** + * Creates an HTTPS agent with optional SSL verification disabled + */ +function createHttpsAgent(): https.Agent | undefined { + if (shouldDisableSSLVerification()) { + logger.warn('SSL certificate verification is disabled. This should only be used in development.'); + return new https.Agent({ + rejectUnauthorized: false + }); + } + return undefined; +} + +/** + * Initializes an import by requesting a presigned upload URL + * + * @param fileName - Name of the file to import + * @param fileSize - Size of the file in bytes + * @returns Promise with import ID, upload URL, and expiration time + * @throws ApiError if the request fails + */ +export async function initImport(fileName: string, fileSize: number): Promise { + const apiEndpoint = getApiEndpoint(); + const url = `${apiEndpoint}/v1/import/init`; + + const agent = createHttpsAgent(); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fileName, + fileSize + }), + agent + }); + + if (!response.ok) { + const responseBody = await response.text(); + logger.error(`Init import failed - Status: ${response.status}, URL: ${url}, Body: ${responseBody}`); + + const error: ApiError = { + message: responseBody, + statusCode: response.status + }; + throw error; + } + + return await response.json(); +} + +/** + * Uploads a file to the presigned S3 URL using XMLHttpRequest for progress tracking + * + * @param uploadUrl - Presigned S3 URL for uploading + * @param fileBuffer - File contents as a Buffer + * @param onProgress - Optional callback for upload progress (0-100) + * @returns Promise that resolves when upload is complete + * @throws ApiError if the upload fails + */ +export async function uploadFile( + uploadUrl: string, + fileBuffer: Buffer, + onProgress?: (progress: number) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // Track upload progress + if (onProgress) { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round((event.loaded / event.total) * 100); + onProgress(percentComplete); + } + }); + } + + // Handle completion + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + logger.error(`Upload failed - Status: ${xhr.status}, Response: ${xhr.responseText}, URL: ${uploadUrl}`); + const error: ApiError = { + message: xhr.responseText || 'Upload failed', + statusCode: xhr.status + }; + reject(error); + } + }); + + // Handle errors + xhr.addEventListener('error', () => { + logger.error(`Network error during upload to: ${uploadUrl}`); + const error: ApiError = { + message: 'Network error during upload', + statusCode: 0 + }; + reject(error); + }); + + xhr.addEventListener('abort', () => { + const error: ApiError = { + message: 'Upload aborted', + statusCode: 0 + }; + reject(error); + }); + + // Start upload + xhr.open('PUT', uploadUrl); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + + // Convert Buffer to Uint8Array then Blob for XMLHttpRequest + const uint8Array = new Uint8Array( + fileBuffer.buffer as ArrayBuffer, + fileBuffer.byteOffset, + fileBuffer.byteLength + ); + const blob = new Blob([uint8Array], { type: 'application/octet-stream' }); + xhr.send(blob); + }); +} + +/** + * Gets a user-friendly error message for an API error + * Logs the full error details for debugging + * + * @param error - The error object + * @returns A user-friendly error message + */ +export function getErrorMessage(error: unknown): string { + // Log the full error details for debugging + logger.error('Import error details:', error); + + if (typeof error === 'object' && error !== null && 'statusCode' in error) { + const apiError = error as ApiError; + + // Log API error specifics + logger.error(`API Error - Status: ${apiError.statusCode}, Message: ${apiError.message}`); + + // Handle rate limiting specifically + if (apiError.statusCode === 429) { + return 'Too many requests. Please try again in a few minutes.'; + } + + // All other API errors return the message from the server + if (apiError.statusCode >= 400) { + return apiError.message || 'An error occurred. Please try again.'; + } + } + + if (error instanceof Error) { + logger.error(`Error message: ${error.message}`, error.stack); + if (error.message.includes('fetch') || error.message.includes('Network')) { + return 'Failed to connect. Check your connection and try again.'; + } + return error.message; + } + + logger.error('Unknown error type:', typeof error, error); + return 'An unknown error occurred'; +} diff --git a/src/notebooks/deepnote/importClient.unit.test.ts b/src/notebooks/deepnote/importClient.unit.test.ts new file mode 100644 index 0000000000..cd6cab2de0 --- /dev/null +++ b/src/notebooks/deepnote/importClient.unit.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { getErrorMessage, MAX_FILE_SIZE, ApiError } from './importClient.node'; + +suite('ImportClient', () => { + suite('getErrorMessage', () => { + test('should return rate limit error for 429 status', () => { + const error: ApiError = { + message: 'Too many requests', + statusCode: 429 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Too many requests. Please try again in a few minutes.'); + }); + + test('should return server message for 400 status', () => { + const error: ApiError = { + message: 'Bad request', + statusCode: 400 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Bad request'); + }); + + test('should return server message for 401 status', () => { + const error: ApiError = { + message: 'Unauthorized', + statusCode: 401 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Unauthorized'); + }); + + test('should return server message for 403 status', () => { + const error: ApiError = { + message: 'Forbidden', + statusCode: 403 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Forbidden'); + }); + + test('should return server message for 413 status', () => { + const error: ApiError = { + message: 'File too large', + statusCode: 413 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'File too large'); + }); + + test('should return server message for 500 status', () => { + const error: ApiError = { + message: 'Internal server error', + statusCode: 500 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Internal server error'); + }); + + test('should return server message for 502 status', () => { + const error: ApiError = { + message: 'Bad gateway', + statusCode: 502 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Bad gateway'); + }); + + test('should return server message for 503 status', () => { + const error: ApiError = { + message: 'Service unavailable', + statusCode: 503 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Service unavailable'); + }); + + test('should return server message for 504 status', () => { + const error: ApiError = { + message: 'Gateway timeout', + statusCode: 504 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Gateway timeout'); + }); + + test('should return default message when no message provided', () => { + const error: ApiError = { + message: '', + statusCode: 400 + }; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'An error occurred. Please try again.'); + }); + + test('should return generic error for Error without fetch message', () => { + const error = new Error('Something went wrong'); + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Something went wrong'); + }); + + test('should return connection error for Error with fetch message', () => { + const error = new Error('fetch failed'); + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Failed to connect. Check your connection and try again.'); + }); + + test('should return connection error for Error with Network message', () => { + const error = new Error('Network error occurred'); + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'Failed to connect. Check your connection and try again.'); + }); + + test('should return unknown error for non-Error objects', () => { + const error = 'string error'; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'An unknown error occurred'); + }); + + test('should return unknown error for null', () => { + const error = null; + + const result = getErrorMessage(error); + + assert.strictEqual(result, 'An unknown error occurred'); + }); + }); + + suite('MAX_FILE_SIZE', () => { + test('should be 100MB', () => { + assert.strictEqual(MAX_FILE_SIZE, 100 * 1024 * 1024); + }); + }); + + // Note: initImport and uploadFile tests would require mocking fetch + // which is beyond the scope of a simple unit test suite. + // These would typically be tested with integration tests or by mocking + // the global fetch function. +}); diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts new file mode 100644 index 0000000000..beb1b48a72 --- /dev/null +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { injectable, inject } from 'inversify'; +import { commands, window, Uri, env, l10n } from 'vscode'; +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IExtensionContext } from '../../platform/common/types'; +import { Commands } from '../../platform/common/constants'; +import { logger } from '../../platform/logging'; +import * as fs from 'fs'; +import * as path from '../../platform/vscode-path/path'; +import { initImport, uploadFile, getErrorMessage, MAX_FILE_SIZE } from './importClient.node'; + +/** + * Handler for the "Open in Deepnote" command + * Uploads .deepnote files to Deepnote and opens them in the web app + */ +@injectable() +export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + /** + * Activates the handler by registering the command + */ + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.OpenInDeepnote, () => this.handleOpenInDeepnote()) + ); + } + + /** + * Main handler for the Open in Deepnote command + */ + private async handleOpenInDeepnote(): Promise { + try { + // Get the active editor + const activeEditor = window.activeTextEditor; + if (!activeEditor) { + void window.showErrorMessage('Please open a .deepnote file first'); + return; + } + + const fileUri = activeEditor.document.uri; + + // Validate that it's a .deepnote file + if (!fileUri.fsPath.endsWith('.deepnote')) { + void window.showErrorMessage('This command only works with .deepnote files'); + return; + } + + // Ensure the file is saved + if (activeEditor.document.isDirty) { + const saved = await activeEditor.document.save(); + if (!saved) { + void window.showErrorMessage('Please save the file before opening in Deepnote'); + return; + } + } + + // Read the file + const filePath = fileUri.fsPath; + const fileName = path.basename(filePath); + + logger.info(`Opening in Deepnote: ${fileName}`); + + // Check file size + const stats = await fs.promises.stat(filePath); + if (stats.size > MAX_FILE_SIZE) { + void window.showErrorMessage(`File exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`); + return; + } + + // Read file into buffer + const fileBuffer = await fs.promises.readFile(filePath); + + // Show progress + await window.withProgress( + { + location: { viewId: 'workbench.view.extension.deepnoteExplorer' }, + title: l10n.t('Opening in Deepnote'), + cancellable: false + }, + async (progress) => { + try { + // Step 1: Initialize import + progress.report({ message: l10n.t('Preparing upload...') }); + logger.debug(`Initializing import for ${fileName} (${stats.size} bytes)`); + + const initResponse = await initImport(fileName, stats.size); + logger.debug(`Import initialized: ${initResponse.importId}`); + + // Step 2: Upload file + progress.report({ message: l10n.t('Uploading file...') }); + await uploadFile(initResponse.uploadUrl, fileBuffer, (uploadProgress) => { + progress.report({ + message: l10n.t('Uploading file... {0}%', uploadProgress.toString()) + }); + }); + logger.debug('File uploaded successfully'); + + // Step 3: Open in browser + progress.report({ message: l10n.t('Opening in Deepnote...') }); + const deepnoteUrl = `https://deepnote.com/import?id=${initResponse.importId}`; + await env.openExternal(Uri.parse(deepnoteUrl)); + + void window.showInformationMessage('Opening in Deepnote...'); + logger.info('Successfully opened file in Deepnote'); + } catch (error) { + logger.error('Failed to open in Deepnote', error); + const errorMessage = getErrorMessage(error); + void window.showErrorMessage(`Failed to open in Deepnote: ${errorMessage}`); + } + } + ); + } catch (error) { + logger.error('Error in handleOpenInDeepnote', error); + const errorMessage = getErrorMessage(error); + void window.showErrorMessage(`Failed to open in Deepnote: ${errorMessage}`); + } + } +} diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts new file mode 100644 index 0000000000..1ee78190e0 --- /dev/null +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.unit.test.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { OpenInDeepnoteHandler } from './openInDeepnoteHandler.node'; + +suite('OpenInDeepnoteHandler', () => { + suite('activate', () => { + test('should be instantiable', () => { + // This test verifies that the class can be instantiated + // Full testing would require mocking the IExtensionContext + // and VSCode APIs, which is typically done in integration tests + assert.ok(OpenInDeepnoteHandler); + }); + }); + + // Note: Full testing of handleOpenInDeepnote would require: + // 1. Mocking window.activeTextEditor + // 2. Mocking file system operations + // 3. Mocking the importClient API calls + // 4. Mocking window.withProgress + // 5. Mocking env.openExternal + // + // These are better suited for integration tests rather than unit tests. + // The core logic is tested through the importClient tests. +}); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 333213be4c..bf9ba6186b 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -70,6 +70,7 @@ import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookComm import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; import { SqlIntegrationStartupCodeProvider } from './deepnote/integrations/sqlIntegrationStartupCodeProvider'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; +import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -163,6 +164,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteCellCopyHandler ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + OpenInDeepnoteHandler + ); // Deepnote kernel services serviceManager.addSingleton(IDeepnoteToolkitInstaller, DeepnoteToolkitInstaller); diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index c6010d716f..f98a36e85f 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -236,6 +236,7 @@ export namespace Commands { export const AddInputDateRangeBlock = 'deepnote.addInputDateRangeBlock'; export const AddInputFileBlock = 'deepnote.addInputFileBlock'; export const AddButtonBlock = 'deepnote.addButtonBlock'; + export const OpenInDeepnote = 'deepnote.openInDeepnote'; export const ExportAsPythonScript = 'jupyter.exportAsPythonScript'; export const ExportToHTML = 'jupyter.exportToHTML'; export const ExportToPDF = 'jupyter.exportToPDF'; From aa755bd3cbe4dd7a87a5367e9ebb932cfed27536 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Wed, 29 Oct 2025 17:04:40 +0200 Subject: [PATCH 2/8] feat: disable ssl checks settings option --- src/notebooks/deepnote/importClient.node.ts | 176 +++++++++++--------- 1 file changed, 96 insertions(+), 80 deletions(-) diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts index e3bb34ebaa..3f3259c9e4 100644 --- a/src/notebooks/deepnote/importClient.node.ts +++ b/src/notebooks/deepnote/importClient.node.ts @@ -50,9 +50,16 @@ function shouldDisableSSLVerification(): boolean { function createHttpsAgent(): https.Agent | undefined { if (shouldDisableSSLVerification()) { logger.warn('SSL certificate verification is disabled. This should only be used in development.'); - return new https.Agent({ - rejectUnauthorized: false - }); + // Create agent with options that bypass both certificate and hostname verification + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agentOptions: any = { + rejectUnauthorized: false, + checkServerIdentity: () => { + // Return undefined to indicate the check passed + return undefined; + } + }; + return new https.Agent(agentOptions); } return undefined; } @@ -69,35 +76,74 @@ export async function initImport(fileName: string, fileSize: number): Promise; + body: string; + agent?: https.Agent; + } + + const options: FetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fileName, + fileSize + }) }; - throw error; - } - return await response.json(); + if (agent) { + options.agent = agent; + logger.debug('Agent attached to request'); + logger.debug(`Options agent set: ${!!options.agent}`); + } + + const response = await fetch(url, options); + + if (!response.ok) { + const responseBody = await response.text(); + logger.error(`Init import failed - Status: ${response.status}, URL: ${url}, Body: ${responseBody}`); + + const error: ApiError = { + message: responseBody, + statusCode: response.status + }; + throw error; + } + + return await response.json(); + } finally { + // Restore original SSL verification setting + if (shouldDisableSSLVerification()) { + if (originalEnvValue === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalEnvValue; + } + } + } } /** - * Uploads a file to the presigned S3 URL using XMLHttpRequest for progress tracking + * Uploads a file to the presigned S3 URL using node-fetch * * @param uploadUrl - Presigned S3 URL for uploading * @param fileBuffer - File contents as a Buffer @@ -110,64 +156,34 @@ export async function uploadFile( fileBuffer: Buffer, onProgress?: (progress: number) => void ): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - // Track upload progress - if (onProgress) { - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const percentComplete = Math.round((event.loaded / event.total) * 100); - onProgress(percentComplete); - } - }); - } + // Note: Progress tracking is limited in Node.js without additional libraries + // For now, we'll report 50% at start and 100% at completion + if (onProgress) { + onProgress(50); + } - // Handle completion - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - logger.error(`Upload failed - Status: ${xhr.status}, Response: ${xhr.responseText}, URL: ${uploadUrl}`); - const error: ApiError = { - message: xhr.responseText || 'Upload failed', - statusCode: xhr.status - }; - reject(error); - } - }); + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': fileBuffer.length.toString() + }, + body: fileBuffer + }); - // Handle errors - xhr.addEventListener('error', () => { - logger.error(`Network error during upload to: ${uploadUrl}`); - const error: ApiError = { - message: 'Network error during upload', - statusCode: 0 - }; - reject(error); - }); + if (!response.ok) { + const responseText = await response.text(); + logger.error(`Upload failed - Status: ${response.status}, Response: ${responseText}, URL: ${uploadUrl}`); + const error: ApiError = { + message: responseText || 'Upload failed', + statusCode: response.status + }; + throw error; + } - xhr.addEventListener('abort', () => { - const error: ApiError = { - message: 'Upload aborted', - statusCode: 0 - }; - reject(error); - }); - - // Start upload - xhr.open('PUT', uploadUrl); - xhr.setRequestHeader('Content-Type', 'application/octet-stream'); - - // Convert Buffer to Uint8Array then Blob for XMLHttpRequest - const uint8Array = new Uint8Array( - fileBuffer.buffer as ArrayBuffer, - fileBuffer.byteOffset, - fileBuffer.byteLength - ); - const blob = new Blob([uint8Array], { type: 'application/octet-stream' }); - xhr.send(blob); - }); + if (onProgress) { + onProgress(100); + } } /** From b765a767dbdc9475bfe7d404e06dc36d00a67310 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Wed, 29 Oct 2025 18:05:48 +0200 Subject: [PATCH 3/8] feat: rm disabling ssl checks option --- src/notebooks/deepnote/importClient.node.ts | 108 ++++---------------- 1 file changed, 19 insertions(+), 89 deletions(-) diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts index 3f3259c9e4..3a9fb0feba 100644 --- a/src/notebooks/deepnote/importClient.node.ts +++ b/src/notebooks/deepnote/importClient.node.ts @@ -3,7 +3,6 @@ import { workspace } from 'vscode'; import { logger } from '../../platform/logging'; -import * as https from 'https'; import fetch from 'node-fetch'; /** @@ -36,34 +35,6 @@ function getApiEndpoint(): string { return config.get('apiEndpoint', 'https://api.deepnote.com'); } -/** - * Checks if SSL verification should be disabled - */ -function shouldDisableSSLVerification(): boolean { - const config = workspace.getConfiguration('deepnote'); - return config.get('disableSSLVerification', false); -} - -/** - * Creates an HTTPS agent with optional SSL verification disabled - */ -function createHttpsAgent(): https.Agent | undefined { - if (shouldDisableSSLVerification()) { - logger.warn('SSL certificate verification is disabled. This should only be used in development.'); - // Create agent with options that bypass both certificate and hostname verification - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const agentOptions: any = { - rejectUnauthorized: false, - checkServerIdentity: () => { - // Return undefined to indicate the check passed - return undefined; - } - }; - return new https.Agent(agentOptions); - } - return undefined; -} - /** * Initializes an import by requesting a presigned upload URL * @@ -76,70 +47,29 @@ export async function initImport(fileName: string, fileSize: number): Promise; - body: string; - agent?: https.Agent; - } + if (!response.ok) { + const responseBody = await response.text(); + logger.error(`Init import failed - Status: ${response.status}, URL: ${url}, Body: ${responseBody}`); - const options: FetchOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - fileName, - fileSize - }) + const error: ApiError = { + message: responseBody, + statusCode: response.status }; - - if (agent) { - options.agent = agent; - logger.debug('Agent attached to request'); - logger.debug(`Options agent set: ${!!options.agent}`); - } - - const response = await fetch(url, options); - - if (!response.ok) { - const responseBody = await response.text(); - logger.error(`Init import failed - Status: ${response.status}, URL: ${url}, Body: ${responseBody}`); - - const error: ApiError = { - message: responseBody, - statusCode: response.status - }; - throw error; - } - - return await response.json(); - } finally { - // Restore original SSL verification setting - if (shouldDisableSSLVerification()) { - if (originalEnvValue === undefined) { - delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } else { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalEnvValue; - } - } + throw error; } + + return await response.json(); } /** From 63cc72aeeb8ad9d61f73f0b992b2780ee5ae88c5 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Thu, 30 Oct 2025 15:17:50 +0200 Subject: [PATCH 4/8] feat: tweak config --- src/notebooks/deepnote/importClient.node.ts | 19 +++++++++++++++++-- .../deepnote/openInDeepnoteHandler.node.ts | 5 +++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts index 3a9fb0feba..9dcbf465b9 100644 --- a/src/notebooks/deepnote/importClient.node.ts +++ b/src/notebooks/deepnote/importClient.node.ts @@ -27,12 +27,20 @@ export interface ApiError { */ export const MAX_FILE_SIZE = 100 * 1024 * 1024; +/** + * Gets the Deepnote domain from configuration + */ +function getDomain(): string { + const config = workspace.getConfiguration('deepnote'); + return config.get('domain', 'deepnote.com'); +} + /** * Gets the API endpoint from configuration */ function getApiEndpoint(): string { - const config = workspace.getConfiguration('deepnote'); - return config.get('apiEndpoint', 'https://api.deepnote.com'); + const domain = getDomain(); + return `https://api.${domain}`; } /** @@ -155,3 +163,10 @@ export function getErrorMessage(error: unknown): string { logger.error('Unknown error type:', typeof error, error); return 'An unknown error occurred'; } + +/** + * Gets the Deepnote domain from configuration for building launch URLs + */ +export function getDeepnoteDomain(): string { + return getDomain(); +} diff --git a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts index beb1b48a72..b5c0e8a89a 100644 --- a/src/notebooks/deepnote/openInDeepnoteHandler.node.ts +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts @@ -9,7 +9,7 @@ import { Commands } from '../../platform/common/constants'; import { logger } from '../../platform/logging'; import * as fs from 'fs'; import * as path from '../../platform/vscode-path/path'; -import { initImport, uploadFile, getErrorMessage, MAX_FILE_SIZE } from './importClient.node'; +import { initImport, uploadFile, getErrorMessage, MAX_FILE_SIZE, getDeepnoteDomain } from './importClient.node'; /** * Handler for the "Open in Deepnote" command @@ -100,7 +100,8 @@ export class OpenInDeepnoteHandler implements IExtensionSyncActivationService { // Step 3: Open in browser progress.report({ message: l10n.t('Opening in Deepnote...') }); - const deepnoteUrl = `https://deepnote.com/import?id=${initResponse.importId}`; + const domain = getDeepnoteDomain(); + const deepnoteUrl = `https://${domain}/launch?importId=${initResponse.importId}`; await env.openExternal(Uri.parse(deepnoteUrl)); void window.showInformationMessage('Opening in Deepnote...'); From cf52b472f68b7e773496c64044a6251eea886d78 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Thu, 30 Oct 2025 15:55:53 +0200 Subject: [PATCH 5/8] feat: config name --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4f16a8b7cc..758f0b8661 100644 --- a/package.json +++ b/package.json @@ -1397,10 +1397,10 @@ "type": "object", "title": "Deepnote", "properties": { - "deepnote.apiEndpoint": { + "deepnote.domain": { "type": "string", - "default": "https://deepnote.com", - "description": "Deepnote API endpoint", + "default": "deepnote.com", + "description": "Deepnote domain (e.g., 'deepnote.com' or 'ra-18838.deepnote-staging.com')", "scope": "application" }, "deepnote.disableSSLVerification": { From 3ea261872e13db090d9d23c868595b7dabc851e4 Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Thu, 30 Oct 2025 16:35:46 +0200 Subject: [PATCH 6/8] fix: create new deepnote file with the correct attributes --- src/notebooks/deepnote/deepnoteExplorerView.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 07809e0876..9720f842d2 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -216,7 +216,7 @@ export class DeepnoteExplorerView { const firstBlock = { blockGroup: generateUuid(), content: '', - executionCount: null, + executionCount: 0, id: generateUuid(), metadata: {}, outputs: [], @@ -226,8 +226,9 @@ export class DeepnoteExplorerView { }; const projectData = { - version: 1.0, + version: '1.0.0', metadata: { + createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }, project: { From 27cf287890459446a7a809e56d395b22aa7495bc Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Thu, 30 Oct 2025 16:44:36 +0200 Subject: [PATCH 7/8] fix: create new blocks in their own group instead of sharing a single one --- src/notebooks/deepnote/blocks.md | 8 ++++---- src/notebooks/deepnote/deepnoteDataConverter.ts | 3 ++- src/platform/deepnote/pocket.ts | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/notebooks/deepnote/blocks.md b/src/notebooks/deepnote/blocks.md index c39a8c698c..d3db1b63da 100644 --- a/src/notebooks/deepnote/blocks.md +++ b/src/notebooks/deepnote/blocks.md @@ -45,7 +45,7 @@ project: type: 'code' content: "df = pd.DataFrame({'a': [1, 2, 3]})\ndf" sortingKey: '001' - blockGroup: 'default-group' + blockGroup: 'uuid-v4' executionCount: 1 metadata: table_state_spec: '{"pageSize": 25, "pageIndex": 0}' @@ -150,7 +150,7 @@ Example of a cell after pocket conversion: __deepnotePocket: { type: 'code', sortingKey: '001', - blockGroup: 'default-group', + blockGroup: 'uuid-v4', executionCount: 1 } }, @@ -472,7 +472,7 @@ blocks: type: 'big-number' content: '' sortingKey: '001' - blockGroup: 'default-group' + blockGroup: 'uuid-v4' metadata: deepnote_big_number_title: 'Customers' deepnote_big_number_value: 'customers' @@ -517,7 +517,7 @@ When opened in VS Code, the block becomes a cell with JSON content showing the c __deepnotePocket: { type: 'big-number', sortingKey: '001', - blockGroup: 'default-group' + blockGroup: 'uuid-v4' } } } diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index eb26e6d955..8f0df7d2ee 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -27,6 +27,7 @@ import { ButtonBlockConverter } from './converters/inputConverters'; import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants'; +import { generateUuid } from '../../platform/common/uuid'; /** * Utility class for converting between Deepnote block structures and VS Code notebook cells. @@ -168,7 +169,7 @@ export class DeepnoteDataConverter { private createFallbackBlock(cell: NotebookCellData, index: number): DeepnoteBlock { return { - blockGroup: 'default-group', + blockGroup: generateUuid(), id: generateBlockId(), sortingKey: generateSortingKey(index), type: cell.kind === NotebookCellKind.Code ? 'code' : 'markdown', diff --git a/src/platform/deepnote/pocket.ts b/src/platform/deepnote/pocket.ts index d20a25d27a..7848d8bc3d 100644 --- a/src/platform/deepnote/pocket.ts +++ b/src/platform/deepnote/pocket.ts @@ -2,6 +2,7 @@ import type { NotebookCellData } from 'vscode'; import type { DeepnoteBlock } from './deepnoteTypes'; import { generateBlockId, generateSortingKey } from '../../notebooks/deepnote/dataConversionUtils'; +import { generateUuid } from '../common/uuid'; // Note: 'id' is intentionally excluded from this list so it remains at the top level of cell.metadata // The id field is needed at runtime for cell identification during execution @@ -64,7 +65,7 @@ export function createBlockFromPocket(cell: NotebookCellData, index: number): De } const block: DeepnoteBlock = { - blockGroup: pocket?.blockGroup || 'default-group', + blockGroup: pocket?.blockGroup || generateUuid(), content: cell.value, id: cellId || generateBlockId(), metadata, From 21bf3ce9e244fd65ff0b5c51f4c41b0aac8fca5e Mon Sep 17 00:00:00 2001 From: Lukas Saltenas Date: Thu, 30 Oct 2025 17:04:41 +0200 Subject: [PATCH 8/8] chore: fix test --- src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 932991024f..f8b403d17d 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -265,7 +265,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const yamlContent = Buffer.from(capturedContent!).toString('utf8'); const projectData = yaml.load(yamlContent) as any; - expect(projectData.version).to.equal(1.0); + expect(projectData.version).to.equal('1.0.0'); + expect(projectData.metadata.createdAt).to.exist; + expect(projectData.metadata.modifiedAt).to.exist; expect(projectData.project.id).to.equal(projectId); expect(projectData.project.name).to.equal(projectName); expect(projectData.project.notebooks).to.have.lengthOf(1);