diff --git a/package.json b/package.json index 7b84ea195..243dd980a 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,48 @@ "category": "Deepnote", "icon": "$(add)" }, + { + "command": "deepnote.newNotebook", + "title": "%deepnote.commands.newNotebook.title%", + "category": "Deepnote", + "icon": "$(notebook)" + }, + { + "command": "deepnote.renameProject", + "title": "%deepnote.commands.renameProject.title%", + "category": "Deepnote", + "icon": "$(edit)" + }, + { + "command": "deepnote.deleteProject", + "title": "%deepnote.commands.deleteProject.title%", + "category": "Deepnote", + "icon": "$(trash)" + }, + { + "command": "deepnote.renameNotebook", + "title": "%deepnote.commands.renameNotebook.title%", + "category": "Deepnote", + "icon": "$(edit)" + }, + { + "command": "deepnote.deleteNotebook", + "title": "%deepnote.commands.deleteNotebook.title%", + "category": "Deepnote", + "icon": "$(trash)" + }, + { + "command": "deepnote.duplicateNotebook", + "title": "%deepnote.commands.duplicateNotebook.title%", + "category": "Deepnote", + "icon": "$(copy)" + }, + { + "command": "deepnote.addNotebookToProject", + "title": "%deepnote.commands.addNotebookToProject.title%", + "category": "Deepnote", + "icon": "$(add)" + }, { "command": "dataScience.ClearCache", "title": "%jupyter.command.dataScience.clearCache.title%", @@ -868,55 +910,60 @@ "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addSqlBlock", + "command": "deepnote.newNotebook", "group": "navigation@1", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addChartBlock", + "command": "deepnote.addSqlBlock", "group": "navigation@2", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addBigNumberChartBlock", + "command": "deepnote.addChartBlock", "group": "navigation@3", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputTextBlock", + "command": "deepnote.addBigNumberChartBlock", "group": "navigation@4", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputTextareaBlock", + "command": "deepnote.addInputTextBlock", "group": "navigation@5", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputSelectBlock", + "command": "deepnote.addInputTextareaBlock", "group": "navigation@6", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputSliderBlock", + "command": "deepnote.addInputSelectBlock", "group": "navigation@7", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputCheckboxBlock", + "command": "deepnote.addInputSliderBlock", "group": "navigation@8", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputDateBlock", + "command": "deepnote.addInputCheckboxBlock", "group": "navigation@9", "when": "notebookType == 'deepnote'" }, { - "command": "deepnote.addInputDateRangeBlock", + "command": "deepnote.addInputDateBlock", "group": "navigation@10", "when": "notebookType == 'deepnote'" }, + { + "command": "deepnote.addInputDateRangeBlock", + "group": "navigation@11", + "when": "notebookType == 'deepnote'" + }, { "command": "jupyter.restartkernel", "group": "navigation/execute@5", @@ -1446,6 +1493,36 @@ "command": "deepnote.revealInExplorer", "when": "view == deepnoteExplorer && viewItem != loading", "group": "inline@2" + }, + { + "command": "deepnote.addNotebookToProject", + "when": "view == deepnoteExplorer && viewItem == projectFile", + "group": "1_project@1" + }, + { + "command": "deepnote.renameProject", + "when": "view == deepnoteExplorer && viewItem == projectFile", + "group": "2_edit@1" + }, + { + "command": "deepnote.deleteProject", + "when": "view == deepnoteExplorer && viewItem == projectFile", + "group": "3_delete@1" + }, + { + "command": "deepnote.renameNotebook", + "when": "view == deepnoteExplorer && viewItem == notebook", + "group": "2_edit@1" + }, + { + "command": "deepnote.duplicateNotebook", + "when": "view == deepnoteExplorer && viewItem == notebook", + "group": "2_edit@2" + }, + { + "command": "deepnote.deleteNotebook", + "when": "view == deepnoteExplorer && viewItem == notebook", + "group": "3_delete@1" } ] }, diff --git a/package.nls.json b/package.nls.json index 504e2565c..0bdd12f06 100644 --- a/package.nls.json +++ b/package.nls.json @@ -266,6 +266,13 @@ "deepnote.commands.addInputDateRangeBlock.title": "Add Input Date Range Block", "deepnote.commands.addInputFileBlock.title": "Add Input File Block", "deepnote.commands.addButtonBlock.title": "Add Button Block", + "deepnote.commands.newNotebook.title": "New Notebook", + "deepnote.commands.renameProject.title": "Rename Project", + "deepnote.commands.deleteProject.title": "Delete Project", + "deepnote.commands.renameNotebook.title": "Rename Notebook", + "deepnote.commands.deleteNotebook.title": "Delete Notebook", + "deepnote.commands.duplicateNotebook.title": "Duplicate Notebook", + "deepnote.commands.addNotebookToProject.title": "Add Notebook", "deepnote.views.explorer.name": "Explorer", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", "deepnote.command.selectNotebook.title": "Select Notebook" diff --git a/src/commands.ts b/src/commands.ts index 2114cdb77..92237164f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -186,6 +186,7 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.InstallPythonExtensionViaKernelPicker]: []; [DSCommands.InstallPythonViaKernelPicker]: []; [DSCommands.ContinueEditSessionInCodespace]: []; + [DSCommands.NewNotebook]: []; [DSCommands.AddSqlBlock]: []; [DSCommands.AddBigNumberChartBlock]: []; [DSCommands.AddChartBlock]: []; @@ -198,5 +199,14 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.AddInputDateRangeBlock]: []; [DSCommands.AddInputFileBlock]: []; [DSCommands.AddButtonBlock]: []; + [DSCommands.NewProject]: []; + [DSCommands.ImportNotebook]: []; + [DSCommands.ImportJupyterNotebook]: []; + [DSCommands.RenameProject]: []; + [DSCommands.DeleteProject]: []; + [DSCommands.RenameNotebook]: []; + [DSCommands.DeleteNotebook]: []; + [DSCommands.DuplicateNotebook]: []; + [DSCommands.AddNotebookToProject]: []; [DSCommands.OpenInDeepnote]: []; } diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index c707e309c..c545ae544 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -38,7 +38,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic */ public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager); - this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager); + this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager, this.logger); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.extensionContext.subscriptions.push(workspace.registerNotebookSerializer('deepnote', this.serializer)); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 8090225cb..73cc64517 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -1,6 +1,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 { IExtensionContext } from '../../platform/common/types'; @@ -8,7 +9,10 @@ import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { type DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; import { generateUuid } from '../../platform/common/uuid'; -import { DeepnoteBlock, DeepnoteFile } from '@deepnote/blocks'; +import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; +import { Commands } from '../../platform/common/constants'; +import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; +import { ILogger } from '../../platform/logging/types'; /** * Manages the Deepnote explorer tree view and related commands @@ -21,9 +25,10 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(IDeepnoteNotebookManager) private readonly manager: IDeepnoteNotebookManager + @inject(IDeepnoteNotebookManager) private readonly manager: IDeepnoteNotebookManager, + @inject(ILogger) logger: ILogger ) { - this.treeDataProvider = new DeepnoteTreeDataProvider(); + this.treeDataProvider = new DeepnoteTreeDataProvider(logger); } public activate(): void { @@ -38,36 +43,467 @@ export class DeepnoteExplorerView { this.registerCommands(); } + /** + * Shared helper that creates and adds a new notebook to a project + * @param fileUri The URI of the project file + * @returns Object with notebook ID and name if successful, or null if aborted/failed + */ + public async createAndAddNotebookToProject(fileUri: Uri): Promise<{ id: string; name: string } | null> { + // Read the Deepnote project file + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + return null; + } + + // Generate suggested name and prompt user + const suggestedName = this.generateSuggestedNotebookName(projectData); + const notebookName = await this.promptForNotebookName( + suggestedName, + new Set(projectData.project.notebooks?.map((nb: DeepnoteNotebook) => nb.name) ?? []) + ); + + if (!notebookName) { + return null; + } + + // Create new notebook with initial block + const newNotebook = this.createNotebookWithFirstBlock(notebookName); + + // Add new notebook to the project (initialize array if needed) + if (!projectData.project.notebooks) { + projectData.project.notebooks = []; + } + projectData.project.notebooks.push(newNotebook); + + // Save and open the new notebook + await this.saveProjectAndOpenNotebook(fileUri, projectData, newNotebook.id); + + return { id: newNotebook.id, name: notebookName }; + } + + public async renameNotebook(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + return; + } + + try { + const fileUri = Uri.file(treeItem.context.filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + if (!projectData?.project?.notebooks) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + return; + } + const targetNotebook = projectData.project.notebooks.find( + (nb: DeepnoteNotebook) => nb.id === treeItem.context.notebookId + ); + + if (!targetNotebook) { + await window.showErrorMessage(l10n.t('Notebook not found')); + return; + } + + const itemNotebook = treeItem.data as DeepnoteNotebook; + const currentName = itemNotebook.name; + + if (targetNotebook.id !== itemNotebook.id) { + await window.showErrorMessage(l10n.t('Selected notebook is not the target notebook')); + return; + } + + const existingNames = new Set( + projectData.project.notebooks + .map((nb: DeepnoteNotebook) => nb.name) + .filter((name: string) => name !== currentName) + ); + + const newName = await this.promptForNotebookName(currentName, existingNames); + + if (!newName || newName === currentName) { + return; + } + + targetNotebook.name = newName; + + if (!projectData.metadata) { + projectData.metadata = { createdAt: new Date().toISOString() }; + } + projectData.metadata.modifiedAt = new Date().toISOString(); + + const updatedYaml = yaml.dump(projectData); + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + + await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + await window.showInformationMessage(l10n.t('Notebook renamed to: {0}', newName)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to rename notebook: {0}', errorMessage)); + } + } + + public async deleteNotebook(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + return; + } + + const notebook = treeItem.data as DeepnoteNotebook; + const notebookName = notebook.name; + + const confirmation = await window.showWarningMessage( + l10n.t('Are you sure you want to delete notebook "{0}"?', notebookName), + { modal: true }, + l10n.t('Delete') + ); + + if (confirmation !== l10n.t('Delete')) { + return; + } + + try { + const fileUri = Uri.file(treeItem.context.filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project?.notebooks) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + return; + } + + projectData.project.notebooks = projectData.project.notebooks.filter( + (nb: DeepnoteNotebook) => nb.id !== treeItem.context.notebookId + ); + + if (!projectData.metadata) { + projectData.metadata = { createdAt: new Date().toISOString() }; + } + projectData.metadata.modifiedAt = new Date().toISOString(); + + const updatedYaml = yaml.dump(projectData); + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + + await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + await window.showInformationMessage(l10n.t('Notebook deleted: {0}', notebookName)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to delete notebook: {0}', errorMessage)); + } + } + + public async duplicateNotebook(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + return; + } + + const notebook = treeItem.data as DeepnoteNotebook; + const originalName = notebook.name; + + try { + const fileUri = Uri.file(treeItem.context.filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + + if (!projectData?.project?.notebooks) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + return; + } + + const targetNotebook = projectData.project.notebooks.find( + (nb: DeepnoteNotebook) => nb.id === treeItem.context.notebookId + ); + + if (!targetNotebook) { + await window.showErrorMessage(l10n.t('Notebook not found')); + return; + } + + // Generate new name + const existingNames = new Set(projectData.project.notebooks.map((nb: DeepnoteNotebook) => nb.name)); + let copyNumber = 1; + let newName = `${originalName} (Copy)`; + while (existingNames.has(newName)) { + copyNumber++; + newName = `${originalName} (Copy ${copyNumber})`; + } + + // Deep clone the notebook and generate new IDs + const newNotebook: DeepnoteNotebook = { + ...targetNotebook, + id: generateUuid(), + name: newName, + blocks: targetNotebook.blocks.map((block: DeepnoteBlock) => { + // Use structuredClone for deep cloning if available, otherwise fall back to JSON + const clonedBlock = + typeof structuredClone !== 'undefined' + ? structuredClone(block) + : JSON.parse(JSON.stringify(block)); + + // Update cloned block with new IDs and reset execution state + clonedBlock.id = generateUuid(); + clonedBlock.blockGroup = generateUuid(); + clonedBlock.executionCount = undefined; + + return clonedBlock; + }) + }; + + projectData.project.notebooks.push(newNotebook); + + if (!projectData.metadata) { + projectData.metadata = { createdAt: new Date().toISOString() }; + } + projectData.metadata.modifiedAt = new Date().toISOString(); + + const updatedYaml = yaml.dump(projectData); + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + + await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + + // Optionally open the duplicated notebook + this.manager.selectNotebookForProject(treeItem.context.projectId, newNotebook.id); + const notebookUri = fileUri.with({ query: `notebook=${newNotebook.id}` }); + const document = await workspace.openNotebookDocument(notebookUri); + await window.showNotebookDocument(document, { + preserveFocus: false, + preview: false + }); + + await window.showInformationMessage(l10n.t('Notebook duplicated: {0}', newName)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to duplicate notebook: {0}', errorMessage)); + } + } + + public async renameProject(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return; + } + + const project = treeItem.data as DeepnoteFile; + const currentName = project.project.name; + + const newName = await window.showInputBox({ + prompt: l10n.t('Enter new project name'), + value: currentName, + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return l10n.t('Project name cannot be empty'); + } + return null; + } + }); + + if (!newName || newName === currentName) { + return; + } + + try { + 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; + } + + projectData.project.name = newName; + + if (!projectData.metadata) { + projectData.metadata = { createdAt: new Date().toISOString() }; + } + projectData.metadata.modifiedAt = new Date().toISOString(); + + const updatedYaml = yaml.dump(projectData); + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + + await this.treeDataProvider.refreshProject(treeItem.context.filePath); + await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to rename project: {0}', errorMessage)); + } + } + private registerCommands(): void { this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.refreshExplorer', () => this.refreshExplorer()) + commands.registerCommand(Commands.RefreshDeepnoteExplorer, () => this.refreshExplorer()) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.openNotebook', (context: DeepnoteTreeItemContext) => + commands.registerCommand(Commands.OpenDeepnoteNotebook, (context: DeepnoteTreeItemContext) => this.openNotebook(context) ) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.openFile', (treeItem: DeepnoteTreeItem) => this.openFile(treeItem)) + commands.registerCommand(Commands.OpenDeepnoteFile, (treeItem: DeepnoteTreeItem) => this.openFile(treeItem)) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.revealInExplorer', () => this.revealActiveNotebook()) + commands.registerCommand(Commands.RevealInDeepnoteExplorer, () => this.revealActiveNotebook()) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.newProject', () => this.newProject()) + commands.registerCommand(Commands.NewProject, () => this.newProject()) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.importNotebook', () => this.importNotebook()) + commands.registerCommand(Commands.ImportNotebook, () => this.importNotebook()) ); this.extensionContext.subscriptions.push( - commands.registerCommand('deepnote.importJupyterNotebook', () => this.importJupyterNotebook()) + commands.registerCommand(Commands.ImportJupyterNotebook, () => this.importJupyterNotebook()) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.NewNotebook, () => this.newNotebook()) + ); + + // Context menu commands for tree items + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.RenameProject, (treeItem: DeepnoteTreeItem) => + this.renameProject(treeItem) + ) ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.DeleteProject, (treeItem: DeepnoteTreeItem) => + this.deleteProject(treeItem) + ) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.RenameNotebook, (treeItem: DeepnoteTreeItem) => + this.renameNotebook(treeItem) + ) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.DeleteNotebook, (treeItem: DeepnoteTreeItem) => + this.deleteNotebook(treeItem) + ) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.DuplicateNotebook, (treeItem: DeepnoteTreeItem) => + this.duplicateNotebook(treeItem) + ) + ); + + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AddNotebookToProject, (treeItem: DeepnoteTreeItem) => + this.addNotebookToProject(treeItem) + ) + ); + } + + /** + * Generates a suggested unique notebook name based on existing notebooks + * @param projectData The project data containing existing notebooks + * @returns A unique suggested notebook name + */ + private generateSuggestedNotebookName(projectData: DeepnoteFile): string { + const notebookCount = projectData.project.notebooks?.length || 0; + const existingNames = new Set(projectData.project.notebooks?.map((nb: DeepnoteNotebook) => nb.name) || []); + + let nextNumber = notebookCount + 1; + let suggestedName = `Notebook ${nextNumber}`; + while (existingNames.has(suggestedName)) { + nextNumber++; + suggestedName = `Notebook ${nextNumber}`; + } + + return suggestedName; + } + + /** + * Prompts the user for a notebook name with validation + * @param suggestedName The default suggested name + * @returns The entered notebook name, or undefined if cancelled + */ + private async promptForNotebookName( + suggestedName: string, + existingNames: Set + ): Promise { + return await window.showInputBox({ + prompt: l10n.t('Enter a name for the new notebook'), + placeHolder: suggestedName, + value: suggestedName, + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return l10n.t('Notebook name cannot be empty'); + } + if (existingNames.has(value)) { + return l10n.t('A notebook with this name already exists'); + } + return null; + } + }); + } + + /** + * Creates a new notebook with an initial empty code block + * @param notebookName The name for the new notebook + * @returns The created notebook with a unique ID and initial block + */ + private createNotebookWithFirstBlock(notebookName: string): DeepnoteNotebook { + const notebookId = generateUuid(); + const firstBlock: DeepnoteBlock = { + blockGroup: generateUuid(), + content: '', + executionCount: undefined, + id: generateUuid(), + metadata: {}, + outputs: [], + sortingKey: '0', + type: 'code', + version: 1 + }; + + return { + blocks: [firstBlock], + executionMode: 'block', + id: notebookId, + name: notebookName + }; + } + + /** + * Saves the project data to file and opens the specified notebook + * @param fileUri The URI of the project file + * @param projectData The project data to save + * @param notebookId The notebook ID to open + */ + private async saveProjectAndOpenNotebook( + fileUri: Uri, + projectData: DeepnoteFile, + notebookId: string + ): Promise { + // Update metadata timestamp + if (!projectData.metadata) { + projectData.metadata = { createdAt: new Date().toISOString() }; + } + projectData.metadata.modifiedAt = new Date().toISOString(); + + // Write the updated YAML + const updatedYaml = yaml.dump(projectData); + const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + + // Refresh the tree view - use granular refresh for notebooks + await this.treeDataProvider.refreshNotebook(projectData.project.id); + + // Open the new notebook + this.manager.selectNotebookForProject(projectData.project.id, notebookId); + const notebookUri = fileUri.with({ query: `notebook=${notebookId}` }); + const document = await workspace.openNotebookDocument(notebookUri); + await window.showNotebookDocument(document, { + preserveFocus: false, + preview: false + }); } private refreshExplorer(): void { @@ -270,6 +706,34 @@ export class DeepnoteExplorerView { } } + private async newNotebook(): Promise { + const activeEditor = window.activeNotebookEditor; + if (!activeEditor || activeEditor.notebook.notebookType !== 'deepnote') { + await window.showErrorMessage(l10n.t('No active Deepnote file opened. Please open a Deepnote file first.')); + return; + } + + const document = activeEditor.notebook; + + // Get the file URI (strip query params if present) + let fileUri = document.uri; + if (fileUri.query) { + fileUri = fileUri.with({ query: '' }); + } + + try { + // Use shared helper to create and add notebook + const result = await this.createAndAddNotebookToProject(fileUri); + + if (result) { + await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to add notebook: {0}', errorMessage)); + } + } + private async importNotebook(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( @@ -453,4 +917,53 @@ export class DeepnoteExplorerView { await window.showErrorMessage(l10n.t(`Failed to import Jupyter notebook: {0}`, errorMessage)); } } + + private async deleteProject(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return; + } + + const project = treeItem.data as DeepnoteFile; + const projectName = project.project.name; + + const confirmation = await window.showWarningMessage( + l10n.t('Are you sure you want to delete project "{0}"?', projectName), + { modal: true }, + l10n.t('Delete') + ); + + if (confirmation !== l10n.t('Delete')) { + return; + } + + try { + const fileUri = Uri.file(treeItem.context.filePath); + await workspace.fs.delete(fileUri); + this.treeDataProvider.refresh(); + await window.showInformationMessage(l10n.t('Project deleted: {0}', projectName)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to delete project: {0}', errorMessage)); + } + } + + private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return; + } + + try { + const fileUri = Uri.file(treeItem.context.filePath); + + // Use shared helper to create and add notebook + const result = await this.createAndAddNotebookToProject(fileUri); + + if (result) { + await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await window.showErrorMessage(l10n.t('Failed to add notebook: {0}', errorMessage)); + } + } } diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index f8b403d17..baa007f6e 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -1,20 +1,34 @@ import { assert, expect } from 'chai'; import * as sinon from 'sinon'; -import { anything, instance, mock, when } from 'ts-mockito'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; import { Uri, workspace } from 'vscode'; import * as yaml from 'js-yaml'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; -import type { DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; import type { IExtensionContext } from '../../platform/common/types'; +import type { DeepnoteFile, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import * as uuidModule from '../../platform/common/uuid'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { ILogger } from '../../platform/logging/types'; + +function createMockLogger(): ILogger { + return { + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + ci: () => undefined + } as ILogger; +} suite('DeepnoteExplorerView', () => { let explorerView: DeepnoteExplorerView; let mockExtensionContext: IExtensionContext; let manager: DeepnoteNotebookManager; + let mockLogger: ILogger; setup(() => { mockExtensionContext = { @@ -22,7 +36,8 @@ suite('DeepnoteExplorerView', () => { } as any; manager = new DeepnoteNotebookManager(); - explorerView = new DeepnoteExplorerView(mockExtensionContext, manager); + mockLogger = createMockLogger(); + explorerView = new DeepnoteExplorerView(mockExtensionContext, manager, mockLogger); }); suite('constructor', () => { @@ -158,8 +173,10 @@ suite('DeepnoteExplorerView', () => { const manager1 = new DeepnoteNotebookManager(); const manager2 = new DeepnoteNotebookManager(); - const view1 = new DeepnoteExplorerView(context1, manager1); - const view2 = new DeepnoteExplorerView(context2, manager2); + const logger1 = createMockLogger(); + const logger2 = createMockLogger(); + const view1 = new DeepnoteExplorerView(context1, manager1, logger1); + const view2 = new DeepnoteExplorerView(context2, manager2, logger2); // Verify each view has its own context assert.strictEqual((view1 as any).extensionContext, context1); @@ -199,7 +216,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { } as unknown as IExtensionContext; mockManager = new DeepnoteNotebookManager(); - explorerView = new DeepnoteExplorerView(mockContext, mockManager); + const mockLogger = createMockLogger(); + explorerView = new DeepnoteExplorerView(mockContext, mockManager, mockLogger); }); teardown(() => { @@ -735,4 +753,1010 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { expect(infoMessageShown).to.be.true; }); }); + + suite('createAndAddNotebookToProject', () => { + test('should create and add a new notebook to an existing project', async () => { + const projectId = 'test-project-id'; + const existingNotebookId = 'existing-notebook-id'; + const newNotebookId = 'new-notebook-id'; + const blockGroupId = 'test-blockgroup-id'; + const blockId = 'test-block-id'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + const notebookName = 'New Notebook'; + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z', + modifiedAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: existingNotebookId, + name: 'Notebook 1', + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user input + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(notebookName)); + + // Mock UUID generation + const generateUuidStub = sandbox.stub(uuidModule, 'generateUuid'); + generateUuidStub.onCall(0).returns(newNotebookId); + generateUuidStub.onCall(1).returns(blockGroupId); + generateUuidStub.onCall(2).returns(blockId); + + // Mock notebook opening + const mockNotebook = { notebookType: 'deepnote' }; + when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( + Promise.resolve(mockNotebook as any) + ); + when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( + Promise.resolve(undefined as any) + ); + + // Execute the method + const result = await explorerView.createAndAddNotebookToProject(fileUri); + + // Verify result + expect(result).to.exist; + expect(result?.id).to.equal(newNotebookId); + expect(result?.name).to.equal(notebookName); + + // Verify file was written + expect(capturedWriteContent).to.exist; + + // Verify YAML content + const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); + const updatedProjectData = yaml.load(updatedYamlContent) as any; + + expect(updatedProjectData.project.notebooks).to.have.lengthOf(2); + expect(updatedProjectData.project.notebooks[1].id).to.equal(newNotebookId); + expect(updatedProjectData.project.notebooks[1].name).to.equal(notebookName); + expect(updatedProjectData.project.notebooks[1].blocks).to.have.lengthOf(1); + expect(updatedProjectData.project.notebooks[1].executionMode).to.equal('block'); + }); + + test('should return null if user cancels notebook name input', async () => { + const projectId = 'test-project-id'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + project: { + id: projectId, + name: 'Test Project', + notebooks: [] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user cancelling input + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + // Execute the method + const result = await explorerView.createAndAddNotebookToProject(fileUri); + + // Verify result is null and file was not written + expect(result).to.be.null; + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('should generate unique notebook name suggestions', async () => { + const projectId = 'test-project-id'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data with multiple notebooks + const existingProjectData = { + version: 1.0, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { id: 'nb1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, + { id: 'nb2', name: 'Notebook 2', blocks: [], executionMode: 'block' } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + let capturedInputBoxOptions: any; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + capturedInputBoxOptions = options; + return Promise.resolve('Test Notebook'); + }); + + sandbox.stub(uuidModule, 'generateUuid').returns('test-id'); + + when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( + Promise.resolve({} as any) + ); + when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( + Promise.resolve(undefined as any) + ); + + // Execute the method + await explorerView.createAndAddNotebookToProject(fileUri); + + // Verify suggested name is 'Notebook 3' (next in sequence) + expect(capturedInputBoxOptions).to.exist; + expect(capturedInputBoxOptions.value).to.equal('Notebook 3'); + }); + }); + + suite('renameNotebook', () => { + test('should successfully rename a notebook with valid input', async () => { + const projectId = 'test-project-id'; + const notebookId = 'notebook-to-rename'; + const otherNotebookId = 'other-notebook-id'; + const oldName = 'Old Notebook Name'; + const newName = 'New Notebook Name'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z', + modifiedAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: otherNotebookId, + name: 'Other Notebook', + blocks: [], + executionMode: 'block' + }, + { + id: notebookId, + name: oldName, + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user input for new name + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(newName)); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: notebookId + }, + data: { + id: notebookId, + name: oldName, + blocks: [], + executionMode: 'block' + } + }; + + // Execute the method + await explorerView.renameNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was written + expect(capturedWriteContent).to.exist; + + // Verify YAML content + const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); + const updatedProjectData = yaml.load(updatedYamlContent) as DeepnoteFile; + + // Find the renamed notebook + const renamedNotebook = updatedProjectData.project.notebooks.find((nb) => nb.id === notebookId); + expect(renamedNotebook).to.exist; + expect(renamedNotebook!.name).to.equal(newName); + + // Verify other notebook was not affected + const otherNotebook = updatedProjectData.project.notebooks.find((nb) => nb.id === otherNotebookId); + expect(otherNotebook).to.exist; + expect(otherNotebook!.name).to.equal('Other Notebook'); + + // Verify metadata was updated + expect(updatedProjectData.metadata.modifiedAt).to.exist; + + // Verify success message was shown + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('should return early if tree item type is not Notebook', async () => { + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/workspace/test-project.deepnote', + projectId: 'test-project-id' + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Execute the method + await explorerView.renameNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify that readFile was not called (early return) + verify(mockFS.readFile(anything())).never(); + }); + + test('should return early if user cancels input or provides same name', async () => { + const projectId = 'test-project-id'; + const notebookId = 'notebook-to-rename'; + const currentName = 'Current Notebook Name'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: notebookId, + name: currentName, + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user cancelling input (returns undefined) + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: notebookId + }, + data: { + id: notebookId, + name: currentName, + blocks: [], + executionMode: 'block' + } as DeepnoteNotebook + }; + + // Execute the method + await explorerView.renameNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was not written (early return) + verify(mockFS.writeFile(anything(), anything())).never(); + + // Test with same name + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(currentName)); + + await explorerView.renameNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was not written (early return) + verify(mockFS.writeFile(anything(), anything())).never(); + }); + }); + + suite('deleteNotebook', () => { + test('should successfully delete a notebook with user confirmation', async () => { + const projectId = 'test-project-id'; + const notebookToDeleteId = 'notebook-to-delete'; + const remainingNotebookId = 'remaining-notebook-id'; + const notebookToDeleteName = 'Notebook to Delete'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z', + modifiedAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: remainingNotebookId, + name: 'Remaining Notebook', + blocks: [], + executionMode: 'block' + }, + { + id: notebookToDeleteId, + name: notebookToDeleteName, + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user confirmation + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: notebookToDeleteId + }, + data: { + id: notebookToDeleteId, + name: notebookToDeleteName, + blocks: [], + executionMode: 'block' + } as DeepnoteNotebook + }; + + // Execute the method + await explorerView.deleteNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was written + expect(capturedWriteContent).to.exist; + + // Verify YAML content + const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); + const updatedProjectData = yaml.load(updatedYamlContent) as DeepnoteFile; + + // Verify notebook was deleted + expect(updatedProjectData.project.notebooks).to.have.lengthOf(1); + expect(updatedProjectData.project.notebooks[0].id).to.equal(remainingNotebookId); + + // Verify deleted notebook is not present + const deletedNotebook = updatedProjectData.project.notebooks.find((nb) => nb.id === notebookToDeleteId); + expect(deletedNotebook).to.be.undefined; + + // Verify metadata was updated + expect(updatedProjectData.metadata.modifiedAt).to.exist; + + // Verify success message was shown + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('should return early if tree item type is not Notebook', async () => { + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/workspace/test-project.deepnote', + projectId: 'test-project-id' + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Execute the method + await explorerView.deleteNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify that readFile was not called (early return) + verify(mockFS.readFile(anything())).never(); + // Verify no warning message was shown + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).never(); + }); + + test('should return early if user cancels confirmation', async () => { + const projectId = 'test-project-id'; + const notebookId = 'notebook-to-delete'; + const notebookName = 'Notebook to Delete'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user cancelling confirmation + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: notebookId + }, + data: { + id: notebookId, + name: notebookName, + blocks: [], + executionMode: 'block' + } as DeepnoteNotebook + }; + + // Execute the method + await explorerView.deleteNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file operations were not called (user cancelled) + verify(mockFS.readFile(anything())).never(); + verify(mockFS.writeFile(anything(), anything())).never(); + }); + }); + + suite('duplicateNotebook', () => { + test('should successfully duplicate a notebook', async () => { + const projectId = 'test-project-id'; + const originalNotebookId = 'original-notebook-id'; + const duplicatedNotebookId = 'duplicated-notebook-id'; + const blockGroupId = 'new-blockgroup-id'; + const blockId = 'new-block-id'; + const originalName = 'Original Notebook'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z', + modifiedAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: originalNotebookId, + name: originalName, + blocks: [ + { + id: 'original-block-id', + blockGroup: 'original-blockgroup-id', + content: 'print("hello")', + type: 'code', + executionCount: 1, + outputs: [], + sortingKey: '0', + version: 1, + metadata: { custom: 'data' } + } + ], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock UUID generation + const generateUuidStub = sandbox.stub(uuidModule, 'generateUuid'); + generateUuidStub.onCall(0).returns(duplicatedNotebookId); + generateUuidStub.onCall(1).returns(blockId); + generateUuidStub.onCall(2).returns(blockGroupId); + + // Mock notebook opening + const mockNotebook = { notebookType: 'deepnote' }; + when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( + Promise.resolve(mockNotebook as any) + ); + when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( + Promise.resolve(undefined as any) + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: originalNotebookId + }, + data: { + id: originalNotebookId, + name: originalName, + blocks: existingProjectData.project.notebooks[0].blocks, + executionMode: 'block' + } as DeepnoteNotebook + }; + + // Execute the method + await explorerView.duplicateNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was written + expect(capturedWriteContent).to.exist; + + // Verify YAML content + const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); + const updatedProjectData = yaml.load(updatedYamlContent) as DeepnoteFile; + + // Verify both notebooks exist + expect(updatedProjectData.project.notebooks).to.have.lengthOf(2); + + // Verify original notebook is unchanged + const originalNotebook = updatedProjectData.project.notebooks.find((nb) => nb.id === originalNotebookId); + expect(originalNotebook).to.exist; + expect(originalNotebook!.name).to.equal(originalName); + + // Verify duplicated notebook exists with correct name + const duplicatedNotebook = updatedProjectData.project.notebooks.find( + (nb) => nb.id === duplicatedNotebookId + ); + expect(duplicatedNotebook).to.exist; + expect(duplicatedNotebook!.name).to.equal(`${originalName} (Copy)`); + expect(duplicatedNotebook!.blocks).to.have.lengthOf(1); + expect(duplicatedNotebook!.blocks[0].content).to.equal('print("hello")'); + expect(duplicatedNotebook!.blocks[0].executionCount).to.be.undefined; + + // Verify new IDs were generated + expect(duplicatedNotebook!.blocks[0].id).to.equal(blockId); + expect(duplicatedNotebook!.blocks[0].blockGroup).to.equal(blockGroupId); + + // Verify metadata was updated + expect(updatedProjectData.metadata.modifiedAt).to.exist; + + // Verify success message was shown + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('should return early if tree item type is not Notebook', async () => { + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: '/workspace/test-project.deepnote', + projectId: 'test-project-id' + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Execute the method + await explorerView.duplicateNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify that readFile was not called (early return) + verify(mockFS.readFile(anything())).never(); + }); + + test('should show error if notebook is not found in project', async () => { + const projectId = 'test-project-id'; + const nonExistentNotebookId = 'non-existent-notebook-id'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data without the target notebook + const existingProjectData = { + version: 1.0, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: 'other-notebook-id', + name: 'Other Notebook', + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: fileUri.fsPath, + projectId: projectId, + notebookId: nonExistentNotebookId + }, + data: { + id: nonExistentNotebookId, + name: 'Non-existent Notebook', + blocks: [], + executionMode: 'block' + } as DeepnoteNotebook + }; + + // Execute the method + await explorerView.duplicateNotebook(mockTreeItem as DeepnoteTreeItem); + + // Verify file was not written + verify(mockFS.writeFile(anything(), anything())).never(); + + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + }); + + test('should deep clone blocks to prevent shared references', async () => { + // This test verifies that duplicating a notebook creates truly independent copies + // of nested objects like outputs and metadata, not just shallow references + const projectData: DeepnoteFile = { + version: '1.0', + metadata: { + createdAt: '2024-01-01T00:00:00Z', + modifiedAt: '2024-01-01T00:00:00Z' + }, + project: { + id: 'test-project-id', + name: 'Test Project', + notebooks: [ + { + id: 'original-notebook-id', + name: 'Original Notebook', + blocks: [ + { + id: 'block-1', + blockGroup: 'group-1', + type: 'code', + content: 'print("test")', + sortingKey: '0', + version: 1, + executionCount: 5, + outputs: [{ type: 'stream', text: 'test output' }], + metadata: { cellId: 'cell-123', custom: { nested: 'value' } } + } + ], + executionMode: 'block' + } + ] + } + }; + + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/workspace/test-project.deepnote', + projectId: 'test-project-id', + notebookId: 'original-notebook-id' + }, + data: projectData.project.notebooks[0] + }; + + // Mock file system + const mockFS = mock(); + const yamlContent = yaml.dump(projectData); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent, 'utf-8'))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Stub generateUuid to return predictable IDs + const generateUuidStub = sinon.stub(uuidModule, 'generateUuid'); + generateUuidStub.onCall(0).returns('duplicate-notebook-id'); + generateUuidStub.onCall(1).returns('duplicate-block-id'); + generateUuidStub.onCall(2).returns('duplicate-blockgroup-id'); + + // Execute duplication + await explorerView.duplicateNotebook(mockTreeItem as DeepnoteTreeItem); + + // Parse the written data + assert.isDefined(capturedWriteContent, 'File should have been written'); + const writtenYaml = Buffer.from(capturedWriteContent!).toString('utf-8'); + const updatedProjectData = yaml.load(writtenYaml) as DeepnoteFile; + + // Find original and duplicated notebooks + const originalNotebook = updatedProjectData.project.notebooks.find( + (nb) => nb.id === 'original-notebook-id' + ); + const duplicateNotebook = updatedProjectData.project.notebooks.find( + (nb) => nb.id === 'duplicate-notebook-id' + ); + + assert.isDefined(originalNotebook, 'Original notebook should exist'); + assert.isDefined(duplicateNotebook, 'Duplicate notebook should exist'); + + // Verify the blocks are truly independent (deep clone) + const originalBlock = originalNotebook!.blocks[0]; + const duplicateBlock = duplicateNotebook!.blocks[0]; + + // Test 1: Verify outputs are not the same reference + assert.notStrictEqual( + originalBlock.outputs, + duplicateBlock.outputs, + 'Outputs should be different array instances' + ); + + // Test 2: Verify metadata is not the same reference + if (originalBlock.metadata && duplicateBlock.metadata) { + assert.notStrictEqual( + originalBlock.metadata, + duplicateBlock.metadata, + 'Metadata should be different object instances' + ); + + // Test 3: Verify nested metadata properties are not shared + if ( + typeof originalBlock.metadata === 'object' && + 'custom' in originalBlock.metadata && + typeof duplicateBlock.metadata === 'object' && + 'custom' in duplicateBlock.metadata + ) { + assert.notStrictEqual( + (originalBlock.metadata as any).custom, + (duplicateBlock.metadata as any).custom, + 'Nested metadata objects should be different instances' + ); + } + } + + // Test 4: Verify that modifying duplicate doesn't affect original + // (This would fail with shallow copy) + duplicateBlock.outputs!.push({ type: 'stream', text: 'new output' }); + assert.strictEqual( + originalBlock.outputs!.length, + 1, + 'Original outputs should not be affected by changes to duplicate' + ); + assert.strictEqual(duplicateBlock.outputs!.length, 2, 'Duplicate outputs should have the new item'); + + generateUuidStub.restore(); + }); + }); + + suite('renameProject', () => { + test('should successfully rename a project with valid input', async () => { + const oldProjectName = 'Old Project Name'; + const newProjectName = 'New Project Name'; + const projectId = 'test-project-id'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z', + modifiedAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: oldProjectName, + notebooks: [ + { + id: 'notebook-1', + name: 'Notebook 1', + blocks: [], + executionMode: 'block' + } + ] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + // Mock file system + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + + let capturedWriteContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + capturedWriteContent = content; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Mock user input for new name + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(newProjectName)); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( + Promise.resolve(undefined) + ); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: fileUri.fsPath, + projectId: projectId + }, + data: existingProjectData as unknown as DeepnoteFile + }; + + // Execute the method + await explorerView.renameProject(mockTreeItem as DeepnoteTreeItem); + + // Verify file was written + expect(capturedWriteContent).to.exist; + + // Verify YAML content + const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); + const updatedProjectData = yaml.load(updatedYamlContent) as DeepnoteFile; + + // Verify project was renamed + expect(updatedProjectData.project.name).to.equal(newProjectName); + + // Verify notebooks were not affected + expect(updatedProjectData.project.notebooks).to.have.lengthOf(1); + expect(updatedProjectData.project.notebooks[0].name).to.equal('Notebook 1'); + + // Verify metadata was updated + expect(updatedProjectData.metadata.modifiedAt).to.exist; + + // Verify success message was shown + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); + + test('should return early if tree item type is not ProjectFile', async () => { + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { + filePath: '/workspace/test-project.deepnote', + projectId: 'test-project-id', + notebookId: 'test-notebook-id' + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Execute the method + await explorerView.renameProject(mockTreeItem as DeepnoteTreeItem); + + // Verify that no input box was shown (early return) + verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); + // Verify that readFile was not called (early return) + verify(mockFS.readFile(anything())).never(); + }); + + test('should return early if user cancels input or provides same name', async () => { + const projectId = 'test-project-id'; + const currentName = 'Current Project Name'; + const fileUri = Uri.file('/workspace/test-project.deepnote'); + + // Mock existing project data + const existingProjectData = { + version: 1.0, + metadata: { + createdAt: '2024-01-01T00:00:00.000Z' + }, + project: { + id: projectId, + name: currentName, + notebooks: [] + } + }; + + const yamlContent = yaml.dump(existingProjectData); + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + // Test 1: User cancels input (returns undefined) + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); + + // Create mock tree item + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { + filePath: fileUri.fsPath, + projectId: projectId + }, + data: existingProjectData as unknown as DeepnoteFile + }; + + // Execute the method + await explorerView.renameProject(mockTreeItem as DeepnoteTreeItem); + + // Verify file was not written (early return) + verify(mockFS.writeFile(anything(), anything())).never(); + + // Test 2: User provides same name + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(currentName)); + + await explorerView.renameProject(mockTreeItem as DeepnoteTreeItem); + + // Verify file was still not written (early return) + verify(mockFS.writeFile(anything(), anything())).never(); + }); + }); }); diff --git a/src/notebooks/deepnote/deepnoteProjectUtils.ts b/src/notebooks/deepnote/deepnoteProjectUtils.ts new file mode 100644 index 000000000..aae0c0726 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteProjectUtils.ts @@ -0,0 +1,10 @@ +import { DeepnoteFile } from '@deepnote/blocks'; +import { Uri, workspace } from 'vscode'; +import * as yaml from 'js-yaml'; + +export async function readDeepnoteProjectFile(fileUri: Uri): Promise { + const fileContent = await workspace.fs.readFile(fileUri); + const yamlContent = new TextDecoder().decode(fileContent); + const projectData = yaml.load(yamlContent) as DeepnoteFile; + return projectData; +} diff --git a/src/notebooks/deepnote/deepnoteProjectUtils.unit.test.ts b/src/notebooks/deepnote/deepnoteProjectUtils.unit.test.ts new file mode 100644 index 000000000..4f6028e10 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteProjectUtils.unit.test.ts @@ -0,0 +1,93 @@ +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, workspace } from 'vscode'; + +import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; + +suite('DeepnoteProjectUtils', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + resetVSCodeMocks(); + }); + + teardown(() => { + sandbox.restore(); + resetVSCodeMocks(); + }); + + suite('readDeepnoteProjectFile', () => { + test('should successfully parse valid YAML content', async () => { + const mockFS = mock(); + const testUri = Uri.file('/test/project.deepnote'); + + const validYaml = ` +version: '1' +project: + id: test-project-id + name: Test Project + notebooks: + - id: test-notebook-id + name: Test Notebook + blocks: [] +`; + + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(validYaml))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const result = await readDeepnoteProjectFile(testUri); + + assert.strictEqual(result.version, '1'); + assert.strictEqual(result.project.id, 'test-project-id'); + assert.strictEqual(result.project.name, 'Test Project'); + assert.strictEqual(result.project.notebooks.length, 1); + assert.strictEqual(result.project.notebooks[0].id, 'test-notebook-id'); + assert.strictEqual(result.project.notebooks[0].name, 'Test Notebook'); + }); + + test('should throw error for invalid YAML content', async () => { + const mockFS = mock(); + const testUri = Uri.file('/test/invalid.deepnote'); + + const invalidYaml = ` +version: '1' +project: + invalid: yaml: content: here + - malformed +`; + + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(invalidYaml))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + try { + await readDeepnoteProjectFile(testUri); + assert.fail('Should have thrown an error'); + } catch (error) { + assert.ok(error instanceof Error); + } + }); + + test('should correctly decode file content with UTF-8 characters', async () => { + const mockFS = mock(); + const testUri = Uri.file('/test/unicode.deepnote'); + + const yamlWithUnicode = ` +version: 1 +project: + id: test-id + name: Test Project with emojis 🚀 + notebooks: [] +`; + + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlWithUnicode, 'utf-8'))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const result = await readDeepnoteProjectFile(testUri); + + assert.strictEqual(result.project.name, 'Test Project with emojis 🚀'); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts index 8bfb5cbbe..1dec6ad59 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts @@ -12,10 +12,20 @@ import { commands, l10n } from 'vscode'; -import * as yaml from 'js-yaml'; import { DeepnoteTreeItem, DeepnoteTreeItemType, DeepnoteTreeItemContext } from './deepnoteTreeItem'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; +import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; +import { ILogger } from '../../platform/logging/types'; + +/** + * Comparator function for sorting tree items alphabetically by label (case-insensitive) + */ +export function compareTreeItemsByLabel(a: DeepnoteTreeItem, b: DeepnoteTreeItem): number { + const labelA = typeof a.label === 'string' ? a.label : ''; + const labelB = typeof b.label === 'string' ? b.label : ''; + return labelA.toLowerCase().localeCompare(labelB.toLowerCase()); +} /** * Tree data provider for the Deepnote explorer view. @@ -29,10 +39,13 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider = new Map(); + private treeItemCache: Map = new Map(); private isInitialScanComplete: boolean = false; private initialScanPromise: Promise | undefined; + private readonly logger?: ILogger; - constructor() { + constructor(logger?: ILogger) { + this.logger = logger; this.setupFileWatcher(); this.updateContextKey(); } @@ -44,39 +57,121 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { + // Get the cached tree item BEFORE clearing caches + const cacheKey = `project:${filePath}`; + const cachedTreeItem = this.treeItemCache.get(cacheKey); + + // Clear the project data cache to force reload + this.cachedProjects.delete(filePath); + + if (cachedTreeItem) { + // Reload the project data and update the cached tree item + try { + const fileUri = Uri.file(filePath); + const project = await this.loadDeepnoteProject(fileUri); + if (project) { + // Update the tree item's data + cachedTreeItem.data = project; + + // Update visual fields (label, description, tooltip) to reflect changes + cachedTreeItem.updateVisualFields(); + } + } catch (error) { + this.logger?.error(`Failed to reload project ${filePath}`, error); + } + + // Fire change event with the existing cached tree item + this._onDidChangeTreeData.fire(cachedTreeItem); + } else { + // If not found in cache, do a full refresh + this._onDidChangeTreeData.fire(); + } + } + + /** + * Refresh notebooks for a specific project + * @param projectId The project ID whose notebooks should be refreshed + */ + public async refreshNotebook(projectId: string): Promise { + // Find the cached tree item by scanning the cache + let cachedTreeItem: DeepnoteTreeItem | undefined; + let filePath: string | undefined; + + for (const [key, item] of this.treeItemCache.entries()) { + if (key.startsWith('project:') && item.context.projectId === projectId) { + cachedTreeItem = item; + filePath = item.context.filePath; + break; + } + } + + if (cachedTreeItem && filePath) { + // Clear the project data cache to force reload + this.cachedProjects.delete(filePath); + + // Reload the project data and update the cached tree item + try { + const fileUri = Uri.file(filePath); + const project = await this.loadDeepnoteProject(fileUri); + if (project) { + // Update the tree item's data + cachedTreeItem.data = project; + + // Update visual fields (label, description, tooltip) to reflect changes + cachedTreeItem.updateVisualFields(); + } + } catch (error) { + this.logger?.error(`Failed to reload project ${filePath}`, error); + } + + // Fire change event with the existing cached tree item to refresh its children + this._onDidChangeTreeData.fire(cachedTreeItem); + } else { + // If not found in cache, do a full refresh + this._onDidChangeTreeData.fire(); + } + } + public getTreeItem(element: DeepnoteTreeItem): TreeItem { return element; } public async getChildren(element?: DeepnoteTreeItem): Promise { - if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { + // If element is provided, we can return children regardless of workspace + if (element) { + if (element.type === DeepnoteTreeItemType.ProjectFile) { + return this.getNotebooksForProject(element); + } + return []; } - if (!element) { - if (!this.isInitialScanComplete) { - if (!this.initialScanPromise) { - this.initialScanPromise = this.performInitialScan(); - } + // For root level, we need workspace folders + if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { + return []; + } - // Show loading item - return [this.createLoadingTreeItem()]; + if (!this.isInitialScanComplete) { + if (!this.initialScanPromise) { + this.initialScanPromise = this.performInitialScan(); } - return this.getDeepnoteProjectFiles(); - } - - if (element.type === DeepnoteTreeItemType.ProjectFile) { - return this.getNotebooksForProject(element); + return [this.createLoadingTreeItem()]; } - return []; + return this.getDeepnoteProjectFiles(); } private createLoadingTreeItem(): DeepnoteTreeItem { @@ -121,25 +216,40 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider 0; - const collapsibleState = hasNotebooks - ? TreeItemCollapsibleState.Collapsed - : TreeItemCollapsibleState.None; - - const treeItem = new DeepnoteTreeItem( - DeepnoteTreeItemType.ProjectFile, - context, - project, - collapsibleState - ); + // Check if we have a cached tree item for this project + const cacheKey = `project:${file.path}`; + let treeItem = this.treeItemCache.get(cacheKey); + + if (!treeItem) { + // Create new tree item only if not cached + const hasNotebooks = project.project.notebooks && project.project.notebooks.length > 0; + const collapsibleState = hasNotebooks + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None; + + treeItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + context, + project, + collapsibleState + ); + + this.treeItemCache.set(cacheKey, treeItem); + } else { + // Update the cached tree item's data + treeItem.data = project; + } deepnoteFiles.push(treeItem); } catch (error) { - console.error(`Failed to load Deepnote project from ${file.path}:`, error); + this.logger?.error(`Failed to load Deepnote project from ${file.path}`, error); } } } + // Sort projects alphabetically by name (case-insensitive) + deepnoteFiles.sort(compareTreeItemsByLabel); + return deepnoteFiles; } @@ -147,7 +257,14 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { + // Sort notebooks alphabetically by name (case-insensitive) + const sortedNotebooks = [...notebooks].sort((a, b) => { + const nameA = a.name || ''; + const nameB = b.name || ''; + return nameA.toLowerCase().localeCompare(nameB.toLowerCase()); + }); + + return sortedNotebooks.map((notebook: DeepnoteNotebook) => { const context: DeepnoteTreeItemContext = { filePath: projectItem.context.filePath, projectId: projectItem.context.projectId, @@ -172,16 +289,14 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { - this.cachedProjects.delete(uri.path); - this._onDidChangeTreeData.fire(); + // Use granular refresh for file changes + void this.refreshProject(uri.path); }); this.fileWatcher.onDidCreate(() => { + // New file created, do full refresh this._onDidChangeTreeData.fire(); }); this.fileWatcher.onDidDelete((uri) => { + // File deleted, clear both caches and do full refresh this.cachedProjects.delete(uri.path); + this.treeItemCache.delete(`project:${uri.path}`); this._onDidChangeTreeData.fire(); }); } diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts index 37e0523f9..614f1b0a2 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import { l10n } from 'vscode'; -import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; +import { DeepnoteTreeDataProvider, compareTreeItemsByLabel } from './deepnoteTreeDataProvider'; import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem'; import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; @@ -309,4 +309,332 @@ suite('DeepnoteTreeDataProvider', () => { } }); }); + + suite('granular tree updates', () => { + test('should support firing change event with undefined for full refresh', () => { + // This is the current behavior - refreshes entire tree + assert.doesNotThrow(() => { + provider.refresh(); + }); + }); + + test('should support selective refresh of a specific project', async () => { + // Verify that refreshProject method exists and doesn't throw + assert.doesNotThrow(() => { + if (typeof (provider as any).refreshProject === 'function') { + void (provider as any).refreshProject('/workspace/project.deepnote'); + } + }); + }); + + test('should support selective refresh of notebooks for a project', async () => { + // Verify that refreshNotebook method exists and doesn't throw + assert.doesNotThrow(() => { + if (typeof (provider as any).refreshNotebook === 'function') { + void (provider as any).refreshNotebook('project-123'); + } + }); + }); + + test('should update visual fields when project data changes', async () => { + // Access private caches + const treeItemCache = (provider as any).treeItemCache as Map; + + // Create initial project with 1 notebook + const filePath = '/workspace/test-project.deepnote'; + const cacheKey = `project:${filePath}`; + const initialProject: DeepnoteProject = { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-01T00:00:00Z' + }, + project: { + id: 'project-123', + name: 'Original Name', + notebooks: [ + { + id: 'notebook-1', + name: 'Notebook 1', + blocks: [], + executionMode: 'block', + isModule: false + } + ], + settings: {} + }, + version: '1.0' + }; + + const mockTreeItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + { + filePath: filePath, + projectId: 'project-123' + }, + initialProject, + 1 + ); + treeItemCache.set(cacheKey, mockTreeItem); + + // Verify initial state + assert.strictEqual(mockTreeItem.label, 'Original Name'); + assert.strictEqual(mockTreeItem.description, '1 notebook'); + + // Update the project data (simulating rename and adding notebooks) + const updatedProject: DeepnoteProject = { + ...initialProject, + project: { + ...initialProject.project, + name: 'Renamed Project', + notebooks: [ + initialProject.project.notebooks[0], + { + id: 'notebook-2', + name: 'Notebook 2', + blocks: [], + executionMode: 'block', + isModule: false + } + ] + } + }; + + mockTreeItem.data = updatedProject; + mockTreeItem.updateVisualFields(); + + // Verify visual fields were updated + assert.strictEqual(mockTreeItem.label, 'Renamed Project', 'Label should reflect new project name'); + assert.strictEqual( + mockTreeItem.description, + '2 notebooks', + 'Description should reflect new notebook count' + ); + assert.include( + mockTreeItem.tooltip as string, + 'Renamed Project', + 'Tooltip should include new project name' + ); + }); + + test('should clear both caches when file is deleted', () => { + // Access private caches + const cachedProjects = (provider as any).cachedProjects as Map; + const treeItemCache = (provider as any).treeItemCache as Map; + + // Add entries to both caches + const filePath = '/workspace/test-project.deepnote'; + const cacheKey = `project:${filePath}`; + + cachedProjects.set(filePath, mockProject); + const mockTreeItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + { + filePath: filePath, + projectId: 'project-123' + }, + mockProject, + 1 + ); + treeItemCache.set(cacheKey, mockTreeItem); + + // Verify both caches have the entry + assert.isTrue(cachedProjects.has(filePath), 'cachedProjects should have entry before deletion'); + assert.isTrue(treeItemCache.has(cacheKey), 'treeItemCache should have entry before deletion'); + + // Simulate file deletion by calling the internal cleanup logic + // (we can't easily trigger the file watcher in unit tests) + cachedProjects.delete(filePath); + treeItemCache.delete(cacheKey); + + // Verify both caches have been cleared + assert.isFalse(cachedProjects.has(filePath), 'cachedProjects should not have entry after deletion'); + assert.isFalse(treeItemCache.has(cacheKey), 'treeItemCache should not have entry after deletion'); + }); + }); + + suite('alphabetical sorting', () => { + test('compareTreeItemsByLabel should sort items alphabetically (case-insensitive)', () => { + // Test the comparator function in isolation + const mockProjects: DeepnoteProject[] = [ + { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-zebra', + name: 'Zebra Project', + notebooks: [], + settings: {} + }, + version: '1.0' + }, + { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-apple', + name: 'Apple Project', + notebooks: [], + settings: {} + }, + version: '1.0' + }, + { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-middle', + name: 'Middle Project', + notebooks: [], + settings: {} + }, + version: '1.0' + } + ]; + + // Create tree items in unsorted order + const treeItems = mockProjects.map( + (project) => + new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + { + filePath: `/workspace/${project.project.name}.deepnote`, + projectId: project.project.id + }, + project, + 0 + ) + ); + + // Verify items are initially unsorted + assert.strictEqual(treeItems[0].label, 'Zebra Project'); + + // Sort using the exported comparator + const sortedItems = [...treeItems].sort(compareTreeItemsByLabel); + + // Verify alphabetical order + assert.strictEqual(sortedItems[0].label, 'Apple Project'); + assert.strictEqual(sortedItems[1].label, 'Middle Project'); + assert.strictEqual(sortedItems[2].label, 'Zebra Project'); + }); + + test('should sort notebooks alphabetically by name within a project', async () => { + // Create a project with unsorted notebooks + const mockProjectWithNotebooks: DeepnoteProject = { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-123', + name: 'Test Project', + notebooks: [ + { + id: 'notebook-z', + name: 'Zebra Notebook', + blocks: [], + executionMode: 'block', + isModule: false + }, + { + id: 'notebook-a', + name: 'Apple Notebook', + blocks: [], + executionMode: 'block', + isModule: false + }, + { + id: 'notebook-m', + name: 'Middle Notebook', + blocks: [], + executionMode: 'block', + isModule: false + } + ], + settings: {} + }, + version: '1.0' + }; + + const mockProjectItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + { + filePath: '/workspace/project.deepnote', + projectId: 'project-123' + }, + mockProjectWithNotebooks, + 1 + ); + + const notebookItems = await provider.getChildren(mockProjectItem); + + // Verify notebooks are sorted alphabetically + assert.strictEqual(notebookItems.length, 3, 'Should have 3 notebooks'); + assert.strictEqual(notebookItems[0].label, 'Apple Notebook', 'First notebook should be Apple Notebook'); + assert.strictEqual(notebookItems[1].label, 'Middle Notebook', 'Second notebook should be Middle Notebook'); + assert.strictEqual(notebookItems[2].label, 'Zebra Notebook', 'Third notebook should be Zebra Notebook'); + }); + + test('should sort notebooks case-insensitively', async () => { + // Create a project with notebooks having different cases + const mockProjectWithNotebooks: DeepnoteProject = { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-123', + name: 'Test Project', + notebooks: [ + { + id: 'notebook-z', + name: 'zebra notebook', + blocks: [], + executionMode: 'block', + isModule: false + }, + { + id: 'notebook-a', + name: 'Apple Notebook', + blocks: [], + executionMode: 'block', + isModule: false + }, + { + id: 'notebook-m', + name: 'MIDDLE Notebook', + blocks: [], + executionMode: 'block', + isModule: false + } + ], + settings: {} + }, + version: '1.0' + }; + + const mockProjectItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectFile, + { + filePath: '/workspace/project.deepnote', + projectId: 'project-123' + }, + mockProjectWithNotebooks, + 1 + ); + + const notebookItems = await provider.getChildren(mockProjectItem); + + // Verify case-insensitive sorting + assert.strictEqual(notebookItems.length, 3, 'Should have 3 notebooks'); + assert.strictEqual(notebookItems[0].label, 'Apple Notebook', 'First should be Apple Notebook'); + assert.strictEqual(notebookItems[1].label, 'MIDDLE Notebook', 'Second should be MIDDLE Notebook'); + assert.strictEqual(notebookItems[2].label, 'zebra notebook', 'Third should be zebra notebook'); + }); + }); }); diff --git a/src/notebooks/deepnote/deepnoteTreeItem.ts b/src/notebooks/deepnote/deepnoteTreeItem.ts index a0e353e3c..9da00c480 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -26,7 +26,7 @@ export class DeepnoteTreeItem extends TreeItem { constructor( public readonly type: DeepnoteTreeItemType, public readonly context: DeepnoteTreeItemContext, - public readonly data: DeepnoteProject | DeepnoteNotebook | null, + public data: DeepnoteProject | DeepnoteNotebook | null, collapsibleState: TreeItemCollapsibleState ) { super('', collapsibleState); @@ -104,4 +104,14 @@ export class DeepnoteTreeItem extends TreeItem { return undefined; } + + /** + * Updates the tree item's visual fields (label, description, tooltip) based on current data. + * Call this after updating the data property to ensure the tree view reflects changes. + */ + public updateVisualFields(): void { + this.label = this.getLabel(); + this.description = this.getDescription(); + this.tooltip = this.getTooltip(); + } } diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 3a8b14f9c..6886d5c36 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -238,6 +238,16 @@ export namespace Commands { export const AddInputDateRangeBlock = 'deepnote.addInputDateRangeBlock'; export const AddInputFileBlock = 'deepnote.addInputFileBlock'; export const AddButtonBlock = 'deepnote.addButtonBlock'; + export const NewNotebook = 'deepnote.newNotebook'; + export const NewProject = 'deepnote.newProject'; + export const ImportNotebook = 'deepnote.importNotebook'; + export const ImportJupyterNotebook = 'deepnote.importJupyterNotebook'; + export const RenameProject = 'deepnote.renameProject'; + export const DeleteProject = 'deepnote.deleteProject'; + export const RenameNotebook = 'deepnote.renameNotebook'; + export const DeleteNotebook = 'deepnote.deleteNotebook'; + export const DuplicateNotebook = 'deepnote.duplicateNotebook'; + export const AddNotebookToProject = 'deepnote.addNotebookToProject'; export const OpenInDeepnote = 'deepnote.openInDeepnote'; export const ExportAsPythonScript = 'jupyter.exportAsPythonScript'; export const ExportToHTML = 'jupyter.exportToHTML';