diff --git a/package.json b/package.json index 7b7ac852ef..32b1271354 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", @@ -767,6 +773,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": [ @@ -1437,6 +1448,18 @@ "type": "object", "title": "Deepnote", "properties": { + "deepnote.domain": { + "type": "string", + "default": "deepnote.com", + "description": "Deepnote domain (e.g., 'deepnote.com' or 'ra-18838.deepnote-staging.com')", + "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 3cbc992794..2114cdb772 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -198,4 +198,5 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.AddInputDateRangeBlock]: []; [DSCommands.AddInputFileBlock]: []; [DSCommands.AddButtonBlock]: []; + [DSCommands.OpenInDeepnote]: []; } 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/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: { 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); diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts new file mode 100644 index 0000000000..9dcbf465b9 --- /dev/null +++ b/src/notebooks/deepnote/importClient.node.ts @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { workspace } from 'vscode'; +import { logger } from '../../platform/logging'; +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 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 domain = getDomain(); + return `https://api.${domain}`; +} + +/** + * 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 response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fileName, + fileSize + }) + }); + + 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 node-fetch + * + * @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 { + // 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); + } + + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': fileBuffer.length.toString() + }, + body: fileBuffer + }); + + 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; + } + + if (onProgress) { + onProgress(100); + } +} + +/** + * 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'; +} + +/** + * Gets the Deepnote domain from configuration for building launch URLs + */ +export function getDeepnoteDomain(): string { + return getDomain(); +} 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..b5c0e8a89a --- /dev/null +++ b/src/notebooks/deepnote/openInDeepnoteHandler.node.ts @@ -0,0 +1,122 @@ +// 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, getDeepnoteDomain } 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 domain = getDeepnoteDomain(); + const deepnoteUrl = `https://${domain}/launch?importId=${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 59e2b63e1f..7100ad0229 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -237,6 +237,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'; 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,