Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,5 @@ export interface ICommandNameArgumentTypeMapping {
[DSCommands.AddInputDateRangeBlock]: [];
[DSCommands.AddInputFileBlock]: [];
[DSCommands.AddButtonBlock]: [];
[DSCommands.OpenInDeepnote]: [];
}
8 changes: 4 additions & 4 deletions src/notebooks/deepnote/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down Expand Up @@ -150,7 +150,7 @@ Example of a cell after pocket conversion:
__deepnotePocket: {
type: 'code',
sortingKey: '001',
blockGroup: 'default-group',
blockGroup: 'uuid-v4',
executionCount: 1
}
},
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/notebooks/deepnote/deepnoteDataConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -168,7 +169,7 @@ export class DeepnoteDataConverter {

private createFallbackBlock(cell: NotebookCellData, index: number): DeepnoteBlock {
return {
blockGroup: 'default-group',
blockGroup: generateUuid(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

id: generateBlockId(),
sortingKey: generateSortingKey(index),
type: cell.kind === NotebookCellKind.Code ? 'code' : 'markdown',
Expand Down
5 changes: 3 additions & 2 deletions src/notebooks/deepnote/deepnoteExplorerView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export class DeepnoteExplorerView {
const firstBlock = {
blockGroup: generateUuid(),
content: '',
executionCount: null,
executionCount: 0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be NULL to signify that the block has not been executed. At least I think that's the reason that the field is nullable.

id: generateUuid(),
metadata: {},
outputs: [],
Expand All @@ -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: {
Expand Down
4 changes: 3 additions & 1 deletion src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
172 changes: 172 additions & 0 deletions src/notebooks/deepnote/importClient.node.ts
Original file line number Diff line number Diff line change
@@ -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<string>('domain', 'deepnote.com');
}

/**
* Gets the API endpoint from configuration
*/
function getApiEndpoint(): string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Is getApiUrl a better name than getApiEndpoint?

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<InitImportResponse> {
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<void> {
// 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();
}
Loading