diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index 3f4092fde4..8e1d39d51a 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -69,7 +69,8 @@ export async function resolve(specifier, context, nextResolve) { }; } - // Intercept @deepnote/convert + // Intercept @deepnote/convert - needed because the real package performs file I/O + // that we need to control in tests if (specifier === '@deepnote/convert') { return { url: 'vscode-mock:///deepnote-convert', @@ -306,7 +307,7 @@ export async function load(url, context, nextLoad) { }; } - // Handle deepnote convert mock + // Handle deepnote convert mock - needed because the real package performs file I/O if (moduleName === 'deepnote-convert') { return { format: 'module', @@ -314,6 +315,32 @@ export async function load(url, context, nextLoad) { export const convertIpynbFilesToDeepnoteFile = async () => { // Mock implementation - does nothing in tests }; + + export const convertDeepnoteToJupyterNotebooks = (deepnoteFile) => { + // Mock implementation that converts Deepnote notebooks to Jupyter format + const notebooks = deepnoteFile?.project?.notebooks || []; + return notebooks.map(nb => ({ + filename: nb.name.replace(/[<>:"/\\\\|?*]/g, '_').replace(/\\s+/g, '-') + '.ipynb', + notebook: { + cells: (nb.blocks || []).map(block => ({ + cell_type: block.type === 'markdown' ? 'markdown' : 'code', + source: block.content || '', + metadata: { + deepnote_cell_type: block.type, + cell_id: block.id + }, + outputs: block.outputs || [] + })), + metadata: { + deepnote_notebook_id: nb.id, + deepnote_notebook_name: nb.name, + deepnote_execution_mode: nb.executionMode + }, + nbformat: 4, + nbformat_minor: 5 + } + })); + }; `, shortCircuit: true }; diff --git a/package-lock.json b/package-lock.json index 98d1d7454f..98954586ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@c4312/evt": "^0.1.1", "@deepnote/blocks": "^1.3.5", - "@deepnote/convert": "^1.2.0", + "@deepnote/convert": "^1.3.0", "@deepnote/database-integrations": "^1.3.0", "@enonic/fnv-plus": "^1.3.0", "@jupyter-widgets/base": "^6.0.8", @@ -1385,14 +1385,14 @@ } }, "node_modules/@deepnote/convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-1.2.0.tgz", - "integrity": "sha512-GrmKajsveWPsxGEEodyOG3AXkgk+nNVd3yF+3zJKxpRXsuaU+XWkM9ZCmlwkuj3gCHeVeU5fXFVR2CRrKYc78g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-1.3.0.tgz", + "integrity": "sha512-JtSDfWCj7dAcUcekBBW2zDwTfCRLeQjn/ffFTEYr8vmOmpfeqCBvA2Rm1/kaDzoNbG9vbbDa8Qwk3BT7eaZ+bQ==", "license": "Apache-2.0", "dependencies": { - "@deepnote/blocks": "1.3.5", + "@deepnote/blocks": "1.3.6", "chalk": "^5.6.2", - "cleye": "^1.3.4", + "cleye": "^2.0.0", "ora": "^9.0.0", "uuid": "^13.0.0", "yaml": "^2.8.1" @@ -1401,17 +1401,8 @@ "deepnote-convert": "dist/bin.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/@deepnote/convert/node_modules/@deepnote/blocks": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-1.3.5.tgz", - "integrity": "sha512-xvZiWZbX5ChktvBeDtRzkCbN550T2LIsaPnRZMXKaU3y8pu89wtsFCzqJ6hS7uwVUaJkNF4Z/1THd4N8VqnFtQ==", - "dependencies": { - "ts-dedent": "^2.2.0", - "yaml": "^2.8.1", - "zod": "3.25.76" + "node": ">=22.14.0", + "pnpm": ">=10.17.1" } }, "node_modules/@deepnote/convert/node_modules/chalk": { @@ -1426,14 +1417,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@deepnote/convert/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@deepnote/database-integrations": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@deepnote/database-integrations/-/database-integrations-1.3.0.tgz", @@ -6565,13 +6548,13 @@ } }, "node_modules/cleye": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/cleye/-/cleye-1.3.4.tgz", - "integrity": "sha512-Rd6M8ecBDtdYdPR22h6gG37lPqqJ3hSOaplaGwuGYey9xKmEElOvTgupqfyLSlISshroRpVhYjDtW3vwNUNBaQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cleye/-/cleye-2.0.1.tgz", + "integrity": "sha512-c7dha4cG1fF3TZOkzhRyhEvFxHbdVL5AuQ2+xEBB7dR0Y/mhgmuzIdG60+wRUIPyRCPhF17CDbwP+LZ/83PnOg==", "license": "MIT", "dependencies": { - "terminal-columns": "^1.4.1", - "type-flag": "^3.0.0" + "terminal-columns": "^2.0.0", + "type-flag": "^4.0.1" }, "funding": { "url": "https://github.com/privatenumber/cleye?sponsor=1" @@ -18355,9 +18338,9 @@ } }, "node_modules/terminal-columns": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/terminal-columns/-/terminal-columns-1.4.1.tgz", - "integrity": "sha512-IKVL/itiMy947XWVv4IHV7a0KQXvKjj4ptbi7Ew9MPMcOLzkiQeyx3Gyvh62hKrfJ0RZc4M1nbhzjNM39Kyujw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/terminal-columns/-/terminal-columns-2.0.0.tgz", + "integrity": "sha512-6IByuUjyNZJXUtwDNm+OIe62zgwwaRbH+WMNTcx05O2G5V9WhvluAAHJY8OvUdwmzMPpqAD/7EUpGdI6ae1aiQ==", "license": "MIT", "funding": { "url": "https://github.com/privatenumber/terminal-columns?sponsor=1" @@ -18902,9 +18885,9 @@ } }, "node_modules/type-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/type-flag/-/type-flag-3.0.0.tgz", - "integrity": "sha512-3YaYwMseXCAhBB14RXW5cRQfJQlEknS6i4C8fCfeUdS3ihG9EdccdR9kt3vP73ZdeTGmPb4bZtkDn5XMIn1DLA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/type-flag/-/type-flag-4.0.2.tgz", + "integrity": "sha512-diALGSg9wBpvowixHbIeCJtXNxUKJHUNR8oE67K9zuPkpBe5FMmg+dpevDWmEwGQDmn/RJD3m7szs6Ssdz5A4w==", "license": "MIT", "funding": { "url": "https://github.com/privatenumber/type-flag?sponsor=1" @@ -21730,37 +21713,22 @@ } }, "@deepnote/convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-1.2.0.tgz", - "integrity": "sha512-GrmKajsveWPsxGEEodyOG3AXkgk+nNVd3yF+3zJKxpRXsuaU+XWkM9ZCmlwkuj3gCHeVeU5fXFVR2CRrKYc78g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-1.3.0.tgz", + "integrity": "sha512-JtSDfWCj7dAcUcekBBW2zDwTfCRLeQjn/ffFTEYr8vmOmpfeqCBvA2Rm1/kaDzoNbG9vbbDa8Qwk3BT7eaZ+bQ==", "requires": { - "@deepnote/blocks": "1.3.5", + "@deepnote/blocks": "1.3.6", "chalk": "^5.6.2", - "cleye": "^1.3.4", + "cleye": "^2.0.0", "ora": "^9.0.0", "uuid": "^13.0.0", "yaml": "^2.8.1" }, "dependencies": { - "@deepnote/blocks": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-1.3.5.tgz", - "integrity": "sha512-xvZiWZbX5ChktvBeDtRzkCbN550T2LIsaPnRZMXKaU3y8pu89wtsFCzqJ6hS7uwVUaJkNF4Z/1THd4N8VqnFtQ==", - "requires": { - "ts-dedent": "^2.2.0", - "yaml": "^2.8.1", - "zod": "3.25.76" - } - }, "chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" - }, - "zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } } }, @@ -25481,12 +25449,12 @@ } }, "cleye": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/cleye/-/cleye-1.3.4.tgz", - "integrity": "sha512-Rd6M8ecBDtdYdPR22h6gG37lPqqJ3hSOaplaGwuGYey9xKmEElOvTgupqfyLSlISshroRpVhYjDtW3vwNUNBaQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cleye/-/cleye-2.0.1.tgz", + "integrity": "sha512-c7dha4cG1fF3TZOkzhRyhEvFxHbdVL5AuQ2+xEBB7dR0Y/mhgmuzIdG60+wRUIPyRCPhF17CDbwP+LZ/83PnOg==", "requires": { - "terminal-columns": "^1.4.1", - "type-flag": "^3.0.0" + "terminal-columns": "^2.0.0", + "type-flag": "^4.0.1" } }, "cli-cursor": { @@ -34106,9 +34074,9 @@ } }, "terminal-columns": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/terminal-columns/-/terminal-columns-1.4.1.tgz", - "integrity": "sha512-IKVL/itiMy947XWVv4IHV7a0KQXvKjj4ptbi7Ew9MPMcOLzkiQeyx3Gyvh62hKrfJ0RZc4M1nbhzjNM39Kyujw==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/terminal-columns/-/terminal-columns-2.0.0.tgz", + "integrity": "sha512-6IByuUjyNZJXUtwDNm+OIe62zgwwaRbH+WMNTcx05O2G5V9WhvluAAHJY8OvUdwmzMPpqAD/7EUpGdI6ae1aiQ==" }, "test-exclude": { "version": "6.0.0", @@ -34517,9 +34485,9 @@ "dev": true }, "type-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/type-flag/-/type-flag-3.0.0.tgz", - "integrity": "sha512-3YaYwMseXCAhBB14RXW5cRQfJQlEknS6i4C8fCfeUdS3ihG9EdccdR9kt3vP73ZdeTGmPb4bZtkDn5XMIn1DLA==" + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/type-flag/-/type-flag-4.0.2.tgz", + "integrity": "sha512-diALGSg9wBpvowixHbIeCJtXNxUKJHUNR8oE67K9zuPkpBe5FMmg+dpevDWmEwGQDmn/RJD3m7szs6Ssdz5A4w==" }, "type-is": { "version": "2.0.1", diff --git a/package.json b/package.json index cabf47c266..a665c65d6b 100644 --- a/package.json +++ b/package.json @@ -279,6 +279,18 @@ "category": "Deepnote", "icon": "$(add)" }, + { + "command": "deepnote.exportProject", + "title": "%deepnote.commands.exportProject.title%", + "category": "Deepnote", + "icon": "$(export)" + }, + { + "command": "deepnote.exportNotebook", + "title": "%deepnote.commands.exportNotebook.title%", + "category": "Deepnote", + "icon": "$(export)" + }, { "command": "dataScience.ClearCache", "title": "%deepnote.command.dataScience.clearCache.title%", @@ -1569,12 +1581,17 @@ { "command": "deepnote.addNotebookToProject", "when": "view == deepnoteExplorer && viewItem == projectFile", - "group": "1_project@1" + "group": "1_add@1" }, { "command": "deepnote.renameProject", "when": "view == deepnoteExplorer && viewItem == projectFile", - "group": "2_edit@1" + "group": "2_manage@1" + }, + { + "command": "deepnote.exportProject", + "when": "view == deepnoteExplorer && viewItem == projectFile", + "group": "2_manage@2" }, { "command": "deepnote.deleteProject", @@ -1584,12 +1601,17 @@ { "command": "deepnote.renameNotebook", "when": "view == deepnoteExplorer && viewItem == notebook", - "group": "2_edit@1" + "group": "1_edit@1" }, { "command": "deepnote.duplicateNotebook", "when": "view == deepnoteExplorer && viewItem == notebook", - "group": "2_edit@2" + "group": "1_edit@2" + }, + { + "command": "deepnote.exportNotebook", + "when": "view == deepnoteExplorer && viewItem == notebook", + "group": "2_export@1" }, { "command": "deepnote.deleteNotebook", @@ -2480,7 +2502,7 @@ "dependencies": { "@c4312/evt": "^0.1.1", "@deepnote/blocks": "^1.3.5", - "@deepnote/convert": "^1.2.0", + "@deepnote/convert": "^1.3.0", "@deepnote/database-integrations": "^1.3.0", "@enonic/fnv-plus": "^1.3.0", "@jupyter-widgets/base": "^6.0.8", diff --git a/package.nls.json b/package.nls.json index 5817dea8b2..5142dcc5f8 100644 --- a/package.nls.json +++ b/package.nls.json @@ -273,6 +273,8 @@ "deepnote.commands.deleteNotebook.title": "Delete Notebook", "deepnote.commands.duplicateNotebook.title": "Duplicate Notebook", "deepnote.commands.addNotebookToProject.title": "Add Notebook", + "deepnote.commands.exportProject.title": "Export Project...", + "deepnote.commands.exportNotebook.title": "Export Notebook...", "deepnote.views.explorer.name": "Explorer", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", "deepnote.views.environments.name": "Environments", diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 4f9ad8af51..ec87e1aedc 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -2,7 +2,7 @@ import { injectable, inject } from 'inversify'; import { commands, window, workspace, type TreeView, Uri, l10n } from 'vscode'; import * as yaml from 'js-yaml'; import { DeepnoteBlock, DeepnoteFile } from '@deepnote/blocks'; -import { convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; +import { convertDeepnoteToJupyterNotebooks, convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; import { IExtensionContext } from '../../platform/common/types'; import { IDeepnoteNotebookManager } from '../types'; @@ -398,6 +398,18 @@ export class DeepnoteExplorerView { this.addNotebookToProject(treeItem) ) ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.ExportProject, (treeItem: DeepnoteTreeItem) => + this.exportProject(treeItem) + ) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.ExportNotebook, (treeItem: DeepnoteTreeItem) => + this.exportNotebook(treeItem) + ) + ); } /** @@ -966,4 +978,184 @@ export class DeepnoteExplorerView { await window.showErrorMessage(l10n.t('Failed to add notebook: {0}', errorMessage)); } } + + /** + * Exports all notebooks from a Deepnote project to Jupyter format + * @param treeItem The tree item representing a project + */ + private async exportProject(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return; + } + + try { + const format = await window.showQuickPick([{ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }], { + placeHolder: l10n.t('Select export format') + }); + + if (!format) { + return; + } + + const fileUri = Uri.file(treeItem.context.filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + + return; + } + + const outputFolder = await window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: l10n.t('Export Here'), + title: l10n.t('Select Export Location') + }); + + if (!outputFolder?.length) { + return; + } + + const jupyterNotebooks = convertDeepnoteToJupyterNotebooks(projectData); + + // Check for existing files before writing + const existingFiles: string[] = []; + for (const { filename } of jupyterNotebooks) { + const outputPath = Uri.joinPath(outputFolder[0], filename); + try { + await workspace.fs.stat(outputPath); + existingFiles.push(filename); + } catch { + // File doesn't exist, safe to write + } + } + + if (existingFiles.length > 0) { + const fileList = existingFiles.join(', '); + const overwrite = l10n.t('Overwrite'); + const result = await window.showWarningMessage( + l10n.t('The following files already exist: {0}. Do you want to overwrite them?', fileList), + { modal: true }, + overwrite + ); + + if (result !== overwrite) { + return; + } + } + + for (const { filename, notebook } of jupyterNotebooks) { + const outputPath = Uri.joinPath(outputFolder[0], filename); + + await workspace.fs.writeFile(outputPath, new TextEncoder().encode(JSON.stringify(notebook, null, 2))); + } + + const count = jupyterNotebooks.length; + const message = + count === 1 + ? l10n.t('Exported 1 notebook successfully') + : l10n.t('Exported {0} notebooks successfully', count); + + await window.showInformationMessage(message); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to export: {0}', errorMessage)); + } + } + + /** + * Exports a single notebook from a Deepnote project to Jupyter format + * @param treeItem The tree item representing a notebook + */ + private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + return; + } + + try { + const format = await window.showQuickPick([{ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }], { + placeHolder: l10n.t('Select export format') + }); + + if (!format) { + return; + } + + const fileUri = Uri.file(treeItem.context.filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + + return; + } + + const outputFolder = await window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: l10n.t('Export Here'), + title: l10n.t('Select Export Location') + }); + + if (!outputFolder?.length) { + return; + } + + const targetNotebook = projectData.project.notebooks.find((nb) => nb.id === treeItem.context.notebookId); + + if (!targetNotebook) { + await window.showErrorMessage(l10n.t('Notebook not found')); + + return; + } + + const filteredProject = { + ...projectData, + project: { + ...projectData.project, + notebooks: [targetNotebook] + } + }; + + const [notebookToExport] = convertDeepnoteToJupyterNotebooks(filteredProject); + const outputPath = Uri.joinPath(outputFolder[0], notebookToExport.filename); + + let fileExists = false; + try { + await workspace.fs.stat(outputPath); + fileExists = true; + } catch { + // File doesn't exist, safe to write + } + + if (fileExists) { + const overwrite = l10n.t('Overwrite'); + const result = await window.showWarningMessage( + l10n.t( + 'A file named "{0}" already exists. Do you want to overwrite it?', + notebookToExport.filename + ), + { modal: true }, + overwrite + ); + + if (result !== overwrite) { + return; + } + } + + await workspace.fs.writeFile( + outputPath, + new TextEncoder().encode(JSON.stringify(notebookToExport.notebook, null, 2)) + ); + + await window.showInformationMessage(l10n.t('Exported 1 notebook successfully')); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to export: {0}', errorMessage)); + } + } } diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index c474fc22ee..0c7beff4eb 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -1771,4 +1771,757 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { verify(mockFS.writeFile(anything(), anything())).never(); }); }); + + suite('exportProject', () => { + test('should return early if user cancels format selection', async () => { + resetVSCodeMocks(); + + const mockFS = mock(); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // User cancels format selection + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify no file operations occurred + verify(mockFS.writeFile(anything(), anything())).never(); + verify(mockFS.readFile(anything())).never(); + }); + + test('should return early if user cancels folder selection', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // User selects format but cancels folder selection + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should show error for invalid Deepnote file format', async () => { + resetVSCodeMocks(); + + // Invalid project data (no project property) + const invalidData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(invalidData)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + + test('should export all notebooks when triggered from project', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [ + { id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, + { id: 'nb-2', name: 'Notebook 2', blocks: [], executionMode: 'block' } + ] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let writeCount = 0; + when(mockFS.writeFile(anything(), anything())).thenCall(() => { + writeCount++; + return Promise.resolve(); + }); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify both notebooks were exported + assert.strictEqual(writeCount, 2); + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('should write correct Jupyter notebook JSON format', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [ + { + id: 'nb-1', + name: 'Test Notebook', + blocks: [{ id: 'block-1', type: 'code', content: 'print("hello")', sortingKey: '0' }], + executionMode: 'block' + } + ] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let capturedContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedContent = content; + return Promise.resolve(); + }); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify the exported content is valid Jupyter notebook JSON + assert.isDefined(capturedContent); + const notebook = JSON.parse(Buffer.from(capturedContent!).toString('utf8')); + + assert.isDefined(notebook.cells); + assert.isDefined(notebook.metadata); + assert.strictEqual(notebook.metadata.deepnote_notebook_id, 'nb-1'); + assert.strictEqual(notebook.metadata.deepnote_notebook_name, 'Test Notebook'); + }); + + test('should use correct output path with Uri.joinPath', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'My Notebook', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let capturedUri: Uri | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri) => { + capturedUri = uri; + return Promise.resolve(); + }); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify the output path is correctly constructed + assert.isDefined(capturedUri); + assert.isTrue(capturedUri!.fsPath.startsWith('/output/folder')); + assert.isTrue(capturedUri!.fsPath.endsWith('.ipynb')); + }); + + test('should handle export errors gracefully', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + // Simulate write error + when(mockFS.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + + test('should prompt for overwrite when files already exist and cancel if declined', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [ + { id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, + { id: 'nb-2', name: 'Notebook 2', blocks: [], executionMode: 'block' } + ] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + // Files exist - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + // User cancels overwrite + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify warning message was shown about files existing + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); + // Verify no files were written + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should overwrite files when user confirms', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + // File exists - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + // User confirms overwrite + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Overwrite') as any + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let writeCount = 0; + when(mockFS.writeFile(anything(), anything())).thenCall(() => { + writeCount++; + return Promise.resolve(); + }); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id' + } + }; + + await (explorerView as any).exportProject(treeItem); + + // Verify file was written after user confirmed overwrite + assert.strictEqual(writeCount, 1); + }); + }); + + suite('exportNotebook', () => { + test('should return early if user cancels format selection', async () => { + resetVSCodeMocks(); + + const mockFS = mock(); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // User cancels format selection + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify no file operations occurred + verify(mockFS.writeFile(anything(), anything())).never(); + verify(mockFS.readFile(anything())).never(); + }); + + test('should return early if user cancels folder selection', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // User selects format but cancels folder selection + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should show error for invalid Deepnote file format', async () => { + resetVSCodeMocks(); + + // Invalid project data (no project property) + const invalidData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(invalidData)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + + test('should export single notebook matching the notebookId', async () => { + resetVSCodeMocks(); + + const targetNotebookId = 'nb-2'; + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [ + { id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, + { id: targetNotebookId, name: 'Notebook 2', blocks: [], executionMode: 'block' } + ] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let writeCount = 0; + const writtenFiles: { uri: Uri; content: Uint8Array }[] = []; + when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { + writeCount++; + writtenFiles.push({ uri, content }); + return Promise.resolve(); + }); + + // Notebook tree item with specific notebookId + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: targetNotebookId + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify only one notebook was exported + assert.strictEqual(writeCount, 1); + + // Verify the exported notebook has correct metadata + const exportedContent = JSON.parse(Buffer.from(writtenFiles[0].content).toString('utf8')); + assert.strictEqual(exportedContent.metadata.deepnote_notebook_id, targetNotebookId); + }); + + test('should show error if notebook not found', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + // Notebook tree item with non-existent notebookId + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'non-existent-nb' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should handle export errors gracefully', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + // Simulate write error + when(mockFS.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + + test('should prompt for overwrite when file already exists and cancel if declined', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + // File exists - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + // User cancels overwrite + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify warning message was shown about file existing + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should overwrite file when user confirms', async () => { + resetVSCodeMocks(); + + const projectData = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: 'project-id', + name: 'Test Project', + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yaml.dump(projectData)))); + // File exists - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any + ); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( + Promise.resolve([Uri.file('/output/folder')]) + ); + // User confirms overwrite + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Overwrite') as any + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + let writeCount = 0; + when(mockFS.writeFile(anything(), anything())).thenCall(() => { + writeCount++; + return Promise.resolve(); + }); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/test/project.deepnote', + projectId: 'project-id', + notebookId: 'nb-1' + } + }; + + await (explorerView as any).exportNotebook(treeItem); + + // Verify file was written after user confirmed overwrite + assert.strictEqual(writeCount, 1); + }); + }); }); diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 426e3fa2c2..5ddd879822 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -247,6 +247,8 @@ export namespace Commands { export const DeleteNotebook = 'deepnote.deleteNotebook'; export const DuplicateNotebook = 'deepnote.duplicateNotebook'; export const AddNotebookToProject = 'deepnote.addNotebookToProject'; + export const ExportProject = 'deepnote.exportProject'; + export const ExportNotebook = 'deepnote.exportNotebook'; export const OpenInDeepnote = 'deepnote.openInDeepnote'; export const ExportAsPythonScript = 'deepnote.exportAsPythonScript'; export const ExportToHTML = 'deepnote.exportToHTML';