Skip to content

Commit 41a2a32

Browse files
authored
feat: open in Deepnote command (#132)
* feat: open in Deepnote command * feat: disable ssl checks settings option * feat: rm disabling ssl checks option * feat: tweak config * feat: config name * fix: create new deepnote file with the correct attributes * fix: create new blocks in their own group instead of sharing a single one * chore: fix test
1 parent 80392e7 commit 41a2a32

13 files changed

+534
-9
lines changed

package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@
9999
"category": "Deepnote",
100100
"icon": "$(plug)"
101101
},
102+
{
103+
"command": "deepnote.openInDeepnote",
104+
"title": "Open in Deepnote",
105+
"category": "Deepnote",
106+
"icon": "$(globe)"
107+
},
102108
{
103109
"command": "deepnote.newProject",
104110
"title": "New project",
@@ -767,6 +773,11 @@
767773
"when": "editorFocus && editorLangId == python && jupyter.hascodecells && !notebookEditorFocused && isWorkspaceTrusted",
768774
"command": "jupyter.exportfileasnotebook",
769775
"group": "Jupyter3@2"
776+
},
777+
{
778+
"when": "resourceExtname == .deepnote",
779+
"command": "deepnote.openInDeepnote",
780+
"group": "navigation"
770781
}
771782
],
772783
"editor.interactiveWindow.context": [
@@ -1437,6 +1448,18 @@
14371448
"type": "object",
14381449
"title": "Deepnote",
14391450
"properties": {
1451+
"deepnote.domain": {
1452+
"type": "string",
1453+
"default": "deepnote.com",
1454+
"description": "Deepnote domain (e.g., 'deepnote.com' or 'ra-18838.deepnote-staging.com')",
1455+
"scope": "application"
1456+
},
1457+
"deepnote.disableSSLVerification": {
1458+
"type": "boolean",
1459+
"default": false,
1460+
"description": "Disable SSL certificate verification (for development only)",
1461+
"scope": "application"
1462+
},
14401463
"jupyter.experiments.enabled": {
14411464
"type": "boolean",
14421465
"default": true,

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,5 @@ export interface ICommandNameArgumentTypeMapping {
198198
[DSCommands.AddInputDateRangeBlock]: [];
199199
[DSCommands.AddInputFileBlock]: [];
200200
[DSCommands.AddButtonBlock]: [];
201+
[DSCommands.OpenInDeepnote]: [];
201202
}

src/notebooks/deepnote/blocks.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ project:
4545
type: 'code'
4646
content: "df = pd.DataFrame({'a': [1, 2, 3]})\ndf"
4747
sortingKey: '001'
48-
blockGroup: 'default-group'
48+
blockGroup: 'uuid-v4'
4949
executionCount: 1
5050
metadata:
5151
table_state_spec: '{"pageSize": 25, "pageIndex": 0}'
@@ -150,7 +150,7 @@ Example of a cell after pocket conversion:
150150
__deepnotePocket: {
151151
type: 'code',
152152
sortingKey: '001',
153-
blockGroup: 'default-group',
153+
blockGroup: 'uuid-v4',
154154
executionCount: 1
155155
}
156156
},
@@ -472,7 +472,7 @@ blocks:
472472
type: 'big-number'
473473
content: ''
474474
sortingKey: '001'
475-
blockGroup: 'default-group'
475+
blockGroup: 'uuid-v4'
476476
metadata:
477477
deepnote_big_number_title: 'Customers'
478478
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
517517
__deepnotePocket: {
518518
type: 'big-number',
519519
sortingKey: '001',
520-
blockGroup: 'default-group'
520+
blockGroup: 'uuid-v4'
521521
}
522522
}
523523
}

src/notebooks/deepnote/deepnoteDataConverter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ButtonBlockConverter
2828
} from './converters/inputConverters';
2929
import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants';
30+
import { generateUuid } from '../../platform/common/uuid';
3031

3132
/**
3233
* Utility class for converting between Deepnote block structures and VS Code notebook cells.
@@ -168,7 +169,7 @@ export class DeepnoteDataConverter {
168169

169170
private createFallbackBlock(cell: NotebookCellData, index: number): DeepnoteBlock {
170171
return {
171-
blockGroup: 'default-group',
172+
blockGroup: generateUuid(),
172173
id: generateBlockId(),
173174
sortingKey: generateSortingKey(index),
174175
type: cell.kind === NotebookCellKind.Code ? 'code' : 'markdown',

src/notebooks/deepnote/deepnoteExplorerView.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export class DeepnoteExplorerView {
216216
const firstBlock = {
217217
blockGroup: generateUuid(),
218218
content: '',
219-
executionCount: null,
219+
executionCount: 0,
220220
id: generateUuid(),
221221
metadata: {},
222222
outputs: [],
@@ -226,8 +226,9 @@ export class DeepnoteExplorerView {
226226
};
227227

228228
const projectData = {
229-
version: 1.0,
229+
version: '1.0.0',
230230
metadata: {
231+
createdAt: new Date().toISOString(),
231232
modifiedAt: new Date().toISOString()
232233
},
233234
project: {

src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => {
265265
const yamlContent = Buffer.from(capturedContent!).toString('utf8');
266266
const projectData = yaml.load(yamlContent) as any;
267267

268-
expect(projectData.version).to.equal(1.0);
268+
expect(projectData.version).to.equal('1.0.0');
269+
expect(projectData.metadata.createdAt).to.exist;
270+
expect(projectData.metadata.modifiedAt).to.exist;
269271
expect(projectData.project.id).to.equal(projectId);
270272
expect(projectData.project.name).to.equal(projectName);
271273
expect(projectData.project.notebooks).to.have.lengthOf(1);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { workspace } from 'vscode';
5+
import { logger } from '../../platform/logging';
6+
import fetch from 'node-fetch';
7+
8+
/**
9+
* Response from the import initialization endpoint
10+
*/
11+
export interface InitImportResponse {
12+
importId: string;
13+
uploadUrl: string;
14+
expiresAt: string;
15+
}
16+
17+
/**
18+
* Error response from the API
19+
*/
20+
export interface ApiError {
21+
message: string;
22+
statusCode: number;
23+
}
24+
25+
/**
26+
* Maximum file size for uploads (100MB)
27+
*/
28+
export const MAX_FILE_SIZE = 100 * 1024 * 1024;
29+
30+
/**
31+
* Gets the Deepnote domain from configuration
32+
*/
33+
function getDomain(): string {
34+
const config = workspace.getConfiguration('deepnote');
35+
return config.get<string>('domain', 'deepnote.com');
36+
}
37+
38+
/**
39+
* Gets the API endpoint from configuration
40+
*/
41+
function getApiEndpoint(): string {
42+
const domain = getDomain();
43+
return `https://api.${domain}`;
44+
}
45+
46+
/**
47+
* Initializes an import by requesting a presigned upload URL
48+
*
49+
* @param fileName - Name of the file to import
50+
* @param fileSize - Size of the file in bytes
51+
* @returns Promise with import ID, upload URL, and expiration time
52+
* @throws ApiError if the request fails
53+
*/
54+
export async function initImport(fileName: string, fileSize: number): Promise<InitImportResponse> {
55+
const apiEndpoint = getApiEndpoint();
56+
const url = `${apiEndpoint}/v1/import/init`;
57+
58+
const response = await fetch(url, {
59+
method: 'POST',
60+
headers: {
61+
'Content-Type': 'application/json'
62+
},
63+
body: JSON.stringify({
64+
fileName,
65+
fileSize
66+
})
67+
});
68+
69+
if (!response.ok) {
70+
const responseBody = await response.text();
71+
logger.error(`Init import failed - Status: ${response.status}, URL: ${url}, Body: ${responseBody}`);
72+
73+
const error: ApiError = {
74+
message: responseBody,
75+
statusCode: response.status
76+
};
77+
throw error;
78+
}
79+
80+
return await response.json();
81+
}
82+
83+
/**
84+
* Uploads a file to the presigned S3 URL using node-fetch
85+
*
86+
* @param uploadUrl - Presigned S3 URL for uploading
87+
* @param fileBuffer - File contents as a Buffer
88+
* @param onProgress - Optional callback for upload progress (0-100)
89+
* @returns Promise that resolves when upload is complete
90+
* @throws ApiError if the upload fails
91+
*/
92+
export async function uploadFile(
93+
uploadUrl: string,
94+
fileBuffer: Buffer,
95+
onProgress?: (progress: number) => void
96+
): Promise<void> {
97+
// Note: Progress tracking is limited in Node.js without additional libraries
98+
// For now, we'll report 50% at start and 100% at completion
99+
if (onProgress) {
100+
onProgress(50);
101+
}
102+
103+
const response = await fetch(uploadUrl, {
104+
method: 'PUT',
105+
headers: {
106+
'Content-Type': 'application/octet-stream',
107+
'Content-Length': fileBuffer.length.toString()
108+
},
109+
body: fileBuffer
110+
});
111+
112+
if (!response.ok) {
113+
const responseText = await response.text();
114+
logger.error(`Upload failed - Status: ${response.status}, Response: ${responseText}, URL: ${uploadUrl}`);
115+
const error: ApiError = {
116+
message: responseText || 'Upload failed',
117+
statusCode: response.status
118+
};
119+
throw error;
120+
}
121+
122+
if (onProgress) {
123+
onProgress(100);
124+
}
125+
}
126+
127+
/**
128+
* Gets a user-friendly error message for an API error
129+
* Logs the full error details for debugging
130+
*
131+
* @param error - The error object
132+
* @returns A user-friendly error message
133+
*/
134+
export function getErrorMessage(error: unknown): string {
135+
// Log the full error details for debugging
136+
logger.error('Import error details:', error);
137+
138+
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
139+
const apiError = error as ApiError;
140+
141+
// Log API error specifics
142+
logger.error(`API Error - Status: ${apiError.statusCode}, Message: ${apiError.message}`);
143+
144+
// Handle rate limiting specifically
145+
if (apiError.statusCode === 429) {
146+
return 'Too many requests. Please try again in a few minutes.';
147+
}
148+
149+
// All other API errors return the message from the server
150+
if (apiError.statusCode >= 400) {
151+
return apiError.message || 'An error occurred. Please try again.';
152+
}
153+
}
154+
155+
if (error instanceof Error) {
156+
logger.error(`Error message: ${error.message}`, error.stack);
157+
if (error.message.includes('fetch') || error.message.includes('Network')) {
158+
return 'Failed to connect. Check your connection and try again.';
159+
}
160+
return error.message;
161+
}
162+
163+
logger.error('Unknown error type:', typeof error, error);
164+
return 'An unknown error occurred';
165+
}
166+
167+
/**
168+
* Gets the Deepnote domain from configuration for building launch URLs
169+
*/
170+
export function getDeepnoteDomain(): string {
171+
return getDomain();
172+
}

0 commit comments

Comments
 (0)