From f0de6cd868843c38d5459921bf135f28dcb9aa38 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 23 Jun 2026 22:23:27 +0000 Subject: [PATCH 01/26] refactor(deepnote): re-key notebook manager per (projectId, notebookId) + add project-id resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 1 of single-notebook migration (§4 partial, §5). No behaviour change. - Manager caches originals in a nested Map> so sibling files sharing a project.id no longer clobber each other. - New API: getOriginalProject(projectId, notebookId) exact/no-fallback, getAnyProjectEntry(projectId), storeOriginalProject/updateOriginalProject (3-arg), updateProjectIntegrations iterates all entries. - Update IDeepnoteNotebookManager and IPlatformDeepnoteNotebookManager; repoint all project-level read-only callers to getAnyProjectEntry. - Add canonical readDeepnoteProjectFile and resolveProjectIdFor{File,Notebook}. - Selection state and init-run tracking intentionally kept (removed in later chunks). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- .../deepnote/deepnoteLspClientManager.node.ts | 2 +- ...epnoteLspClientManager.node.vscode.test.ts | 2 +- .../deepnote/deepnoteFileChangeWatcher.ts | 2 +- .../deepnoteFileChangeWatcher.unit.test.ts | 2 +- .../deepnoteInitNotebookRunner.node.ts | 2 +- .../deepnoteKernelAutoSelector.node.ts | 2 +- .../deepnote/deepnoteNotebookManager.ts | 138 ++++++++++----- .../deepnoteNotebookManager.unit.test.ts | 166 +++++++++++++++--- .../deepnote/deepnoteProjectUtils.ts | 11 +- src/notebooks/deepnote/deepnoteSerializer.ts | 9 +- .../deepnote/deepnoteSerializer.unit.test.ts | 32 ++-- .../federatedAuthKernelRestartBridge.node.ts | 2 +- ...dAuthKernelRestartBridge.node.unit.test.ts | 16 +- .../integrations/integrationDetector.ts | 2 +- .../integrations/integrationManager.ts | 2 +- .../integrations/integrationWebview.ts | 2 +- .../deepnote/snapshots/snapshotService.ts | 2 +- .../snapshots/snapshotService.unit.test.ts | 2 +- .../deepnote/sqlCellStatusBarProvider.ts | 4 +- .../sqlCellStatusBarProvider.unit.test.ts | 32 ++-- src/notebooks/types.ts | 25 ++- .../deepnote/deepnoteProjectFileReader.ts | 19 ++ .../deepnoteProjectFileReader.unit.test.ts | 99 +++++++++++ .../deepnote/deepnoteProjectIdResolver.ts | 48 +++++ .../deepnoteProjectIdResolver.unit.test.ts | 137 +++++++++++++++ ...IntegrationEnvironmentVariablesProvider.ts | 2 +- ...nEnvironmentVariablesProvider.unit.test.ts | 22 +-- src/platform/notebooks/deepnote/types.ts | 7 +- 28 files changed, 638 insertions(+), 153 deletions(-) create mode 100644 src/platform/deepnote/deepnoteProjectFileReader.ts create mode 100644 src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts create mode 100644 src/platform/deepnote/deepnoteProjectIdResolver.ts create mode 100644 src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts diff --git a/src/kernels/deepnote/deepnoteLspClientManager.node.ts b/src/kernels/deepnote/deepnoteLspClientManager.node.ts index 8389da4bad..a092cec861 100644 --- a/src/kernels/deepnote/deepnoteLspClientManager.node.ts +++ b/src/kernels/deepnote/deepnoteLspClientManager.node.ts @@ -610,7 +610,7 @@ export class DeepnoteLspClientManager return []; } - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); if (!project) { logger.warn(`SQL LSP: No project found for ID: ${projectId}`); diff --git a/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts b/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts index 31a6c85845..64f6be1b13 100644 --- a/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts +++ b/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts @@ -48,7 +48,7 @@ suite('DeepnoteLspClientManager Integration Tests', () => { // Mock notebook manager // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockNotebookManager = { - getOriginalProject: () => undefined + getAnyProjectEntry: () => undefined } as any; setup(() => { diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index 090c84d815..06aae1b58d 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -366,7 +366,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic } // Look up original project blocks for fallback block ID resolution - const originalProject = this.notebookManager.getOriginalProject(projectId); + const originalProject = this.notebookManager.getAnyProjectEntry(projectId); const notebookBlocksMap = new Map(); if (originalProject) { for (const nb of originalProject.project.notebooks) { diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index 0ab2d21fed..d6e0656875 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -1019,7 +1019,7 @@ project: test('should apply snapshot outputs using original blocks when metadata is lost', async () => { // Create a mock notebook manager that returns an original project const mockedManager = mock(); - when(mockedManager.getOriginalProject('project-1')).thenReturn({ + when(mockedManager.getAnyProjectEntry('project-1')).thenReturn({ version: '1.0', metadata: { createdAt: '2025-01-01T00:00:00Z' }, project: { diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index a201cc93ea..fc29c87409 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts @@ -72,7 +72,7 @@ export class DeepnoteInitNotebookRunner { } // Get the project data - const project = this.notebookManager.getOriginalProject(projectId) as DeepnoteProject | undefined; + const project = this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteProject | undefined; if (!project) { logger.warn(`Project ${projectId} not found, cannot run init notebook`); return; diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index 63e7129200..a0205555a4 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -671,7 +671,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Prepare init notebook execution const projectId = notebook.metadata?.deepnoteProjectId; const project = projectId - ? (this.notebookManager.getOriginalProject(projectId) as DeepnoteFile | undefined) + ? (this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteFile | undefined) : undefined; if (project) { diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 069f3a570c..e82e41e2ea 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -10,10 +10,35 @@ import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; @injectable() export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { private readonly currentNotebookId = new Map(); - private readonly originalProjects = new Map(); + // Cached originals are keyed by projectId, then by notebookId, so sibling files + // that share a single project.id do not clobber each other's cached project data. + private readonly originalProjects = new Map>(); private readonly projectsWithInitNotebookRun = new Set(); private readonly selectedNotebookByProject = new Map(); + /** + * Returns any cached project entry for the given project id. + * Intended for project-level read-only callers that have only a `projectId` + * (no specific notebook). Because sibling files deliberately share a `project.id`, + * this may return the cached project of any one sibling — it must never be used + * on a save path (use {@link getOriginalProject} there). + * @param projectId Project identifier + * @returns A cached project for the project, or undefined if none is cached + */ + getAnyProjectEntry(projectId: string): DeepnoteProject | undefined { + const notebookEntries = this.originalProjects.get(projectId); + + if (!notebookEntries) { + return undefined; + } + + for (const project of notebookEntries.values()) { + return project; + } + + return undefined; + } + /** * Gets the currently selected notebook ID for a project. * @param projectId Project identifier @@ -24,12 +49,15 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { } /** - * Retrieves the original project data for a given project ID. + * Retrieves the cached project data for an exact (projectId, notebookId) pair. + * This performs an exact match only and never falls back to another sibling's + * project — it returns undefined when that precise entry is not cached. * @param projectId Project identifier - * @returns Original project data or undefined if not found + * @param notebookId Notebook identifier within the project + * @returns The cached project data for that notebook, or undefined if not found */ - getOriginalProject(projectId: string): DeepnoteProject | undefined { - return this.originalProjects.get(projectId); + getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined { + return this.originalProjects.get(projectId)?.get(notebookId); } /** @@ -41,6 +69,23 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { return this.selectedNotebookByProject.get(projectId); } + /** + * Checks if the init notebook has already been run for a project. + * @param projectId Project identifier + * @returns True if init notebook has been run, false otherwise + */ + hasInitNotebookBeenRun(projectId: string): boolean { + return this.projectsWithInitNotebookRun.has(projectId); + } + + /** + * Marks the init notebook as having been run for a project. + * @param projectId Project identifier + */ + markInitNotebookAsRun(projectId: string): void { + this.projectsWithInitNotebookRun.add(projectId); + } + /** * Associates a notebook ID with a project to remember user's notebook selection. * When a Deepnote project contains multiple notebooks, this mapping persists the user's @@ -54,20 +99,26 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { } /** - * Stores the original project data and sets the initial current notebook. + * Stores the original project data for an exact (projectId, notebookId) pair and + * records the notebook as the project's current notebook. * This is used during deserialization to cache project data and track the active notebook. * @param projectId Project identifier + * @param notebookId Notebook identifier within the project * @param project Original project data to store - * @param notebookId Initial notebook ID to set as current */ - storeOriginalProject(projectId: string, project: DeepnoteProject, notebookId: string): void { - // Deep clone to prevent mutations from affecting stored state - // This is critical for multi-notebook projects where multiple notebooks - // share the same stored project reference - // Using structuredClone to handle circular references (e.g., in output metadata) + storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void { + // Deep clone to prevent mutations from affecting stored state. + // Using structuredClone to handle circular references (e.g., in output metadata). const clonedProject = structuredClone(project); - this.originalProjects.set(projectId, clonedProject); + let notebookEntries = this.originalProjects.get(projectId); + + if (!notebookEntries) { + notebookEntries = new Map(); + this.originalProjects.set(projectId, notebookEntries); + } + + notebookEntries.set(notebookId, clonedProject); this.currentNotebookId.set(projectId, notebookId); } @@ -82,48 +133,49 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { } /** - * Updates the integrations list in the project data. - * This modifies the stored project to reflect changes in configured integrations. + * Updates the cached project data for an exact (projectId, notebookId) pair. + * Unlike {@link storeOriginalProject}, this does not change the project's current + * notebook bookkeeping — it only refreshes the cached project for that notebook. + * @param projectId Project identifier + * @param notebookId Notebook identifier within the project + * @param project Updated project data to store + */ + updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void { + const clonedProject = structuredClone(project); + + let notebookEntries = this.originalProjects.get(projectId); + + if (!notebookEntries) { + notebookEntries = new Map(); + this.originalProjects.set(projectId, notebookEntries); + } + + notebookEntries.set(notebookId, clonedProject); + } + + /** + * Updates the integrations list in the cached project data (cache-only). + * Iterates every cached notebook entry under the project and updates each entry's + * integrations. The on-disk fan-out across sibling files is handled separately. * * @param projectId - Project identifier * @param integrations - Array of integration metadata to store in the project - * @returns `true` if the project was found and updated successfully, `false` if the project does not exist + * @returns `true` if at least one cached entry was found and updated, `false` otherwise */ updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean { - const project = this.originalProjects.get(projectId); + const notebookEntries = this.originalProjects.get(projectId); - if (!project) { + if (!notebookEntries || notebookEntries.size === 0) { return false; } - const updatedProject = structuredClone(project); - updatedProject.project.integrations = integrations; - - const currentNotebookId = this.currentNotebookId.get(projectId); + for (const [notebookId, project] of notebookEntries) { + const updatedProject = structuredClone(project); + updatedProject.project.integrations = integrations; - if (currentNotebookId) { - this.storeOriginalProject(projectId, updatedProject, currentNotebookId); - } else { - this.originalProjects.set(projectId, updatedProject); + notebookEntries.set(notebookId, updatedProject); } return true; } - - /** - * Checks if the init notebook has already been run for a project. - * @param projectId Project identifier - * @returns True if init notebook has been run, false otherwise - */ - hasInitNotebookBeenRun(projectId: string): boolean { - return this.projectsWithInitNotebookRun.has(projectId); - } - - /** - * Marks the init notebook as having been run for a project. - * @param projectId Project identifier - */ - markInitNotebookAsRun(projectId: string): void { - this.projectsWithInitNotebookRun.add(projectId); - } } diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index feb2842848..e642a93e07 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -33,7 +33,7 @@ suite('DeepnoteNotebookManager', () => { }); test('should return notebook ID after storing project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); const result = manager.getCurrentNotebookId('project-123'); @@ -41,7 +41,7 @@ suite('DeepnoteNotebookManager', () => { }); test('should return updated notebook ID', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); manager.updateCurrentNotebookId('project-123', 'notebook-789'); const result = manager.getCurrentNotebookId('project-123'); @@ -52,15 +52,15 @@ suite('DeepnoteNotebookManager', () => { suite('getOriginalProject', () => { test('should return undefined for unknown project', () => { - const result = manager.getOriginalProject('unknown-project'); + const result = manager.getOriginalProject('unknown-project', 'notebook-456'); assert.strictEqual(result, undefined); }); test('should return original project after storing', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const result = manager.getOriginalProject('project-123'); + const result = manager.getOriginalProject('project-123', 'notebook-456'); assert.deepStrictEqual(result, mockProject); }); @@ -125,9 +125,9 @@ suite('DeepnoteNotebookManager', () => { suite('storeOriginalProject', () => { test('should store both project and current notebook ID', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const storedProject = manager.getOriginalProject('project-123'); + const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); const currentNotebookId = manager.getCurrentNotebookId('project-123'); assert.deepStrictEqual(storedProject, mockProject); @@ -143,20 +143,20 @@ suite('DeepnoteNotebookManager', () => { } }; - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); - manager.storeOriginalProject('project-123', updatedProject, 'notebook-789'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); + manager.storeOriginalProject('project-123', 'notebook-456', updatedProject); - const storedProject = manager.getOriginalProject('project-123'); + const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); const currentNotebookId = manager.getCurrentNotebookId('project-123'); assert.deepStrictEqual(storedProject, updatedProject); - assert.strictEqual(currentNotebookId, 'notebook-789'); + assert.strictEqual(currentNotebookId, 'notebook-456'); }); }); suite('updateCurrentNotebookId', () => { test('should update notebook ID for existing project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); manager.updateCurrentNotebookId('project-123', 'notebook-789'); const result = manager.getCurrentNotebookId('project-123'); @@ -186,7 +186,7 @@ suite('DeepnoteNotebookManager', () => { suite('updateProjectIntegrations', () => { test('should update integrations list for existing project and return true', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); const integrations: ProjectIntegration[] = [ { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, @@ -197,7 +197,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, integrations); }); @@ -210,7 +210,7 @@ suite('DeepnoteNotebookManager', () => { } }; - manager.storeOriginalProject('project-123', projectWithIntegrations, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', projectWithIntegrations); const newIntegrations: ProjectIntegration[] = [ { id: 'new-int-1', name: 'New Integration 1', type: 'pgsql' }, @@ -221,7 +221,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations); }); @@ -234,13 +234,13 @@ suite('DeepnoteNotebookManager', () => { } }; - manager.storeOriginalProject('project-123', projectWithIntegrations, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', projectWithIntegrations); const result = manager.updateProjectIntegrations('project-123', []); assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, []); }); @@ -251,12 +251,12 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, false); - const project = manager.getOriginalProject('unknown-project'); + const project = manager.getOriginalProject('unknown-project', 'notebook-456'); assert.strictEqual(project, undefined); }); test('should preserve other project properties and return true', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); const integrations: ProjectIntegration[] = [{ id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }]; @@ -264,7 +264,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); assert.strictEqual(updatedProject?.project.id, mockProject.project.id); assert.strictEqual(updatedProject?.project.name, mockProject.project.name); assert.strictEqual(updatedProject?.version, mockProject.version); @@ -273,7 +273,7 @@ suite('DeepnoteNotebookManager', () => { test('should update integrations when currentNotebookId is undefined and return true', () => { // Store project with a notebook ID, then clear it to simulate the edge case - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); manager.updateCurrentNotebookId('project-123', undefined as any); const integrations: ProjectIntegration[] = [ @@ -285,7 +285,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, integrations); // Verify other properties remain unchanged assert.strictEqual(updatedProject?.project.id, mockProject.project.id); @@ -297,10 +297,10 @@ suite('DeepnoteNotebookManager', () => { suite('integration scenarios', () => { test('should handle complete workflow for multiple projects', () => { - manager.storeOriginalProject('project-1', mockProject, 'notebook-1'); + manager.storeOriginalProject('project-1', 'notebook-1', mockProject); manager.selectNotebookForProject('project-1', 'notebook-1'); - manager.storeOriginalProject('project-2', mockProject, 'notebook-2'); + manager.storeOriginalProject('project-2', 'notebook-2', mockProject); manager.selectNotebookForProject('project-2', 'notebook-2'); assert.strictEqual(manager.getCurrentNotebookId('project-1'), 'notebook-1'); @@ -310,7 +310,7 @@ suite('DeepnoteNotebookManager', () => { }); test('should handle notebook switching within same project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-1'); + manager.storeOriginalProject('project-123', 'notebook-1', mockProject); manager.selectNotebookForProject('project-123', 'notebook-1'); manager.updateCurrentNotebookId('project-123', 'notebook-2'); @@ -322,7 +322,7 @@ suite('DeepnoteNotebookManager', () => { test('should maintain separation between current and selected notebook IDs', () => { // Store original project sets current notebook - manager.storeOriginalProject('project-123', mockProject, 'notebook-original'); + manager.storeOriginalProject('project-123', 'notebook-original', mockProject); // Selecting a different notebook for the project manager.selectNotebookForProject('project-123', 'notebook-selected'); @@ -332,4 +332,116 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-123'), 'notebook-selected'); }); }); + + // Two sibling .deepnote files of ONE project share project.id but each holds a + // different single notebook. These tests pin the load-bearing new semantics: + // nested (projectId, notebookId) storage with an exact, no-fallback lookup. + suite('nested sibling storage', () => { + const projectId = 'shared-project-id'; + const nbA = 'notebook-A'; + const nbB = 'notebook-B'; + + // A project (whole DeepnoteFile) for one sibling: same projectId, distinct notebook. + function siblingProject(notebookId: string, notebookName: string): DeepnoteProject { + return { + ...mockProject, + project: { + ...mockProject.project, + id: projectId, + notebooks: [ + { + id: notebookId, + name: notebookName, + blocks: [] + } + ] + } + }; + } + + test('stores two siblings of the same project without clobbering each other', () => { + const projectA = siblingProject(nbA, 'Sibling A'); + const projectB = siblingProject(nbB, 'Sibling B'); + + manager.storeOriginalProject(projectId, nbA, projectA); + manager.storeOriginalProject(projectId, nbB, projectB); + + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA), projectA); + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB), projectB); + }); + + test('getOriginalProject is exact: returns undefined for an uncached notebook even though a sibling IS cached (NO fallback)', () => { + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + + // A different notebook of the SAME project is cached, but the requested one is not. + // The exact lookup must NOT fall back to the sibling — this is the key anti-regression + // (a save path relies on it to never write against the wrong sibling's project). + const result = manager.getOriginalProject(projectId, 'not-cached'); + + assert.strictEqual(result, undefined); + }); + + test('getAnyProjectEntry returns one of the project entries (project-level)', () => { + const projectA = siblingProject(nbA, 'Sibling A'); + const projectB = siblingProject(nbB, 'Sibling B'); + + manager.storeOriginalProject(projectId, nbA, projectA); + manager.storeOriginalProject(projectId, nbB, projectB); + + const result = manager.getAnyProjectEntry(projectId); + + assert.notStrictEqual(result, undefined); + // It must be one of the project's own cached entries. + const isOneOfTheSiblings = + JSON.stringify(result) === JSON.stringify(projectA) || + JSON.stringify(result) === JSON.stringify(projectB); + assert.strictEqual( + isOneOfTheSiblings, + true, + 'getAnyProjectEntry should return one of the cached sibling projects' + ); + }); + + test('getAnyProjectEntry returns undefined for an unknown project', () => { + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + + assert.strictEqual(manager.getAnyProjectEntry('unknown-project'), undefined); + }); + + test('updateOriginalProject refreshes the exact entry without affecting the sibling', () => { + const projectA = siblingProject(nbA, 'Sibling A'); + const projectB = siblingProject(nbB, 'Sibling B'); + + manager.storeOriginalProject(projectId, nbA, projectA); + manager.storeOriginalProject(projectId, nbB, projectB); + + const renamedA: DeepnoteProject = { + ...projectA, + project: { ...projectA.project, name: 'Sibling A Renamed' } + }; + + manager.updateOriginalProject(projectId, nbA, renamedA); + + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA), renamedA); + // Sibling B must be untouched by the update to A. + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB), projectB); + }); + + test('updateProjectIntegrations updates every cached notebook entry under the project', () => { + manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); + manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B')); + + const integrations: ProjectIntegration[] = [ + { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, + { id: 'int-2', name: 'BigQuery', type: 'big-query' } + ]; + + const updated = manager.updateProjectIntegrations(projectId, integrations); + + assert.strictEqual(updated, true); + // BOTH siblings of the project must see the new integrations. + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA)?.project.integrations, integrations); + assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB)?.project.integrations, integrations); + }); + }); }); diff --git a/src/notebooks/deepnote/deepnoteProjectUtils.ts b/src/notebooks/deepnote/deepnoteProjectUtils.ts index 976008ac6e..bba0d564c7 100644 --- a/src/notebooks/deepnote/deepnoteProjectUtils.ts +++ b/src/notebooks/deepnote/deepnoteProjectUtils.ts @@ -1,11 +1,6 @@ -import { deserializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; -import { Uri, workspace } from 'vscode'; - -export async function readDeepnoteProjectFile(fileUri: Uri): Promise { - const fileContent = await workspace.fs.readFile(fileUri); - const yamlContent = new TextDecoder().decode(fileContent); - return deserializeDeepnoteFile(yamlContent); -} +// Re-export the platform-layer reader so there is a single source of truth for +// reading and parsing `.deepnote` files (see src/platform/deepnote/deepnoteProjectFileReader.ts). +export { readDeepnoteProjectFile } from '../../platform/deepnote/deepnoteProjectFileReader'; /** * Compute a hash of the requirements to detect changes. diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index 17443e93b9..f1149f6d1c 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -157,7 +157,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { ); } - this.notebookManager.storeOriginalProject(deepnoteFile.project.id, deepnoteFile, selectedNotebook.id); + this.notebookManager.storeOriginalProject(deepnoteFile.project.id, selectedNotebook.id, deepnoteFile); logger.debug(`DeepnoteSerializer: Stored project ${projectId} in notebook manager`); return { @@ -248,7 +248,10 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { // Clone the project before modifying to prevent state corruption // This is critical for multi-notebook projects where the stored project // is shared between notebook serialization calls - const storedProject = this.notebookManager.getOriginalProject(projectId) as DeepnoteFile | undefined; + // Chunk 1 keeps the save path behaviour identical to before by using the + // project-only lookup. Chunk 2 will switch this to the exact (projectId, notebookId) + // lookup (getOriginalProject) once the notebook id is resolved first. + const storedProject = this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteFile | undefined; if (!storedProject) { throw new Error('Original Deepnote project not found. Cannot save changes.'); @@ -347,7 +350,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { } // Store the updated project back so subsequent saves start from correct state - this.notebookManager.storeOriginalProject(projectId, originalProject, notebookId); + this.notebookManager.storeOriginalProject(projectId, notebookId, originalProject); logger.debug('SerializeNotebook: Serializing to YAML'); diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index c3332f974d..3f62358b9f 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -179,7 +179,7 @@ project: test('should serialize notebook when original project exists', async () => { // First store the original project - manager.storeOriginalProject('project-123', mockProject, 'notebook-1'); + manager.storeOriginalProject('project-123', 'notebook-1', mockProject); const mockNotebookData = { cells: [ @@ -472,7 +472,7 @@ project: } }; - manager.storeOriginalProject('project-circular', projectWithCircularRef, 'notebook-1'); + manager.storeOriginalProject('project-circular', 'notebook-1', projectWithCircularRef); const notebookData = { cells: [ @@ -540,7 +540,7 @@ project: }; // Store the project - manager.storeOriginalProject('project-id-test', projectData, 'notebook-1'); + manager.storeOriginalProject('project-id-test', 'notebook-1', projectData); // Create cells with the EXACT metadata structure that deserializeNotebook produces // This simulates what VS Code should preserve from deserialization @@ -626,7 +626,7 @@ project: } }; - manager.storeOriginalProject('project-recover-ids', projectData, 'notebook-1'); + manager.storeOriginalProject('project-recover-ids', 'notebook-1', projectData); // Cells WITHOUT id metadata (simulating what VS Code might provide if it strips metadata) // But content matches the original block @@ -693,7 +693,7 @@ project: } }; - manager.storeOriginalProject('project-new-content', projectData, 'notebook-1'); + manager.storeOriginalProject('project-new-content', 'notebook-1', projectData); // Cell with different content than any original block const notebookData = { @@ -1491,7 +1491,7 @@ project: } }; - manager.storeOriginalProject('project-snapshot-hash', projectData, 'notebook-1'); + manager.storeOriginalProject('project-snapshot-hash', 'notebook-1', projectData); const notebookData = { cells: [ @@ -1566,13 +1566,13 @@ project: }; // Serialize twice - manager.storeOriginalProject('project-deterministic', structuredClone(projectData), 'notebook-1'); + manager.storeOriginalProject('project-deterministic', 'notebook-1', structuredClone(projectData)); const result1 = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed1 = parseYaml(new TextDecoder().decode(result1)) as DeepnoteFile & { metadata: { snapshotHash?: string }; }; - manager.storeOriginalProject('project-deterministic', structuredClone(projectData), 'notebook-1'); + manager.storeOriginalProject('project-deterministic', 'notebook-1', structuredClone(projectData)); const result2 = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed2 = parseYaml(new TextDecoder().decode(result2)) as DeepnoteFile & { metadata: { snapshotHash?: string }; @@ -1667,7 +1667,7 @@ project: // Serialize 5 times and collect all hashes for (let i = 0; i < 5; i++) { - manager.storeOriginalProject('project-multi-serialize', structuredClone(projectData), 'notebook-1'); + manager.storeOriginalProject('project-multi-serialize', 'notebook-1', structuredClone(projectData)); const result = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed = parseYaml(new TextDecoder().decode(result)) as DeepnoteFile & { metadata: { snapshotHash?: string }; @@ -1716,7 +1716,7 @@ project: } }; - manager.storeOriginalProject('project-content-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-content-change', 'notebook-1', projectData1); const notebookData1 = { cells: [ @@ -1794,7 +1794,7 @@ project: } }; - manager.storeOriginalProject('project-version-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-version-change', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1818,7 +1818,7 @@ project: // Change version const projectData2: DeepnoteFile = { ...structuredClone(projectData1), version: '2.0' }; - manager.storeOriginalProject('project-version-change', projectData2, 'notebook-1'); + manager.storeOriginalProject('project-version-change', 'notebook-1', projectData2); const result2 = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed2 = parseYaml(new TextDecoder().decode(result2)) as DeepnoteFile & { @@ -1860,7 +1860,7 @@ project: } }; - manager.storeOriginalProject('project-integrations-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-integrations-change', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1885,7 +1885,7 @@ project: // Add integrations const projectData2 = structuredClone(projectData1); projectData2.project.integrations = [{ id: 'int-1', name: 'PostgreSQL', type: 'postgres' }]; - manager.storeOriginalProject('project-integrations-change', projectData2, 'notebook-1'); + manager.storeOriginalProject('project-integrations-change', 'notebook-1', projectData2); const result2 = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed2 = parseYaml(new TextDecoder().decode(result2)) as DeepnoteFile & { @@ -1927,7 +1927,7 @@ project: } }; - manager.storeOriginalProject('project-env-hash', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-env-hash', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1952,7 +1952,7 @@ project: // Add environment hash const projectData2 = structuredClone(projectData1); projectData2.environment = { hash: 'env-hash-123' }; - manager.storeOriginalProject('project-env-hash', projectData2, 'notebook-1'); + manager.storeOriginalProject('project-env-hash', 'notebook-1', projectData2); const result2 = await serializer.serializeNotebook(notebookData as any, {} as any); const parsed2 = parseYaml(new TextDecoder().decode(result2)) as DeepnoteFile & { diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts index de858ad723..40a554800c 100644 --- a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts @@ -62,7 +62,7 @@ export class FederatedAuthKernelRestartBridge implements IExtensionSyncActivatio continue; } - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); if (!project) { continue; } diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts index dbd76c0f9a..fde8cf060a 100644 --- a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts @@ -71,8 +71,8 @@ suite('FederatedAuthKernelRestartBridge', () => { when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); // Only project A references 'bq-shared'. - when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['other-bq'])); + when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['other-bq'])); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); @@ -90,8 +90,8 @@ suite('FederatedAuthKernelRestartBridge', () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); - when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); @@ -130,7 +130,7 @@ suite('FederatedAuthKernelRestartBridge', () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); when(kernelProvider.get(notebook)).thenReturn(instance(kernel)); if (project) { - when(notebookManager.getOriginalProject('project-a')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(project); } onDidChangeTokens.fire('bq-1'); @@ -138,7 +138,7 @@ suite('FederatedAuthKernelRestartBridge', () => { verify(kernel.restart()).never(); if (!project) { - verify(notebookManager.getOriginalProject(anyString())).never(); + verify(notebookManager.getAnyProjectEntry(anyString())).never(); } }); }); @@ -152,8 +152,8 @@ suite('FederatedAuthKernelRestartBridge', () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); - when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); diff --git a/src/notebooks/deepnote/integrations/integrationDetector.ts b/src/notebooks/deepnote/integrations/integrationDetector.ts index fe64c64a7e..40c5668bd1 100644 --- a/src/notebooks/deepnote/integrations/integrationDetector.ts +++ b/src/notebooks/deepnote/integrations/integrationDetector.ts @@ -26,7 +26,7 @@ export class IntegrationDetector implements IIntegrationDetector { */ async detectIntegrations(projectId: string): Promise> { // Get the project - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); if (!project) { logger.warn( `IntegrationDetector: No project found for ID: ${projectId}. The project may not have been loaded yet.` diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index 1b495e19a6..ff6e5b0e73 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -146,7 +146,7 @@ export class IntegrationManager implements IIntegrationManager { const config = await this.integrationStorage.getIntegrationConfig(selectedIntegrationId); // Try to get integration metadata from the project - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); const projectIntegration = project?.project.integrations?.find((i) => i.id === selectedIntegrationId); let integrationName: string | undefined; diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 7b611702b2..17d0c35de3 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -471,7 +471,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { // Get the project name from the notebook manager let projectName: string | undefined; if (this.projectId) { - const project = this.notebookManager.getOriginalProject(this.projectId); + const project = this.notebookManager.getAnyProjectEntry(this.projectId); projectName = project?.project.name; } diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 6e4b99e96e..4f4aacab42 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -739,7 +739,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } - const originalProject = this.notebookManager?.getOriginalProject(projectId); + const originalProject = this.notebookManager?.getAnyProjectEntry(projectId); if (!originalProject) { logger.warn(`[Snapshot] No original project found for ${projectId}`); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index 36666343a7..41f5ba8c94 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -1117,7 +1117,7 @@ project: }; const mockNotebookManager = { - getOriginalProject: sinon.stub().returns(originalProject) + getAnyProjectEntry: sinon.stub().returns(originalProject) }; // Create a new service with the mock notebook manager diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index 210fcdaa21..26db219e77 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -229,7 +229,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid displayName = config.name; } else { // Integration is not configured, try to get the name from the project's integration list - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); const projectIntegration = project?.project.integrations?.find((i) => i.id === integrationId); const baseName = projectIntegration?.name || l10n.t('Unknown integration'); displayName = l10n.t('{0} (configure)', baseName); @@ -331,7 +331,7 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid } // Get the project to access its integrations list - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); if (!project) { void window.showErrorMessage(l10n.t('Project not found')); return; diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index f008154bcd..76c9933e28 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -169,7 +169,7 @@ suite('SqlCellStatusBarProvider', () => { }); when(integrationStorage.getProjectIntegrationConfig(anything(), anything())).thenResolve(undefined); - when(notebookManager.getOriginalProject('project-1')).thenReturn({ + when(notebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -200,7 +200,7 @@ suite('SqlCellStatusBarProvider', () => { }); when(integrationStorage.getProjectIntegrationConfig(anything(), anything())).thenResolve(undefined); - when(notebookManager.getOriginalProject('project-1')).thenReturn({ + when(notebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -430,7 +430,7 @@ suite('SqlCellStatusBarProvider', () => { }, selection: { start: 0 } } as any); - when(activateNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(activateNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } } as any); when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); @@ -840,7 +840,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -872,7 +872,7 @@ suite('SqlCellStatusBarProvider', () => { notebookMetadata }); - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -897,7 +897,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -922,7 +922,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -952,7 +952,7 @@ suite('SqlCellStatusBarProvider', () => { notebookMetadata }); - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -978,7 +978,7 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } @@ -1001,7 +1001,7 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -1030,7 +1030,7 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -1064,7 +1064,7 @@ suite('SqlCellStatusBarProvider', () => { }); let quickPickItems: any[] = []; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -1101,7 +1101,7 @@ suite('SqlCellStatusBarProvider', () => { const notebookMetadata = { deepnoteProjectId: 'missing-project' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); - when(commandNotebookManager.getOriginalProject('missing-project')).thenReturn(undefined); + when(commandNotebookManager.getAnyProjectEntry('missing-project')).thenReturn(undefined); await switchIntegrationHandler(cell); @@ -1114,7 +1114,7 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -1163,7 +1163,7 @@ suite('SqlCellStatusBarProvider', () => { notebookMetadata }); - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [ { @@ -1193,7 +1193,7 @@ suite('SqlCellStatusBarProvider', () => { notebookMetadata }); - when(commandNotebookManager.getOriginalProject('project-1')).thenReturn({ + when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ project: { integrations: [] } diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index a12dacf52a..41e4d9bb96 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -37,20 +37,35 @@ export interface ProjectIntegration { export const IDeepnoteNotebookManager = Symbol('IDeepnoteNotebookManager'); export interface IDeepnoteNotebookManager { + /** + * Returns any cached project entry for the project id (project-level read-only callers). + * Because sibling files share a `project.id`, this may return any one sibling's cached + * project — never use it on a save path. + */ + getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; getCurrentNotebookId(projectId: string): string | undefined; - getOriginalProject(projectId: string): DeepnoteProject | undefined; + /** + * Returns the cached project for an exact (projectId, notebookId) pair, or undefined. + * Exact match only — never falls back to another sibling. The save path uses this. + */ + getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined; getTheSelectedNotebookForAProject(projectId: string): string | undefined; selectNotebookForProject(projectId: string, notebookId: string): void; - storeOriginalProject(projectId: string, project: DeepnoteProject, notebookId: string): void; + storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; updateCurrentNotebookId(projectId: string, notebookId: string): void; + /** + * Updates the cached project for an exact (projectId, notebookId) pair, without changing + * the project's current-notebook bookkeeping. + */ + updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; /** - * Updates the integrations list in the project data. - * This modifies the stored project to reflect changes in configured integrations. + * Updates the integrations list in the cached project data (cache-only). + * Iterates every cached notebook entry under the project and updates each. * * @param projectId - Project identifier * @param integrations - Array of integration metadata to store in the project - * @returns `true` if the project was found and updated successfully, `false` if the project does not exist + * @returns `true` if at least one cached entry was found and updated, `false` otherwise */ updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean; diff --git a/src/platform/deepnote/deepnoteProjectFileReader.ts b/src/platform/deepnote/deepnoteProjectFileReader.ts new file mode 100644 index 0000000000..eaca07853b --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectFileReader.ts @@ -0,0 +1,19 @@ +import { deserializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { Uri, workspace } from 'vscode'; + +/** + * Reads a `.deepnote` file from disk and parses it into a {@link DeepnoteFile}. + * + * This is the single source of truth for turning a file URI into a parsed Deepnote + * project: it reads the bytes via `workspace.fs`, decodes them as UTF-8, and parses + * them with `@deepnote/blocks`' `deserializeDeepnoteFile`. + * + * @param fileUri The URI of the `.deepnote` file to read. + * @returns The parsed Deepnote file. + */ +export async function readDeepnoteProjectFile(fileUri: Uri): Promise { + const fileContent = await workspace.fs.readFile(fileUri); + const yamlContent = new TextDecoder().decode(fileContent); + + return deserializeDeepnoteFile(yamlContent); +} diff --git a/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts b/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts new file mode 100644 index 0000000000..586d79f364 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts @@ -0,0 +1,99 @@ +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; + +import { readDeepnoteProjectFile } from './deepnoteProjectFileReader'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; + +suite('DeepnoteProjectFileReader', () => { + setup(() => { + resetVSCodeMocks(); + }); + + function createDeepnoteFile(): DeepnoteFile { + return { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-round-trip', + name: 'Round Trip Project', + notebooks: [ + { + id: 'notebook-1', + name: 'Notebook One', + blocks: [ + { + blockGroup: 'group-1', + id: 'block-1', + content: 'print("hello")', + sortingKey: 'a0', + metadata: {}, + type: 'code' + } + ] + } + ], + settings: {} + }, + version: '1.0.0' + }; + } + + function stubReadFile(value: string | Error): void { + const mockFs = mock(); + + if (value instanceof Error) { + when(mockFs.readFile(anything())).thenReject(value); + } else { + when(mockFs.readFile(anything())).thenResolve(new TextEncoder().encode(value) as never); + } + + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + } + + test('round-trips a valid .deepnote buffer to a DeepnoteFile', async () => { + const original = createDeepnoteFile(); + + stubReadFile(serializeDeepnoteFile(original)); + + const result = await readDeepnoteProjectFile(Uri.file('/workspace/project.deepnote')); + + assert.strictEqual(result.project.id, original.project.id); + assert.strictEqual(result.project.name, original.project.name); + assert.strictEqual(result.project.notebooks.length, 1); + assert.strictEqual(result.project.notebooks[0].id, 'notebook-1'); + assert.strictEqual(result.project.notebooks[0].name, 'Notebook One'); + assert.strictEqual(result.version, '1.0.0'); + }); + + test('rejects (propagates the parse error) on a schema-invalid buffer', async () => { + // A YAML-valid but schema-invalid document (missing the required `metadata`/`project`). + stubReadFile('version: 1.0'); + + let threw = false; + try { + await readDeepnoteProjectFile(Uri.file('/workspace/bad.deepnote')); + } catch { + threw = true; + } + + assert.isTrue(threw, 'readDeepnoteProjectFile should surface (not swallow) a malformed buffer'); + }); + + test('rejects on a non-YAML / garbage buffer', async () => { + stubReadFile('not: valid: yaml: ['); + + let threw = false; + try { + await readDeepnoteProjectFile(Uri.file('/workspace/garbage.deepnote')); + } catch { + threw = true; + } + + assert.isTrue(threw, 'readDeepnoteProjectFile should surface a non-parseable buffer'); + }); +}); diff --git a/src/platform/deepnote/deepnoteProjectIdResolver.ts b/src/platform/deepnote/deepnoteProjectIdResolver.ts new file mode 100644 index 0000000000..98ab10a319 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectIdResolver.ts @@ -0,0 +1,48 @@ +import { NotebookDocument, Uri } from 'vscode'; + +import { logger } from '../logging'; +import { readDeepnoteProjectFile } from './deepnoteProjectFileReader'; + +/** + * Resolves the Deepnote `project.id` that a given file belongs to. + * + * Reads and parses the `.deepnote` file at `fileUri` and returns its `project.id`. + * I/O and parse errors are swallowed (logged) so callers can treat an unreadable or + * malformed file as "no project". + * + * @param fileUri The URI of the `.deepnote` file. + * @returns The project id, or `undefined` if it cannot be determined. + */ +export async function resolveProjectIdForFile(fileUri: Uri): Promise { + try { + const deepnoteFile = await readDeepnoteProjectFile(fileUri); + + return deepnoteFile.project?.id; + } catch (error) { + logger.warn(`Failed to resolve Deepnote project id for file ${fileUri.toString()}`, error); + + return undefined; + } +} + +/** + * Resolves the Deepnote `project.id` for a notebook document. + * + * Prefers the project id stamped on the notebook metadata (`deepnoteProjectId`); + * when that is absent it falls back to reading the underlying file (with any query + * and fragment stripped from the notebook URI). + * + * @param notebook The notebook document. + * @returns The project id, or `undefined` if it cannot be determined. + */ +export async function resolveProjectIdForNotebook(notebook: NotebookDocument): Promise { + const projectIdFromMetadata = notebook.metadata?.deepnoteProjectId; + + if (typeof projectIdFromMetadata === 'string' && projectIdFromMetadata) { + return projectIdFromMetadata; + } + + const fileUri = notebook.uri.with({ query: '', fragment: '' }); + + return resolveProjectIdForFile(fileUri); +} diff --git a/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts b/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts new file mode 100644 index 0000000000..d692ce944a --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts @@ -0,0 +1,137 @@ +import { assert } from 'chai'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import { NotebookDocument, Uri } from 'vscode'; + +import { serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; + +import { resolveProjectIdForFile, resolveProjectIdForNotebook } from './deepnoteProjectIdResolver'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; + +suite('DeepnoteProjectIdResolver', () => { + setup(() => { + resetVSCodeMocks(); + }); + + function createDeepnoteFile(projectId = 'project-on-disk'): DeepnoteFile { + return { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: projectId, + name: 'Disk Project', + notebooks: [ + { + id: 'notebook-1', + name: 'Notebook One', + blocks: [] + } + ], + settings: {} + }, + version: '1.0.0' + }; + } + + /** + * Stubs `workspace.fs.readFile`. Returns the underlying mock so callers can + * assert which URI it was asked to read (proving metadata short-circuits the read). + */ + function stubReadFile(value: string | Error): typeof import('vscode').workspace.fs { + const mockFs = mock(); + + if (value instanceof Error) { + when(mockFs.readFile(anything())).thenReject(value); + } else { + when(mockFs.readFile(anything())).thenResolve(new TextEncoder().encode(value) as never); + } + + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return mockFs; + } + + function createNotebook(uri: Uri, metadata?: Record): NotebookDocument { + return { + uri, + metadata: metadata ?? {} + } as unknown as NotebookDocument; + } + + suite('resolveProjectIdForFile', () => { + test("returns the file's project.id", async () => { + stubReadFile(serializeDeepnoteFile(createDeepnoteFile('the-project-id'))); + + const result = await resolveProjectIdForFile(Uri.file('/workspace/project.deepnote')); + + assert.strictEqual(result, 'the-project-id'); + }); + + test('returns undefined (does not throw) on a read failure', async () => { + stubReadFile(new Error('ENOENT')); + + const result = await resolveProjectIdForFile(Uri.file('/workspace/missing.deepnote')); + + assert.strictEqual(result, undefined); + }); + + test('returns undefined (does not throw) on a parse failure', async () => { + stubReadFile('not: valid: yaml: ['); + + const result = await resolveProjectIdForFile(Uri.file('/workspace/garbage.deepnote')); + + assert.strictEqual(result, undefined); + }); + }); + + suite('resolveProjectIdForNotebook', () => { + test('returns notebook.metadata.deepnoteProjectId WITHOUT reading the file', async () => { + const mockFs = stubReadFile(new Error('readFile must not be called')); + const notebook = createNotebook(Uri.file('/workspace/project.deepnote'), { + deepnoteProjectId: 'metadata-project-id' + }); + + const result = await resolveProjectIdForNotebook(notebook); + + assert.strictEqual(result, 'metadata-project-id'); + // The metadata short-circuit means the (rejecting) file read is never attempted. + verify(mockFs.readFile(anything())).never(); + }); + + test('falls back to reading the file when metadata is absent', async () => { + stubReadFile(serializeDeepnoteFile(createDeepnoteFile('file-fallback-id'))); + const notebook = createNotebook(Uri.file('/workspace/project.deepnote'), {}); + + const result = await resolveProjectIdForNotebook(notebook); + + assert.strictEqual(result, 'file-fallback-id'); + }); + + test('strips query + fragment from the notebook URI before reading the file', async () => { + const mockFs = stubReadFile(serializeDeepnoteFile(createDeepnoteFile('stripped-id'))); + const notebook = createNotebook( + Uri.file('/workspace/project.deepnote').with({ query: 'notebook=abc', fragment: 'cell0' }), + {} + ); + + const result = await resolveProjectIdForNotebook(notebook); + + assert.strictEqual(result, 'stripped-id'); + + const [readUri] = capture(mockFs.readFile).last(); + assert.strictEqual((readUri as Uri).query, ''); + assert.strictEqual((readUri as Uri).fragment, ''); + assert.strictEqual((readUri as Uri).path, '/workspace/project.deepnote'); + }); + + test('returns undefined (does not throw) when the fallback file read fails', async () => { + stubReadFile(new Error('ENOENT')); + const notebook = createNotebook(Uri.file('/workspace/missing.deepnote'), {}); + + const result = await resolveProjectIdForNotebook(notebook); + + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index a5a827b575..8171c93f32 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts @@ -96,7 +96,7 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati logger.trace(`SqlIntegrationEnvironmentVariablesProvider: Project ID: ${projectId}`); // Get the project from the notebook manager - const project = this.notebookManager.getOriginalProject(projectId); + const project = this.notebookManager.getAnyProjectEntry(projectId); if (!project) { logger.trace(`SqlIntegrationEnvironmentVariablesProvider: No project found for ID: ${projectId}`); return {}; diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts index a00da868c1..8fb191882e 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts @@ -102,7 +102,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { const notebook = mock(); when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(undefined); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(undefined); const result = await provider.getEnvironmentVariables(resource); @@ -116,7 +116,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -148,7 +148,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); const result = await provider.getEnvironmentVariables(resource); @@ -180,7 +180,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('missing-integration')).thenResolve(undefined); @@ -197,7 +197,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -237,7 +237,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('bigquery-1')).thenResolve(bigqueryConfig); @@ -270,7 +270,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-postgres')).thenResolve(postgresConfig); const result = await provider.getEnvironmentVariables(resource); @@ -322,7 +322,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-bigquery')).thenResolve(bigqueryConfig); const result = await provider.getEnvironmentVariables(resource); @@ -348,7 +348,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -381,7 +381,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-snowflake')).thenResolve(snowflakeConfig); const result = await provider.getEnvironmentVariables(resource); @@ -442,7 +442,7 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('pg-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('bq-oauth')).thenResolve(federatedConfig); diff --git a/src/platform/notebooks/deepnote/types.ts b/src/platform/notebooks/deepnote/types.ts index 6ae06bc731..61d6cc4726 100644 --- a/src/platform/notebooks/deepnote/types.ts +++ b/src/platform/notebooks/deepnote/types.ts @@ -115,5 +115,10 @@ export interface IPlatformNotebookEditorProvider { */ export const IPlatformDeepnoteNotebookManager = Symbol('IPlatformDeepnoteNotebookManager'); export interface IPlatformDeepnoteNotebookManager { - getOriginalProject(projectId: string): DeepnoteProject | undefined; + /** + * Returns any cached project entry for the project id, for project-level read-only callers + * that have only a `projectId` (no specific notebook). Sibling files share a `project.id`, + * so this may return any one sibling's cached project. + */ + getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; } From 005accf35f9e70433e1b48f840aaa9e36f54103f Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 23 Jun 2026 22:48:16 +0000 Subject: [PATCH 02/26] refactor(deepnote): render single notebook + serialize by metadata, drop selection machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 2 of single-notebook migration (§1 + Cleanup). - deserializeNotebook renders the first non-init notebook (findDefaultNotebook), falling back to the only/init notebook; never composes init. - serializeNotebook resolves the target from document metadata alone (projectId + notebookId required) and looks it up with the exact getOriginalProject, throwing clear errors instead of falling back to a wrong sibling. - detectContentChanges collapses to a single-notebook comparison. - Remove the ?notebook= selection machinery: findCurrentNotebookId, the manager's selection state + interface methods, the explorer's query-param opens and selectNotebookForProject calls, and the tree item's custom resourceUri. - Explorer no longer depends on IDeepnoteNotebookManager. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- .../deepnote/deepnoteActivationService.ts | 2 +- .../deepnote/deepnoteExplorerView.ts | 50 +- .../deepnoteExplorerView.unit.test.ts | 15 +- .../deepnote/deepnoteNotebookManager.ts | 48 +- .../deepnoteNotebookManager.unit.test.ts | 186 +---- src/notebooks/deepnote/deepnoteSerializer.ts | 167 ++--- .../deepnote/deepnoteSerializer.unit.test.ts | 675 ++++++++++-------- src/notebooks/deepnote/deepnoteTreeItem.ts | 6 +- .../deepnote/deepnoteTreeItem.unit.test.ts | 7 - src/notebooks/types.ts | 4 - 10 files changed, 474 insertions(+), 686 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 96c35288cd..272c88c6b8 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -45,7 +45,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic */ public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); - this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.notebookManager, this.logger); + this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 33599da2f0..5a12e60235 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -4,7 +4,6 @@ import { serializeDeepnoteFile, type DeepnoteBlock, type DeepnoteFile } from '@d import { convertDeepnoteToJupyterNotebooks, convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; import { IExtensionContext } from '../../platform/common/types'; -import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { type DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; import { uuidUtils } from '../../platform/common/uuid'; @@ -24,7 +23,6 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(IDeepnoteNotebookManager) private readonly manager: IDeepnoteNotebookManager, @inject(ILogger) logger: ILogger ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); @@ -77,7 +75,7 @@ export class DeepnoteExplorerView { projectData.project.notebooks.push(newNotebook); // Save and open the new notebook - await this.saveProjectAndOpenNotebook(fileUri, projectData, newNotebook.id); + await this.saveProjectAndOpenNotebook(fileUri, projectData); return { id: newNotebook.id, name: notebookName }; } @@ -260,9 +258,7 @@ export class DeepnoteExplorerView { 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); + const document = await workspace.openNotebookDocument(fileUri); await window.showNotebookDocument(document, { preserveFocus: false, preview: false @@ -483,16 +479,11 @@ export class DeepnoteExplorerView { } /** - * Saves the project data to file and opens the specified notebook + * Saves the project data to file and opens it as a 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 { + private async saveProjectAndOpenNotebook(fileUri: Uri, projectData: DeepnoteFile): Promise { // Update metadata timestamp if (!projectData.metadata) { projectData.metadata = { createdAt: new Date().toISOString() }; @@ -507,10 +498,8 @@ export class DeepnoteExplorerView { // 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); + // Open the notebook + const document = await workspace.openNotebookDocument(fileUri); await window.showNotebookDocument(document, { preserveFocus: false, preview: false @@ -522,8 +511,6 @@ export class DeepnoteExplorerView { } private async openNotebook(context: DeepnoteTreeItemContext): Promise { - console.log(`Opening notebook: ${context.notebookId} in project: ${context.projectId}.`); - if (!context.notebookId) { await window.showWarningMessage(l10n.t('Cannot open: missing notebook id.')); @@ -531,20 +518,9 @@ export class DeepnoteExplorerView { } try { - // Create a unique URI by adding the notebook ID as a query parameter - // This ensures VS Code treats each notebook as a separate document - const fileUri = Uri.file(context.filePath).with({ query: `notebook=${context.notebookId}` }); - - console.log(`Selecting notebook in manager.`); - - this.manager.selectNotebookForProject(context.projectId, context.notebookId); - - console.log(`Opening notebook document.`, fileUri); - + const fileUri = Uri.file(context.filePath); const document = await workspace.openNotebookDocument(fileUri); - console.log(`Showing notebook document.`); - await window.showNotebookDocument(document, { preview: false, preserveFocus: false @@ -701,10 +677,7 @@ export class DeepnoteExplorerView { this.treeDataProvider.refresh(); - this.manager.selectNotebookForProject(projectId, notebookId); - - const notebookUri = fileUri.with({ query: `notebook=${notebookId}` }); - const document = await workspace.openNotebookDocument(notebookUri); + const document = await workspace.openNotebookDocument(fileUri); await window.showNotebookDocument(document, { preserveFocus: false, @@ -725,12 +698,7 @@ export class DeepnoteExplorerView { } const document = activeEditor.notebook; - - // Get the file URI (strip query params if present) - let fileUri = document.uri; - if (fileUri.query) { - fileUri = fileUri.with({ query: '' }); - } + const fileUri = document.uri; try { // Use shared helper to create and add notebook diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 5e5e2de826..81c0879f86 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -6,7 +6,6 @@ import { Uri, workspace } from 'vscode'; import { stringify as yamlStringify } from 'yaml'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; -import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; import type { IExtensionContext } from '../../platform/common/types'; import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; @@ -42,7 +41,6 @@ function createUuidMock(uuids: string[]): sinon.SinonStub { suite('DeepnoteExplorerView', () => { let explorerView: DeepnoteExplorerView; let mockExtensionContext: IExtensionContext; - let manager: DeepnoteNotebookManager; let mockLogger: ILogger; setup(() => { @@ -50,9 +48,8 @@ suite('DeepnoteExplorerView', () => { subscriptions: [] } as any; - manager = new DeepnoteNotebookManager(); mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockExtensionContext, manager, mockLogger); + explorerView = new DeepnoteExplorerView(mockExtensionContext, mockLogger); }); suite('constructor', () => { @@ -186,12 +183,10 @@ suite('DeepnoteExplorerView', () => { const context1 = { subscriptions: [] } as any; const context2 = { subscriptions: [] } as any; - const manager1 = new DeepnoteNotebookManager(); - const manager2 = new DeepnoteNotebookManager(); const logger1 = createMockLogger(); const logger2 = createMockLogger(); - const view1 = new DeepnoteExplorerView(context1, manager1, logger1); - const view2 = new DeepnoteExplorerView(context2, manager2, logger2); + const view1 = new DeepnoteExplorerView(context1, logger1); + const view2 = new DeepnoteExplorerView(context2, logger2); // Verify each view has its own context assert.strictEqual((view1 as any).extensionContext, context1); @@ -219,7 +214,6 @@ suite('DeepnoteExplorerView', () => { suite('DeepnoteExplorerView - Empty State Commands', () => { let explorerView: DeepnoteExplorerView; let mockContext: IExtensionContext; - let mockManager: DeepnoteNotebookManager; let sandbox: sinon.SinonSandbox; let uuidStubs: sinon.SinonStub[] = []; @@ -232,9 +226,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { subscriptions: [] } as unknown as IExtensionContext; - mockManager = new DeepnoteNotebookManager(); const mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockContext, mockManager, mockLogger); + explorerView = new DeepnoteExplorerView(mockContext, mockLogger); }); teardown(() => { diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index e82e41e2ea..dfe8269215 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -9,12 +9,10 @@ import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; */ @injectable() export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { - private readonly currentNotebookId = new Map(); // Cached originals are keyed by projectId, then by notebookId, so sibling files // that share a single project.id do not clobber each other's cached project data. private readonly originalProjects = new Map>(); private readonly projectsWithInitNotebookRun = new Set(); - private readonly selectedNotebookByProject = new Map(); /** * Returns any cached project entry for the given project id. @@ -39,15 +37,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { return undefined; } - /** - * Gets the currently selected notebook ID for a project. - * @param projectId Project identifier - * @returns Current notebook ID or undefined if not set - */ - getCurrentNotebookId(projectId: string): string | undefined { - return this.currentNotebookId.get(projectId); - } - /** * Retrieves the cached project data for an exact (projectId, notebookId) pair. * This performs an exact match only and never falls back to another sibling's @@ -60,15 +49,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { return this.originalProjects.get(projectId)?.get(notebookId); } - /** - * Gets the selected notebook ID for a specific project. - * @param projectId Project identifier - * @returns Selected notebook ID or undefined if not set - */ - getTheSelectedNotebookForAProject(projectId: string): string | undefined { - return this.selectedNotebookByProject.get(projectId); - } - /** * Checks if the init notebook has already been run for a project. * @param projectId Project identifier @@ -87,21 +67,8 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { } /** - * Associates a notebook ID with a project to remember user's notebook selection. - * When a Deepnote project contains multiple notebooks, this mapping persists the user's - * choice so we can automatically open the same notebook on subsequent file opens. - * - * @param projectId - The project ID that identifies the Deepnote project - * @param notebookId - The ID of the selected notebook within the project - */ - selectNotebookForProject(projectId: string, notebookId: string): void { - this.selectedNotebookByProject.set(projectId, notebookId); - } - - /** - * Stores the original project data for an exact (projectId, notebookId) pair and - * records the notebook as the project's current notebook. - * This is used during deserialization to cache project data and track the active notebook. + * Stores the original project data for an exact (projectId, notebookId) pair. + * This is used during deserialization to cache project data. * @param projectId Project identifier * @param notebookId Notebook identifier within the project * @param project Original project data to store @@ -119,17 +86,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { } notebookEntries.set(notebookId, clonedProject); - this.currentNotebookId.set(projectId, notebookId); - } - - /** - * Updates the current notebook ID for a project. - * Used when switching notebooks within the same project. - * @param projectId Project identifier - * @param notebookId New current notebook ID - */ - updateCurrentNotebookId(projectId: string, notebookId: string): void { - this.currentNotebookId.set(projectId, notebookId); } /** diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index e642a93e07..efad7923dc 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -25,31 +25,6 @@ suite('DeepnoteNotebookManager', () => { manager = new DeepnoteNotebookManager(); }); - suite('getCurrentNotebookId', () => { - test('should return undefined for unknown project', () => { - const result = manager.getCurrentNotebookId('unknown-project'); - - assert.strictEqual(result, undefined); - }); - - test('should return notebook ID after storing project', () => { - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - - const result = manager.getCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-456'); - }); - - test('should return updated notebook ID', () => { - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - manager.updateCurrentNotebookId('project-123', 'notebook-789'); - - const result = manager.getCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-789'); - }); - }); - suite('getOriginalProject', () => { test('should return undefined for unknown project', () => { const result = manager.getOriginalProject('unknown-project', 'notebook-456'); @@ -66,72 +41,13 @@ suite('DeepnoteNotebookManager', () => { }); }); - suite('getTheSelectedNotebookForAProject', () => { - test('should return undefined for unknown project', () => { - const result = manager.getTheSelectedNotebookForAProject('unknown-project'); - - assert.strictEqual(result, undefined); - }); - - test('should return notebook ID after setting', () => { - manager.selectNotebookForProject('project-123', 'notebook-456'); - - const result = manager.getTheSelectedNotebookForAProject('project-123'); - - assert.strictEqual(result, 'notebook-456'); - }); - - test('should handle multiple projects independently', () => { - manager.selectNotebookForProject('project-1', 'notebook-1'); - manager.selectNotebookForProject('project-2', 'notebook-2'); - - const result1 = manager.getTheSelectedNotebookForAProject('project-1'); - const result2 = manager.getTheSelectedNotebookForAProject('project-2'); - - assert.strictEqual(result1, 'notebook-1'); - assert.strictEqual(result2, 'notebook-2'); - }); - }); - - suite('selectNotebookForProject', () => { - test('should store notebook selection for project', () => { - manager.selectNotebookForProject('project-123', 'notebook-456'); - - const selectedNotebook = manager.getTheSelectedNotebookForAProject('project-123'); - - assert.strictEqual(selectedNotebook, 'notebook-456'); - }); - - test('should overwrite existing selection', () => { - manager.selectNotebookForProject('project-123', 'notebook-456'); - manager.selectNotebookForProject('project-123', 'notebook-789'); - - const result = manager.getTheSelectedNotebookForAProject('project-123'); - - assert.strictEqual(result, 'notebook-789'); - }); - - test('should handle multiple projects independently', () => { - manager.selectNotebookForProject('project-1', 'notebook-1'); - manager.selectNotebookForProject('project-2', 'notebook-2'); - - const result1 = manager.getTheSelectedNotebookForAProject('project-1'); - const result2 = manager.getTheSelectedNotebookForAProject('project-2'); - - assert.strictEqual(result1, 'notebook-1'); - assert.strictEqual(result2, 'notebook-2'); - }); - }); - suite('storeOriginalProject', () => { - test('should store both project and current notebook ID', () => { + test('should store the project for the (projectId, notebookId) pair', () => { manager.storeOriginalProject('project-123', 'notebook-456', mockProject); const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); - const currentNotebookId = manager.getCurrentNotebookId('project-123'); assert.deepStrictEqual(storedProject, mockProject); - assert.strictEqual(currentNotebookId, 'notebook-456'); }); test('should overwrite existing project data', () => { @@ -147,40 +63,8 @@ suite('DeepnoteNotebookManager', () => { manager.storeOriginalProject('project-123', 'notebook-456', updatedProject); const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); - const currentNotebookId = manager.getCurrentNotebookId('project-123'); assert.deepStrictEqual(storedProject, updatedProject); - assert.strictEqual(currentNotebookId, 'notebook-456'); - }); - }); - - suite('updateCurrentNotebookId', () => { - test('should update notebook ID for existing project', () => { - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - manager.updateCurrentNotebookId('project-123', 'notebook-789'); - - const result = manager.getCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-789'); - }); - - test('should set notebook ID for new project', () => { - manager.updateCurrentNotebookId('new-project', 'notebook-123'); - - const result = manager.getCurrentNotebookId('new-project'); - - assert.strictEqual(result, 'notebook-123'); - }); - - test('should handle multiple projects independently', () => { - manager.updateCurrentNotebookId('project-1', 'notebook-1'); - manager.updateCurrentNotebookId('project-2', 'notebook-2'); - - const result1 = manager.getCurrentNotebookId('project-1'); - const result2 = manager.getCurrentNotebookId('project-2'); - - assert.strictEqual(result1, 'notebook-1'); - assert.strictEqual(result2, 'notebook-2'); }); }); @@ -270,66 +154,24 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(updatedProject?.version, mockProject.version); assert.deepStrictEqual(updatedProject?.metadata, mockProject.metadata); }); - - test('should update integrations when currentNotebookId is undefined and return true', () => { - // Store project with a notebook ID, then clear it to simulate the edge case - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - manager.updateCurrentNotebookId('project-123', undefined as any); - - const integrations: ProjectIntegration[] = [ - { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, - { id: 'int-2', name: 'BigQuery', type: 'big-query' } - ]; - - const result = manager.updateProjectIntegrations('project-123', integrations); - - assert.strictEqual(result, true); - - const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); - assert.deepStrictEqual(updatedProject?.project.integrations, integrations); - // Verify other properties remain unchanged - assert.strictEqual(updatedProject?.project.id, mockProject.project.id); - assert.strictEqual(updatedProject?.project.name, mockProject.project.name); - assert.strictEqual(updatedProject?.version, mockProject.version); - assert.deepStrictEqual(updatedProject?.metadata, mockProject.metadata); - }); }); suite('integration scenarios', () => { - test('should handle complete workflow for multiple projects', () => { - manager.storeOriginalProject('project-1', 'notebook-1', mockProject); - manager.selectNotebookForProject('project-1', 'notebook-1'); - - manager.storeOriginalProject('project-2', 'notebook-2', mockProject); - manager.selectNotebookForProject('project-2', 'notebook-2'); - - assert.strictEqual(manager.getCurrentNotebookId('project-1'), 'notebook-1'); - assert.strictEqual(manager.getCurrentNotebookId('project-2'), 'notebook-2'); - assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-1'), 'notebook-1'); - assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-2'), 'notebook-2'); - }); - - test('should handle notebook switching within same project', () => { - manager.storeOriginalProject('project-123', 'notebook-1', mockProject); - manager.selectNotebookForProject('project-123', 'notebook-1'); - - manager.updateCurrentNotebookId('project-123', 'notebook-2'); - manager.selectNotebookForProject('project-123', 'notebook-2'); - - assert.strictEqual(manager.getCurrentNotebookId('project-123'), 'notebook-2'); - assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-123'), 'notebook-2'); - }); - - test('should maintain separation between current and selected notebook IDs', () => { - // Store original project sets current notebook - manager.storeOriginalProject('project-123', 'notebook-original', mockProject); + test('should store and retrieve projects for multiple project ids independently', () => { + const projectOne: DeepnoteProject = { + ...mockProject, + project: { ...mockProject.project, id: 'project-1' } + }; + const projectTwo: DeepnoteProject = { + ...mockProject, + project: { ...mockProject.project, id: 'project-2' } + }; - // Selecting a different notebook for the project - manager.selectNotebookForProject('project-123', 'notebook-selected'); + manager.storeOriginalProject('project-1', 'notebook-1', projectOne); + manager.storeOriginalProject('project-2', 'notebook-2', projectTwo); - // Both should be maintained independently - assert.strictEqual(manager.getCurrentNotebookId('project-123'), 'notebook-original'); - assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-123'), 'notebook-selected'); + assert.deepStrictEqual(manager.getOriginalProject('project-1', 'notebook-1'), projectOne); + assert.deepStrictEqual(manager.getOriginalProject('project-2', 'notebook-2'), projectTwo); }); }); diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index f1149f6d1c..0cae4e8dd6 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -1,7 +1,7 @@ import type { DeepnoteBlock, DeepnoteFile, DeepnoteSnapshot } from '@deepnote/blocks'; import { deserializeDeepnoteFile, isExecutableBlock, serializeDeepnoteSnapshot } from '@deepnote/blocks'; import { inject, injectable, optional } from 'inversify'; -import { l10n, window, workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; +import { workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; import { logger } from '../../platform/logging'; import { IDeepnoteNotebookManager } from '../types'; @@ -62,7 +62,8 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { /** * Deserializes a Deepnote YAML file into VS Code notebook format. * Parses YAML and converts the selected notebook's blocks to cells. - * The notebook to deserialize must be pre-selected and stored in the manager. + * A .deepnote file holds a single notebook; the first non-init notebook is rendered + * (falling back to the init notebook only when it is the file's only notebook). * @param content Raw file content as bytes * @param token Cancellation token (unused) * @returns Promise resolving to notebook data @@ -90,22 +91,21 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { } const projectId = deepnoteFile.project.id; - const notebookId = this.findCurrentNotebookId(projectId); - - logger.debug(`DeepnoteSerializer: Project ID: ${projectId}, Selected notebook ID: ${notebookId}`); if (deepnoteFile.project.notebooks.length === 0) { throw new Error('Deepnote project contains no notebooks.'); } - const selectedNotebook = notebookId - ? deepnoteFile.project.notebooks.find((nb) => nb.id === notebookId) - : this.findDefaultNotebook(deepnoteFile); + // A .deepnote file holds a single notebook. Render the first non-init notebook, + // falling back to the init notebook only when it is the only notebook in the file. + const selectedNotebook = this.findDefaultNotebook(deepnoteFile); if (!selectedNotebook) { - throw new Error(l10n.t('No notebook selected or found')); + throw new Error('No notebook found in Deepnote file'); } + logger.debug(`DeepnoteSerializer: Project ID: ${projectId}, Selected notebook ID: ${selectedNotebook.id}`); + // Log block IDs from source file for (let i = 0; i < (selectedNotebook.blocks ?? []).length; i++) { const block = selectedNotebook.blocks![i]; @@ -181,39 +181,6 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { } } - /** - * Finds the notebook ID to deserialize by checking the manager's stored selection. - * The notebook ID should be set via selectNotebookForProject before opening the document. - * @param projectId The project ID to find a notebook for - * @returns The notebook ID to deserialize, or undefined if none found - */ - findCurrentNotebookId(projectId: string): string | undefined { - // Prefer the active notebook editor when it matches the project - const activeEditorNotebook = window.activeNotebookEditor?.notebook; - - if ( - activeEditorNotebook?.notebookType === 'deepnote' && - activeEditorNotebook.metadata?.deepnoteProjectId === projectId && - activeEditorNotebook.metadata?.deepnoteNotebookId - ) { - return activeEditorNotebook.metadata.deepnoteNotebookId; - } - - // Check the manager's stored selection - this should be set when opening from explorer - const storedNotebookId = this.notebookManager.getTheSelectedNotebookForAProject(projectId); - - if (storedNotebookId) { - return storedNotebookId; - } - - // Fallback: Check if there's an active notebook document for this project - const openNotebook = workspace.notebookDocuments.find( - (doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId - ); - - return openNotebook?.metadata?.deepnoteNotebookId; - } - /** * Gets the data converter instance for cell/block conversion. * @returns DeepnoteDataConverter instance @@ -238,38 +205,29 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { logger.debug('SerializeNotebook: Starting serialization'); const projectId = data.metadata?.deepnoteProjectId; + const notebookId = data.metadata?.deepnoteNotebookId; - if (!projectId) { - throw new Error('Missing Deepnote project ID in notebook metadata'); + // Resolve the target notebook from the document metadata alone. Both ids must be + // present; the file holds a single notebook keyed by this exact (projectId, notebookId). + if (!projectId || !notebookId) { + throw new Error('Cannot determine which notebook to save'); } - logger.debug(`SerializeNotebook: Project ID: ${projectId}`); + logger.debug(`SerializeNotebook: Project ID: ${projectId}, Notebook ID: ${notebookId}`); - // Clone the project before modifying to prevent state corruption - // This is critical for multi-notebook projects where the stored project - // is shared between notebook serialization calls - // Chunk 1 keeps the save path behaviour identical to before by using the - // project-only lookup. Chunk 2 will switch this to the exact (projectId, notebookId) - // lookup (getOriginalProject) once the notebook id is resolved first. - const storedProject = this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteFile | undefined; + // Fetch the cached project with an exact (projectId, notebookId) lookup. Sibling files + // share a project.id, so a project-only lookup could return a different sibling's project. + const storedProject = this.notebookManager.getOriginalProject(projectId, notebookId); if (!storedProject) { throw new Error('Original Deepnote project not found. Cannot save changes.'); } + // Clone the project before modifying to prevent state corruption. const originalProject = structuredClone(storedProject); logger.debug('SerializeNotebook: Got and cloned original project'); - const notebookId = - data.metadata?.deepnoteNotebookId || this.notebookManager.getTheSelectedNotebookForAProject(projectId); - - if (!notebookId) { - throw new Error('Cannot determine which notebook to save'); - } - - logger.debug(`SerializeNotebook: Notebook ID: ${notebookId}`); - const notebook = originalProject.project.notebooks.find((nb: { id: string }) => nb.id === notebookId); if (!notebook) { @@ -497,82 +455,65 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { /** * Detects whether actual content has changed between two project versions. - * Compares notebook content (block sources, types, and IDs) while ignoring + * A .deepnote file holds a single notebook, so this compares that one notebook's + * notebook-level fields and block content (sources, types, and IDs) while ignoring * outputs, execution metadata, and timestamps. * @param newProject The project with potential changes * @param originalProject The stored original project * @returns true if content has changed, false otherwise */ private detectContentChanges(newProject: DeepnoteFile, originalProject: DeepnoteFile): boolean { - for (const originalNotebook of originalProject.project.notebooks) { - const newNotebook = newProject.project.notebooks.find((nb) => nb.id === originalNotebook.id); + const newNotebook = newProject.project.notebooks[0]; + const originalNotebook = originalProject.project.notebooks[0]; - if (!newNotebook) { - return true; // Notebook removed - } + if (!newNotebook || !originalNotebook) { + return newNotebook !== originalNotebook; } - for (const newNotebook of newProject.project.notebooks) { - const originalNotebook = originalProject.project.notebooks.find((nb) => nb.id === newNotebook.id); + if ( + newNotebook.id !== originalNotebook.id || + newNotebook.name !== originalNotebook.name || + newNotebook.executionMode !== originalNotebook.executionMode || + newNotebook.isModule !== originalNotebook.isModule || + newNotebook.workingDirectory !== originalNotebook.workingDirectory + ) { + return true; + } - if (!originalNotebook) { - return true; // New notebook added - } + const newBlocks = newNotebook.blocks ?? []; + const originalBlocks = originalNotebook.blocks ?? []; - if ( - newNotebook.name !== originalNotebook.name || - newNotebook.executionMode !== originalNotebook.executionMode || - newNotebook.isModule !== originalNotebook.isModule || - newNotebook.workingDirectory !== originalNotebook.workingDirectory - ) { - return true; - } + if (newBlocks.length !== originalBlocks.length) { + return true; + } - const newBlocks = newNotebook.blocks ?? []; - const originalBlocks = originalNotebook.blocks ?? []; + for (let i = 0; i < newBlocks.length; i++) { + const newBlock = newBlocks[i]; + const originalBlock = originalBlocks[i]; - if (newBlocks.length !== originalBlocks.length) { + // Compare content and type (the things that matter for actual changes) + if ( + newBlock.content !== originalBlock.content || + newBlock.type !== originalBlock.type || + newBlock.id !== originalBlock.id + ) { return true; } - - for (let i = 0; i < newBlocks.length; i++) { - const newBlock = newBlocks[i]; - const originalBlock = originalBlocks[i]; - - // Compare content and type (the things that matter for actual changes) - if ( - newBlock.content !== originalBlock.content || - newBlock.type !== originalBlock.type || - newBlock.id !== originalBlock.id - ) { - return true; - } - } } return false; } /** - * Finds the default notebook to open when no selection is made. - * @param file - * @returns + * Finds the notebook to render: the first non-init notebook, falling back to the + * first notebook when the only notebook in the file is the init notebook. + * @param file The parsed Deepnote file + * @returns The notebook to render, or undefined if the file has no notebooks */ private findDefaultNotebook(file: DeepnoteFile): DeepnoteNotebook | undefined { - if (file.project.notebooks.length === 0) { - return undefined; - } - - const sortedNotebooks = file.project.notebooks.slice().sort((a, b) => a.name.localeCompare(b.name)); - const sortedNotebooksWithoutInit = file.project.initNotebookId - ? sortedNotebooks.filter((nb) => nb.id !== file.project.initNotebookId) - : sortedNotebooks; - - if (sortedNotebooksWithoutInit.length > 0) { - return sortedNotebooksWithoutInit[0]; - } + const { notebooks, initNotebookId } = file.project; - return sortedNotebooks[0]; + return notebooks.find((nb) => nb.id !== initNotebookId) ?? notebooks[0]; } /** diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index 3f62358b9f..6b909c259a 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -1,13 +1,10 @@ import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert } from 'chai'; import { parse as parseYaml } from 'yaml'; -import { when } from 'ts-mockito'; -import type { NotebookDocument } from 'vscode'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; -import { mockedVSCodeNamespaces } from '../../test/vscode-mock'; suite('DeepnoteNotebookSerializer', () => { let serializer: DeepnoteNotebookSerializer; @@ -75,9 +72,6 @@ suite('DeepnoteNotebookSerializer', () => { suite('deserializeNotebook', () => { test('should deserialize valid project with selected notebook', async () => { - // Set up the manager to select the first notebook - manager.selectNotebookForProject('project-123', 'notebook-1'); - const yamlContent = ` version: '1.0.0' metadata: @@ -158,7 +152,21 @@ project: await assert.isRejected( serializer.serializeNotebook(mockNotebookData, {} as any), - /Missing Deepnote project ID in notebook metadata/ + /Cannot determine which notebook to save/ + ); + }); + + test('should throw error when notebook ID is missing from metadata', async () => { + const mockNotebookData = { + cells: [], + metadata: { + deepnoteProjectId: 'project-123' + } + }; + + await assert.isRejected( + serializer.serializeNotebook(mockNotebookData, {} as any), + /Cannot determine which notebook to save/ ); }); @@ -205,163 +213,129 @@ project: assert.include(yamlString, 'project-123'); assert.include(yamlString, 'notebook-1'); }); - }); - - suite('findCurrentNotebookId', () => { - teardown(() => { - // Reset only the specific mocks used in this suite - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - }); - - test('should return stored notebook ID when available', () => { - manager.selectNotebookForProject('project-123', 'notebook-456'); - - const result = serializer.findCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-456'); - }); - - test('should fall back to active notebook document when no stored selection', () => { - // Create a mock notebook document - const mockNotebookDoc = { - then: undefined, // Prevent mock from being treated as a Promise-like thenable - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: 'project-123', - deepnoteNotebookId: 'notebook-from-workspace' - }, - uri: {} as any, - version: 1, - isDirty: false, - isUntitled: false, - isClosed: false, - cellCount: 0, - cellAt: () => ({}) as any, - getCells: () => [], - save: async () => true - } as NotebookDocument; - - // Configure the mocked workspace.notebookDocuments (same pattern as other tests) - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([mockNotebookDoc]); - - const result = serializer.findCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-from-workspace'); - }); - - test('should return undefined for unknown project', () => { - const result = serializer.findCurrentNotebookId('unknown-project'); - - assert.strictEqual(result, undefined); - }); - - test('should prioritize stored selection over fallback', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); - - const result = serializer.findCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'stored-notebook'); - }); - - test('should handle multiple projects independently', () => { - manager.selectNotebookForProject('project-1', 'notebook-1'); - manager.selectNotebookForProject('project-2', 'notebook-2'); - - const result1 = serializer.findCurrentNotebookId('project-1'); - const result2 = serializer.findCurrentNotebookId('project-2'); - - assert.strictEqual(result1, 'notebook-1'); - assert.strictEqual(result2, 'notebook-2'); - }); - - test('should prioritize active notebook editor over stored selection', () => { - // Store a selection for the project - manager.selectNotebookForProject('project-123', 'stored-notebook'); - - // Mock the active notebook editor to return a different notebook - const mockActiveNotebook = { - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: 'project-123', - deepnoteNotebookId: 'active-editor-notebook' - } - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); - - const result = serializer.findCurrentNotebookId('project-123'); - // Should return the active editor's notebook, not the stored one - assert.strictEqual(result, 'active-editor-notebook'); - }); - - test('should ignore active editor when project ID does not match', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); - - // Mock active editor with a different project - const mockActiveNotebook = { - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: 'different-project', - deepnoteNotebookId: 'active-editor-notebook' - } - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); - - const result = serializer.findCurrentNotebookId('project-123'); + suite('correct-sibling save (Chunk 2 anti-regression)', () => { + const sharedProjectId = 'shared-project'; + const nbA = 'sibling-a'; + const nbB = 'sibling-b'; + + // Two siblings of ONE project: same project.id, distinct single notebook each, with + // distinguishable block ids/content so the serialized output reveals which one was saved. + function siblingFile(notebookId: string, blockId: string, content: string): DeepnoteFile { + return { + version: '1.0.0', + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: sharedProjectId, + name: 'Shared Project', + notebooks: [ + { + id: notebookId, + name: notebookId, + blocks: [ + { + id: blockId, + content, + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + type: 'code' + } + ], + executionMode: 'block', + isModule: false + } + ], + settings: {} + } + }; + } - // Should fall back to stored selection since active editor is for different project - assert.strictEqual(result, 'stored-notebook'); - }); + test('catches wrong-sibling save: with both siblings cached under one projectId, saving notebookId=B writes sibling B (not A)', async () => { + manager.storeOriginalProject(sharedProjectId, nbA, siblingFile(nbA, 'block-a', 'print("A")')); + manager.storeOriginalProject(sharedProjectId, nbB, siblingFile(nbB, 'block-b', 'print("B")')); - test('should ignore active editor when notebook type is not deepnote', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); + // The document's metadata identifies sibling B; its cell carries B's block id. + const notebookData = { + cells: [ + { + kind: 2, + value: 'print("B")', + languageId: 'python', + metadata: { id: 'block-b' } + } + ], + metadata: { + deepnoteProjectId: sharedProjectId, + deepnoteNotebookId: nbB + } + }; - // Mock active editor with non-deepnote notebook type - const mockActiveNotebook = { - notebookType: 'jupyter-notebook', - metadata: { - deepnoteProjectId: 'project-123', - deepnoteNotebookId: 'active-editor-notebook' - } - }; + const result = await serializer.serializeNotebook(notebookData as any, {} as any); + const parsed = deserializeDeepnoteFile(new TextDecoder().decode(result)); - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); + // Exactly sibling B's single notebook is serialized — never sibling A's. + assert.strictEqual(parsed.project.notebooks.length, 1); + assert.strictEqual(parsed.project.notebooks[0].id, nbB); + assert.strictEqual(parsed.project.notebooks[0].blocks[0].id, 'block-b'); + assert.notStrictEqual(parsed.project.notebooks[0].id, nbA); + }); - const result = serializer.findCurrentNotebookId('project-123'); + test('catches wrong-sibling save: saving notebookId=A writes sibling A (not B) from the same project cache', async () => { + manager.storeOriginalProject(sharedProjectId, nbA, siblingFile(nbA, 'block-a', 'print("A")')); + manager.storeOriginalProject(sharedProjectId, nbB, siblingFile(nbB, 'block-b', 'print("B")')); - // Should fall back to stored selection since active editor is not a deepnote notebook - assert.strictEqual(result, 'stored-notebook'); - }); + const notebookData = { + cells: [ + { + kind: 2, + value: 'print("A")', + languageId: 'python', + metadata: { id: 'block-a' } + } + ], + metadata: { + deepnoteProjectId: sharedProjectId, + deepnoteNotebookId: nbA + } + }; - test('should ignore active editor when notebook ID is missing', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); + const result = await serializer.serializeNotebook(notebookData as any, {} as any); + const parsed = deserializeDeepnoteFile(new TextDecoder().decode(result)); - // Mock active editor without notebook ID in metadata - const mockActiveNotebook = { - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: 'project-123' - // Missing deepnoteNotebookId - } - }; + assert.strictEqual(parsed.project.notebooks.length, 1); + assert.strictEqual(parsed.project.notebooks[0].id, nbA); + assert.strictEqual(parsed.project.notebooks[0].blocks[0].id, 'block-a'); + }); - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); + test('catches save-against-wrong-sibling-on-cache-miss: when only sibling A is cached, saving notebookId=B throws the clear error instead of saving against A', async () => { + // Only sibling A is cached; the document is sibling B. An exact (projectId, notebookId) + // lookup must miss and throw — it must NOT fall back to A (which shares project.id). + manager.storeOriginalProject(sharedProjectId, nbA, siblingFile(nbA, 'block-a', 'print("A")')); - const result = serializer.findCurrentNotebookId('project-123'); + const notebookData = { + cells: [ + { + kind: 2, + value: 'print("B")', + languageId: 'python', + metadata: { id: 'block-b' } + } + ], + metadata: { + deepnoteProjectId: sharedProjectId, + deepnoteNotebookId: nbB + } + }; - // Should fall back to stored selection since active editor has no notebook ID - assert.strictEqual(result, 'stored-notebook'); + await assert.isRejected( + serializer.serializeNotebook(notebookData as any, {} as any), + /Original Deepnote project not found/ + ); + }); }); }); @@ -388,19 +362,10 @@ project: }); test('should handle manager state operations', () => { - assert.isFunction(manager.getCurrentNotebookId, 'has getCurrentNotebookId method'); + assert.isFunction(manager.getAnyProjectEntry, 'has getAnyProjectEntry method'); assert.isFunction(manager.getOriginalProject, 'has getOriginalProject method'); - assert.isFunction( - manager.getTheSelectedNotebookForAProject, - 'has getTheSelectedNotebookForAProject method' - ); - assert.isFunction(manager.selectNotebookForProject, 'has selectNotebookForProject method'); assert.isFunction(manager.storeOriginalProject, 'has storeOriginalProject method'); }); - - test('should have findCurrentNotebookId method', () => { - assert.isFunction(serializer.findCurrentNotebookId, 'has findCurrentNotebookId method'); - }); }); suite('data structure handling', () => { @@ -856,7 +821,7 @@ project: assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Init'); }); - test('should select alphabetically first notebook when no initNotebookId', async () => { + test('should select the first notebook when no initNotebookId', async () => { const projectData: DeepnoteFile = { version: '1.0.0', metadata: { @@ -864,8 +829,8 @@ project: modifiedAt: '2023-01-02T00:00:00Z' }, project: { - id: 'project-alphabetical', - name: 'Project Alphabetical', + id: 'project-first', + name: 'Project First', notebooks: [ { id: 'zebra-notebook', @@ -898,22 +863,6 @@ project: ], executionMode: 'block', isModule: false - }, - { - id: 'bravo-notebook', - name: 'Bravo Notebook', - blocks: [ - { - id: 'block-b', - content: 'print("bravo")', - sortingKey: 'a0', - blockGroup: '1', - metadata: {}, - type: 'code' - } - ], - executionMode: 'block', - isModule: false } ], settings: {} @@ -923,12 +872,12 @@ project: const content = projectToYaml(projectData); const result = await serializer.deserializeNotebook(content, {} as any); - // Should select the alphabetically first notebook - assert.strictEqual(result.metadata?.deepnoteNotebookId, 'alpha-notebook'); - assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Alpha Notebook'); + // Should select the first notebook in the file (no name-based sorting) + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'zebra-notebook'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Zebra Notebook'); }); - test('should sort Init notebook last when multiple notebooks exist', async () => { + test('should select the first non-init notebook when multiple notebooks exist', async () => { const projectData: DeepnoteFile = { version: '1.0.0', metadata: { @@ -941,12 +890,12 @@ project: initNotebookId: 'init-notebook', notebooks: [ { - id: 'charlie-notebook', - name: 'Charlie', + id: 'init-notebook', + name: 'Init', blocks: [ { - id: 'block-c', - content: 'print("charlie")', + id: 'block-init', + content: 'print("init")', sortingKey: 'a0', blockGroup: '1', metadata: {}, @@ -957,12 +906,12 @@ project: isModule: false }, { - id: 'init-notebook', - name: 'Init', + id: 'charlie-notebook', + name: 'Charlie', blocks: [ { - id: 'block-init', - content: 'print("init")', + id: 'block-c', + content: 'print("charlie")', sortingKey: 'a0', blockGroup: '1', metadata: {}, @@ -996,130 +945,220 @@ project: const content = projectToYaml(projectData); const result = await serializer.deserializeNotebook(content, {} as any); - // Should select Alpha, not Init even though "Init" comes before "Alpha" alphabetically when in upper case - assert.strictEqual(result.metadata?.deepnoteNotebookId, 'alpha-notebook'); - assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Alpha'); + // Should select the first non-init notebook in file order (Charlie), skipping Init. + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'charlie-notebook'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Charlie'); }); }); - suite('detectContentChanges', () => { - test('should detect no changes when content is identical', () => { - const project: DeepnoteFile = { + suite('first-non-init render (Chunk 2 use cases)', () => { + // An [init, main] file where the init id matches project.initNotebookId. + function initMainFile(): DeepnoteFile { + return { version: '1.0.0', - metadata: { createdAt: '2023-01-01T00:00:00Z' }, + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, project: { - id: 'project-1', - name: 'Test', + id: 'project-init-main', + name: 'Init + Main', + initNotebookId: 'init-notebook', notebooks: [ { - id: 'nb-1', - name: 'Notebook', + id: 'init-notebook', + name: 'Init', blocks: [ { - id: 'b1', - type: 'code', + id: 'init-block-1', + content: 'import setup_only', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + type: 'code' + }, + { + id: 'init-block-2', + content: 'configure_environment()', + sortingKey: 'a1', + blockGroup: '1', + metadata: {}, + type: 'code' } - ] + ], + executionMode: 'block', + isModule: false + }, + { + id: 'main-notebook', + name: 'Main', + blocks: [ + { + id: 'main-block-1', + content: 'print("main work")', + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + type: 'code' + } + ], + executionMode: 'block', + isModule: false } - ] + ], + settings: {} } }; + } - const serializerAny = serializer as any; - const projectCopy = structuredClone(project); - const result = serializerAny.detectContentChanges(project, projectCopy); + test('catches init-first render: an [init, main] file renders main (not the init referenced by initNotebookId)', async () => { + const content = projectToYaml(initMainFile()); + const result = await serializer.deserializeNotebook(content, {} as any); - assert.isFalse(result); + // The rendered notebook must be the main one, never the init. + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'main-notebook'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main'); }); - test('should detect changes when block content differs', () => { - const newProject: DeepnoteFile = { + test('catches wrong-default render: a [main1, main2] file with no init renders the first (main1)', async () => { + const file: DeepnoteFile = { version: '1.0.0', - metadata: { createdAt: '2023-01-01T00:00:00Z' }, + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, project: { - id: 'project-1', - name: 'Test', + id: 'project-two-mains', + name: 'Two Mains', notebooks: [ { - id: 'nb-1', - name: 'Notebook', + id: 'main1', + name: 'Main One', blocks: [ { - id: 'b1', - type: 'code', + id: 'm1-block', + content: 'print("one")', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(2)' + type: 'code' } - ] - } - ] - } - }; - - const originalProject: DeepnoteFile = { - version: '1.0.0', - metadata: { createdAt: '2023-01-01T00:00:00Z' }, - project: { - id: 'project-1', - name: 'Test', - notebooks: [ + ], + executionMode: 'block', + isModule: false + }, { - id: 'nb-1', - name: 'Notebook', + id: 'main2', + name: 'Main Two', blocks: [ { - id: 'b1', - type: 'code', + id: 'm2-block', + content: 'print("two")', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + type: 'code' } - ] + ], + executionMode: 'block', + isModule: false } - ] + ], + settings: {} } }; - const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const content = projectToYaml(file); + const result = await serializer.deserializeNotebook(content, {} as any); - assert.isTrue(result); + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'main1'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main One'); }); - test('should detect changes when block type differs', () => { - const newProject: DeepnoteFile = { + test('catches URI-driven selection: deserialize receives only bytes, so a ?notebook= query cannot change the selection', async () => { + // deserializeNotebook's contract is (content, token) — no URI is ever passed. Even if a + // caller's document URI carries ?notebook=init-notebook, the serializer has no access to + // it and must still render the first non-init notebook (main). This pins that the dropped + // selection-by-query machinery has no path back in. + const bytes = projectToYaml(initMainFile()); + + const result = await serializer.deserializeNotebook(bytes, {} as any); + + // Selection is byte-derived (first non-init), independent of any URL query. + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'main-notebook'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main'); + }); + + test('catches init composition at deserialize: an [init, main] file renders ONLY main blocks (init setup blocks are not merged)', async () => { + const content = projectToYaml(initMainFile()); + const result = await serializer.deserializeNotebook(content, {} as any); + + // Exactly main's block count — init's two setup blocks are not composed in. + assert.strictEqual(result.cells.length, 1, 'should render only the single main block'); + + const renderedBlockIds = result.cells.map((cell) => cell.metadata?.id); + assert.deepStrictEqual(renderedBlockIds, ['main-block-1']); + + // No init block id may leak into the rendered cells. + assert.notInclude(renderedBlockIds, 'init-block-1'); + assert.notInclude(renderedBlockIds, 'init-block-2'); + + // And the rendered content is main's, not init's setup code. + const renderedValues = result.cells.map((cell) => cell.value); + assert.deepStrictEqual(renderedValues, ['print("main work")']); + assert.notInclude(renderedValues, 'import setup_only'); + assert.notInclude(renderedValues, 'configure_environment()'); + }); + + test('catches lost init fallback: a standalone init file (the init is the only notebook) renders that init notebook', async () => { + const file: DeepnoteFile = { version: '1.0.0', - metadata: { createdAt: '2023-01-01T00:00:00Z' }, + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, project: { - id: 'project-1', - name: 'Test', + id: 'project-standalone-init', + name: 'Standalone Init', + initNotebookId: 'init-notebook', notebooks: [ { - id: 'nb-1', - name: 'Notebook', + id: 'init-notebook', + name: 'Init', blocks: [ { - id: 'b1', - type: 'markdown', + id: 'init-only-block', + content: 'print("init")', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: '# Hello' + type: 'code' } - ] + ], + executionMode: 'block', + isModule: false } - ] + ], + settings: {} } }; - const originalProject: DeepnoteFile = { + const content = projectToYaml(file); + const result = await serializer.deserializeNotebook(content, {} as any); + + // The `?? notebooks[0]` fallback: when the init is the ONLY notebook, it is rendered. + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'init-notebook'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Init'); + assert.deepStrictEqual( + result.cells.map((cell) => cell.metadata?.id), + ['init-only-block'] + ); + }); + }); + + suite('detectContentChanges', () => { + test('should detect no changes when content is identical', () => { + const project: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, project: { @@ -1136,7 +1175,7 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: '# Hello' + content: 'print(1)' } ] } @@ -1145,12 +1184,13 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const projectCopy = structuredClone(project); + const result = serializerAny.detectContentChanges(project, projectCopy); - assert.isTrue(result); + assert.isFalse(result); }); - test('should detect changes when block count differs', () => { + test('should detect changes when block content differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1168,14 +1208,6 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' - }, - { - id: 'b2', - type: 'code', - sortingKey: 'a1', - blockGroup: '1', - metadata: {}, content: 'print(2)' } ] @@ -1215,7 +1247,7 @@ project: assert.isTrue(result); }); - test('should detect new notebook added', () => { + test('should detect changes when block type differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1229,18 +1261,13 @@ project: blocks: [ { id: 'b1', - type: 'code', + type: 'markdown', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + content: '# Hello' } ] - }, - { - id: 'nb-2', - name: 'New Notebook', - blocks: [] } ] } @@ -1263,7 +1290,7 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + content: '# Hello' } ] } @@ -1277,7 +1304,7 @@ project: assert.isTrue(result); }); - test('should detect notebook removed', () => { + test('should detect changes when block count differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1296,6 +1323,14 @@ project: blockGroup: '1', metadata: {}, content: 'print(1)' + }, + { + id: 'b2', + type: 'code', + sortingKey: 'a1', + blockGroup: '1', + metadata: {}, + content: 'print(2)' } ] } @@ -1323,11 +1358,6 @@ project: content: 'print(1)' } ] - }, - { - id: 'nb-2', - name: 'Second Notebook', - blocks: [] } ] } @@ -1456,6 +1486,79 @@ project: assert.isFalse(result); }); + + // Notebook-level field changes must be detected even when the blocks are byte-identical. + // A single-notebook file with overridable notebook-level fields. + function singleNotebookFile(overrides: Record): DeepnoteFile { + return { + version: '1.0.0', + metadata: { createdAt: '2023-01-01T00:00:00Z' }, + project: { + id: 'project-nb-fields', + name: 'Test', + notebooks: [ + { + id: 'nb-1', + name: 'Notebook', + executionMode: 'block', + isModule: false, + workingDirectory: '/work', + blocks: [ + { + id: 'b1', + type: 'code', + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + content: 'print(1)' + } + ], + ...overrides + } + ] + } + }; + } + + const notebookLevelFieldCases: Array<{ field: string; original: unknown; changed: unknown }> = [ + { field: 'name', original: 'Notebook', changed: 'Renamed Notebook' }, + { field: 'executionMode', original: 'block', changed: 'notebook' }, + { field: 'isModule', original: false, changed: true }, + { field: 'workingDirectory', original: '/work', changed: '/different' } + ]; + + for (const { field, original, changed } of notebookLevelFieldCases) { + test(`catches missed notebook-level diff: a change to '${field}' is detected even with identical blocks`, () => { + const originalProject = singleNotebookFile({ [field]: original }); + const newProject = singleNotebookFile({ [field]: changed }); + + const serializerAny = serializer as any; + const result = serializerAny.detectContentChanges(newProject, originalProject); + + assert.isTrue(result, `change to notebook-level field '${field}' should be detected`); + }); + } + + test('catches over-eager diff: identical single-notebook input (all notebook-level fields equal) reports no change', () => { + const originalProject = singleNotebookFile({}); + const newProject = structuredClone(originalProject); + + const serializerAny = serializer as any; + const result = serializerAny.detectContentChanges(newProject, originalProject); + + assert.isFalse(result); + }); + + test('catches missed block-id diff: a block id change (same content/type) is detected', () => { + const originalProject = singleNotebookFile({}); + const newProject = singleNotebookFile({}); + newProject.project.notebooks[0].blocks[0].id = 'b1-renamed'; + + const serializerAny = serializer as any; + const result = serializerAny.detectContentChanges(newProject, originalProject); + + assert.isTrue(result); + }); }); suite('snapshotHash', () => { diff --git a/src/notebooks/deepnote/deepnoteTreeItem.ts b/src/notebooks/deepnote/deepnoteTreeItem.ts index 0763f0e1bb..e17a56a771 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -1,4 +1,4 @@ -import { TreeItem, TreeItemCollapsibleState, Uri, ThemeIcon } from 'vscode'; +import { TreeItem, TreeItemCollapsibleState, ThemeIcon } from 'vscode'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; /** @@ -78,10 +78,6 @@ export class DeepnoteTreeItem extends TreeItem { } if (this.type === DeepnoteTreeItemType.Notebook) { - // getNotebookUri() inline - if (this.context.notebookId) { - this.resourceUri = Uri.parse(`deepnote-notebook://${this.context.filePath}#${this.context.notebookId}`); - } this.command = { command: 'deepnote.openNotebook', title: 'Open Notebook', diff --git a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts index bbadeceee4..addc75f68a 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts @@ -258,13 +258,6 @@ suite('DeepnoteTreeItem', () => { assert.strictEqual(item.command!.command, 'deepnote.openNotebook'); assert.strictEqual(item.command!.title, 'Open Notebook'); assert.deepStrictEqual(item.command!.arguments, [context]); - - // Should have resource URI - assert.isDefined(item.resourceUri); - assert.strictEqual( - item.resourceUri!.toString(), - 'deepnote-notebook:/workspace/project.deepnote#notebook-789' - ); }); test('should handle notebook with multiple blocks', () => { diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index 41e4d9bb96..eab2f228ba 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -43,16 +43,12 @@ export interface IDeepnoteNotebookManager { * project — never use it on a save path. */ getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; - getCurrentNotebookId(projectId: string): string | undefined; /** * Returns the cached project for an exact (projectId, notebookId) pair, or undefined. * Exact match only — never falls back to another sibling. The save path uses this. */ getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined; - getTheSelectedNotebookForAProject(projectId: string): string | undefined; - selectNotebookForProject(projectId: string, notebookId: string): void; storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; - updateCurrentNotebookId(projectId: string, notebookId: string): void; /** * Updates the cached project for an exact (projectId, notebookId) pair, without changing * the project's current-notebook bookkeeping. From 0ee7f536dc74e301246a4ac0c8337f16850c7bf5 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 23 Jun 2026 23:19:53 +0000 Subject: [PATCH 03/26] feat(deepnote): split legacy multi-notebook files into single-notebook siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 3 of single-notebook migration (§0, §2, §3). - Add allocateSiblingUri: the single filesystem-aware, collision-safe sibling filename allocator (bumps -2/-3 before .deepnote, honors an in-batch reserved set, bounded retries). - Add a notebook file factory (buildSingleNotebookFile / buildSiblingNotebookFileUri) for creating sibling single-notebook files (wired into the explorer in a later chunk). - Add DeepnoteMultiNotebookSplitter: on opening a multi-notebook .deepnote file, offer to split it into one new single-notebook file per notebook. The action flushes the editor if dirty, writes all children, migrates the environment selection, then closes the tab and deletes the original to trash. A child-write failure leaves the original intact (write-before-delete). - Wire the splitter into activation with an optional (desktop-only) environment mapper; add a refresh() passthrough on the explorer. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- build/mocha-esm-loader.js | 42 ++ .../deepnote/deepnoteActivationService.ts | 19 +- .../deepnote/deepnoteExplorerView.ts | 9 + .../deepnote/deepnoteMultiNotebookSplitter.ts | 197 ++++++ ...deepnoteMultiNotebookSplitter.unit.test.ts | 593 ++++++++++++++++++ .../deepnote/deepnoteNotebookFileFactory.ts | 80 +++ .../deepnoteNotebookFileFactory.unit.test.ts | 175 ++++++ .../deepnote/deepnoteSiblingFileAllocator.ts | 84 +++ .../deepnoteSiblingFileAllocator.unit.test.ts | 88 +++ 9 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts create mode 100644 src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookFileFactory.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts create mode 100644 src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts create mode 100644 src/notebooks/deepnote/deepnoteSiblingFileAllocator.unit.test.ts diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index 7a45afa78b..2fb8fb2d61 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -328,6 +328,7 @@ export async function load(url, context, nextLoad) { export const NotebookRendererMessaging = createClassProxy('NotebookRendererMessaging'); export const NotebookRendererScript = createClassProxy('NotebookRendererScript'); export const NotebookVariableProvider = createClassProxy('NotebookVariableProvider'); + export const TabInputNotebook = createClassProxy('TabInputNotebook'); export const ColorThemeKind = createClassProxy('ColorThemeKind'); export const UIKind = createClassProxy('UIKind'); export const ThemeIcon = createClassProxy('ThemeIcon'); @@ -386,6 +387,47 @@ export async function load(url, context, nextLoad) { } })); }; + + export const slugifyProjectName = (name) => { + return (name || '') + .normalize('NFD') + .replace(/[\\u0300-\\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + }; + + export const isSingleNotebookDeepnoteFile = (file) => { + return (file?.project?.notebooks?.length || 0) === 1; + }; + + export const splitByNotebooks = (file, sourceFileStem) => { + const notebooks = file?.project?.notebooks || []; + const initNotebookId = file?.project?.initNotebookId; + const used = new Set(); + const allocate = (slug) => { + const base = sourceFileStem + '-' + (slug || 'notebook'); + let candidate = base + '.deepnote'; + let n = 2; + while (used.has(candidate)) { + candidate = base + '-' + n + '.deepnote'; + n++; + } + used.add(candidate); + return candidate; + }; + const ordered = [...notebooks].sort((a, b) => { + const aInit = a.id === initNotebookId ? 0 : 1; + const bInit = b.id === initNotebookId ? 0 : 1; + return aInit - bInit; + }); + return ordered.map((nb) => ({ + notebook: { id: nb.id, name: nb.name }, + file: { ...file, project: { ...file.project, notebooks: [nb] } }, + outputFilename: allocate(slugifyProjectName(nb.name)) + })); + }; `, shortCircuit: true }; diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 272c88c6b8..8ccdafd32a 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -4,9 +4,12 @@ import { commands, l10n, workspace, window, type Disposable, type NotebookDocume import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; +import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; +import { DeepnoteMultiNotebookSplitter } from './deepnoteMultiNotebookSplitter'; +import { deepnoteFileExists } from './deepnoteSiblingFileAllocator'; import { IIntegrationManager } from './integrations/types'; import { DeepnoteInputBlockEditProtection } from './deepnoteInputBlockEditProtection'; import { SnapshotService } from './snapshots/snapshotService'; @@ -23,6 +26,8 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic private integrationManager: IIntegrationManager; + private multiNotebookSplitter: DeepnoteMultiNotebookSplitter; + private serializer: DeepnoteNotebookSerializer; private serializerRegistration?: Disposable; @@ -34,7 +39,10 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(IIntegrationManager) integrationManager: IIntegrationManager, @inject(ILogger) private readonly logger: ILogger, - @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService + @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, + @inject(IDeepnoteNotebookEnvironmentMapper) + @optional() + private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper ) { this.integrationManager = integrationManager; } @@ -68,6 +76,15 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic this.explorerView.activate(); this.integrationManager.activate(); + + this.multiNotebookSplitter = new DeepnoteMultiNotebookSplitter( + this.environmentMapper, + () => this.explorerView.refresh(), + this.logger, + deepnoteFileExists + ); + this.extensionContext.subscriptions.push(...this.multiNotebookSplitter.activate()); + this.extensionContext.subscriptions.push(this.multiNotebookSplitter); } private isSnapshotsEnabled(): boolean { diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 5a12e60235..8267eae438 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -45,6 +45,15 @@ export class DeepnoteExplorerView { * @param fileUri The URI of the project file * @returns Object with notebook ID and name if successful, or null if aborted/failed */ + /** + * Refreshes the full Deepnote explorer tree. + * Exposed so callers outside the explorer (e.g. the multi-notebook splitter) can + * trigger a refresh without reaching into the private tree data provider. + */ + public refresh(): void { + this.treeDataProvider.refresh(); + } + public async createAndAddNotebookToProject(fileUri: Uri): Promise<{ id: string; name: string } | null> { // Read the Deepnote project file const projectData = await readDeepnoteProjectFile(fileUri); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts new file mode 100644 index 0000000000..b2a72d4533 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -0,0 +1,197 @@ +import { l10n, TabInputNotebook, Uri, window, workspace, type Disposable, type NotebookDocument } from 'vscode'; +import { serializeDeepnoteFile } from '@deepnote/blocks'; +import { isSingleNotebookDeepnoteFile, splitByNotebooks } from '@deepnote/convert'; + +import { ILogger } from '../../platform/logging/types'; +import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; +import { DEEPNOTE_NOTEBOOK_TYPE } from '../../kernels/deepnote/types'; +import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; +import { allocateSiblingUri } from './deepnoteSiblingFileAllocator'; +import { getFileStem } from './deepnoteNotebookFileFactory'; + +const SPLIT_ACTION = l10n.t('Split into separate files'); + +/** + * Detects legacy multi-notebook `.deepnote` files when they are opened and, on explicit + * user action, splits them into one new single-notebook sibling file per notebook before + * deleting the original. There is NO automatic rewrite on open. + * + * The environment mapper is optional: it is undefined on the web target, where environment + * migration is a desktop-only no-op. + */ +export class DeepnoteMultiNotebookSplitter { + private readonly disposables: Disposable[] = []; + + private readonly envMapper: IDeepnoteNotebookEnvironmentMapper | undefined; + + private readonly exists: (uri: Uri) => Promise; + + private readonly logger: ILogger; + + private readonly promptedUris = new Set(); + + private readonly refreshTree: () => void; + + constructor( + envMapper: IDeepnoteNotebookEnvironmentMapper | undefined, + refreshTree: () => void, + logger: ILogger, + exists: (uri: Uri) => Promise + ) { + this.envMapper = envMapper; + this.refreshTree = refreshTree; + this.logger = logger; + this.exists = exists; + } + + public activate(): Disposable[] { + this.disposables.push( + workspace.onDidOpenNotebookDocument((notebook) => { + void this.handleNotebookOpened(notebook); + }) + ); + + // One activation sweep over already-open notebooks (event-driven only; no polling). + for (const notebook of workspace.notebookDocuments) { + try { + void this.handleNotebookOpened(notebook); + } catch (error) { + this.logger.error('Failed to inspect open Deepnote notebook for multi-notebook split', error); + } + } + + return this.disposables; + } + + public dispose(): void { + while (this.disposables.length > 0) { + this.disposables.pop()?.dispose(); + } + } + + private async handleNotebookOpened(notebook: NotebookDocument): Promise { + if (notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { + return; + } + + const fileUri = notebook.uri.with({ query: '', fragment: '' }); + const uriKey = fileUri.toString(); + + if (this.promptedUris.has(uriKey)) { + return; + } + + try { + const file = await readDeepnoteProjectFile(fileUri); + + if (isSingleNotebookDeepnoteFile(file)) { + return; + } + + // Mark as prompted before showing so a rapid re-open can't double-prompt. + this.promptedUris.add(uriKey); + + const selection = await window.showWarningMessage( + l10n.t('This .deepnote file contains multiple notebooks. Split it into one file per notebook?'), + SPLIT_ACTION + ); + + if (selection === SPLIT_ACTION) { + await this.splitFile(fileUri); + } + } catch (error) { + this.logger.error(`Failed to inspect Deepnote file for multi-notebook split: ${fileUri.toString()}`, error); + } + } + + private async splitFile(fileUri: Uri): Promise { + try { + // 1. Dirty gate: flush the open document first, then re-read from disk. + const openDocument = workspace.notebookDocuments.find( + (doc) => doc.uri.with({ query: '', fragment: '' }).toString() === fileUri.toString() + ); + + if (openDocument?.isDirty) { + let saved = false; + + try { + saved = await openDocument.save(); + } catch (error) { + this.logger.error(`Failed to save Deepnote file before split: ${fileUri.toString()}`, error); + } + + if (!saved) { + await window.showErrorMessage( + l10n.t('Could not save the file before splitting. The file was left unchanged.') + ); + + return; + } + } + + const deepnoteFile = await readDeepnoteProjectFile(fileUri); + const parentDir = Uri.joinPath(fileUri, '..'); + + // 2. Write the children: N new files, then (only after) delete the original. + const entries = splitByNotebooks(deepnoteFile, getFileStem(fileUri)); + const reserved = new Set(); + const newUris: Uri[] = []; + const encoder = new TextEncoder(); + + for (const entry of entries) { + const targetUri = await allocateSiblingUri(parentDir, entry.outputFilename, this.exists, reserved); + + await workspace.fs.writeFile(targetUri, encoder.encode(serializeDeepnoteFile(entry.file))); + newUris.push(targetUri); + } + + // 3. Migrate the environment selection onto each new file (desktop-only). + if (this.envMapper) { + const env = this.envMapper.getEnvironmentForNotebook(fileUri); + + if (env) { + for (const newUri of newUris) { + await this.envMapper.setEnvironmentForNotebook(newUri, env); + } + } + } + + // 4. Only after all children are durably written: close the tab + delete the original. + await this.closeNotebookTab(fileUri); + await workspace.fs.delete(fileUri, { useTrash: true }); + + if (this.envMapper) { + await this.envMapper.removeEnvironmentForNotebook(fileUri); + } + + this.refreshTree(); + + await window.showInformationMessage(l10n.t('Split into {0} files.', newUris.length)); + } catch (error) { + // Any write failure leaves the original intact: already-written children are + // harmless and a re-run re-derives the rest via the allocator's suffixing. + this.logger.error(`Failed to split Deepnote file: ${fileUri.toString()}`, error); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + await window.showErrorMessage( + l10n.t('Failed to split file: {0}. The original file was left unchanged.', errorMessage) + ); + } + } + + private async closeNotebookTab(fileUri: Uri): Promise { + for (const group of window.tabGroups.all) { + for (const tab of group.tabs) { + if ( + tab.input instanceof TabInputNotebook && + tab.input.uri.with({ query: '', fragment: '' }).toString() === fileUri.toString() + ) { + await window.tabGroups.close(tab); + + return; + } + } + } + } +} diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts new file mode 100644 index 0000000000..31c48f4aeb --- /dev/null +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -0,0 +1,593 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter, NotebookDocument, Uri } from 'vscode'; + +import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; +import type { ILogger } from '../../platform/logging/types'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { DeepnoteMultiNotebookSplitter } from './deepnoteMultiNotebookSplitter'; + +const SPLIT_ACTION = 'Split into separate files'; +const PROMPT_MESSAGE = 'This .deepnote file contains multiple notebooks. Split it into one file per notebook?'; + +const waitTimeoutMs = 4000; +const waitIntervalMs = 10; + +async function waitFor(condition: () => boolean, timeoutMs = waitTimeoutMs): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, waitIntervalMs)); + } +} + +/** A short settle delay used to PROVE that nothing further happened (no write/delete/prompt). */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 80)); +} + +function basename(uri: Uri): string { + return uri.path.split('/').pop() ?? ''; +} + +/** + * Tests for the on-demand multi-notebook splitter (§2). These exercise the splitter's + * ORCHESTRATION (prompt gating, write/delete ORDER, env migration, dirty gate, abort-on-failure) + * plus the REAL local `allocateSiblingUri`, against the MOCKED `@deepnote/convert` `splitByNotebooks`. + * + * NOTE: `instanceof TabInputNotebook` is always false against the test class-proxy, so the + * tab-close path is NOT unit-exercisable and is intentionally not asserted (see harness notes). + */ +suite('DeepnoteMultiNotebookSplitter', () => { + let splitter: DeepnoteMultiNotebookSplitter; + let onDidOpen: EventEmitter; + let refreshTreeCount: number; + let envMapper: IDeepnoteNotebookEnvironmentMapper; + + // Ordered log of side-effecting fs operations, so we can assert write-before-delete ORDER. + let callLog: Array<{ op: 'write' | 'delete'; name: string }>; + let writeTargets: string[]; + let deleteTargets: string[]; + let warnCount: number; + // Names that the injected `exists` probe reports as already present on disk. + let existingOnDisk: Set; + // If set, writing a file with this basename rejects (to test abort-on-failure). + let failWriteFor: string | undefined; + + const logger: ILogger = { + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + ci: () => undefined + } as unknown as ILogger; + + function makeNotebook(id: string, name: string, content: string): DeepnoteFile['project']['notebooks'][number] { + return { + id, + name, + blocks: [{ id: `${id}-b`, type: 'code', sortingKey: 'a0', blockGroup: 'g', content }] + } as unknown as DeepnoteFile['project']['notebooks'][number]; + } + + function makeFile(notebooks: DeepnoteFile['project']['notebooks'], initNotebookId?: string): DeepnoteFile { + return { + version: '1.0.0', + metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, + project: { + id: 'project-1', + name: 'Proj', + ...(initNotebookId ? { initNotebookId } : {}), + notebooks + } + } as unknown as DeepnoteFile; + } + + /** Wire `workspace.fs.readFile` to return the serialized bytes of `file` for any URI. */ + function stubReadFile(file: DeepnoteFile): void { + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall(() => + Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + ); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { + const name = basename(uri); + if (failWriteFor && name === failWriteFor) { + return Promise.reject(new Error(`write failed for ${name}`)); + } + callLog.push({ op: 'write', name }); + writeTargets.push(name); + // A successful write makes the file "exist" for any subsequent allocator probe. + existingOnDisk.add(name); + return Promise.resolve(); + }); + when(mockFs.delete(anything(), anything())).thenCall((uri: Uri) => { + const name = basename(uri); + callLog.push({ op: 'delete', name }); + deleteTargets.push(name); + return Promise.resolve(); + }); + when(mockFs.stat(anything())).thenCall((uri: Uri) => { + if (existingOnDisk.has(basename(uri))) { + return Promise.resolve({} as never); + } + return Promise.reject(new Error('not found')); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + } + + /** Build a NotebookDocument stub for the given file URI. */ + function notebookDoc(fileUri: Uri, opts?: { isDirty?: boolean; saveResult?: boolean }): NotebookDocument { + let saved = false; + return { + uri: fileUri, + notebookType: 'deepnote', + isDirty: opts?.isDirty ?? false, + save: () => { + saved = true; + return Promise.resolve(opts?.saveResult ?? true); + }, + get _saved() { + return saved; + } + } as unknown as NotebookDocument; + } + + setup(() => { + resetVSCodeMocks(); + callLog = []; + writeTargets = []; + deleteTargets = []; + warnCount = 0; + refreshTreeCount = 0; + existingOnDisk = new Set(); + failWriteFor = undefined; + + // Re-stub the open-notebook event with our own emitter so tests can fire opens. + onDidOpen = new EventEmitter(); + when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(onDidOpen.event); + + // Empty tab groups: closeNotebookTab iterates harmlessly (instanceof TabInputNotebook is false anyway). + when(mockedVSCodeNamespaces.window.tabGroups).thenReturn({ all: [] } as never); + + // Count split prompts; default resolves to "dismiss" — individual tests opt into accepting. + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything())).thenCall(() => { + warnCount++; + return Promise.resolve(undefined); + }); + + // Environment mapper: per-notebook env, recorded via real-ish maps. + const envMock = mock(); + when(envMock.getEnvironmentForNotebook(anything())).thenReturn(undefined); + when(envMock.setEnvironmentForNotebook(anything(), anything())).thenResolve(); + when(envMock.removeEnvironmentForNotebook(anything())).thenResolve(); + envMapper = instance(envMock); + + splitter = new DeepnoteMultiNotebookSplitter( + envMapper, + () => { + refreshTreeCount++; + }, + logger, + // `exists` probe injected directly (mirrors deepnoteFileExists, but synchronous-set-backed). + (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))) + ); + splitter.activate(); + }); + + teardown(() => { + splitter.dispose(); + onDidOpen.dispose(); + }); + + /** Make the next (and subsequent) split prompt(s) resolve to the accept action. */ + function acceptSplit(): void { + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything())).thenCall((message: string) => { + warnCount++; + if (message === PROMPT_MESSAGE) { + return Promise.resolve(SPLIT_ACTION); + } + return Promise.resolve(undefined); + }); + } + + suite('prompt gating', () => { + test('a 3-notebook file prompts and writes/deletes NOTHING until the action is taken (regression: no silent rewrite on open)', async () => { + const file = makeFile([ + makeNotebook('n1', 'Alpha', 'a'), + makeNotebook('n2', 'Beta', 'b'), + makeNotebook('n3', 'Gamma', 'c') + ]); + stubReadFile(file); + // Default prompt resolves to dismiss. + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + + await waitFor(() => warnCount >= 1); + await settle(); + + assert.strictEqual(warnCount, 1, 'should prompt exactly once'); + assert.strictEqual(writeTargets.length, 0, 'no writeFile until the split action is taken'); + assert.strictEqual(deleteTargets.length, 0, 'no delete until the split action is taken'); + }); + + test('a single-notebook file does NOT prompt (regression: a valid file must not be flagged)', async () => { + const file = makeFile([makeNotebook('only', 'Solo', 's')]); + stubReadFile(file); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/solo.deepnote'))); + + await settle(); + + assert.strictEqual(warnCount, 0, 'single-notebook file must not prompt'); + }); + + test('a standalone init file (one notebook, id === initNotebookId) does NOT prompt (regression: init file is a valid single-notebook file)', async () => { + const file = makeFile([makeNotebook('init-1', 'Init', 'setup')], 'init-1'); + stubReadFile(file); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/init.deepnote'))); + + await settle(); + + assert.strictEqual(warnCount, 0, 'standalone init file (length 1) must not prompt'); + }); + + test('prompts at most once per file per session (regression: re-open must not re-prompt)', async () => { + const file = makeFile([makeNotebook('n1', 'A', 'a'), makeNotebook('n2', 'B', 'b')]); + stubReadFile(file); + const uri = Uri.file('/ws/dup.deepnote'); + + onDidOpen.fire(notebookDoc(uri)); + await waitFor(() => warnCount >= 1); + + // Fire the open again for the SAME file. + onDidOpen.fire(notebookDoc(uri)); + await settle(); + + assert.strictEqual(warnCount, 1, 'a file must be prompted at most once per session'); + }); + }); + + suite('split action', () => { + test('writes N new files then deletes the original — delete happens AFTER the last write (ORDER, load-bearing)', async () => { + const file = makeFile([ + makeNotebook('n1', 'Alpha', 'a'), + makeNotebook('n2', 'Beta', 'b'), + makeNotebook('n3', 'Gamma', 'c') + ]); + stubReadFile(file); + acceptSplit(); + + const originalUri = Uri.file('/ws/multi.deepnote'); + onDidOpen.fire(notebookDoc(originalUri)); + + await waitFor(() => deleteTargets.length >= 1); + + // N = 3 writes, exactly one delete. + assert.strictEqual(writeTargets.length, 3, 'should write one new file per notebook (N=3)'); + assert.strictEqual(deleteTargets.length, 1, 'should delete the original exactly once'); + + // The convert mock names files {stem}-{slug}.deepnote. + assert.deepStrictEqual(writeTargets, [ + 'multi-alpha.deepnote', + 'multi-beta.deepnote', + 'multi-gamma.deepnote' + ]); + + // ORDER: every write must come before the single delete in the call log. + const deleteIndex = callLog.findIndex((c) => c.op === 'delete'); + const lastWriteIndex = callLog.map((c) => c.op).lastIndexOf('write'); + assert.isAbove(deleteIndex, lastWriteIndex, 'delete must happen AFTER the last write'); + assert.strictEqual( + callLog.filter((c) => c.op === 'write').length, + 3, + 'all three writes must precede the delete' + ); + + // The deleted file is the original. + assert.strictEqual(deleteTargets[0], 'multi.deepnote', 'the deleted file must be the original'); + }); + + test('deletes the original with { useTrash: true } (regression: must go to trash, not hard-delete)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + const mockFs = mock(); + let deleteOptions: { useTrash?: boolean } | undefined; + when(mockFs.readFile(anything())).thenCall(() => + Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + ); + when(mockFs.writeFile(anything(), anything())).thenResolve(); + when(mockFs.stat(anything())).thenReject(new Error('not found')); + when(mockFs.delete(anything(), anything())).thenCall((_uri: Uri, opts: { useTrash?: boolean }) => { + deleteOptions = opts; + deleteTargets.push('deleted'); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + assert.deepStrictEqual(deleteOptions, { useTrash: true }, 'delete must request the trash'); + }); + + test('copies the original env mapping onto each new file and removes the original mapping (regression: split-time env migration)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + const setCalls: string[] = []; + const removeCalls: string[] = []; + const envMock = mock(); + when(envMock.getEnvironmentForNotebook(anything())).thenReturn('env-xyz'); + when(envMock.setEnvironmentForNotebook(anything(), anything())).thenCall((uri: Uri, env: string) => { + setCalls.push(`${basename(uri)}=${env}`); + return Promise.resolve(); + }); + when(envMock.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { + removeCalls.push(basename(uri)); + return Promise.resolve(); + }); + + // Point the open event at a fresh local emitter BEFORE constructing/activating the + // env-returning splitter, so the new splitter subscribes to the emitter we fire below. + const localEmitter = new EventEmitter(); + when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(localEmitter.event); + + const splitterWithEnv = new DeepnoteMultiNotebookSplitter( + instance(envMock), + () => { + refreshTreeCount++; + }, + logger, + (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))) + ); + splitterWithEnv.activate(); + + localEmitter.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + + await waitFor(() => removeCalls.length >= 1); + await settle(); + + assert.deepStrictEqual( + setCalls.sort(), + ['multi-alpha.deepnote=env-xyz', 'multi-beta.deepnote=env-xyz'], + 'the original env must be copied onto every new sibling' + ); + assert.deepStrictEqual(removeCalls, ['multi.deepnote'], 'the original mapping must be removed'); + + splitterWithEnv.dispose(); + localEmitter.dispose(); + }); + + test('refreshes the tree after a successful split', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => refreshTreeCount >= 1); + + assert.strictEqual(refreshTreeCount, 1, 'tree should refresh once after split'); + }); + }); + + suite('abort-before-delete on write failure (load-bearing safety)', () => { + test('if a child writeFile rejects, delete is NEVER called and an error is surfaced (original left intact)', async () => { + const file = makeFile([ + makeNotebook('n1', 'Alpha', 'a'), + makeNotebook('n2', 'Beta', 'b'), + makeNotebook('n3', 'Gamma', 'c') + ]); + stubReadFile(file); + acceptSplit(); + + let errorShown = false; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall(() => { + errorShown = true; + return Promise.resolve(undefined); + }); + + // The SECOND child write fails. + failWriteFor = 'multi-beta.deepnote'; + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + + await waitFor(() => errorShown); + await settle(); + + assert.strictEqual(deleteTargets.length, 0, 'delete must NEVER be called when a child write fails'); + // The first write succeeded before the failure; the original is still present (never deleted). + assert.isTrue(errorShown, 'an error must be surfaced on write failure'); + assert.deepStrictEqual(writeTargets, ['multi-alpha.deepnote'], 'only writes before the failure occurred'); + }); + }); + + suite('dirty gate (load-bearing safety)', () => { + test('a dirty document is saved first before the split proceeds', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + const uri = Uri.file('/ws/multi.deepnote'); + const doc = notebookDoc(uri, { isDirty: true, saveResult: true }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([doc]); + + onDidOpen.fire(doc); + + await waitFor(() => deleteTargets.length >= 1); + + assert.isTrue( + (doc as unknown as { _saved: boolean })._saved, + 'document.save() must be called for a dirty doc' + ); + assert.strictEqual(writeTargets.length, 2, 'split proceeds after a successful save'); + }); + + test('if save() returns false (declined), the split ABORTS — no writeFile, no delete (regression: must not lose unsaved edits)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + let errorShown = false; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall(() => { + errorShown = true; + return Promise.resolve(undefined); + }); + + const uri = Uri.file('/ws/multi.deepnote'); + const doc = notebookDoc(uri, { isDirty: true, saveResult: false }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([doc]); + + onDidOpen.fire(doc); + + await waitFor(() => errorShown); + await settle(); + + assert.strictEqual(writeTargets.length, 0, 'declined save must abort before any write'); + assert.strictEqual(deleteTargets.length, 0, 'declined save must abort before any delete'); + }); + }); + + suite('collision safety', () => { + test('an allocated name already on disk is bumped to -2 (the existing path is NOT a write target)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + // `multi-alpha.deepnote` already exists on disk (e.g. a previous split / user file). + existingOnDisk.add('multi-alpha.deepnote'); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + assert.deepStrictEqual( + writeTargets, + ['multi-alpha-2.deepnote', 'multi-beta.deepnote'], + 'the colliding name must be bumped to -2 and the existing file must not be a write target' + ); + assert.notInclude(writeTargets, 'multi-alpha.deepnote', 'must NOT overwrite the pre-existing file'); + }); + + test('two notebooks whose slugs collide within one batch get distinct names via the shared reserved set', async () => { + // Both notebooks slugify to the same `dup` slug. + const file = makeFile([makeNotebook('n1', 'Dup', 'a'), makeNotebook('n2', 'Dup', 'b')]); + stubReadFile(file); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + // The mocked splitByNotebooks already de-dupes its own outputFilename in-batch + // (multi-dup.deepnote, multi-dup-2.deepnote); the shared `reserved` set in the + // splitter guarantees the writes land on two DISTINCT names regardless. + assert.strictEqual(writeTargets.length, 2, 'two writes for two notebooks'); + assert.strictEqual(new Set(writeTargets).size, 2, 'the two siblings must get distinct names'); + }); + + test('the ORIGINAL file URI is never a write target (regression: never rewrite the open document in place)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + assert.notInclude(writeTargets, 'multi.deepnote', 'the original file must never be written'); + }); + }); + + suite('init shape', () => { + test('a legacy [init, main] file splits into an init file + a main file that still references initNotebookId', async () => { + const file = makeFile( + [makeNotebook('init-1', 'Init', 'setup'), makeNotebook('main-1', 'Main', 'work')], + 'init-1' + ); + + // Capture the parsed file written for each target so we can inspect notebook ids / initNotebookId. + const writtenFiles: Array<{ name: string; parsed: DeepnoteFile }> = []; + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall(() => + Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + ); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, bytes: Uint8Array) => { + // serializeDeepnoteFile emits YAML — parse it back with the real deserializer. + writtenFiles.push({ + name: basename(uri), + parsed: deserializeDeepnoteFile(new TextDecoder().decode(bytes)) + }); + writeTargets.push(basename(uri)); + return Promise.resolve(); + }); + when(mockFs.stat(anything())).thenReject(new Error('not found')); + when(mockFs.delete(anything(), anything())).thenCall(() => { + deleteTargets.push('deleted'); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/legacy.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + // The mock splitByNotebooks emits the init notebook FIRST. + assert.strictEqual(writtenFiles.length, 2, 'two files written for [init, main]'); + + const initFile = writtenFiles.find((w) => w.parsed.project.notebooks[0].id === 'init-1'); + const mainFile = writtenFiles.find((w) => w.parsed.project.notebooks[0].id === 'main-1'); + + assert.isDefined(initFile, 'an init file (containing the init notebook) must be written'); + assert.isDefined(mainFile, 'a main file (containing the main notebook) must be written'); + assert.strictEqual( + initFile!.parsed.project.notebooks.length, + 1, + 'the init notebook lives in its own single-notebook file' + ); + assert.strictEqual( + mainFile!.parsed.project.initNotebookId, + 'init-1', + 'the main file must still reference the init notebook via initNotebookId' + ); + }); + }); + + suite('snapshots untouched', () => { + test('no write or delete target is a path under snapshots/ (regression: split must not touch snapshots)', async () => { + const file = makeFile([ + makeNotebook('n1', 'Alpha', 'a'), + makeNotebook('n2', 'Beta', 'b'), + makeNotebook('n3', 'Gamma', 'c') + ]); + const writeUris: Uri[] = []; + const deleteUris: Uri[] = []; + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall(() => + Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) + ); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { + writeUris.push(uri); + writeTargets.push(basename(uri)); + return Promise.resolve(); + }); + when(mockFs.stat(anything())).thenReject(new Error('not found')); + when(mockFs.delete(anything(), anything())).thenCall((uri: Uri) => { + deleteUris.push(uri); + deleteTargets.push(basename(uri)); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + acceptSplit(); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/project/multi.deepnote'))); + await waitFor(() => deleteTargets.length >= 1); + + for (const uri of [...writeUris, ...deleteUris]) { + assert.notInclude(uri.path, '/snapshots/', `must not target a snapshots/ path: ${uri.path}`); + } + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts b/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts new file mode 100644 index 0000000000..c44505997b --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts @@ -0,0 +1,80 @@ +import { Uri } from 'vscode'; +import type { DeepnoteFile } from '@deepnote/blocks'; +import { slugifyProjectName } from '@deepnote/convert'; + +import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; +import { allocateSiblingUri } from './deepnoteSiblingFileAllocator'; + +const FALLBACK_NOTEBOOK_SLUG = 'notebook'; +const DEEPNOTE_EXTENSION = '.deepnote'; + +/** + * Returns the basename of a URI up to (but not including) the FIRST `.`. + * e.g. `report.backup.deepnote` → `report`. + * @param uri The file URI + */ +export function getFileStem(uri: Uri): string { + const fileName = uri.path.split('/').pop() ?? ''; + const firstDotIndex = fileName.indexOf('.'); + + if (firstDotIndex === -1) { + return fileName; + } + + return fileName.slice(0, firstDotIndex); +} + +/** + * Build a new single-notebook `DeepnoteFile` from a source file and a single notebook. + * + * Clones `source.metadata` (or `{ createdAt: now }`), stamps `modifiedAt = now`, preserves + * the source's top-level fields, spreads `source.project` (preserving `id`, `name`, + * `integrations`, `settings`, and carrying `initNotebookId` forward), and sets + * `notebooks` to the single provided notebook. + * + * Note: `metadata.snapshotHash` is intentionally NOT stamped — it is a snapshot-only field + * that `serializeDeepnoteFile` strips, so stamping it on a source file is a no-op. + * + * @param source The source file to derive project-level metadata from + * @param notebook The single notebook the new file should contain + * @returns A new single-notebook `DeepnoteFile` + */ +export function buildSingleNotebookFile(source: DeepnoteFile, notebook: DeepnoteNotebook): DeepnoteFile { + const now = new Date().toISOString(); + const metadata = source.metadata ? { ...source.metadata } : { createdAt: now }; + + metadata.modifiedAt = now; + + return { + ...source, + metadata, + project: { + ...source.project, + notebooks: [notebook] + } + }; +} + +/** + * Compute a collision-free sibling URI for a new notebook file, named consistently with + * `@deepnote/convert`'s split output (`{stem}-{slug}.deepnote`). + * + * The desired basename is `${getFileStem(originalUri)}-${slugifyProjectName(notebookName) || 'notebook'}.deepnote`; + * collision handling is delegated to the shared `allocateSiblingUri` from §0. + * + * @param originalUri The URI of the originating file (used for parent dir + stem) + * @param notebookName The name of the notebook (slugified into the filename) + * @param exists Injected existence probe + * @returns A collision-free URI for the new sibling file + */ +export async function buildSiblingNotebookFileUri( + originalUri: Uri, + notebookName: string, + exists: (uri: Uri) => Promise +): Promise { + const parentDir = Uri.joinPath(originalUri, '..'); + const slug = slugifyProjectName(notebookName) || FALLBACK_NOTEBOOK_SLUG; + const desiredFilename = `${getFileStem(originalUri)}-${slug}${DEEPNOTE_EXTENSION}`; + + return allocateSiblingUri(parentDir, desiredFilename, exists); +} diff --git a/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts new file mode 100644 index 0000000000..1cebe7e880 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts @@ -0,0 +1,175 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import { Uri } from 'vscode'; + +import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; +import { buildSiblingNotebookFileUri, buildSingleNotebookFile, getFileStem } from './deepnoteNotebookFileFactory'; + +/** + * Tests for the notebook file factory (§3): the "new notebook" / "duplicate notebook" flows + * build a sibling FILE (never an extra notebook appended into one file). Uses the REAL + * `@deepnote/blocks` serializer for the snapshot-hash round-trip assertion. + */ +suite('DeepnoteNotebookFileFactory', () => { + function makeNotebook(id: string, name: string): DeepnoteNotebook { + return { + id, + name, + blocks: [ + { + id: `${id}-block`, + type: 'code', + sortingKey: 'a0', + blockGroup: 'g1', + content: 'print(1)' + } + ] + } as unknown as DeepnoteNotebook; + } + + function makeSource(overrides?: Partial): DeepnoteFile { + return { + version: '1.0.0', + metadata: { + createdAt: '2020-01-01T00:00:00Z', + modifiedAt: '2021-01-01T00:00:00Z', + ...overrides + }, + project: { + id: 'project-1', + name: 'My Project', + initNotebookId: 'init-notebook', + integrations: [{ id: 'int-1', name: 'My Postgres', type: 'postgres' }], + settings: { requirements: ['pandas'] }, + notebooks: [makeNotebook('nb-1', 'First')] + } + } as unknown as DeepnoteFile; + } + + suite('getFileStem', () => { + test('returns basename up to the FIRST dot (regression: a.b.deepnote must collapse to a)', () => { + assert.strictEqual(getFileStem(Uri.file('/x/a.b.deepnote')), 'a'); + assert.strictEqual(getFileStem(Uri.file('/x/report.deepnote')), 'report'); + assert.strictEqual(getFileStem(Uri.file('/x/report.backup.deepnote')), 'report'); + }); + }); + + suite('buildSingleNotebookFile', () => { + test('carries initNotebookId forward and sets exactly one notebook (regression: must not drop init pointer or keep siblings)', () => { + const source = makeSource(); + const newNotebook = makeNotebook('nb-2', 'Second'); + + const built = buildSingleNotebookFile(source, newNotebook); + + assert.strictEqual(built.project.initNotebookId, 'init-notebook', 'initNotebookId must carry forward'); + assert.strictEqual(built.project.notebooks.length, 1, 'built file must contain exactly one notebook'); + assert.deepStrictEqual( + built.project.notebooks[0], + newNotebook, + 'the one notebook must be the provided one' + ); + }); + + test('preserves project id/name/integrations/settings + top-level version (regression: project-level metadata must survive)', () => { + const source = makeSource(); + + const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); + + assert.strictEqual(built.project.id, 'project-1'); + assert.strictEqual(built.project.name, 'My Project'); + assert.deepStrictEqual(built.project.integrations, [ + { id: 'int-1', name: 'My Postgres', type: 'postgres' } + ]); + assert.deepStrictEqual(built.project.settings, { requirements: ['pandas'] }); + assert.strictEqual(built.version, '1.0.0', 'top-level version must be preserved'); + }); + + test('stamps a fresh modifiedAt but preserves the source createdAt (regression: createdAt must not be reset)', () => { + const source = makeSource(); + const before = Date.now(); + + const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); + + assert.strictEqual(built.metadata.createdAt, '2020-01-01T00:00:00Z', 'createdAt must be preserved'); + assert.notStrictEqual(built.metadata.modifiedAt, '2021-01-01T00:00:00Z', 'modifiedAt must be refreshed'); + const stampedMs = Date.parse(built.metadata.modifiedAt as string); + assert.isAtLeast(stampedMs, before, 'modifiedAt must be a fresh timestamp'); + }); + + test('synthesizes a createdAt when the source has no metadata (regression: missing metadata must not crash)', () => { + const source = makeSource(); + delete (source as { metadata?: unknown }).metadata; + + const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); + + assert.isString(built.metadata.createdAt, 'a createdAt must be synthesized when absent'); + assert.isString(built.metadata.modifiedAt, 'a modifiedAt must be stamped when absent'); + }); + + test('does NOT stamp a metadata.snapshotHash onto the built file (regression: snapshotHash is snapshot-only and must not be synthesized)', () => { + // The source carries no snapshotHash; the factory must not invent one (it is a + // snapshot-only field). Any pre-existing in-memory hash is harmless because + // serializeDeepnoteFile strips it — see the round-trip test below. + const source = makeSource(); + + const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); + + assert.notProperty( + built.metadata, + 'snapshotHash', + 'buildSingleNotebookFile must not stamp a snapshotHash on the built file' + ); + }); + + test('built file has no metadata.snapshotHash after a serialize -> deserialize round-trip (schema-stripped)', () => { + const source = makeSource(); + + const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); + const roundTripped = deserializeDeepnoteFile(serializeDeepnoteFile(built)); + + assert.notProperty( + roundTripped.metadata ?? {}, + 'snapshotHash', + 'snapshotHash must be absent after a serialize/deserialize round-trip' + ); + // The init pointer and project metadata must survive the round-trip too. + assert.strictEqual(roundTripped.project.initNotebookId, 'init-notebook'); + assert.strictEqual(roundTripped.project.notebooks.length, 1); + }); + }); + + suite('buildSiblingNotebookFileUri', () => { + const original = Uri.file('/workspace/project/report.deepnote'); + const neverExists = () => Promise.resolve(false); + + test('produces {stem}-{slug}.deepnote (regression: must match convert split naming)', async () => { + const uri = await buildSiblingNotebookFileUri(original, 'My Notebook', neverExists); + + assert.deepStrictEqual(uri, Uri.file('/workspace/project/report-my-notebook.deepnote')); + }); + + test('bumps -2 / -3 via the shared allocator on collision (regression: must not clobber an existing sibling)', async () => { + const existsFirst = (uri: Uri) => + Promise.resolve((uri.path.split('/').pop() ?? '') === 'report-my-notebook.deepnote'); + const uri2 = await buildSiblingNotebookFileUri(original, 'My Notebook', existsFirst); + assert.deepStrictEqual(uri2, Uri.file('/workspace/project/report-my-notebook-2.deepnote')); + + const existsTwo = (uri: Uri) => { + const name = uri.path.split('/').pop() ?? ''; + return Promise.resolve( + name === 'report-my-notebook.deepnote' || name === 'report-my-notebook-2.deepnote' + ); + }; + const uri3 = await buildSiblingNotebookFileUri(original, 'My Notebook', existsTwo); + assert.deepStrictEqual(uri3, Uri.file('/workspace/project/report-my-notebook-3.deepnote')); + }); + + test('falls back to {stem}-notebook.deepnote for an empty/blank notebook name (regression: blank slug must not yield {stem}-.deepnote)', async () => { + const emptyName = await buildSiblingNotebookFileUri(original, '', neverExists); + assert.deepStrictEqual(emptyName, Uri.file('/workspace/project/report-notebook.deepnote')); + + const blankName = await buildSiblingNotebookFileUri(original, ' ', neverExists); + assert.deepStrictEqual(blankName, Uri.file('/workspace/project/report-notebook.deepnote')); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts new file mode 100644 index 0000000000..3979a41e18 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts @@ -0,0 +1,84 @@ +import { Uri, workspace } from 'vscode'; + +/** + * Upper bound on the number of `-N` suffix attempts when resolving a collision-free + * sibling filename. Mirrors the internal cap used by `@deepnote/convert`'s splitter. + */ +export const MAX_SIBLING_ALLOCATION_ATTEMPTS = 10_000; + +const DEEPNOTE_EXTENSION = '.deepnote'; + +/** + * Default `exists` probe backed by `workspace.fs.stat`. A throwing stat (file not found, + * permission error, etc.) is treated as "does not exist". + * @param uri The URI to probe + */ +export async function deepnoteFileExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + + return true; + } catch { + return false; + } +} + +/** + * Resolve a collision-free sibling URI for a desired basename in `parentDir`. + * + * `desiredFilename` is a full basename including the `.deepnote` extension (e.g. convert's + * `entry.outputFilename`, or `${stem}-${slug}.deepnote`). On a clash, a numeric suffix is + * inserted immediately before the extension: `name.deepnote` → `name-2.deepnote` → + * `name-3.deepnote`, … The suffix is applied to the WHOLE basename before `.deepnote` + * (not a first-dot stem), so `report.backup.deepnote` → `report.backup-2.deepnote`. + * + * This helper only allocates NEW URIs; it never returns an existing path. When `reserved` + * is supplied, the chosen name is added to it before returning, so a batch that allocates + * several names before writing any of them cannot pick the same name twice. + * + * @param parentDir The directory in which to allocate the sibling + * @param desiredFilename The desired full basename (including `.deepnote` extension) + * @param exists Injected existence probe (default backs onto `workspace.fs.stat`) + * @param reserved Optional set of names already chosen in this batch but not yet written + * @returns A collision-free URI under `parentDir` + * @throws If a free name cannot be found within `MAX_SIBLING_ALLOCATION_ATTEMPTS` + */ +export async function allocateSiblingUri( + parentDir: Uri, + desiredFilename: string, + exists: (uri: Uri) => Promise, + reserved?: Set +): Promise { + const { base, extension } = splitBasename(desiredFilename); + + for (let attempt = 1; attempt <= MAX_SIBLING_ALLOCATION_ATTEMPTS; attempt++) { + const candidateName = attempt === 1 ? `${base}${extension}` : `${base}-${attempt}${extension}`; + const candidateUri = Uri.joinPath(parentDir, candidateName); + + if (!reserved?.has(candidateName) && !(await exists(candidateUri))) { + reserved?.add(candidateName); + + return candidateUri; + } + } + + throw new Error( + `Unable to allocate a free sibling filename for "${desiredFilename}" after ${MAX_SIBLING_ALLOCATION_ATTEMPTS} attempts.` + ); +} + +/** + * Split a basename into the portion before the trailing `.deepnote` extension and the + * extension itself. Names without the extension are returned unchanged with an empty + * extension so suffixing still appends to the whole basename. + */ +function splitBasename(filename: string): { base: string; extension: string } { + if (filename.endsWith(DEEPNOTE_EXTENSION)) { + return { + base: filename.slice(0, -DEEPNOTE_EXTENSION.length), + extension: DEEPNOTE_EXTENSION + }; + } + + return { base: filename, extension: '' }; +} diff --git a/src/notebooks/deepnote/deepnoteSiblingFileAllocator.unit.test.ts b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.unit.test.ts new file mode 100644 index 0000000000..11c34e5420 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.unit.test.ts @@ -0,0 +1,88 @@ +import { assert } from 'chai'; +import { Uri } from 'vscode'; + +import { allocateSiblingUri, MAX_SIBLING_ALLOCATION_ATTEMPTS } from './deepnoteSiblingFileAllocator'; + +/** + * Tests for the shared, collision-safe sibling-file allocator (§0). + * + * The allocator is the SINGLE filesystem-aware filename-allocation code path shared by the + * splitter (§2) and the notebook file factory (§3). These tests exercise the REAL allocator + * against an INJECTED `exists` probe, so no `workspace.fs` mocking is needed. + */ +suite('DeepnoteSiblingFileAllocator (allocateSiblingUri)', () => { + const parentDir = Uri.file('/workspace/project'); + + /** Build an `exists` probe that reports the given set of basenames (within parentDir) as present. */ + function existsFor(existingBasenames: string[]): (uri: Uri) => Promise { + const present = new Set(existingBasenames); + + return (uri: Uri) => Promise.resolve(present.has(uri.path.split('/').pop() ?? '')); + } + + test('returns desiredFilename verbatim when nothing exists (regression: must not suffix a free name)', async () => { + const result = await allocateSiblingUri(parentDir, 'report.deepnote', existsFor([])); + + assert.deepStrictEqual(result, Uri.joinPath(parentDir, 'report.deepnote')); + }); + + test('bumps to name-2 then name-3 as exists reports clashes (regression: must walk past every taken name)', async () => { + // Only `report.deepnote` taken -> first free is `report-2.deepnote`. + const second = await allocateSiblingUri(parentDir, 'report.deepnote', existsFor(['report.deepnote'])); + assert.deepStrictEqual(second, Uri.joinPath(parentDir, 'report-2.deepnote')); + + // `report.deepnote` AND `report-2.deepnote` taken -> first free is `report-3.deepnote`. + const third = await allocateSiblingUri( + parentDir, + 'report.deepnote', + existsFor(['report.deepnote', 'report-2.deepnote']) + ); + assert.deepStrictEqual(third, Uri.joinPath(parentDir, 'report-3.deepnote')); + }); + + test('suffixes the WHOLE base before .deepnote, not the first-dot stem (regression: report.backup -> report.backup-2)', async () => { + const result = await allocateSiblingUri( + parentDir, + 'report.backup.deepnote', + existsFor(['report.backup.deepnote']) + ); + + // If the allocator suffixed the first-dot stem it would produce `report-2.backup.deepnote` + // (and could clobber a sibling named `report-2...`); it must suffix the whole basename. + assert.deepStrictEqual(result, Uri.joinPath(parentDir, 'report.backup-2.deepnote')); + }); + + test('respects an in-batch reserved set even when exists is false, and adds the chosen name to reserved (regression: two un-written siblings must not collide)', async () => { + const reserved = new Set(); + + // First allocation: nothing on disk, nothing reserved -> takes the desired name and reserves it. + const first = await allocateSiblingUri(parentDir, 'nb.deepnote', existsFor([]), reserved); + assert.deepStrictEqual(first, Uri.joinPath(parentDir, 'nb.deepnote')); + assert.isTrue(reserved.has('nb.deepnote'), 'chosen name must be added to reserved'); + + // Second allocation of the SAME desired name: `nb.deepnote` is free on disk (exists=false) + // but is reserved from the first pass, so it must be skipped and bumped to `nb-2.deepnote`. + const second = await allocateSiblingUri(parentDir, 'nb.deepnote', existsFor([]), reserved); + assert.deepStrictEqual(second, Uri.joinPath(parentDir, 'nb-2.deepnote')); + assert.isTrue(reserved.has('nb-2.deepnote'), 'second chosen name must also be reserved'); + }); + + test('throws after MAX_SIBLING_ALLOCATION_ATTEMPTS when everything clashes (regression: must not loop forever)', async () => { + // `exists` always true -> every candidate clashes -> must throw, not hang. + const alwaysExists = () => Promise.resolve(true); + + let threw = false; + try { + await allocateSiblingUri(parentDir, 'taken.deepnote', alwaysExists); + } catch (error) { + threw = true; + assert.include( + (error as Error).message, + String(MAX_SIBLING_ALLOCATION_ATTEMPTS), + 'error should mention the attempt cap' + ); + } + + assert.isTrue(threw, 'allocateSiblingUri should throw when no free name can be found'); + }); +}); From 8e27889b40bd52e0d243bf3d935ae3568b0bb7f6 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 23 Jun 2026 23:45:49 +0000 Subject: [PATCH 04/26] feat(deepnote): propagate project metadata across sibling files on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 4 of single-notebook migration (§6). - Add DeepnoteProjectMetadataPropagator (desktop): given a project id and a project-level mutator, enumerate every sibling .deepnote file on disk (open or closed), apply the change, and write it back. Skips no-op writes, refreshes the manager cache for open siblings, and collects per-file failures instead of aborting. Fires an onFileWritten hook so the file watcher treats each write as a self-write (no reload/save storm). - Route integration updates and project rename through the propagator so closed siblings stay consistent; web falls back to the cache-only / single-file paths. - Expose getOriginalProject/updateOriginalProject on the platform manager interface. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- .../deepnote/deepnoteActivationService.ts | 8 +- .../deepnote/deepnoteExplorerView.ts | 18 +- .../deepnote/deepnoteFileChangeWatcher.ts | 56 ++- .../deepnoteFileChangeWatcher.unit.test.ts | 113 ++++++ .../integrations/integrationWebview.ts | 35 +- .../integrationWebview.unit.test.ts | 123 +++++- src/notebooks/serviceRegistry.node.ts | 7 + .../deepnoteProjectMetadataPropagator.node.ts | 196 ++++++++++ ...rojectMetadataPropagator.node.unit.test.ts | 352 ++++++++++++++++++ src/platform/deepnote/types.ts | 46 +++ src/platform/notebooks/deepnote/types.ts | 13 + 11 files changed, 940 insertions(+), 27 deletions(-) create mode 100644 src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts create mode 100644 src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts create mode 100644 src/platform/deepnote/types.ts diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 8ccdafd32a..e3e8272f9e 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -4,6 +4,7 @@ import { commands, l10n, workspace, window, type Disposable, type NotebookDocume import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; +import { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; @@ -42,7 +43,10 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, @inject(IDeepnoteNotebookEnvironmentMapper) @optional() - private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper + private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper, + @inject(IDeepnoteProjectMetadataPropagator) + @optional() + private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator ) { this.integrationManager = integrationManager; } @@ -53,7 +57,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic */ public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); - this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger); + this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger, this.metadataPropagator); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 8267eae438..73980de503 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -11,6 +11,7 @@ import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { Commands } from '../../platform/common/constants'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; +import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; /** * Manages the Deepnote explorer tree view and related commands @@ -23,7 +24,8 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(ILogger) logger: ILogger + @inject(ILogger) logger: ILogger, + private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); } @@ -304,6 +306,20 @@ export class DeepnoteExplorerView { } try { + // Desktop: rename the project across every sibling .deepnote file (open or closed), + // not just the clicked file. + if (this.metadataPropagator) { + await this.metadataPropagator.propagateProjectMetadata(treeItem.context.projectId, (file) => { + file.project.name = newName; + }); + + this.treeDataProvider.refresh(); + await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); + + return; + } + + // Web fallback (no filesystem fan-out): rename only the clicked file. const fileUri = Uri.file(treeItem.context.filePath); const projectData = await readDeepnoteProjectFile(fileUri); diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index 06aae1b58d..f4f36372d4 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -17,6 +17,7 @@ import { IControllerRegistration } from '../controllers/types'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IDisposableRegistry } from '../../platform/common/types'; import { logger } from '../../platform/logging'; +import { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; @@ -88,7 +89,10 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, - @inject(IControllerRegistration) @optional() private readonly controllerRegistration?: IControllerRegistration + @inject(IControllerRegistration) @optional() private readonly controllerRegistration?: IControllerRegistration, + @inject(IDeepnoteProjectMetadataPropagator) + @optional() + private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator ) { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); } @@ -102,25 +106,14 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic this.disposables.push({ dispose: () => this.clearAllTimers() }); if (this.snapshotService) { - this.disposables.push( - this.snapshotService.onFileWritten((uri) => { - const key = this.selfWriteKey(uri); - this.snapshotSelfWriteUris.add(key); - - // Safety net: clean stale entries after 30s - const existing = this.snapshotSelfWriteTimers.get(key); - if (existing) { - clearTimeout(existing); - } - this.snapshotSelfWriteTimers.set( - key, - setTimeout(() => { - this.snapshotSelfWriteUris.delete(key); - this.snapshotSelfWriteTimers.delete(key); - }, selfWriteExpirationMs) - ); - }) - ); + this.disposables.push(this.snapshotService.onFileWritten((uri) => this.markSnapshotSelfWrite(uri))); + } + + if (this.metadataPropagator) { + // A propagated write to an open sibling must be treated as a self-write so it does + // not bounce through the reload-and-resave path. Reuse the same self-write registry + // and consumption path as the snapshot subscription. + this.disposables.push(this.metadataPropagator.onFileWritten((uri) => this.markSnapshotSelfWrite(uri))); } } @@ -595,6 +588,29 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic ); } + /** + * Marks a URI as written by the snapshot service or the metadata propagator, so the + * resulting fs event is treated as a self-write and skipped. Shared by both subscriptions + * to keep a single, non-divergent self-write registry. + */ + private markSnapshotSelfWrite(uri: Uri): void { + const key = this.selfWriteKey(uri); + this.snapshotSelfWriteUris.add(key); + + // Safety net: clean stale entries after 30s + const existing = this.snapshotSelfWriteTimers.get(key); + if (existing) { + clearTimeout(existing); + } + this.snapshotSelfWriteTimers.set( + key, + setTimeout(() => { + this.snapshotSelfWriteUris.delete(key); + this.snapshotSelfWriteTimers.delete(key); + }, selfWriteExpirationMs) + ); + } + /** * Compares two output arrays for equality. * Uses a simple length + JSON comparison for output items. diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index d6e0656875..562374185a 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -6,6 +6,7 @@ import { Disposable, EventEmitter, FileSystemWatcher, NotebookCellKind, Notebook import type { IControllerRegistration } from '../controllers/types'; import type { IDisposableRegistry } from '../../platform/common/types'; import type { DeepnoteOutput, DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; +import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteFileChangeWatcher } from './deepnoteFileChangeWatcher'; @@ -1343,4 +1344,116 @@ project: fbOnDidCreate.dispose(); }); }); + + suite('metadata-propagator self-write suppression', () => { + let propagatorDisposables: IDisposableRegistry; + let propagatorOnDidChange: EventEmitter; + let propagatorOnDidCreate: EventEmitter; + let onFileWrittenCallback: ((uri: Uri) => void) | undefined; + let propagatorReadFileCalls: number; + let propagatorApplyEditCount: number; + let propagatorSaveCount: number; + + setup(() => { + propagatorDisposables = []; + onFileWrittenCallback = undefined; + propagatorReadFileCalls = 0; + propagatorApplyEditCount = 0; + propagatorSaveCount = 0; + + propagatorOnDidChange = new EventEmitter(); + propagatorOnDidCreate = new EventEmitter(); + const fsWatcherProp = mock(); + when(fsWatcherProp.onDidChange).thenReturn(propagatorOnDidChange.event); + when(fsWatcherProp.onDidCreate).thenReturn(propagatorOnDidCreate.event); + when(fsWatcherProp.dispose()).thenReturn(); + when(mockedVSCodeNamespaces.workspace.createFileSystemWatcher(anything())).thenReturn( + instance(fsWatcherProp) + ); + + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall(() => { + propagatorReadFileCalls++; + return Promise.resolve(new TextEncoder().encode(validYaml)); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenCall(() => { + propagatorApplyEditCount++; + return Promise.resolve(true); + }); + when(mockedVSCodeNamespaces.workspace.save(anything())).thenCall(() => { + propagatorSaveCount++; + return Promise.resolve(Uri.file('/workspace/test.deepnote')); + }); + + const mockPropagator = mock(); + when(mockPropagator.onFileWritten(anything())).thenCall((cb: (uri: Uri) => void) => { + onFileWrittenCallback = cb; + return { + dispose: () => { + onFileWrittenCallback = undefined; + } + } as Disposable; + }); + + const propagatorWatcher = new DeepnoteFileChangeWatcher( + propagatorDisposables, + mockNotebookManager, + undefined, + undefined, + instance(mockPropagator) + ); + propagatorWatcher.activate(); + }); + + teardown(() => { + for (const d of propagatorDisposables) { + d.dispose(); + } + propagatorOnDidChange.dispose(); + propagatorOnDidCreate.dispose(); + }); + + test('a propagated write marks the uri as a self-write so handleFileChange skips the reload-and-resave path', async () => { + const uri = Uri.file('/workspace/sibling.deepnote'); + // Live cells differ from the YAML on disk, so WITHOUT the self-write the watcher would + // read + applyEdit + save. The propagated-write marker must short-circuit all of that. + const notebook = createMockNotebook({ uri, cellCount: 0, cells: [] }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + // The propagator wrote this sibling → fires its onFileWritten callback (registered on activate()). + assert.isDefined( + onFileWrittenCallback, + 'metadataPropagator.onFileWritten should be subscribed on activate' + ); + onFileWrittenCallback!(uri); + + // The resulting fs change event for that write must be consumed as a self-write. + propagatorOnDidChange.fire(uri); + + // Wait well past the debounce to prove nothing was scheduled. + await new Promise((resolve) => setTimeout(resolve, debounceWaitMs)); + + assert.strictEqual(propagatorReadFileCalls, 0, 'readFile must NOT be called for a propagated self-write'); + assert.strictEqual(propagatorApplyEditCount, 0, 'applyEdit must NOT be called for a propagated self-write'); + assert.strictEqual(propagatorSaveCount, 0, 'save must NOT be called for a propagated self-write'); + }); + + test('without a preceding propagated write, an external change to the same sibling still reloads (proves the suppression is the propagated mark, not a blanket skip)', async () => { + const uri = Uri.file('/workspace/sibling-external.deepnote'); + const notebook = createMockNotebook({ uri, cellCount: 0, cells: [] }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + // No onFileWrittenCallback fired → a genuine external change reloads. + propagatorOnDidChange.fire(uri); + + await waitFor(() => propagatorSaveCount > 0); + + assert.isAtLeast(propagatorApplyEditCount, 1, 'a genuine external change should applyEdit'); + assert.isAtLeast(propagatorSaveCount, 1, 'a genuine external change should save'); + }); + }); }); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 17d0c35de3..277a2fdde8 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -8,6 +8,7 @@ import { IDisposableRegistry, IExtensionContext } from '../../../platform/common import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; +import { IDeepnoteProjectMetadataPropagator } from '../../../platform/deepnote/types'; import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; import { IFederatedAuthTokenStorage, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; import { @@ -40,7 +41,10 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, @inject(IFederatedAuthTokenStorage) @optional() - private readonly tokenStorage?: IFederatedAuthTokenStorage + private readonly tokenStorage?: IFederatedAuthTokenStorage, + @inject(IDeepnoteProjectMetadataPropagator) + @optional() + private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator ) { // Refresh on token-storage change so the auth pill flips without panel reload. Pushed into the extension-lifetime registry to survive panel close/reopen. if (this.tokenStorage) { @@ -764,7 +768,34 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { `IntegrationWebviewProvider: Updating project ${this.projectId} with ${projectIntegrations.length} integrations` ); - // Update the project in the notebook manager + // Desktop: fan out the integrations change to every sibling .deepnote file on disk + // (open or closed). The propagator's cache refresh subsumes the manager cache update, + // so this is the single place that updates both disk and the in-memory copies. + if (this.metadataPropagator) { + const { updated, failures } = await this.metadataPropagator.propagateProjectMetadata( + this.projectId, + (file) => { + file.project.integrations = projectIntegrations; + } + ); + + // Preserve the existing "project not found" contract: surface it only when no file + // matched the project (failures are surfaced by the propagator itself). + if (updated.length === 0 && failures.length === 0) { + logger.error( + `IntegrationWebviewProvider: Failed to update integrations for project ${this.projectId} - project not found` + ); + void window.showErrorMessage( + l10n.t( + 'Failed to update integrations: project not found. Please reopen the notebook and try again.' + ) + ); + } + + return; + } + + // Web fallback (no filesystem fan-out): update only the cached project entries. const success = this.notebookManager.updateProjectIntegrations(this.projectId, projectIntegrations); if (!success) { diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 474d06eae0..a999b3dfa1 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -6,6 +6,7 @@ import { anyString, anything, instance, mock, reset, verify, when } from 'ts-moc import { IExtensionContext, IDisposable } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import { IDeepnoteNotebookManager } from '../../types'; +import { IDeepnoteProjectMetadataPropagator, ProjectMetadataPropagationResult } from '../../../platform/deepnote/types'; import { IntegrationWebviewProvider } from './integrationWebview'; import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage, IIntegrationStorage } from './types'; import { computeMetadataFingerprint } from './federatedAuth/federatedAuthTokenStorage.node'; @@ -147,13 +148,19 @@ suite('IntegrationWebviewProvider', () => { onDidChangeTokens.dispose(); }); - function buildProvider(opts: { tokenStorage?: IFederatedAuthTokenStorage } = {}): IntegrationWebviewProvider { + function buildProvider( + opts: { + tokenStorage?: IFederatedAuthTokenStorage; + metadataPropagator?: IDeepnoteProjectMetadataPropagator; + } = {} + ): IntegrationWebviewProvider { return new IntegrationWebviewProvider( instance(extensionContext), instance(integrationStorage), instance(notebookManager), extensionSubscriptions, - opts.tokenStorage + opts.tokenStorage, + opts.metadataPropagator ); } @@ -431,4 +438,116 @@ suite('IntegrationWebviewProvider', () => { const updateMessages = allPostedMessages.filter((m) => m.type === 'update'); assert.isEmpty(updateMessages, 'no `update` postMessage should be issued after the panel disposes mid-update'); }); + + suite('updateProjectIntegrationsList: propagator vs cache-only fan-out', () => { + function makePropagator(result: ProjectMetadataPropagationResult): { + propagator: IDeepnoteProjectMetadataPropagator; + calls: Array<{ projectId: string; mutator: (file: import('@deepnote/blocks').DeepnoteFile) => void }>; + } { + const calls: Array<{ + projectId: string; + mutator: (file: import('@deepnote/blocks').DeepnoteFile) => void; + }> = []; + const propagator: IDeepnoteProjectMetadataPropagator = { + onFileWritten: () => ({ dispose: () => undefined }), + propagateProjectMetadata: async (projectId, mutator) => { + calls.push({ projectId, mutator }); + + return result; + } + }; + + return { propagator, calls }; + } + + async function callUpdateProjectIntegrationsList(provider: IntegrationWebviewProvider): Promise { + // `show()` sets `projectId` + the integrations map; then drive the private fan-out method. + await show(provider, singleIntegrationMap('pg-1', buildPostgresIntegration({ id: 'pg-1' }))); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (provider as any).updateProjectIntegrationsList(); + } + + test('when the propagator is present it drives propagateProjectMetadata (NOT the cache-only updateProjectIntegrations)', async () => { + const { propagator, calls } = makePropagator({ updated: [Uri.file('/x/a.deepnote')], failures: [] }); + const updateProjectIntegrationsSpy = sinon.spy((_projectId: string, _integrations: unknown[]) => true); + when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenCall( + updateProjectIntegrationsSpy + ); + + const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + await callUpdateProjectIntegrationsList(provider); + + assert.strictEqual(calls.length, 1, 'propagateProjectMetadata should be called once'); + assert.strictEqual(calls[0].projectId, PROJECT_ID); + sinon.assert.notCalled(updateProjectIntegrationsSpy); + }); + + test('"project not found" error shows only when updated.length === 0 AND failures.length === 0', async () => { + const errors: string[] = []; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { + errors.push(msg); + + return Promise.resolve(undefined); + }); + + // updated.length === 0 && failures.length === 0 → group empty/unresolvable → error. + const { propagator } = makePropagator({ updated: [], failures: [] }); + const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + await callUpdateProjectIntegrationsList(provider); + + assert.strictEqual( + errors.length, + 1, + 'project-not-found error should show when nothing was updated and nothing failed' + ); + }); + + test('"project not found" error does NOT show when at least one file was updated', async () => { + const errors: string[] = []; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { + errors.push(msg); + + return Promise.resolve(undefined); + }); + + const { propagator } = makePropagator({ updated: [Uri.file('/x/a.deepnote')], failures: [] }); + const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + await callUpdateProjectIntegrationsList(provider); + + assert.deepStrictEqual(errors, [], 'no project-not-found error when a file was updated'); + }); + + test('"project not found" error does NOT show when there were per-file failures (propagator already surfaced them)', async () => { + const errors: string[] = []; + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { + errors.push(msg); + + return Promise.resolve(undefined); + }); + + // updated empty but failures present → propagator already warned; no project-not-found error. + const { propagator } = makePropagator({ + updated: [], + failures: [{ uri: Uri.file('/x/bad.deepnote'), error: new Error('boom') }] + }); + const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + await callUpdateProjectIntegrationsList(provider); + + assert.deepStrictEqual(errors, [], 'no project-not-found error when failures were collected'); + }); + + test('when the propagator is absent (web) it falls back to notebookManager.updateProjectIntegrations', async () => { + const updateProjectIntegrationsSpy = sinon.spy((_projectId: string, _integrations: unknown[]) => true); + when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenCall( + updateProjectIntegrationsSpy + ); + + const provider = buildProvider({ tokenStorage }); // no metadataPropagator + await callUpdateProjectIntegrationsList(provider); + + sinon.assert.calledOnce(updateProjectIntegrationsSpy); + sinon.assert.calledWith(updateProjectIntegrationsSpy, PROJECT_ID); + }); + }); }); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 3a46a272ec..9ab328a1e4 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -64,6 +64,8 @@ import { IPlatformNotebookEditorProvider, IPlatformDeepnoteNotebookManager } from '../platform/notebooks/deepnote/types'; +import { IDeepnoteProjectMetadataPropagator } from '../platform/deepnote/types'; +import { DeepnoteProjectMetadataPropagator } from '../platform/deepnote/deepnoteProjectMetadataPropagator.node'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { DirtyInputBlockStatusBarProvider } from './deepnote/dirtyInputBlockStatusBarProvider'; import { StaleOutputStatusBarProvider } from './deepnote/staleOutputStatusBarProvider'; @@ -183,6 +185,11 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IDeepnoteNotebookManager, DeepnoteNotebookManager); // Bind the platform-layer interface to the same implementation serviceManager.addBinding(IDeepnoteNotebookManager, IPlatformDeepnoteNotebookManager); + // Project-metadata propagation across sibling .deepnote files (desktop-only filesystem fan-out) + serviceManager.addSingleton( + IDeepnoteProjectMetadataPropagator, + DeepnoteProjectMetadataPropagator + ); serviceManager.addSingleton(IIntegrationStorage, IntegrationStorage); serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); diff --git a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts new file mode 100644 index 0000000000..4455ac11b4 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts @@ -0,0 +1,196 @@ +import { inject, injectable } from 'inversify'; +import { Disposable, RelativePattern, Uri, window, workspace } from 'vscode'; +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; + +import { IDisposableRegistry } from '../common/types'; +import { logger } from '../logging'; +import { IPlatformDeepnoteNotebookManager } from '../notebooks/deepnote/types'; +import { IDeepnoteProjectMetadataPropagator, ProjectMetadataPropagationResult } from './types'; + +/** + * Suffix that identifies snapshot `.deepnote` files. Snapshots are notebook-output sidecars, + * not project source files, so they are excluded from the propagation group. + * + * Re-implemented locally (rather than imported from the notebooks-layer + * `snapshots/snapshotFiles.ts`) to avoid this platform-layer module reaching across into the + * notebooks layer for a one-line `endsWith` check. + */ +const SNAPSHOT_FILE_SUFFIX = '.snapshot.deepnote'; + +/** + * Upper bound on `.deepnote` files inspected per workspace folder. Bounds the on-disk scan + * so a pathological workspace cannot stall the propagation pass. + */ +const MAX_DEEPNOTE_FILES_PER_FOLDER = 5_000; + +/** + * Desktop-only implementation of {@link IDeepnoteProjectMetadataPropagator}. + * + * Walks the workspace via `workspace.findFiles`/`workspace.fs` and rewrites every sibling + * `.deepnote` file of a project so that a project-level edit (integrations, name, …) reaches + * closed siblings, not just the open editor. For open siblings it additionally refreshes the + * manager cache so an open editor does not save a stale project. + */ +@injectable() +export class DeepnoteProjectMetadataPropagator implements IDeepnoteProjectMetadataPropagator { + private readonly fileWrittenCallbacks: ((uri: Uri) => void)[] = []; + + constructor( + @inject(IDisposableRegistry) disposables: IDisposableRegistry, + @inject(IPlatformDeepnoteNotebookManager) + private readonly notebookManager: IPlatformDeepnoteNotebookManager + ) { + disposables.push({ dispose: () => (this.fileWrittenCallbacks.length = 0) }); + } + + public onFileWritten(callback: (uri: Uri) => void): Disposable { + this.fileWrittenCallbacks.push(callback); + + return { + dispose: () => { + const idx = this.fileWrittenCallbacks.indexOf(callback); + + if (idx >= 0) { + this.fileWrittenCallbacks.splice(idx, 1); + } + } + }; + } + + public async propagateProjectMetadata( + projectId: string, + mutator: (file: DeepnoteFile) => void + ): Promise { + const updated: Uri[] = []; + const failures: Array<{ uri: Uri; error: unknown }> = []; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + const candidates = await this.enumerateProjectFiles(projectId, decoder); + + for (const { uri, file, originalBytes } of candidates) { + try { + mutator(file); + + // No-op skip: serialize the post-mutator file BEFORE bumping modifiedAt and + // compare to the original on-disk bytes. If the mutator changed nothing, skip + // entirely (no write, no modifiedAt bump) so a "save" that changes nothing + // cannot start a churn loop. + const serialized = serializeDeepnoteFile(file); + + if (serialized === originalBytes) { + continue; + } + + if (!file.metadata) { + file.metadata = { createdAt: new Date().toISOString() }; + } + file.metadata.modifiedAt = new Date().toISOString(); + + const content = encoder.encode(serializeDeepnoteFile(file)); + + // Fire self-write callbacks BEFORE the write so the file watcher marks this a + // self-write and skips the reload-and-resave for an open sibling. + this.fireFileWritten(uri); + + await workspace.fs.writeFile(uri, content); + + updated.push(uri); + + this.refreshManagerCache(projectId, file); + } catch (error) { + logger.error(`[MetadataPropagator] Failed to update project file: ${uri.path}`, error); + failures.push({ uri, error }); + } + } + + if (failures.length > 0) { + const total = updated.length + failures.length; + + await window.showWarningMessage( + `Updated ${updated.length} of ${total} project files; ${failures.length} could not be updated.` + ); + } + + return { updated, failures }; + } + + /** + * Enumerates every non-snapshot `.deepnote` file across the workspace whose + * `project.id === projectId`. Membership is "matches project.id on disk" — open documents + * and the manager cache are never consulted here. + */ + private async enumerateProjectFiles( + projectId: string, + decoder: TextDecoder + ): Promise> { + const matches: Array<{ uri: Uri; file: DeepnoteFile; originalBytes: string }> = []; + + const workspaceFolders = workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + return matches; + } + + for (const folder of workspaceFolders) { + let files: Uri[]; + + try { + files = await workspace.findFiles( + new RelativePattern(folder, '**/*.deepnote'), + undefined, + MAX_DEEPNOTE_FILES_PER_FOLDER + ); + } catch (error) { + logger.warn(`[MetadataPropagator] Failed to enumerate .deepnote files in ${folder.uri.path}`, error); + + continue; + } + + for (const uri of files) { + if (uri.path.endsWith(SNAPSHOT_FILE_SUFFIX)) { + continue; + } + + try { + const bytes = await workspace.fs.readFile(uri); + const originalBytes = decoder.decode(bytes); + const file = deserializeDeepnoteFile(originalBytes); + + if (file.project.id === projectId) { + matches.push({ uri, file, originalBytes }); + } + } catch (error) { + logger.warn(`[MetadataPropagator] Failed to read/parse candidate file: ${uri.path}`, error); + } + } + } + + return matches; + } + + private fireFileWritten(uri: Uri): void { + for (const callback of this.fileWrittenCallbacks) { + try { + callback(uri); + } catch (error) { + logger.warn('[MetadataPropagator] File written callback failed', error); + } + } + } + + /** + * Refreshes the manager cache for an updated file that is also open, so an open editor + * does not save a stale project. This is the only place open-state is consulted, and it is + * for cache coherence — not to decide which files to write. + */ + private refreshManagerCache(projectId: string, file: DeepnoteFile): void { + // Single-notebook files: refresh the one notebook entry if it is cached. + for (const notebook of file.project.notebooks) { + if (this.notebookManager.getOriginalProject(projectId, notebook.id)) { + this.notebookManager.updateOriginalProject(projectId, notebook.id, file); + } + } + } +} diff --git a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts new file mode 100644 index 0000000000..2f176267eb --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts @@ -0,0 +1,352 @@ +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, WorkspaceFolder } from 'vscode'; + +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; + +import { DeepnoteProjectMetadataPropagator } from './deepnoteProjectMetadataPropagator.node'; +import { IDisposableRegistry } from '../common/types'; +import { IPlatformDeepnoteNotebookManager } from '../notebooks/deepnote/types'; +import type { DeepnoteProject } from './deepnoteTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; + +suite('MetadataPropagator', () => { + let propagator: DeepnoteProjectMetadataPropagator; + let disposables: IDisposableRegistry; + let mockManager: IPlatformDeepnoteNotebookManager; + + const PROJECT_ID = 'project-1'; + const OTHER_PROJECT_ID = 'project-2'; + + setup(() => { + resetVSCodeMocks(); + disposables = []; + mockManager = mock(); + // Default: nothing cached, so the cache refresh is a no-op unless a test says otherwise. + when(mockManager.getOriginalProject(anything(), anything())).thenReturn(undefined); + + propagator = new DeepnoteProjectMetadataPropagator(disposables, instance(mockManager)); + }); + + teardown(() => { + for (const d of disposables) { + d.dispose(); + } + }); + + /** Builds a single-notebook DeepnoteFile for a given project + notebook id. */ + function buildFile(projectId: string, notebookId: string, notebookName = 'Notebook'): DeepnoteFile { + return { + metadata: { createdAt: '2025-01-01T00:00:00Z', modifiedAt: '2025-01-01T00:00:00Z' }, + version: '1.0.0', + project: { + id: projectId, + name: 'My Project', + integrations: [], + notebooks: [ + { + id: notebookId, + name: notebookName, + blocks: [ + { + id: `${notebookId}-block-1`, + type: 'code', + blockGroup: '1', + sortingKey: 'a0', + metadata: {}, + content: 'print(1)', + outputs: [] + } + ] + } + ] + } + } as DeepnoteFile; + } + + /** + * Canonical on-disk bytes for a file: what `serializeDeepnoteFile` produces. The propagator + * reads these verbatim and compares them against its own re-serialization for the no-op skip, + * so tests must feed the canonical form (not hand-written YAML). + */ + function canonicalBytes(file: DeepnoteFile): Uint8Array { + return new TextEncoder().encode(serializeDeepnoteFile(file)); + } + + /** + * Wires the workspace mock so `findFiles` returns `entries.map(e => e.uri)` and `fs.readFile` + * returns the canonical bytes for each uri. Returns a `writes` array capturing every + * `fs.writeFile(uri, content)` call so tests can assert on-disk results, plus an `order` log + * interleaving `onFileWritten` and `writeFile` events keyed by uri path. + */ + function setupWorkspace( + entries: Array<{ uri: Uri; file: DeepnoteFile }>, + opts: { rejectWriteForPath?: string } = {} + ): { writes: Array<{ uri: Uri; content: Uint8Array }>; order: string[] } { + const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve( + entries.map((e) => e.uri) as any + ); + + const byPath = new Map(entries.map((e) => [e.uri.path, e.file])); + const writes: Array<{ uri: Uri; content: Uint8Array }> = []; + const order: string[] = []; + + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const file = byPath.get(uri.path); + if (!file) { + return Promise.reject(new Error(`No file for ${uri.path}`)); + } + + return Promise.resolve(canonicalBytes(file)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { + order.push(`write:${uri.path}`); + + if (opts.rejectWriteForPath && uri.path === opts.rejectWriteForPath) { + return Promise.reject(new Error('Write failed')); + } + writes.push({ uri, content }); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return { writes, order }; + } + + function parseWritten(writes: Array<{ uri: Uri; content: Uint8Array }>, uri: Uri): DeepnoteFile { + const entry = writes.find((w) => w.uri.path === uri.path); + assert.isDefined(entry, `expected a write for ${uri.path}`); + + return deserializeDeepnoteFile(new TextDecoder().decode(entry!.content)); + } + + test('without on-disk fan-out, a closed sibling keeps stale integrations: both siblings are rewritten and the CLOSED one reflects the change', async () => { + const openUri = Uri.file('/workspace/open.deepnote'); + const closedUri = Uri.file('/workspace/closed.deepnote'); + const { writes } = setupWorkspace([ + { uri: openUri, file: buildFile(PROJECT_ID, 'nb-open') }, + { uri: closedUri, file: buildFile(PROJECT_ID, 'nb-closed') } + ]); + + const newIntegrations = [{ id: 'pg-1', name: 'Postgres', type: 'postgres' }]; + const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.integrations = newIntegrations as DeepnoteFile['project']['integrations']; + }); + + // Both uris written. + assert.strictEqual(writes.length, 2, 'both siblings should be written to disk'); + assert.deepStrictEqual(result.updated.map((u) => u.path).sort(), [closedUri.path, openUri.path].sort()); + assert.deepStrictEqual(result.failures, []); + + // Load-bearing: the CLOSED sibling's on-disk bytes reflect the new integrations. + const closedWritten = parseWritten(writes, closedUri); + assert.deepStrictEqual(closedWritten.project.integrations, newIntegrations); + const openWritten = parseWritten(writes, openUri); + assert.deepStrictEqual(openWritten.project.integrations, newIntegrations); + }); + + test('a sibling with a non-matching project.id is never written (skips out-of-group files)', async () => { + const matchUri = Uri.file('/workspace/match.deepnote'); + const otherUri = Uri.file('/workspace/other.deepnote'); + const { writes } = setupWorkspace([ + { uri: matchUri, file: buildFile(PROJECT_ID, 'nb-match') }, + { uri: otherUri, file: buildFile(OTHER_PROJECT_ID, 'nb-other') } + ]); + + const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed'; + }); + + assert.strictEqual(writes.length, 1, 'only the matching project file should be written'); + assert.strictEqual(writes[0].uri.path, matchUri.path); + assert.deepStrictEqual( + result.updated.map((u) => u.path), + [matchUri.path] + ); + // The non-matching file is absent from writes entirely. + assert.isUndefined(writes.find((w) => w.uri.path === otherUri.path)); + }); + + test("a name/integrations mutator leaves each file's single project.notebooks[0] untouched", async () => { + const uriA = Uri.file('/workspace/a.deepnote'); + const uriB = Uri.file('/workspace/b.deepnote'); + const fileA = buildFile(PROJECT_ID, 'nb-a', 'Notebook A'); + const fileB = buildFile(PROJECT_ID, 'nb-b', 'Notebook B'); + const { writes } = setupWorkspace([ + { uri: uriA, file: fileA }, + { uri: uriB, file: fileB } + ]); + + await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed Project'; + f.project.integrations = [ + { id: 'i', name: 'PG', type: 'postgres' } + ] as DeepnoteFile['project']['integrations']; + }); + + const writtenA = parseWritten(writes, uriA); + const writtenB = parseWritten(writes, uriB); + + // Project-level fields changed. + assert.strictEqual(writtenA.project.name, 'Renamed Project'); + // The single original notebook (id + name) is preserved verbatim per file. + assert.strictEqual(writtenA.project.notebooks.length, 1); + assert.deepStrictEqual( + { id: writtenA.project.notebooks[0].id, name: writtenA.project.notebooks[0].name }, + { id: 'nb-a', name: 'Notebook A' } + ); + assert.strictEqual(writtenB.project.notebooks.length, 1); + assert.deepStrictEqual( + { id: writtenB.project.notebooks[0].id, name: writtenB.project.notebooks[0].name }, + { id: 'nb-b', name: 'Notebook B' } + ); + }); + + test('a mutator that sets a field to its current value is a no-op: no writeFile, no modifiedAt bump, no updated entry', async () => { + const uri = Uri.file('/workspace/noop.deepnote'); + const file = buildFile(PROJECT_ID, 'nb-1'); + const { writes } = setupWorkspace([{ uri, file }]); + + const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + // Set name to its CURRENT value → serialized bytes are unchanged. + f.project.name = 'My Project'; + }); + + assert.strictEqual(writes.length, 0, 'writeFile must NOT be called for a no-op mutation'); + assert.deepStrictEqual(result.updated, [], 'no file should be reported as updated'); + assert.deepStrictEqual(result.failures, []); + }); + + test('a writeFile rejection for one uri is isolated: the other file is still written, the failure is collected (not thrown), and a summarized warning fires', async () => { + const goodUri = Uri.file('/workspace/good.deepnote'); + const badUri = Uri.file('/workspace/bad.deepnote'); + const { writes } = setupWorkspace( + [ + { uri: goodUri, file: buildFile(PROJECT_ID, 'nb-good') }, + { uri: badUri, file: buildFile(PROJECT_ID, 'nb-bad') } + ], + { rejectWriteForPath: badUri.path } + ); + + let warned = false; + when(mockedVSCodeNamespaces.window.showWarningMessage(anything())).thenCall(() => { + warned = true; + + return Promise.resolve(undefined); + }); + + const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed'; + }); + + // The good file is still written despite the bad file failing. + assert.strictEqual(writes.length, 1, 'the non-failing file should still be written'); + assert.strictEqual(writes[0].uri.path, goodUri.path); + assert.deepStrictEqual( + result.updated.map((u) => u.path), + [goodUri.path] + ); + // The failure is collected, not thrown. + assert.strictEqual(result.failures.length, 1); + assert.strictEqual(result.failures[0].uri.path, badUri.path); + // A single summarized warning is shown. + assert.isTrue(warned, 'a summarized showWarningMessage should fire on partial failure'); + }); + + test('onFileWritten fires synchronously BEFORE the write for that uri (self-write marked before fs.writeFile)', async () => { + const uri = Uri.file('/workspace/ordered.deepnote'); + const { order } = setupWorkspace([{ uri, file: buildFile(PROJECT_ID, 'nb-1') }]); + + const callbackOrder: string[] = []; + const disposable = propagator.onFileWritten((u) => { + callbackOrder.push(`callback:${u.path}`); + order.push(`callback:${u.path}`); + }); + + await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed'; + }); + disposable.dispose(); + + // The callback received the uri. + assert.deepStrictEqual(callbackOrder, [`callback:${uri.path}`]); + // Ordering: the callback for this uri runs BEFORE its write. + assert.deepStrictEqual(order, [`callback:${uri.path}`, `write:${uri.path}`]); + }); + + test('cache refresh runs only for cached entries: updateOriginalProject is called for the open sibling and NOT for the closed sibling', async () => { + const openUri = Uri.file('/workspace/open.deepnote'); + const closedUri = Uri.file('/workspace/closed.deepnote'); + setupWorkspace([ + { uri: openUri, file: buildFile(PROJECT_ID, 'nb-open') }, + { uri: closedUri, file: buildFile(PROJECT_ID, 'nb-closed') } + ]); + + // The open sibling is cached; the closed one is not. + when(mockManager.getOriginalProject(PROJECT_ID, 'nb-open')).thenReturn({} as DeepnoteProject); + when(mockManager.getOriginalProject(PROJECT_ID, 'nb-closed')).thenReturn(undefined); + + const refreshed: Array<{ projectId: string; notebookId: string }> = []; + when(mockManager.updateOriginalProject(anything(), anything(), anything())).thenCall( + (projectId: string, notebookId: string) => { + refreshed.push({ projectId, notebookId }); + } + ); + + await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed'; + }); + + // Exactly one cache refresh: for the open (cached) sibling only. + assert.deepStrictEqual(refreshed, [{ projectId: PROJECT_ID, notebookId: 'nb-open' }]); + }); + + test('a *.snapshot.deepnote returned by findFiles is never read or written (snapshot sidecars excluded)', async () => { + const sourceUri = Uri.file('/workspace/source.deepnote'); + const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + + const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ + sourceUri, + snapshotUri + ] as any); + + const sourceFile = buildFile(PROJECT_ID, 'nb-1'); + const readPaths: string[] = []; + const writes: Uri[] = []; + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + readPaths.push(uri.path); + + return Promise.resolve(canonicalBytes(sourceFile)); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { + writes.push(uri); + + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { + f.project.name = 'Renamed'; + }); + + // The snapshot file is never read nor written. + assert.notInclude(readPaths, snapshotUri.path, 'snapshot file must not be read'); + assert.isUndefined( + writes.find((u) => u.path === snapshotUri.path), + 'snapshot file must not be written' + ); + // The real source file is processed. + assert.include(readPaths, sourceUri.path); + assert.deepStrictEqual( + writes.map((u) => u.path), + [sourceUri.path] + ); + }); +}); diff --git a/src/platform/deepnote/types.ts b/src/platform/deepnote/types.ts new file mode 100644 index 0000000000..8c5d9a30b3 --- /dev/null +++ b/src/platform/deepnote/types.ts @@ -0,0 +1,46 @@ +import type { Disposable, Uri } from 'vscode'; +import type { DeepnoteFile } from '@deepnote/blocks'; + +/** + * Result of a project-metadata propagation pass. + * `updated` holds the URIs whose on-disk content was rewritten; `failures` holds the + * per-file errors that did not abort the rest of the pass. + */ +export interface ProjectMetadataPropagationResult { + updated: Uri[]; + failures: Array<{ uri: Uri; error: unknown }>; +} + +export const IDeepnoteProjectMetadataPropagator = Symbol('IDeepnoteProjectMetadataPropagator'); + +/** + * Propagates project-level metadata changes across every sibling `.deepnote` file of a + * project (open or closed) by enumerating the project group on disk and rewriting each file. + * + * Because each sibling file carries its own copy of the project-level fields + * (`project.integrations`, `project.settings`, `project.name`, …), any edit to such a field + * must be written into all sibling files of the project, or unopened siblings keep stale + * values. Membership is determined purely by `project.id` on disk — never by which editors + * happen to be open or which documents the manager has cached. + */ +export interface IDeepnoteProjectMetadataPropagator { + /** + * Registers a callback that is invoked synchronously before each file the propagator writes. + * Used by the file-change watcher for deterministic self-write detection. + * @returns A disposable that removes the callback. + */ + onFileWritten(callback: (uri: Uri) => void): Disposable; + + /** + * Enumerates every `.deepnote` file in the workspace whose `project.id === projectId`, + * applies `mutator` to each file's project-level fields, and writes it back to disk. + * Files whose serialized bytes are unchanged by the mutator are skipped (no write, no + * `modifiedAt` bump). Returns the URIs that were rewritten and any per-file failures. + * @param projectId The project whose sibling files should be updated + * @param mutator Mutates the parsed file in place; must touch only project-level fields + */ + propagateProjectMetadata( + projectId: string, + mutator: (file: DeepnoteFile) => void + ): Promise; +} diff --git a/src/platform/notebooks/deepnote/types.ts b/src/platform/notebooks/deepnote/types.ts index 61d6cc4726..7ab818dffd 100644 --- a/src/platform/notebooks/deepnote/types.ts +++ b/src/platform/notebooks/deepnote/types.ts @@ -121,4 +121,17 @@ export interface IPlatformDeepnoteNotebookManager { * so this may return any one sibling's cached project. */ getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; + + /** + * Returns the cached project for an exact (projectId, notebookId) pair, or undefined if + * that precise entry is not cached. Performs an exact match only and never falls back to + * another sibling's project. + */ + getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined; + + /** + * Refreshes the cached project for an exact (projectId, notebookId) pair so the in-memory + * copy matches disk. Used by the project-metadata propagator to keep open siblings in sync. + */ + updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; } From 3995237e3530c98c84c99f89c80ae677beb047e6 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 00:25:52 +0000 Subject: [PATCH 05/26] feat(deepnote): group the explorer by project and add a notebook status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 5 of single-notebook migration (§7). - Tree is grouped: ProjectGroup (by project id) -> ProjectFile -> Notebook. A single-notebook file is a leaf labelled with its notebook; legacy multi-notebook files stay collapsible. The init notebook is excluded from counts everywhere. - Refresh is grouping-safe: refreshNotebook evicts every sibling cache entry for a project id and all refreshes fire a full-tree change (no per-item fires). - Commands are project-scoped vs notebook-scoped; new/duplicate/add-notebook create sibling files via the factory (never appended), delete removes the file for a single-notebook file, and notebook names are unique within a project group. - Add a status bar item showing the active Deepnote notebook with a "Copy Active Deepnote Notebook Details" command. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- package.json | 22 +- package.nls.json | 1 + .../deepnote/deepnoteExplorerView.ts | 530 ++++++++++------- .../deepnoteExplorerView.unit.test.ts | 547 ++++++++++++++++-- .../deepnote/deepnoteNotebookInfoStatusBar.ts | 138 +++++ ...deepnoteNotebookInfoStatusBar.unit.test.ts | 236 ++++++++ .../deepnote/deepnoteTreeDataProvider.ts | 388 ++++++++----- .../deepnoteTreeDataProvider.unit.test.ts | 298 +++++++++- src/notebooks/deepnote/deepnoteTreeItem.ts | 222 ++++--- .../deepnote/deepnoteTreeItem.unit.test.ts | 12 +- src/notebooks/serviceRegistry.node.ts | 5 + src/notebooks/serviceRegistry.web.ts | 5 + src/platform/common/constants.ts | 1 + 13 files changed, 1889 insertions(+), 516 deletions(-) create mode 100644 src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.ts create mode 100644 src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts diff --git a/package.json b/package.json index 06fc638006..4dd5934f3a 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,12 @@ "category": "Deepnote", "icon": "$(export)" }, + { + "command": "deepnote.copyNotebookDetails", + "title": "%deepnote.commands.copyNotebookDetails.title%", + "category": "Deepnote", + "icon": "$(copy)" + }, { "command": "dataScience.ClearCache", "title": "%deepnote.command.dataScience.clearCache.title%", @@ -1603,42 +1609,42 @@ }, { "command": "deepnote.addNotebookToProject", - "when": "view == deepnoteExplorer && viewItem == projectFile", + "when": "view == deepnoteExplorer && viewItem == projectGroup", "group": "1_add@1" }, { "command": "deepnote.renameProject", - "when": "view == deepnoteExplorer && viewItem == projectFile", + "when": "view == deepnoteExplorer && viewItem == projectGroup", "group": "2_manage@1" }, { "command": "deepnote.exportProject", - "when": "view == deepnoteExplorer && viewItem == projectFile", + "when": "view == deepnoteExplorer && viewItem == projectGroup", "group": "2_manage@2" }, { "command": "deepnote.deleteProject", - "when": "view == deepnoteExplorer && viewItem == projectFile", + "when": "view == deepnoteExplorer && viewItem == projectGroup", "group": "3_delete@1" }, { "command": "deepnote.renameNotebook", - "when": "view == deepnoteExplorer && viewItem == notebook", + "when": "view == deepnoteExplorer && (viewItem == notebookFile || viewItem == notebook)", "group": "1_edit@1" }, { "command": "deepnote.duplicateNotebook", - "when": "view == deepnoteExplorer && viewItem == notebook", + "when": "view == deepnoteExplorer && (viewItem == notebookFile || viewItem == notebook)", "group": "1_edit@2" }, { "command": "deepnote.exportNotebook", - "when": "view == deepnoteExplorer && viewItem == notebook", + "when": "view == deepnoteExplorer && (viewItem == notebookFile || viewItem == notebook)", "group": "2_export@1" }, { "command": "deepnote.deleteNotebook", - "when": "view == deepnoteExplorer && viewItem == notebook", + "when": "view == deepnoteExplorer && (viewItem == notebookFile || viewItem == notebook)", "group": "3_delete@1" } ] diff --git a/package.nls.json b/package.nls.json index 79fdb1ed8e..c604b25279 100644 --- a/package.nls.json +++ b/package.nls.json @@ -284,6 +284,7 @@ "deepnote.commands.addNotebookToProject.title": "Add Notebook", "deepnote.commands.exportProject.title": "Export Project...", "deepnote.commands.exportNotebook.title": "Export Notebook...", + "deepnote.commands.copyNotebookDetails.title": "Copy Active Deepnote Notebook Details", "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 73980de503..1bf3fe591c 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -1,20 +1,33 @@ import { injectable, inject } from 'inversify'; -import { commands, window, workspace, type TreeView, Uri, l10n } from 'vscode'; +import { commands, window, workspace, type TreeView, RelativePattern, Uri, l10n } from 'vscode'; import { serializeDeepnoteFile, type DeepnoteBlock, type DeepnoteFile } from '@deepnote/blocks'; import { convertDeepnoteToJupyterNotebooks, convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; import { IExtensionContext } from '../../platform/common/types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; -import { type DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { + type DeepnoteTreeItem, + DeepnoteTreeItemType, + type DeepnoteTreeItemContext, + type ProjectGroupData, + getNonInitNotebooks +} from './deepnoteTreeItem'; import { uuidUtils } from '../../platform/common/uuid'; import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { Commands } from '../../platform/common/constants'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; +import { buildSingleNotebookFile, buildSiblingNotebookFileUri } from './deepnoteNotebookFileFactory'; +import { deepnoteFileExists } from './deepnoteSiblingFileAllocator'; /** - * Manages the Deepnote explorer tree view and related commands + * Manages the Deepnote explorer tree view and related commands. + * + * Under single-notebook-per-file, the tree groups sibling `.deepnote` files by `project.id`. + * Project-scoped commands (rename/delete/export project, add notebook) operate over EVERY sibling + * file in the group; notebook-scoped commands operate on a single file (single-notebook leaf) or a + * legacy in-file notebook child. New/duplicated notebooks become NEW SIBLING FILES via the factory. */ @injectable() export class DeepnoteExplorerView { @@ -24,7 +37,7 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(ILogger) logger: ILogger, + @inject(ILogger) private readonly logger: ILogger, private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); @@ -42,11 +55,6 @@ 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 - */ /** * Refreshes the full Deepnote explorer tree. * Exposed so callers outside the explorer (e.g. the multi-notebook splitter) can @@ -56,75 +64,66 @@ export class DeepnoteExplorerView { this.treeDataProvider.refresh(); } - public async createAndAddNotebookToProject(fileUri: Uri): Promise<{ id: string; name: string } | null> { - // Read the Deepnote project file - const projectData = await readDeepnoteProjectFile(fileUri); + /** + * Creates a new sibling `.deepnote` file containing a single new notebook, derived from a source + * project file, then opens it. Never appends to `project.notebooks`. + * @param sourceUri The URI of a sibling file used as the source for project-level metadata + * @param existingNames Notebook names already in use across the project group (for uniqueness) + * @returns Object with notebook id and name if successful, or null if aborted/failed + */ + public async createNotebookSiblingFile( + sourceUri: Uri, + existingNames: Set + ): Promise<{ id: string; name: string } | null> { + const sourceProject = await readDeepnoteProjectFile(sourceUri); - if (!projectData?.project) { + if (!sourceProject?.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) ?? []) - ); + const suggestedName = this.generateSuggestedNotebookName(existingNames); + const notebookName = await this.promptForNotebookName(suggestedName, existingNames); if (!notebookName) { return null; } - // Create new notebook with initial block const newNotebook = this.createNotebookWithFirstBlock(notebookName); + const newFile = buildSingleNotebookFile(sourceProject, newNotebook); + const targetUri = await buildSiblingNotebookFileUri(sourceUri, notebookName, deepnoteFileExists); - // 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); + await this.writeAndOpenNotebookFile(targetUri, newFile); return { id: newNotebook.id, name: notebookName }; } public async renameNotebook(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + if (!this.isNotebookScoped(treeItem)) { 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 - ); + + const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); 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 currentName = targetNotebook.name; + const existingNames = await this.collectNotebookNamesForProject(treeItem.context.projectId, currentName); const newName = await this.promptForNotebookName(currentName, existingNames); @@ -134,16 +133,9 @@ export class DeepnoteExplorerView { targetNotebook.name = newName; - if (!projectData.metadata) { - projectData.metadata = { createdAt: new Date().toISOString() }; - } - projectData.metadata.modifiedAt = new Date().toISOString(); - - const updatedYaml = serializeDeepnoteFile(projectData); - const encoder = new TextEncoder(); - await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + await this.writeProjectFile(fileUri, projectData); - await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + 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'; @@ -152,20 +144,7 @@ export class DeepnoteExplorerView { } 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')) { + if (!this.isNotebookScoped(treeItem)) { return; } @@ -175,23 +154,47 @@ export class DeepnoteExplorerView { 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 + const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); + + if (!targetNotebook) { + await window.showErrorMessage(l10n.t('Notebook not found')); + + return; + } + + const notebookName = targetNotebook.name; + + const confirmation = await window.showWarningMessage( + l10n.t('Are you sure you want to delete notebook "{0}"?', notebookName), + { modal: true }, + l10n.t('Delete') ); - if (!projectData.metadata) { - projectData.metadata = { createdAt: new Date().toISOString() }; + if (confirmation !== l10n.t('Delete')) { + return; } - projectData.metadata.modifiedAt = new Date().toISOString(); - const updatedYaml = serializeDeepnoteFile(projectData); - const encoder = new TextEncoder(); - await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + // A single-notebook file's only non-init notebook is the file itself: delete the file. + if (this.isSingleNotebookFile(treeItem, projectData)) { + await workspace.fs.delete(fileUri, { useTrash: true }); + this.treeDataProvider.refresh(); + await window.showInformationMessage(l10n.t('Notebook deleted: {0}', notebookName)); + + return; + } + + // Legacy multi-notebook file: remove the notebook from the array. + projectData.project.notebooks = projectData.project.notebooks.filter( + (nb: DeepnoteNotebook) => nb.id !== targetNotebook.id + ); - await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + await this.writeProjectFile(fileUri, projectData); + + 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'; @@ -200,75 +203,51 @@ export class DeepnoteExplorerView { } public async duplicateNotebook(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + if (!this.isNotebookScoped(treeItem)) { 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 - ); + const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); 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})`; - } + const existingNames = await this.collectNotebookNamesForProject(treeItem.context.projectId); + const newName = this.generateCopyName(targetNotebook.name, existingNames); + const newNotebook = this.cloneNotebook(targetNotebook, newName); - // Deep clone the notebook and generate new IDs - const newNotebook: DeepnoteNotebook = { - ...targetNotebook, - id: uuidUtils.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 = uuidUtils.generateUuid(); - clonedBlock.blockGroup = uuidUtils.generateUuid(); - clonedBlock.executionCount = undefined; - - return clonedBlock; - }) - }; + // Single-notebook file: the duplicate becomes a NEW SIBLING FILE. + if (this.isSingleNotebookFile(treeItem, projectData)) { + const newFile = buildSingleNotebookFile(projectData, newNotebook); + const targetUri = await buildSiblingNotebookFileUri(fileUri, newName, deepnoteFileExists); - projectData.project.notebooks.push(newNotebook); + await this.writeAndOpenNotebookFile(targetUri, newFile); + this.treeDataProvider.refresh(); + await window.showInformationMessage(l10n.t('Notebook duplicated: {0}', newName)); - if (!projectData.metadata) { - projectData.metadata = { createdAt: new Date().toISOString() }; + return; } - projectData.metadata.modifiedAt = new Date().toISOString(); - const updatedYaml = serializeDeepnoteFile(projectData); - const encoder = new TextEncoder(); - await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + // Legacy multi-notebook file: append the duplicate in place (existing behaviour). + projectData.project.notebooks.push(newNotebook); - await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + await this.writeProjectFile(fileUri, projectData); + + this.treeDataProvider.refreshNotebook(treeItem.context.projectId); - // Optionally open the duplicated notebook const document = await workspace.openNotebookDocument(fileUri); await window.showNotebookDocument(document, { preserveFocus: false, @@ -283,12 +262,12 @@ export class DeepnoteExplorerView { } public async renameProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; } - const project = treeItem.data as DeepnoteFile; - const currentName = project.project.name; + const group = treeItem.data as ProjectGroupData; + const currentName = group.projectName; const newName = await window.showInputBox({ prompt: l10n.t('Enter new project name'), @@ -297,6 +276,7 @@ export class DeepnoteExplorerView { if (!value || value.trim().length === 0) { return l10n.t('Project name cannot be empty'); } + return null; } }); @@ -306,8 +286,7 @@ export class DeepnoteExplorerView { } try { - // Desktop: rename the project across every sibling .deepnote file (open or closed), - // not just the clicked file. + // Desktop: rename the project across every sibling .deepnote file (open or closed). if (this.metadataPropagator) { await this.metadataPropagator.propagateProjectMetadata(treeItem.context.projectId, (file) => { file.project.name = newName; @@ -319,27 +298,25 @@ export class DeepnoteExplorerView { return; } - // Web fallback (no filesystem fan-out): rename only the clicked file. - const fileUri = Uri.file(treeItem.context.filePath); - const projectData = await readDeepnoteProjectFile(fileUri); + // Web fallback (no filesystem fan-out): rename each sibling file in the group. + for (const { filePath } of group.files) { + try { + const fileUri = Uri.file(filePath); + const projectData = await readDeepnoteProjectFile(fileUri); - if (!projectData?.project) { - await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; - } + if (!projectData?.project) { + continue; + } - projectData.project.name = newName; + projectData.project.name = newName; - if (!projectData.metadata) { - projectData.metadata = { createdAt: new Date().toISOString() }; + await this.writeProjectFile(fileUri, projectData); + } catch (error) { + this.logger.error(`Failed to rename project file ${filePath}`, error); + } } - projectData.metadata.modifiedAt = new Date().toISOString(); - const updatedYaml = serializeDeepnoteFile(projectData); - const encoder = new TextEncoder(); - await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); - - await this.treeDataProvider.refreshProject(treeItem.context.filePath); + this.treeDataProvider.refresh(); await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -433,16 +410,89 @@ export class DeepnoteExplorerView { } /** - * Generates a suggested unique notebook name based on existing notebooks - * @param projectData The project data containing existing notebooks - * @returns A unique suggested notebook name + * Whether a tree item is notebook-scoped: a single-notebook leaf file (`ProjectFile`) or a + * legacy in-file notebook child (`Notebook`). + */ + private isNotebookScoped(treeItem: DeepnoteTreeItem): boolean { + return treeItem.type === DeepnoteTreeItemType.ProjectFile || treeItem.type === DeepnoteTreeItemType.Notebook; + } + + /** + * Whether the tree item targets a single-notebook file (the file holds exactly one non-init + * notebook), as opposed to a legacy multi-notebook file's in-file child. + */ + private isSingleNotebookFile(treeItem: DeepnoteTreeItem, projectData: DeepnoteFile): boolean { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return false; + } + + return getNonInitNotebooks(projectData).length === 1; + } + + /** + * Resolve the notebook a notebook-scoped command targets. For a legacy `Notebook` child the + * `context.notebookId` selects it; for a single-notebook leaf file it is the file's only + * non-init notebook. + */ + private resolveTargetNotebook(treeItem: DeepnoteTreeItem, projectData: DeepnoteFile): DeepnoteNotebook | undefined { + if (treeItem.context.notebookId) { + return projectData.project.notebooks?.find((nb: DeepnoteNotebook) => nb.id === treeItem.context.notebookId); + } + + return getNonInitNotebooks(projectData)[0]; + } + + /** + * Collect the names of every non-init notebook across all sibling files of a project group, + * for cross-group name uniqueness in rename/new/duplicate flows. + * @param projectId The project group's id + * @param excludeName Optional name to exclude (e.g. the notebook's current name when renaming) */ - private generateSuggestedNotebookName(projectData: DeepnoteFile): string { - const notebookCount = projectData.project.notebooks?.length || 0; - const existingNames = new Set(projectData.project.notebooks?.map((nb: DeepnoteNotebook) => nb.name) || []); + private async collectNotebookNamesForProject(projectId: string, excludeName?: string): Promise> { + const names = new Set(); + + for (const workspaceFolder of workspace.workspaceFolders || []) { + let files: Uri[]; - let nextNumber = notebookCount + 1; + try { + files = await workspace.findFiles(new RelativePattern(workspaceFolder, '**/*.deepnote')); + } catch (error) { + this.logger.error('Failed to enumerate .deepnote files for name collection', error); + + continue; + } + + for (const fileUri of files) { + try { + const projectData = await readDeepnoteProjectFile(fileUri); + + if (projectData?.project?.id !== projectId) { + continue; + } + + for (const notebook of getNonInitNotebooks(projectData)) { + if (notebook.name && notebook.name !== excludeName) { + names.add(notebook.name); + } + } + } catch (error) { + this.logger.error(`Failed to read ${fileUri.path} for name collection`, error); + } + } + } + + return names; + } + + /** + * Generates a suggested unique notebook name based on existing names in the project group. + * @param existingNames Names already in use across the project group + * @returns A unique suggested notebook name + */ + private generateSuggestedNotebookName(existingNames: Set): string { + let nextNumber = existingNames.size + 1; let suggestedName = `Notebook ${nextNumber}`; + while (existingNames.has(suggestedName)) { nextNumber++; suggestedName = `Notebook ${nextNumber}`; @@ -452,8 +502,45 @@ export class DeepnoteExplorerView { } /** - * Prompts the user for a notebook name with validation + * Generate a unique `(Copy)` name for a duplicated notebook. + */ + private generateCopyName(originalName: string, existingNames: Set): string { + let copyNumber = 1; + let newName = `${originalName} (Copy)`; + + while (existingNames.has(newName)) { + copyNumber++; + newName = `${originalName} (Copy ${copyNumber})`; + } + + return newName; + } + + /** + * Deep clone a notebook with fresh ids and cleared execution state. + */ + private cloneNotebook(source: DeepnoteNotebook, newName: string): DeepnoteNotebook { + return { + ...source, + id: uuidUtils.generateUuid(), + name: newName, + blocks: source.blocks.map((block: DeepnoteBlock) => { + const clonedBlock = + typeof structuredClone !== 'undefined' ? structuredClone(block) : JSON.parse(JSON.stringify(block)); + + clonedBlock.id = uuidUtils.generateUuid(); + clonedBlock.blockGroup = uuidUtils.generateUuid(); + clonedBlock.executionCount = undefined; + + return clonedBlock; + }) + }; + } + + /** + * Prompts the user for a notebook name with validation. * @param suggestedName The default suggested name + * @param existingNames Names already in use (rejected as duplicates) * @returns The entered notebook name, or undefined if cancelled */ private async promptForNotebookName( @@ -468,16 +555,18 @@ export class DeepnoteExplorerView { 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 + * 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 */ @@ -504,26 +593,30 @@ export class DeepnoteExplorerView { } /** - * Saves the project data to file and opens it as a notebook - * @param fileUri The URI of the project file - * @param projectData The project data to save + * Serializes a project file and writes it back to disk, stamping `modifiedAt`. */ - private async saveProjectAndOpenNotebook(fileUri: Uri, projectData: DeepnoteFile): Promise { - // Update metadata timestamp + private async writeProjectFile(fileUri: Uri, projectData: DeepnoteFile): Promise { if (!projectData.metadata) { projectData.metadata = { createdAt: new Date().toISOString() }; } + projectData.metadata.modifiedAt = new Date().toISOString(); - // Write the updated YAML const updatedYaml = serializeDeepnoteFile(projectData); const encoder = new TextEncoder(); + await workspace.fs.writeFile(fileUri, encoder.encode(updatedYaml)); + } + + /** + * Writes a new single-notebook file to disk, refreshes the tree, and opens it. + */ + private async writeAndOpenNotebookFile(fileUri: Uri, projectData: DeepnoteFile): Promise { + const yamlContent = serializeDeepnoteFile(projectData); + const encoder = new TextEncoder(); - // Refresh the tree view - use granular refresh for notebooks - await this.treeDataProvider.refreshNotebook(projectData.project.id); + await workspace.fs.writeFile(fileUri, encoder.encode(yamlContent)); - // Open the notebook const document = await workspace.openNotebookDocument(fileUri); await window.showNotebookDocument(document, { preserveFocus: false, @@ -536,12 +629,6 @@ export class DeepnoteExplorerView { } private async openNotebook(context: DeepnoteTreeItemContext): Promise { - if (!context.notebookId) { - await window.showWarningMessage(l10n.t('Cannot open: missing notebook id.')); - - return; - } - try { const fileUri = Uri.file(context.filePath); const document = await workspace.openNotebookDocument(fileUri); @@ -575,8 +662,10 @@ export class DeepnoteExplorerView { private async revealActiveNotebook(): Promise { const activeEditor = window.activeNotebookEditor; + if (!activeEditor || activeEditor.notebook.notebookType !== 'deepnote') { await window.showInformationMessage('No active Deepnote notebook found.'); + return; } @@ -586,6 +675,7 @@ export class DeepnoteExplorerView { if (!projectId || !notebookId) { await window.showWarningMessage('Cannot reveal notebook: missing metadata.'); + return; } @@ -605,7 +695,7 @@ export class DeepnoteExplorerView { } } catch (error) { // Fall back to showing information if reveal fails - console.error('Failed to reveal notebook in explorer:', error); + this.logger.error('Failed to reveal notebook in explorer', error); await window.showInformationMessage( `Active notebook: ${notebookMetadata?.deepnoteNotebookName || 'Untitled'} in project ${ notebookMetadata?.deepnoteProjectName || 'Untitled' @@ -654,6 +744,7 @@ export class DeepnoteExplorerView { try { await workspace.fs.stat(fileUri); await window.showErrorMessage(l10n.t('A file named "{0}" already exists in this workspace.', fileName)); + return; } catch { // File doesn't exist, continue @@ -717,8 +808,10 @@ 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; } @@ -726,10 +819,13 @@ export class DeepnoteExplorerView { const fileUri = document.uri; try { - // Use shared helper to create and add notebook - const result = await this.createAndAddNotebookToProject(fileUri); + const projectId = document.metadata?.deepnoteProjectId as string | undefined; + const existingNames = projectId ? await this.collectNotebookNamesForProject(projectId) : new Set(); + + const result = await this.createNotebookSiblingFile(fileUri, existingNames); if (result) { + this.treeDataProvider.refresh(); await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); } } catch (error) { @@ -783,6 +879,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', fileName) ); + return; } catch { // File doesn't exist, continue @@ -801,6 +898,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) ); + return; } catch { // File doesn't exist, continue @@ -894,6 +992,7 @@ export class DeepnoteExplorerView { await window.showErrorMessage( l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) ); + return; } catch { // File doesn't exist, continue @@ -923,12 +1022,12 @@ export class DeepnoteExplorerView { } private async deleteProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; } - const project = treeItem.data as DeepnoteFile; - const projectName = project.project.name; + const group = treeItem.data as ProjectGroupData; + const projectName = group.projectName; const confirmation = await window.showWarningMessage( l10n.t('Are you sure you want to delete project "{0}"?', projectName), @@ -940,29 +1039,52 @@ export class DeepnoteExplorerView { 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)); + // Delete every sibling file in the group, with per-iteration error handling so one failure + // does not stop the rest. + const failedFiles: string[] = []; + + for (const { filePath } of group.files) { + try { + await workspace.fs.delete(Uri.file(filePath), { useTrash: true }); + } catch (error) { + this.logger.error(`Failed to delete project file ${filePath}`, error); + failedFiles.push(filePath); + } + } + + this.treeDataProvider.refresh(); + + if (failedFiles.length > 0) { + await window.showErrorMessage( + l10n.t('Failed to delete {0} of {1} project files.', failedFiles.length, group.files.length) + ); + + return; } + + await window.showInformationMessage(l10n.t('Project deleted: {0}', projectName)); } private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; } - try { - const fileUri = Uri.file(treeItem.context.filePath); + const group = treeItem.data as ProjectGroupData; + const sourceFile = group.files[0]; + + if (!sourceFile) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); + + return; + } - // Use shared helper to create and add notebook - const result = await this.createAndAddNotebookToProject(fileUri); + try { + const existingNames = await this.collectNotebookNamesForProject(treeItem.context.projectId); + const result = await this.createNotebookSiblingFile(Uri.file(sourceFile.filePath), existingNames); if (result) { + this.treeDataProvider.refresh(); await window.showInformationMessage(l10n.t('Created new notebook: {0}', result.name)); } } catch (error) { @@ -972,14 +1094,16 @@ export class DeepnoteExplorerView { } /** - * Exports all notebooks from a Deepnote project to Jupyter format - * @param treeItem The tree item representing a project + * Exports every notebook of a project group (across all sibling files) to Jupyter format. + * @param treeItem The tree item representing a project group */ private async exportProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; } + const group = treeItem.data as ProjectGroupData; + try { const format = await window.showQuickPick([{ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }], { placeHolder: l10n.t('Select export format') @@ -989,10 +1113,25 @@ export class DeepnoteExplorerView { return; } - const fileUri = Uri.file(treeItem.context.filePath); - const projectData = await readDeepnoteProjectFile(fileUri); + // Collect the Jupyter notebooks to write across every sibling file (validate before + // prompting for an output folder). + const jupyterNotebooks: Array<{ filename: string; notebook: unknown }> = []; - if (!projectData?.project) { + for (const { filePath } of group.files) { + try { + const projectData = await readDeepnoteProjectFile(Uri.file(filePath)); + + if (!projectData?.project) { + continue; + } + + jupyterNotebooks.push(...convertDeepnoteToJupyterNotebooks(projectData)); + } catch (error) { + this.logger.error(`Failed to read ${filePath} for export`, error); + } + } + + if (jupyterNotebooks.length === 0) { await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); return; @@ -1010,12 +1149,12 @@ export class DeepnoteExplorerView { 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); @@ -1058,11 +1197,11 @@ export class DeepnoteExplorerView { } /** - * Exports a single notebook from a Deepnote project to Jupyter format + * Exports a single notebook (single-notebook leaf file or legacy in-file notebook) to Jupyter. * @param treeItem The tree item representing a notebook */ private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + if (!this.isNotebookScoped(treeItem)) { return; } @@ -1096,7 +1235,7 @@ export class DeepnoteExplorerView { return; } - const targetNotebook = projectData.project.notebooks.find((nb) => nb.id === treeItem.context.notebookId); + const targetNotebook = this.resolveTargetNotebook(treeItem, projectData); if (!targetNotebook) { await window.showErrorMessage(l10n.t('Notebook not found')); @@ -1116,6 +1255,7 @@ export class DeepnoteExplorerView { const outputPath = Uri.joinPath(outputFolder[0], notebookToExport.filename); let fileExists = false; + try { await workspace.fs.stat(outputPath); fileExists = true; diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 81c0879f86..5936f7ea93 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -767,8 +767,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); }); - suite('createAndAddNotebookToProject', () => { - test('should create and add a new notebook to an existing project', async () => { + suite('createNotebookSiblingFile', () => { + test('should create a new sibling file with a single new notebook', async () => { const projectId = 'test-project-id'; const existingNotebookId = 'existing-notebook-id'; const newNotebookId = 'new-notebook-id'; @@ -777,7 +777,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const fileUri = Uri.file('/workspace/test-project.deepnote'); const notebookName = 'New Notebook'; - // Mock existing project data + // Mock existing project data (the SOURCE file for project-level metadata) const existingProjectData: DeepnoteFile = { version: '1.0.0', metadata: { @@ -803,9 +803,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Mock file system const mockFS = mock(); when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); + // stat must reject so the sibling allocator treats the target name as free + when(mockFS.stat(anything())).thenReturn(Promise.reject(new Error('not found'))); + let capturedWriteUri: Uri | undefined; let capturedWriteContent: Uint8Array | undefined; - when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { + capturedWriteUri = uri; capturedWriteContent = content; return Promise.resolve(); }); @@ -814,7 +818,6 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Mock user input when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(notebookName)); - // Mock UUID generation by mocking crypto.randomUUID const uuidStub = createUuidMock([newNotebookId, blockGroupId, blockId]); uuidStubs.push(uuidStub); @@ -828,25 +831,28 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { ); // Execute the method - const result = await explorerView.createAndAddNotebookToProject(fileUri); + const result = await explorerView.createNotebookSiblingFile(fileUri, new Set(['Notebook 1'])); // Verify result expect(result).to.exist; expect(result?.id).to.equal(newNotebookId); expect(result?.name).to.equal(notebookName); - // Verify file was written + // Verify a NEW sibling file was written (not the source file) expect(capturedWriteContent).to.exist; + expect(capturedWriteUri).to.exist; + expect(capturedWriteUri!.path).to.not.equal(fileUri.path); - // Verify YAML content + // Verify the new file is single-notebook and contains only the new notebook const updatedYamlContent = Buffer.from(capturedWriteContent!).toString('utf8'); const updatedProjectData = deserializeDeepnoteFile(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'); + expect(updatedProjectData.project.id).to.equal(projectId); + expect(updatedProjectData.project.notebooks).to.have.lengthOf(1); + expect(updatedProjectData.project.notebooks[0].id).to.equal(newNotebookId); + expect(updatedProjectData.project.notebooks[0].name).to.equal(notebookName); + expect(updatedProjectData.project.notebooks[0].blocks).to.have.lengthOf(1); + expect(updatedProjectData.project.notebooks[0].executionMode).to.equal('block'); }); test('should return null if user cancels notebook name input', async () => { @@ -879,7 +885,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); // Execute the method - const result = await explorerView.createAndAddNotebookToProject(fileUri); + const result = await explorerView.createNotebookSiblingFile(fileUri, new Set()); // Verify result is null and file was not written expect(result).to.be.null; @@ -890,7 +896,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const projectId = 'test-project-id'; const fileUri = Uri.file('/workspace/test-project.deepnote'); - // Mock existing project data with multiple notebooks + // Mock existing project data const existingProjectData: DeepnoteFile = { version: '1.0.0', metadata: { @@ -900,10 +906,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { project: { id: projectId, name: 'Test Project', - notebooks: [ - { id: 'nb1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, - { id: 'nb2', name: 'Notebook 2', blocks: [], executionMode: 'block' } - ] + notebooks: [{ id: 'nb1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] } }; @@ -913,6 +916,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const mockFS = mock(); when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlContent))); when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockFS.stat(anything())).thenReturn(Promise.reject(new Error('not found'))); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); let capturedInputBoxOptions: any; @@ -931,8 +935,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { Promise.resolve(undefined as any) ); - // Execute the method - await explorerView.createAndAddNotebookToProject(fileUri); + // existingNames has 2 entries → suggested name is 'Notebook 3' (size + 1) + await explorerView.createNotebookSiblingFile(fileUri, new Set(['Notebook 1', 'Notebook 2'])); // Verify suggested name is 'Notebook 3' (next in sequence) expect(capturedInputBoxOptions).to.exist; @@ -1038,9 +1042,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('should return early if tree item type is not Notebook', async () => { + test('should return early if tree item is project-scoped (not notebook-scoped)', async () => { const mockTreeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/workspace/test-project.deepnote', projectId: 'test-project-id' @@ -1225,9 +1229,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('should return early if tree item type is not Notebook', async () => { + test('should return early if tree item is project-scoped (not notebook-scoped)', async () => { const mockTreeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/workspace/test-project.deepnote', projectId: 'test-project-id' @@ -1253,8 +1257,24 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { const notebookName = 'Notebook to Delete'; const fileUri = Uri.file('/workspace/test-project.deepnote'); + // Legacy multi-notebook project so the target resolves and confirmation is reached. + const existingProjectData: DeepnoteFile = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { id: notebookId, name: notebookName, blocks: [], executionMode: 'block' }, + { id: 'other-notebook', name: 'Other Notebook', blocks: [], executionMode: 'block' } + ] + } + }; + const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(''))); + when(mockFS.readFile(anything())).thenReturn( + Promise.resolve(Buffer.from(serializeDeepnoteFile(existingProjectData))) + ); when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); @@ -1282,9 +1302,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Execute the method await explorerView.deleteNotebook(mockTreeItem as DeepnoteTreeItem); - // Verify file operations were not called (user cancelled) - verify(mockFS.readFile(anything())).never(); + // Verify no write/delete occurred (user cancelled the confirmation) verify(mockFS.writeFile(anything(), anything())).never(); + verify(mockFS.delete(anything(), anything())).never(); }); }); @@ -1415,9 +1435,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('should return early if tree item type is not Notebook', async () => { + test('should return early if tree item is project-scoped (not notebook-scoped)', async () => { const mockTreeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/workspace/test-project.deepnote', projectId: 'test-project-id' @@ -1671,14 +1691,18 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { Promise.resolve(undefined) ); - // Create mock tree item + // Create mock project group tree item (project-scoped commands operate on the group) const mockTreeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: fileUri.fsPath, projectId: projectId }, - data: existingProjectData as unknown as DeepnoteFile + data: { + projectId, + projectName: oldProjectName, + files: [{ filePath: fileUri.fsPath, project: existingProjectData }] + } }; // Execute the method @@ -1705,7 +1729,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); }); - test('should return early if tree item type is not ProjectFile', async () => { + test('should return early if tree item type is not a project group', async () => { const mockTreeItem: Partial = { type: DeepnoteTreeItemType.Notebook, context: { @@ -1757,14 +1781,18 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Test 1: User cancels input (returns undefined) when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); - // Create mock tree item + // Create mock project group tree item const mockTreeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: fileUri.fsPath, projectId: projectId }, - data: existingProjectData as unknown as DeepnoteFile + data: { + projectId, + projectName: currentName, + files: [{ filePath: fileUri.fsPath, project: existingProjectData }] + } }; // Execute the method @@ -1796,10 +1824,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { ); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -1839,10 +1872,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -1871,10 +1909,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -1927,10 +1970,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -1997,10 +2045,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -2056,10 +2109,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -2106,10 +2164,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockFS.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -2158,10 +2221,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { ); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -2218,10 +2286,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.ProjectGroup, context: { filePath: '/test/project.deepnote', projectId: 'project-id' + }, + data: { + projectId: 'project-id', + projectName: 'Test Project', + files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] } }; @@ -2610,3 +2683,385 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); }); }); + +// Sibling-file command semantics (§7): project-group operations span every sibling file; new/ +// duplicate notebooks become NEW sibling files (never appended); single-notebook deletes remove +// the FILE; name uniqueness is collected across the whole group. +suite('DeepnoteExplorerView - Sibling-file command semantics', () => { + let explorerView: DeepnoteExplorerView; + let mockContext: IExtensionContext; + let sandbox: sinon.SinonSandbox; + let uuidStubs: sinon.SinonStub[] = []; + + setup(() => { + sandbox = sinon.createSandbox(); + resetVSCodeMocks(); + uuidStubs = []; + + mockContext = { subscriptions: [] } as unknown as IExtensionContext; + explorerView = new DeepnoteExplorerView(mockContext, createMockLogger()); + }); + + teardown(() => { + sandbox.restore(); + uuidStubs.forEach((stub) => stub.restore()); + uuidStubs = []; + resetVSCodeMocks(); + }); + + function singleNotebookFile(projectId: string, notebookId: string, notebookName: string): DeepnoteFile { + return { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [{ id: notebookId, name: notebookName, blocks: [], executionMode: 'block' }] + } + }; + } + + suite('addNotebookToProject', () => { + test('writes a NEW sibling .deepnote file and does NOT append to any existing file project.notebooks', async () => { + const projectId = 'group-project'; + const sourceFilePath = '/workspace/proj-a.deepnote'; + const sourceFile = singleNotebookFile(projectId, 'nb-a', 'Notebook A'); + const newNotebookId = 'new-nb-id'; + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + { uri: Uri.file('/workspace') } as any + ]); + // collectNotebookNamesForProject enumerates .deepnote files in the workspace. + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn( + Promise.resolve([Uri.file(sourceFilePath)]) + ); + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn( + Promise.resolve(Buffer.from(serializeDeepnoteFile(sourceFile))) + ); + // stat rejects so the allocator treats every candidate sibling name as free. + when(mockFS.stat(anything())).thenReturn(Promise.reject(new Error('not found'))); + + const writes: Array<{ uri: Uri; content: Uint8Array }> = []; + when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { + writes.push({ uri, content }); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('Notebook B')); + when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( + Promise.resolve({ notebookType: 'deepnote' } as any) + ); + when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( + Promise.resolve(undefined as any) + ); + + uuidStubs.push(createUuidMock([newNotebookId, 'bg', 'blk'])); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectGroup, + context: { filePath: sourceFilePath, projectId }, + data: { + projectId, + projectName: 'Test Project', + files: [{ filePath: sourceFilePath, project: sourceFile }] + } + }; + + await (explorerView as any).addNotebookToProject(treeItem as DeepnoteTreeItem); + + // Exactly one file was written, and it is a NEW path (not the source file). + assert.strictEqual(writes.length, 1, 'addNotebookToProject must write exactly one new sibling file'); + assert.notStrictEqual(writes[0].uri.path, sourceFilePath, 'the new file must not overwrite the source'); + + // The written file is single-notebook and holds only the new notebook (no append). + const written = deserializeDeepnoteFile(Buffer.from(writes[0].content).toString('utf8')); + assert.strictEqual(written.project.id, projectId, 'sibling carries the same project.id'); + assert.strictEqual(written.project.notebooks.length, 1, 'sibling is single-notebook (no append)'); + assert.strictEqual(written.project.notebooks[0].id, newNotebookId); + assert.strictEqual(written.project.notebooks[0].name, 'Notebook B'); + }); + + test('returns early for a non-group tree item (does not write)', async () => { + const mockFS = mock(); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { filePath: '/workspace/x.deepnote', projectId: 'p' } + }; + + await (explorerView as any).addNotebookToProject(treeItem as DeepnoteTreeItem); + + verify(mockFS.writeFile(anything(), anything())).never(); + }); + }); + + suite('deleteProject', () => { + test('deletes EVERY sibling file in the group (one delete per file)', async () => { + const projectId = 'group-project'; + const files = [ + { filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }, + { filePath: '/workspace/b.deepnote', project: singleNotebookFile(projectId, 'nb-b', 'B') }, + { filePath: '/workspace/c.deepnote', project: singleNotebookFile(projectId, 'nb-c', 'C') } + ]; + + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + + const deleted: string[] = []; + const mockFS = mock(); + when(mockFS.delete(anything(), anything())).thenCall((uri: Uri) => { + deleted.push(uri.fsPath); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectGroup, + context: { filePath: files[0].filePath, projectId }, + data: { projectId, projectName: 'Test Project', files } + }; + + await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); + + assert.strictEqual(deleted.length, 3, 'all three sibling files must be deleted'); + for (const { filePath } of files) { + assert.include(deleted, Uri.file(filePath).fsPath, `${filePath} must be deleted`); + } + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); + }); + + test('partial failure: one delete rejects, the others still delete and the failure is surfaced', async () => { + const projectId = 'group-project'; + const files = [ + { filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }, + { filePath: '/workspace/b.deepnote', project: singleNotebookFile(projectId, 'nb-b', 'B') }, + { filePath: '/workspace/c.deepnote', project: singleNotebookFile(projectId, 'nb-c', 'C') } + ]; + + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + + const deleted: string[] = []; + const mockFS = mock(); + when(mockFS.delete(anything(), anything())).thenCall((uri: Uri) => { + if (uri.fsPath === Uri.file('/workspace/b.deepnote').fsPath) { + return Promise.reject(new Error('permission denied')); + } + deleted.push(uri.fsPath); + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectGroup, + context: { filePath: files[0].filePath, projectId }, + data: { projectId, projectName: 'Test Project', files } + }; + + await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); + + // The two healthy files are still deleted (one failure does not abort the loop). + assert.strictEqual(deleted.length, 2, 'the non-failing files must still be deleted'); + assert.include(deleted, Uri.file('/workspace/a.deepnote').fsPath); + assert.include(deleted, Uri.file('/workspace/c.deepnote').fsPath); + // The failure is surfaced (not silent) and no success message is shown. + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).never(); + }); + + test('returns early when the user cancels the confirmation (no deletes)', async () => { + const projectId = 'group-project'; + const files = [{ filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }]; + + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); + + const mockFS = mock(); + when(mockFS.delete(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectGroup, + context: { filePath: files[0].filePath, projectId }, + data: { projectId, projectName: 'Test Project', files } + }; + + await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); + + verify(mockFS.delete(anything(), anything())).never(); + }); + }); + + suite('deleteNotebook — single-notebook file vs legacy', () => { + test('a single-notebook file deletes the FILE (does not rewrite an array)', async () => { + const projectId = 'group-project'; + const filePath = '/workspace/single.deepnote'; + const fileUri = Uri.file(filePath); + const projectData = singleNotebookFile(projectId, 'only-nb', 'Only Notebook'); + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn( + Promise.resolve(Buffer.from(serializeDeepnoteFile(projectData))) + ); + + let deletedUri: Uri | undefined; + when(mockFS.delete(anything(), anything())).thenCall((uri: Uri) => { + deletedUri = uri; + return Promise.resolve(); + }); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + + // ProjectFile node with no notebookId => single-notebook leaf => delete the file. + const treeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { filePath, projectId }, + data: projectData + }; + + await explorerView.deleteNotebook(treeItem as DeepnoteTreeItem); + + assert.isDefined(deletedUri, 'the file must be deleted for a single-notebook file'); + assert.strictEqual(deletedUri!.fsPath, fileUri.fsPath, 'the deleted file must be the target file'); + // It must NOT rewrite the file's notebooks array on a single-notebook delete. + verify(mockFS.writeFile(anything(), anything())).never(); + }); + + test('a legacy multi-notebook file removes the notebook from the array and writes the file back (no file delete)', async () => { + const projectId = 'group-project'; + const filePath = '/workspace/legacy.deepnote'; + const keepId = 'keep-nb'; + const dropId = 'drop-nb'; + const legacy: DeepnoteFile = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { id: keepId, name: 'Keep', blocks: [], executionMode: 'block' }, + { id: dropId, name: 'Drop', blocks: [], executionMode: 'block' } + ] + } + }; + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(serializeDeepnoteFile(legacy)))); + + let writtenContent: Uint8Array | undefined; + when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + writtenContent = content; + return Promise.resolve(); + }); + when(mockFS.delete(anything(), anything())).thenReturn(Promise.resolve()); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + + // Legacy Notebook child selected by notebookId. + const treeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, + context: { filePath, projectId, notebookId: dropId }, + data: { id: dropId, name: 'Drop', blocks: [], executionMode: 'block' } as DeepnoteNotebook + }; + + await explorerView.deleteNotebook(treeItem as DeepnoteTreeItem); + + // The file is rewritten (array op) and NOT deleted. + assert.isDefined(writtenContent, 'the legacy file must be rewritten'); + verify(mockFS.delete(anything(), anything())).never(); + + const updated = deserializeDeepnoteFile(Buffer.from(writtenContent!).toString('utf8')); + assert.strictEqual(updated.project.notebooks.length, 1, 'the dropped notebook is removed from the array'); + assert.strictEqual(updated.project.notebooks[0].id, keepId, 'the kept notebook survives'); + }); + }); + + suite('collectNotebookNamesForProject', () => { + test('gathers non-init notebook names across ALL sibling files of the group', async () => { + const projectId = 'group-project'; + const otherProjectId = 'other-project'; + const pathA = '/workspace/a.deepnote'; + const pathB = '/workspace/b.deepnote'; + const pathOther = '/workspace/other.deepnote'; + + const fileA = singleNotebookFile(projectId, 'nb-a', 'Alpha'); + const fileB: DeepnoteFile = { + version: '1.0.0', + metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, + project: { + id: projectId, + name: 'Test Project', + initNotebookId: 'init-b', + notebooks: [ + { id: 'init-b', name: 'Init B', blocks: [], executionMode: 'block' }, + { id: 'nb-b', name: 'Beta', blocks: [], executionMode: 'block' } + ] + } + }; + const fileOther = singleNotebookFile(otherProjectId, 'nb-o', 'ShouldNotAppear'); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + { uri: Uri.file('/workspace') } as any + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn( + Promise.resolve([Uri.file(pathA), Uri.file(pathB), Uri.file(pathOther)]) + ); + + // Dispatch readFile by URI path so each sibling returns its own content. + const byPath: Record = { + [Uri.file(pathA).fsPath]: fileA, + [Uri.file(pathB).fsPath]: fileB, + [Uri.file(pathOther).fsPath]: fileOther + }; + const mockFS = mock(); + when(mockFS.readFile(anything())).thenCall((uri: Uri) => + Promise.resolve(Buffer.from(serializeDeepnoteFile(byPath[uri.fsPath]))) + ); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const names: Set = await (explorerView as any).collectNotebookNamesForProject(projectId); + + // Names from BOTH siblings of the group; init excluded; other project excluded. + assert.isTrue(names.has('Alpha'), 'name from sibling A must be collected'); + assert.isTrue(names.has('Beta'), 'name from sibling B must be collected'); + assert.isFalse(names.has('Init B'), 'the init notebook name must be excluded'); + assert.isFalse(names.has('ShouldNotAppear'), 'names from a different project must be excluded'); + assert.deepStrictEqual([...names].sort(), ['Alpha', 'Beta']); + }); + + test('excludes the provided current name (so renaming to the same value is allowed)', async () => { + const projectId = 'group-project'; + const pathA = '/workspace/a.deepnote'; + const fileA = singleNotebookFile(projectId, 'nb-a', 'Alpha'); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + { uri: Uri.file('/workspace') } as any + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve([Uri.file(pathA)])); + + const mockFS = mock(); + when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(serializeDeepnoteFile(fileA)))); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const names: Set = await (explorerView as any).collectNotebookNamesForProject(projectId, 'Alpha'); + + assert.isFalse(names.has('Alpha'), 'the excluded current name must not be in the set'); + assert.strictEqual(names.size, 0); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.ts b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.ts new file mode 100644 index 0000000000..0e970ff2a5 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.ts @@ -0,0 +1,138 @@ +import { inject, injectable } from 'inversify'; +import { + Disposable, + NotebookDocument, + NotebookDocumentChangeEvent, + NotebookEditor, + StatusBarAlignment, + StatusBarItem, + commands, + env, + l10n, + window, + workspace +} from 'vscode'; + +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import { Commands } from '../../platform/common/constants'; + +const DEEPNOTE_NOTEBOOK_TYPE = 'deepnote'; +const STATUS_BAR_PRIORITY = 100; + +/** + * Shows the active Deepnote notebook's name in a left-aligned status bar item. Clicking it copies + * the notebook's details (name, ids, project, version, URI) to the clipboard. + * + * Web-safe: depends only on `window`/`workspace`/`env`/`commands`. + */ +@injectable() +export class DeepnoteNotebookInfoStatusBar implements IExtensionSyncActivationService, Disposable { + private readonly disposables: Disposable[] = []; + + private statusBarItem: StatusBarItem | undefined; + + constructor(@inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry) { + disposableRegistry.push(this); + } + + public activate(): void { + this.statusBarItem = window.createStatusBarItem( + 'deepnote.notebookInfo', + StatusBarAlignment.Left, + STATUS_BAR_PRIORITY + ); + this.statusBarItem.name = l10n.t('Deepnote Notebook'); + this.statusBarItem.command = Commands.CopyNotebookDetails; + this.statusBarItem.hide(); + this.disposables.push(this.statusBarItem); + + this.disposables.push( + commands.registerCommand(Commands.CopyNotebookDetails, () => this.copyActiveNotebookDetails()) + ); + + window.onDidChangeActiveNotebookEditor(this.handleActiveEditorChanged, this, this.disposables); + workspace.onDidChangeNotebookDocument(this.handleNotebookDocumentChanged, this, this.disposables); + + this.updateStatusBar(); + } + + public dispose(): void { + while (this.disposables.length) { + const disposable = this.disposables.pop(); + + try { + disposable?.dispose(); + } catch { + // Ignore disposal errors during teardown. + } + } + } + + private handleActiveEditorChanged(_editor: NotebookEditor | undefined): void { + this.updateStatusBar(); + } + + private handleNotebookDocumentChanged(event: NotebookDocumentChangeEvent): void { + const activeNotebook = window.activeNotebookEditor?.notebook; + + if (activeNotebook && event.notebook === activeNotebook) { + this.updateStatusBar(); + } + } + + private updateStatusBar(): void { + const item = this.statusBarItem; + + if (!item) { + return; + } + + const notebook = window.activeNotebookEditor?.notebook; + + if (!notebook || notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { + item.hide(); + + return; + } + + const notebookName = (notebook.metadata?.deepnoteNotebookName as string | undefined) || l10n.t('Untitled'); + + item.text = `$(notebook) ${notebookName}`; + item.tooltip = l10n.t('Copy Active Deepnote Notebook Details'); + item.show(); + } + + private async copyActiveNotebookDetails(): Promise { + const notebook = window.activeNotebookEditor?.notebook; + + if (!notebook || notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { + await window.showWarningMessage(l10n.t('No active Deepnote notebook found.')); + + return; + } + + const details = this.formatNotebookDetails(notebook); + + await env.clipboard.writeText(details); + await window.showInformationMessage(l10n.t('Copied Deepnote notebook details to clipboard.')); + } + + private formatNotebookDetails(notebook: NotebookDocument): string { + const metadata = notebook.metadata ?? {}; + const notebookName = (metadata.deepnoteNotebookName as string | undefined) ?? ''; + const notebookId = (metadata.deepnoteNotebookId as string | undefined) ?? ''; + const projectName = (metadata.deepnoteProjectName as string | undefined) ?? ''; + const projectId = (metadata.deepnoteProjectId as string | undefined) ?? ''; + const version = (metadata.deepnoteVersion as string | undefined) ?? ''; + + return [ + `Notebook name: ${notebookName}`, + `Notebook ID: ${notebookId}`, + `Project name: ${projectName}`, + `Project ID: ${projectId}`, + `Version: ${version}`, + `URI: ${notebook.uri.toString()}` + ].join('\n'); + } +} diff --git a/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts new file mode 100644 index 0000000000..e91ffa0ddb --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts @@ -0,0 +1,236 @@ +import { assert, expect } from 'chai'; +import { anything, capture, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { DeepnoteNotebookInfoStatusBar } from './deepnoteNotebookInfoStatusBar'; +import { Commands } from '../../platform/common/constants'; +import { mockedVSCode, mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import type { IDisposableRegistry } from '../../platform/common/types'; + +const EventEmitter = (mockedVSCode as any).EventEmitter; + +/** + * Minimal fake StatusBarItem that records the fields the status bar sets so tests can assert on them. + */ +interface FakeStatusBarItem { + name?: string; + text: string; + tooltip?: string; + command?: string; + visible: boolean; + disposed: boolean; + show(): void; + hide(): void; + dispose(): void; +} + +function createFakeStatusBarItem(): FakeStatusBarItem { + return { + text: '', + visible: false, + disposed: false, + show() { + this.visible = true; + }, + hide() { + this.visible = false; + }, + dispose() { + this.disposed = true; + } + }; +} + +/** + * Build a fake NotebookDocument with the Deepnote metadata the status bar reads. + */ +function makeNotebook(options: { notebookType?: string; metadata?: Record; uri?: Uri }): any { + return { + notebookType: options.notebookType ?? 'deepnote', + metadata: options.metadata ?? {}, + uri: options.uri ?? Uri.file('/workspace/proj.deepnote') + }; +} + +suite('DeepnoteNotebookInfoStatusBar', () => { + let statusBar: DeepnoteNotebookInfoStatusBar; + let fakeItem: FakeStatusBarItem; + let disposableRegistry: IDisposableRegistry; + let activeEditorEmitter: any; + let docChangeEmitter: any; + + setup(() => { + resetVSCodeMocks(); + + fakeItem = createFakeStatusBarItem(); + disposableRegistry = [] as unknown as IDisposableRegistry; + + // Real emitters drive the active-editor / document-change subscriptions; the status bar + // subscribes through `.event` (which honours thisArg + the disposables array it passes). + activeEditorEmitter = new EventEmitter(); + docChangeEmitter = new EventEmitter(); + + when(mockedVSCodeNamespaces.window.createStatusBarItem(anything(), anything(), anything())).thenReturn( + fakeItem as any + ); + when(mockedVSCodeNamespaces.window.onDidChangeActiveNotebookEditor).thenReturn(activeEditorEmitter.event); + when(mockedVSCodeNamespaces.workspace.onDidChangeNotebookDocument).thenReturn(docChangeEmitter.event); + + statusBar = new DeepnoteNotebookInfoStatusBar(disposableRegistry); + }); + + teardown(() => { + try { + statusBar.dispose(); + } catch { + // ignore teardown disposal errors + } + resetVSCodeMocks(); + }); + + test('constructor registers the status bar in the disposable registry', () => { + assert.include(disposableRegistry as unknown as unknown[], statusBar, 'status bar must register itself'); + }); + + test('shows "$(notebook) " for an active deepnote notebook (name from metadata.deepnoteNotebookName)', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: makeNotebook({ metadata: { deepnoteNotebookName: 'My Analysis' } }) + } as any); + + statusBar.activate(); + + assert.strictEqual(fakeItem.text, '$(notebook) My Analysis', 'must show the notebook icon + name'); + assert.isTrue(fakeItem.visible, 'status bar must be visible for a deepnote notebook'); + assert.strictEqual(fakeItem.command, Commands.CopyNotebookDetails, 'clicking copies notebook details'); + }); + + test('HIDES the status bar for a non-deepnote active editor', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ + notebook: makeNotebook({ notebookType: 'jupyter-notebook', metadata: { deepnoteNotebookName: 'X' } }) + } as any); + + statusBar.activate(); + + assert.isFalse(fakeItem.visible, 'a non-deepnote editor must not show the Deepnote status bar'); + }); + + test('HIDES the status bar when there is no active notebook editor', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + + statusBar.activate(); + + assert.isFalse(fakeItem.visible, 'no active editor must hide the status bar'); + }); + + test('updates on active-editor change (hidden → shown when a deepnote notebook becomes active)', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + statusBar.activate(); + assert.isFalse(fakeItem.visible, 'initially hidden with no active editor'); + + // Now a deepnote notebook becomes active and the active-editor event fires. + const editor = { notebook: makeNotebook({ metadata: { deepnoteNotebookName: 'Switched In' } }) }; + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(editor as any); + activeEditorEmitter.fire(editor); + + assert.isTrue(fakeItem.visible, 'becomes visible after the active editor switches to a deepnote notebook'); + assert.strictEqual(fakeItem.text, '$(notebook) Switched In'); + }); + + test('updates on a document change to the ACTIVE notebook (renaming reflects in the status bar)', () => { + const notebook = makeNotebook({ metadata: { deepnoteNotebookName: 'Before' } }); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ notebook } as any); + statusBar.activate(); + assert.strictEqual(fakeItem.text, '$(notebook) Before'); + + // Mutate the active notebook's metadata and fire a change for THAT notebook. + notebook.metadata.deepnoteNotebookName = 'After'; + docChangeEmitter.fire({ notebook }); + + assert.strictEqual(fakeItem.text, '$(notebook) After', 'a change to the active notebook must refresh the text'); + }); + + test('does NOT update on a document change to a DIFFERENT (non-active) notebook', () => { + const activeNotebook = makeNotebook({ metadata: { deepnoteNotebookName: 'Active' } }); + const otherNotebook = makeNotebook({ + metadata: { deepnoteNotebookName: 'Other' }, + uri: Uri.file('/o.deepnote') + }); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ notebook: activeNotebook } as any); + statusBar.activate(); + assert.strictEqual(fakeItem.text, '$(notebook) Active'); + + // A change event for a different notebook must be ignored. + otherNotebook.metadata.deepnoteNotebookName = 'Other Changed'; + docChangeEmitter.fire({ notebook: otherNotebook }); + + assert.strictEqual(fakeItem.text, '$(notebook) Active', 'a non-active notebook change must not alter the bar'); + }); + + test('CopyNotebookDetails writes the expected multi-line details (name, ids, project, version, URI) to the clipboard', async () => { + const uri = Uri.file('/workspace/my-proj.deepnote'); + const editor = { + notebook: makeNotebook({ + uri, + metadata: { + deepnoteNotebookName: 'NB Name', + deepnoteNotebookId: 'nb-123', + deepnoteProjectName: 'Proj Name', + deepnoteProjectId: 'proj-456', + deepnoteVersion: '1.0.0' + } + }) + }; + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(editor as any); + + statusBar.activate(); + + // Invoke the command handler registered against Commands.CopyNotebookDetails. + const [, handler] = capture(mockedVSCodeNamespaces.commands.registerCommand).first() as any; + await handler(); + + const clipboardText = await mockedVSCode.env!.clipboard.readText(); + const expected = [ + 'Notebook name: NB Name', + 'Notebook ID: nb-123', + 'Project name: Proj Name', + 'Project ID: proj-456', + 'Version: 1.0.0', + `URI: ${uri.toString()}` + ].join('\n'); + + assert.strictEqual(clipboardText, expected, 'clipboard must contain the full notebook detail block'); + }); + + test('CopyNotebookDetails warns and writes nothing when there is no active deepnote notebook', async () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + + statusBar.activate(); + + const [, handler] = capture(mockedVSCodeNamespaces.commands.registerCommand).first() as any; + await handler(); + + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything())).once(); + const clipboardText = await mockedVSCode.env!.clipboard.readText(); + assert.strictEqual(clipboardText, '', 'nothing should be copied when there is no active deepnote notebook'); + }); + + test('registers the CopyNotebookDetails command on activate', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + + statusBar.activate(); + + const [commandId] = capture(mockedVSCodeNamespaces.commands.registerCommand).first() as any; + assert.strictEqual(commandId, Commands.CopyNotebookDetails); + }); + + test('dispose() disposes the status bar item and clears its subscriptions', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); + statusBar.activate(); + + statusBar.dispose(); + + assert.isTrue(fakeItem.disposed, 'the status bar item must be disposed'); + // A second dispose must be a harmless no-op (subscriptions already drained). + expect(() => statusBar.dispose()).to.not.throw(); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts index be9e471e27..053ee6c0e9 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts @@ -13,7 +13,13 @@ import { l10n } from 'vscode'; -import { DeepnoteTreeItem, DeepnoteTreeItemType, DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { + DeepnoteTreeItem, + DeepnoteTreeItemType, + DeepnoteTreeItemContext, + ProjectGroupData, + getNonInitNotebooks +} from './deepnoteTreeItem'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; @@ -25,12 +31,20 @@ import { isSnapshotFile, SNAPSHOT_FILE_SUFFIX } from './snapshots/snapshotFiles' 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. - * Manages the tree structure displaying Deepnote project files and their notebooks. + * + * The tree is grouped by `project.id`: root → `ProjectGroup` (one per distinct project id) → + * `ProjectFile` (one per sibling file) → `Notebook` (legacy multi-notebook files only). + * + * `cachedProjects` is keyed by **file path**; the `ProjectGroup` layer is re-derived from that + * cache on every read (group membership is "files whose `project.id` matches"). Because sibling + * files deliberately share one `project.id`, every refresh fires a full-tree change rather than a + * per-item scoped change. */ export class DeepnoteTreeDataProvider implements TreeDataProvider { private _onDidChangeTreeData: EventEmitter = new EventEmitter< @@ -39,8 +53,9 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider = this._onDidChangeTreeData.event; private fileWatcher: FileSystemWatcher | undefined; - private cachedProjects: Map = new Map(); - private treeItemCache: Map = new Map(); + private cachedProjects: Map = new Map(); + private groupItemCache: Map = new Map(); + private fileItemCache: Map = new Map(); private isInitialScanComplete: boolean = false; private initialScanPromise: Promise | undefined; private readonly logger?: ILogger; @@ -58,91 +73,43 @@ 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 + public refreshProject(filePath: string): void { 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(); - } + this.fileItemCache.delete(filePath); + this._onDidChangeTreeData.fire(undefined); } /** - * Refresh notebooks for a specific project - * @param projectId The project ID whose notebooks should be refreshed + * Refresh every sibling file of a project by evicting ALL `cachedProjects` entries whose + * `project.id` matches, then firing a full-tree change. Iterating all entries (never breaking + * on the first match) keeps the whole project group consistent when one sibling's notebook is + * renamed/deleted/duplicated. + * @param projectId The project ID whose sibling files 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; + public refreshNotebook(projectId: string): void { + for (const [filePath, project] of this.cachedProjects) { + if (project.project.id === projectId) { + this.cachedProjects.delete(filePath); + this.fileItemCache.delete(filePath); } } - 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(); - } + this.groupItemCache.delete(projectId); + this._onDidChangeTreeData.fire(undefined); } public getTreeItem(element: DeepnoteTreeItem): TreeItem { @@ -150,10 +117,13 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { - // If element is provided, we can return children regardless of workspace if (element) { + if (element.type === DeepnoteTreeItemType.ProjectGroup) { + return this.getFilesForGroup(element); + } + if (element.type === DeepnoteTreeItemType.ProjectFile) { - return this.getNotebooksForProject(element); + return this.getNotebooksForProjectFile(element); } return []; @@ -172,7 +142,44 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { + const groups = await this.getProjectGroups(); + + for (const group of groups) { + if (group.context.projectId !== projectId) { + continue; + } + + if (!notebookId) { + return group; + } + + const files = await this.getFilesForGroup(group); + + for (const fileItem of files) { + if (fileItem.context.notebookId === notebookId) { + return fileItem; + } + + const notebooks = await this.getNotebooksForProjectFile(fileItem); + const match = notebooks.find((notebookItem) => notebookItem.context.notebookId === notebookId); + + if (match) { + return match; + } + } + + return group; + } + + return undefined; } private createLoadingTreeItem(): DeepnoteTreeItem { @@ -184,85 +191,142 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { try { - await this.getDeepnoteProjectFiles(); + await this.loadAllProjects(); } finally { this.isInitialScanComplete = true; this.initialScanPromise = undefined; this.updateContextKey(); - this._onDidChangeTreeData.fire(); + this._onDidChangeTreeData.fire(undefined); } } - private async getDeepnoteProjectFiles(): Promise { - const deepnoteFiles: DeepnoteTreeItem[] = []; + /** + * Build the root-level `ProjectGroup` nodes: one per distinct `project.id` across all + * `.deepnote` files, sorted by project name. Single-file groups are expanded; multi-file + * groups are collapsed. + */ + private async getProjectGroups(): Promise { + const projectsByPath = await this.loadAllProjects(); + const groups = this.buildProjectGroups(projectsByPath); + + const groupItems: DeepnoteTreeItem[] = []; + + for (const group of groups) { + const collapsibleState = + group.files.length > 1 ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.Expanded; + + let groupItem = this.groupItemCache.get(group.projectId); + + if (groupItem) { + groupItem.data = group; + groupItem.collapsibleState = collapsibleState; + groupItem.updateVisualFields(); + } else { + groupItem = new DeepnoteTreeItem( + DeepnoteTreeItemType.ProjectGroup, + { filePath: group.files[0]?.filePath ?? '', projectId: group.projectId }, + group, + collapsibleState + ); + this.groupItemCache.set(group.projectId, groupItem); + } - for (const workspaceFolder of workspace.workspaceFolders || []) { - const pattern = new RelativePattern(workspaceFolder, '**/*.deepnote'); - const files = await workspace.findFiles(pattern); - const projectFiles = files.filter((file) => !file.path.endsWith(SNAPSHOT_FILE_SUFFIX)); + groupItems.push(groupItem); + } - for (const file of projectFiles) { - try { - const project = await this.loadDeepnoteProject(file); - if (!project) { - continue; - } - - const context: DeepnoteTreeItemContext = { - filePath: file.path, - projectId: project.project.id - }; - - // 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) { - this.logger?.error(`Failed to load Deepnote project from ${file.path}`, error); - } + groupItems.sort(compareTreeItemsByLabel); + + return groupItems; + } + + /** + * Group the cached file→project entries by `project.id` into `ProjectGroupData`, sorted by + * project name. Files within a group are sorted by file path for stable ordering. + */ + private buildProjectGroups(projectsByPath: Map): ProjectGroupData[] { + const groupsById = new Map(); + + for (const [filePath, project] of projectsByPath) { + const projectId = project.project.id; + let group = groupsById.get(projectId); + + if (!group) { + group = { + projectId, + projectName: project.project.name || 'Untitled Project', + files: [] + }; + groupsById.set(projectId, group); } + + group.files.push({ filePath, project }); } - // Sort projects alphabetically by name (case-insensitive) - deepnoteFiles.sort(compareTreeItemsByLabel); + const groups = Array.from(groupsById.values()); - return deepnoteFiles; + for (const group of groups) { + group.files.sort((a, b) => a.filePath.localeCompare(b.filePath)); + } + + groups.sort((a, b) => a.projectName.toLowerCase().localeCompare(b.projectName.toLowerCase())); + + return groups; + } + + /** + * Children of a `ProjectGroup`: one `ProjectFile` per sibling file. A single-notebook file is a + * leaf; a legacy multi-notebook file is collapsible into its notebooks. + */ + private async getFilesForGroup(groupItem: DeepnoteTreeItem): Promise { + const group = groupItem.data as ProjectGroupData; + const fileItems: DeepnoteTreeItem[] = []; + + for (const { filePath, project } of group.files) { + const isLeaf = getNonInitNotebooks(project).length === 1; + const collapsibleState = isLeaf ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed; + + const context: DeepnoteTreeItemContext = { + filePath, + projectId: project.project.id + }; + + let fileItem = this.fileItemCache.get(filePath); + + if (fileItem) { + fileItem.data = project; + fileItem.collapsibleState = collapsibleState; + fileItem.updateVisualFields(); + } else { + fileItem = new DeepnoteTreeItem(DeepnoteTreeItemType.ProjectFile, context, project, collapsibleState); + this.fileItemCache.set(filePath, fileItem); + } + + fileItems.push(fileItem); + } + + fileItems.sort(compareTreeItemsByLabel); + + return fileItems; } - private async getNotebooksForProject(projectItem: DeepnoteTreeItem): Promise { + /** + * Children of a legacy multi-notebook `ProjectFile`: one `Notebook` per non-init notebook. + */ + private async getNotebooksForProjectFile(projectItem: DeepnoteTreeItem): Promise { const project = projectItem.data as DeepnoteProject; - const notebooks = project.project.notebooks || []; + const notebooks = getNonInitNotebooks(project); // 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()); }); @@ -282,10 +346,33 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider> { + for (const workspaceFolder of workspace.workspaceFolders || []) { + const pattern = new RelativePattern(workspaceFolder, '**/*.deepnote'); + const files = await workspace.findFiles(pattern); + const projectFiles = files.filter((file) => !file.path.endsWith(SNAPSHOT_FILE_SUFFIX)); + + for (const file of projectFiles) { + try { + await this.loadDeepnoteProject(file); + } catch (error) { + this.logger?.error(`Failed to load Deepnote project from ${file.path}`, error); + } + } + } + + return this.cachedProjects; + } + private async loadDeepnoteProject(fileUri: Uri): Promise { const filePath = fileUri.path; const cached = this.cachedProjects.get(filePath); + if (cached) { return cached; } @@ -295,6 +382,7 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { if (isSnapshotFile(uri)) { return; } - // New file created, do full refresh - this._onDidChangeTreeData.fire(); + + // New file created: evict by path (no-op if absent) and do a full-tree refresh. + this.cachedProjects.delete(uri.path); + this.fileItemCache.delete(uri.path); + this._onDidChangeTreeData.fire(undefined); }); this.fileWatcher.onDidDelete((uri) => { if (isSnapshotFile(uri)) { return; } - // File deleted, clear both caches and do full refresh + + // File deleted: evict by path and do a full-tree refresh. this.cachedProjects.delete(uri.path); - this.treeItemCache.delete(`project:${uri.path}`); - this._onDidChangeTreeData.fire(); + this.fileItemCache.delete(uri.path); + this._onDidChangeTreeData.fire(undefined); }); } - /** - * Find a tree item by project ID and optional notebook ID - */ - public async findTreeItem(projectId: string, notebookId?: string): Promise { - const projectFiles = await this.getDeepnoteProjectFiles(); - - for (const projectItem of projectFiles) { - if (projectItem.context.projectId === projectId) { - if (!notebookId) { - return projectItem; - } - - const notebooks = await this.getNotebooksForProject(projectItem); - for (const notebookItem of notebooks) { - if (notebookItem.context.notebookId === notebookId) { - return notebookItem; - } - } - } - } - - return undefined; - } - private updateContextKey(): void { void commands.executeCommand('setContext', 'deepnote.explorerInitialScanComplete', this.isInitialScanComplete); } diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts index 4944422590..0d9212c075 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts @@ -2,9 +2,37 @@ import { assert } from 'chai'; import { l10n } from 'vscode'; import { DeepnoteTreeDataProvider, compareTreeItemsByLabel } from './deepnoteTreeDataProvider'; -import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem'; +import { DeepnoteTreeItem, DeepnoteTreeItemType, getNonInitNotebooks } from './deepnoteTreeItem'; import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; +/** + * Build a single-notebook DeepnoteProject (whole-file shape) for a given project/notebook id. + */ +function makeSingleNotebookProject( + projectId: string, + notebookId: string, + projectName = 'Test Project' +): DeepnoteProject { + return { + metadata: { createdAt: '2023-01-01T00:00:00Z', modifiedAt: '2023-01-02T00:00:00Z' }, + project: { + id: projectId, + name: projectName, + notebooks: [ + { + id: notebookId, + name: `Notebook ${notebookId}`, + blocks: [], + executionMode: 'block', + isModule: false + } + ], + settings: {} + }, + version: '1.0.0' + }; +} + suite('DeepnoteTreeDataProvider', () => { let provider: DeepnoteTreeDataProvider; @@ -339,12 +367,11 @@ suite('DeepnoteTreeDataProvider', () => { }); test('should update visual fields when project data changes', async () => { - // Access private caches - const treeItemCache = (provider as any).treeItemCache as Map; + // Access the file-item cache (keyed by file path) + const fileItemCache = (provider as any).fileItemCache as Map; - // Create initial project with 1 notebook + // Create initial legacy multi-notebook project (2 notebooks → projectFile node) const filePath = '/workspace/test-project.deepnote'; - const cacheKey = `project:${filePath}`; const initialProject: DeepnoteProject = { metadata: { createdAt: '2023-01-01T00:00:00Z', @@ -360,6 +387,13 @@ suite('DeepnoteTreeDataProvider', () => { blocks: [], executionMode: 'block', isModule: false + }, + { + id: 'notebook-2', + name: 'Notebook 2', + blocks: [], + executionMode: 'block', + isModule: false } ], settings: {} @@ -376,23 +410,23 @@ suite('DeepnoteTreeDataProvider', () => { initialProject, 1 ); - treeItemCache.set(cacheKey, mockTreeItem); + fileItemCache.set(filePath, mockTreeItem); // Verify initial state assert.strictEqual(mockTreeItem.label, 'Original Name'); - assert.strictEqual(mockTreeItem.description, '1 notebook'); + assert.strictEqual(mockTreeItem.description, '2 notebooks'); - // Update the project data (simulating rename and adding notebooks) + // Update the project data (simulating rename and adding a notebook) const updatedProject: DeepnoteProject = { ...initialProject, project: { ...initialProject.project, name: 'Renamed Project', notebooks: [ - initialProject.project.notebooks[0], + ...initialProject.project.notebooks, { - id: 'notebook-2', - name: 'Notebook 2', + id: 'notebook-3', + name: 'Notebook 3', blocks: [], executionMode: 'block', isModule: false @@ -402,11 +436,11 @@ suite('DeepnoteTreeDataProvider', () => { }; mockTreeItem.data = updatedProject; - // Call updateVisualFields if it exists (it may not work properly in test environment due to proxy limitations) + // Call updateVisualFields if it is available (in the VS Code test mock the subclass + // method may not be exposed on the proxied TreeItem); otherwise update fields manually. if (typeof mockTreeItem.updateVisualFields === 'function') { mockTreeItem.updateVisualFields(); } else { - // Manually update visual fields for testing purposes mockTreeItem.label = updatedProject.project.name || 'Untitled Project'; mockTreeItem.tooltip = `Deepnote Project: ${updatedProject.project.name}\nFile: ${mockTreeItem.context.filePath}`; const notebookCount = updatedProject.project.notebooks?.length || 0; @@ -417,7 +451,7 @@ suite('DeepnoteTreeDataProvider', () => { assert.strictEqual(mockTreeItem.label, 'Renamed Project', 'Label should reflect new project name'); assert.strictEqual( mockTreeItem.description, - '2 notebooks', + '3 notebooks', 'Description should reflect new notebook count' ); assert.include( @@ -430,11 +464,10 @@ suite('DeepnoteTreeDataProvider', () => { 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; + const fileItemCache = (provider as any).fileItemCache as Map; - // Add entries to both caches + // Add entries to both caches (both keyed by file path) const filePath = '/workspace/test-project.deepnote'; - const cacheKey = `project:${filePath}`; cachedProjects.set(filePath, mockProject); const mockTreeItem = new DeepnoteTreeItem( @@ -446,20 +479,20 @@ suite('DeepnoteTreeDataProvider', () => { mockProject, 1 ); - treeItemCache.set(cacheKey, mockTreeItem); + fileItemCache.set(filePath, 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'); + assert.isTrue(fileItemCache.has(filePath), 'fileItemCache 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); + fileItemCache.delete(filePath); // 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'); + assert.isFalse(fileItemCache.has(filePath), 'fileItemCache should not have entry after deletion'); }); }); @@ -648,4 +681,227 @@ suite('DeepnoteTreeDataProvider', () => { assert.strictEqual(notebookItems[2].label, 'zebra notebook', 'Third should be zebra notebook'); }); }); + + // Load-bearing: sibling files share one project.id, so refresh must rebuild the whole grouped + // subtree rather than patch a single cached item. These assert the §7 grouping-safe semantics. + suite('grouping-safe refresh semantics', () => { + const projectId = 'shared-project-id'; + const otherProjectId = 'other-project-id'; + const filePathA = '/workspace/proj-a.deepnote'; + const filePathB = '/workspace/proj-b.deepnote'; + const filePathOther = '/workspace/other.deepnote'; + + let cachedProjects: Map; + let fireArgs: Array; + + setup(() => { + // Seed two sibling files sharing one project.id plus a third file of a DIFFERENT project. + cachedProjects = (provider as any).cachedProjects as Map; + cachedProjects.set(filePathA, makeSingleNotebookProject(projectId, 'nb-a')); + cachedProjects.set(filePathB, makeSingleNotebookProject(projectId, 'nb-b')); + cachedProjects.set(filePathOther, makeSingleNotebookProject(otherProjectId, 'nb-other')); + + // Capture every fire arg through the PUBLIC event so a scoped fire(item) would be visible. + fireArgs = []; + provider.onDidChangeTreeData((arg) => fireArgs.push(arg)); + }); + + test('refreshNotebook evicts BOTH sibling entries (not just the first match), so a stale sibling cannot win', () => { + provider.refreshNotebook(projectId); + + assert.isFalse( + cachedProjects.has(filePathA), + 'sibling A must be evicted (refreshNotebook must not break on the first match)' + ); + assert.isFalse(cachedProjects.has(filePathB), 'sibling B must be evicted too'); + }); + + test('refreshNotebook leaves the OTHER project entry intact (does not over-evict across projects)', () => { + provider.refreshNotebook(projectId); + + assert.isTrue( + cachedProjects.has(filePathOther), + 'a file belonging to a different project.id must NOT be evicted' + ); + }); + + test('refreshNotebook fires a FULL-tree change (undefined), never a scoped fire(item)', () => { + provider.refreshNotebook(projectId); + + assert.strictEqual(fireArgs.length, 1, 'refreshNotebook must fire exactly once'); + assert.isUndefined(fireArgs[0], 'refreshNotebook must fire undefined (full-tree), not a tree item'); + }); + + test('refreshProject evicts ONLY that file path, leaving sibling B and the other project cached', () => { + provider.refreshProject(filePathA); + + assert.isFalse(cachedProjects.has(filePathA), 'the targeted file path must be evicted'); + assert.isTrue(cachedProjects.has(filePathB), 'the sibling sharing project.id must remain cached'); + assert.isTrue(cachedProjects.has(filePathOther), 'the other project must remain cached'); + }); + + test('refreshProject fires a FULL-tree change (undefined), never a scoped fire(item)', () => { + provider.refreshProject(filePathA); + + assert.strictEqual(fireArgs.length, 1, 'refreshProject must fire exactly once'); + assert.isUndefined(fireArgs[0], 'refreshProject must fire undefined (full-tree), not a tree item'); + }); + + test('every refresh path (refresh/refreshProject/refreshNotebook) fires undefined only — no scoped fire(item)', () => { + provider.refresh(); + provider.refreshProject(filePathB); + provider.refreshNotebook(projectId); + + assert.strictEqual(fireArgs.length, 3, 'each refresh call fires exactly once'); + for (const arg of fireArgs) { + assert.isUndefined(arg, 'no refresh path may use a scoped fire(item); all fires must be undefined'); + } + }); + }); + + suite('getNonInitNotebooks excludes the init notebook', () => { + test('the init notebook (project.initNotebookId) is excluded from the file notebook list', () => { + const project: DeepnoteProject = { + metadata: { createdAt: '2023-01-01T00:00:00Z', modifiedAt: '2023-01-02T00:00:00Z' }, + project: { + id: 'project-with-init', + name: 'Has Init', + initNotebookId: 'init-nb', + notebooks: [ + { id: 'init-nb', name: 'Init', blocks: [], executionMode: 'block', isModule: false }, + { id: 'main-nb', name: 'Main', blocks: [], executionMode: 'block', isModule: false } + ], + settings: {} + }, + version: '1.0.0' + }; + + const nonInit = getNonInitNotebooks(project); + + assert.strictEqual(nonInit.length, 1, 'only the non-init notebook should remain'); + assert.strictEqual(nonInit[0].id, 'main-nb', 'the surviving notebook must be the main (non-init) one'); + }); + }); + + // If feasible with the test mocks: build the grouped tree from a seeded cache and assert the + // node types/contextValues/labels for grouping, single-notebook leaf vs legacy collapsible, and + // init exclusion. + suite('getChildren groups siblings and distinguishes leaf vs legacy files', () => { + const projectId = 'group-project'; + + // Seed the file→project cache and call the private group builder directly. We invoke + // `getProjectGroups()`/`getChildren(groupItem)` rather than the root `getChildren()` because + // the root branch short-circuits to `[]` when `workspace.workspaceFolders` is unset (the + // tree test deliberately avoids ts-mockito); `loadAllProjects` then iterates an empty + // folder list and simply returns the pre-seeded cache, so grouping is exercised faithfully. + function seed(entries: Array<[string, DeepnoteProject]>): void { + const cachedProjects = (provider as any).cachedProjects as Map; + for (const [filePath, project] of entries) { + cachedProjects.set(filePath, project); + } + } + + async function getGroupItems(): Promise { + return (provider as any).getProjectGroups() as Promise; + } + + test('two siblings sharing one project.id collapse into ONE ProjectGroup', async () => { + seed([ + ['/workspace/a.deepnote', makeSingleNotebookProject(projectId, 'nb-a', 'Grouped')], + ['/workspace/b.deepnote', makeSingleNotebookProject(projectId, 'nb-b', 'Grouped')] + ]); + + const groups = (await getGroupItems()).filter((item) => item.type === DeepnoteTreeItemType.ProjectGroup); + + assert.strictEqual(groups.length, 1, 'both siblings must roll up into a single ProjectGroup'); + assert.strictEqual(groups[0].context.projectId, projectId); + assert.strictEqual(groups[0].contextValue, 'projectGroup', 'group node contextValue'); + }); + + test('a single-notebook file renders as a notebookFile leaf; a legacy multi-notebook file is collapsible', async () => { + const legacyMulti: DeepnoteProject = { + metadata: { createdAt: '2023-01-01T00:00:00Z', modifiedAt: '2023-01-02T00:00:00Z' }, + project: { + id: projectId, + name: 'Grouped', + notebooks: [ + { id: 'm1', name: 'Main 1', blocks: [], executionMode: 'block', isModule: false }, + { id: 'm2', name: 'Main 2', blocks: [], executionMode: 'block', isModule: false } + ], + settings: {} + }, + version: '1.0.0' + }; + + const singleFile: [string, DeepnoteProject] = [ + '/workspace/single.deepnote', + makeSingleNotebookProject(projectId, 'only-nb', 'Grouped') + ]; + + seed([singleFile, ['/workspace/legacy.deepnote', legacyMulti]]); + + const group = (await getGroupItems()).find((item) => item.type === DeepnoteTreeItemType.ProjectGroup); + assert.isDefined(group, 'a ProjectGroup must exist'); + + const files = await provider.getChildren(group); + + const leaf = files.find((f) => f.context.filePath === '/workspace/single.deepnote'); + const legacy = files.find((f) => f.context.filePath === '/workspace/legacy.deepnote'); + + assert.isDefined(leaf, 'single-notebook file item must exist'); + assert.isDefined(legacy, 'legacy multi-notebook file item must exist'); + + assert.strictEqual(leaf!.contextValue, 'notebookFile', 'single-notebook file is a notebookFile leaf'); + assert.strictEqual( + leaf!.collapsibleState, + 0 /* TreeItemCollapsibleState.None */, + 'a single-notebook leaf must not be collapsible' + ); + + assert.strictEqual(legacy!.contextValue, 'projectFile', 'legacy multi-notebook file is a projectFile'); + assert.strictEqual( + legacy!.collapsibleState, + 1 /* TreeItemCollapsibleState.Collapsed */, + 'a legacy multi-notebook file must be collapsible' + ); + + // The legacy file expands into its non-init Notebook children. + const notebooks = await provider.getChildren(legacy); + assert.strictEqual(notebooks.length, 2, 'legacy file expands into its notebooks'); + assert.isTrue( + notebooks.every((n) => n.type === DeepnoteTreeItemType.Notebook), + 'legacy children are Notebook items' + ); + }); + + test('the init notebook is excluded from a file group/leaf — an init+main file renders as a single-notebook leaf', async () => { + const initPlusMain: DeepnoteProject = { + metadata: { createdAt: '2023-01-01T00:00:00Z', modifiedAt: '2023-01-02T00:00:00Z' }, + project: { + id: projectId, + name: 'Grouped', + initNotebookId: 'the-init', + notebooks: [ + { id: 'the-init', name: 'Init', blocks: [], executionMode: 'block', isModule: false }, + { id: 'the-main', name: 'Main', blocks: [], executionMode: 'block', isModule: false } + ], + settings: {} + }, + version: '1.0.0' + }; + + seed([['/workspace/initmain.deepnote', initPlusMain]]); + + const group = (await getGroupItems()).find((item) => item.type === DeepnoteTreeItemType.ProjectGroup); + const files = await provider.getChildren(group); + + assert.strictEqual(files.length, 1, 'one file in the group'); + assert.strictEqual( + files[0].contextValue, + 'notebookFile', + 'with the init excluded, exactly one non-init notebook remains → leaf' + ); + assert.strictEqual(files[0].label, 'Main', 'the leaf is labelled with the non-init notebook name'); + }); + }); }); diff --git a/src/notebooks/deepnote/deepnoteTreeItem.ts b/src/notebooks/deepnote/deepnoteTreeItem.ts index e17a56a771..33d4bb1a56 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -2,9 +2,16 @@ import { TreeItem, TreeItemCollapsibleState, ThemeIcon } from 'vscode'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; /** - * Represents different types of items in the Deepnote tree view + * Represents different types of items in the Deepnote tree view. + * + * - `ProjectGroup` — a project (one per distinct `project.id`) grouping its sibling files. + * - `ProjectFile` — a single `.deepnote` file. A file with exactly one non-init notebook is + * rendered as a leaf (`notebookFile`); a legacy multi-notebook file is collapsible into + * `Notebook` children (`projectFile`). + * - `Notebook` — a legacy in-file notebook child of a multi-notebook `ProjectFile`. */ export enum DeepnoteTreeItemType { + ProjectGroup = 'projectGroup', ProjectFile = 'projectFile', Notebook = 'notebook', Loading = 'loading' @@ -20,103 +27,150 @@ export interface DeepnoteTreeItemContext { } /** - * Tree item representing a Deepnote project file or notebook in the explorer view + * Data backing a `ProjectGroup` node: all sibling `.deepnote` files that share one `project.id`. */ -export class DeepnoteTreeItem extends TreeItem { - constructor( - public readonly type: DeepnoteTreeItemType, - public readonly context: DeepnoteTreeItemContext, - public data: DeepnoteProject | DeepnoteNotebook | null, - collapsibleState: TreeItemCollapsibleState - ) { - super('', collapsibleState); +export interface ProjectGroupData { + readonly projectId: string; + readonly projectName: string; + readonly files: Array<{ filePath: string; project: DeepnoteProject }>; +} - this.contextValue = this.type; - - // Inline method calls to avoid ES module TreeItem extension issues - if (this.type === DeepnoteTreeItemType.Loading) { - this.label = 'Loading…'; - this.tooltip = 'Loading…'; - this.description = ''; - this.iconPath = new ThemeIcon('loading~spin'); - } else { - // getTooltip() inline - if (this.type === DeepnoteTreeItemType.ProjectFile) { - const project = this.data as DeepnoteProject; - this.tooltip = `Deepnote Project: ${project.project.name}\nFile: ${this.context.filePath}`; - } else { - const notebook = this.data as DeepnoteNotebook; - this.tooltip = `Notebook: ${notebook.name}\nExecution Mode: ${notebook.executionMode}`; - } - - // getIcon() inline - if (this.type === DeepnoteTreeItemType.ProjectFile) { - this.iconPath = new ThemeIcon('notebook'); - } else { - this.iconPath = new ThemeIcon('file-code'); - } - - // getLabel() inline - if (this.type === DeepnoteTreeItemType.ProjectFile) { - const project = this.data as DeepnoteProject; - this.label = project.project.name || 'Untitled Project'; - } else { - const notebook = this.data as DeepnoteNotebook; - this.label = notebook.name || 'Untitled Notebook'; - } - - // getDescription() inline - if (this.type === DeepnoteTreeItemType.ProjectFile) { - const project = this.data as DeepnoteProject; - const notebookCount = project.project.notebooks?.length || 0; - this.description = `${notebookCount} notebook${notebookCount !== 1 ? 's' : ''}`; - } else { - const notebook = this.data as DeepnoteNotebook; - const blockCount = notebook.blocks?.length || 0; - this.description = `${blockCount} cell${blockCount !== 1 ? 's' : ''}`; - } - } +/** + * Returns the notebooks of a project file that are NOT the init notebook. + * The init notebook (referenced by `project.initNotebookId`) is excluded from every count/label. + */ +export function getNonInitNotebooks(project: DeepnoteProject): DeepnoteNotebook[] { + const notebooks = project.project.notebooks ?? []; + const initNotebookId = project.project.initNotebookId; + + return notebooks.filter((notebook) => notebook.id !== initNotebookId); +} + +/** + * Resolves the single notebook to render for a single-notebook file: the first non-init notebook, + * falling back to the first notebook when the only notebook IS the init notebook. + */ +function resolveLeafNotebook(project: DeepnoteProject): DeepnoteNotebook | undefined { + const nonInit = getNonInitNotebooks(project); + + if (nonInit.length > 0) { + return nonInit[0]; + } + + return project.project.notebooks?.[0]; +} + +/** + * Mutates the tree item's visual fields (label, description, tooltip, icon, command, context value) + * based on its current type and data. + * + * Implemented as a free function (rather than an instance method) so it can be called from the + * constructor: in the transpiled ES-module output, calling a subclass instance method from a + * `TreeItem` subclass constructor is not safe (the prototype is not yet fully wired), which is why + * the original implementation inlined all rendering in the constructor body. + */ +function applyVisualFields(item: DeepnoteTreeItem): void { + if (item.type === DeepnoteTreeItemType.Loading) { + item.contextValue = 'loading'; + item.label = 'Loading…'; + item.tooltip = 'Loading…'; + item.description = ''; + item.iconPath = new ThemeIcon('loading~spin'); + + return; + } + + if (item.type === DeepnoteTreeItemType.ProjectGroup) { + const group = item.data as ProjectGroupData; + const fileCount = group.files?.length ?? 0; - if (this.type === DeepnoteTreeItemType.Notebook) { - this.command = { + item.contextValue = 'projectGroup'; + item.label = group.projectName || 'Untitled Project'; + item.tooltip = `Deepnote Project: ${group.projectName}`; + item.description = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + item.iconPath = new ThemeIcon('folder'); + item.command = undefined; + + return; + } + + if (item.type === DeepnoteTreeItemType.ProjectFile) { + const project = item.data as DeepnoteProject; + const nonInitNotebooks = getNonInitNotebooks(project); + + // A file with exactly one non-init notebook is a leaf labelled with that notebook's name. + if (nonInitNotebooks.length === 1) { + const notebook = resolveLeafNotebook(project); + const blockCount = notebook?.blocks?.length ?? 0; + + item.contextValue = 'notebookFile'; + item.label = notebook?.name || 'Untitled Notebook'; + item.tooltip = `Notebook: ${notebook?.name ?? ''}\nFile: ${item.context.filePath}`; + item.description = `${blockCount} cell${blockCount !== 1 ? 's' : ''}`; + item.iconPath = new ThemeIcon('notebook'); + item.command = { command: 'deepnote.openNotebook', title: 'Open Notebook', - arguments: [this.context] + arguments: [ + { + filePath: item.context.filePath, + projectId: item.context.projectId, + notebookId: notebook?.id + } satisfies DeepnoteTreeItemContext + ] }; - } - } - /** - * 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 { - if (this.type === DeepnoteTreeItemType.Loading) { - this.label = 'Loading…'; - this.tooltip = 'Loading…'; - this.description = ''; - this.iconPath = new ThemeIcon('loading~spin'); return; } - if (this.type === DeepnoteTreeItemType.ProjectFile) { - const project = this.data as DeepnoteProject; - - this.label = project.project.name || 'Untitled Project'; - this.tooltip = `Deepnote Project: ${project.project.name}\nFile: ${this.context.filePath}`; + // Legacy multi-notebook (or empty) file: collapsible into Notebook children. + item.contextValue = 'projectFile'; + item.label = project.project.name || 'Untitled Project'; + item.tooltip = `Deepnote Project: ${project.project.name}\nFile: ${item.context.filePath}`; + item.description = `${nonInitNotebooks.length} notebook${nonInitNotebooks.length !== 1 ? 's' : ''}`; + item.iconPath = new ThemeIcon('notebook'); + item.command = undefined; - const notebookCount = project.project.notebooks?.length || 0; + return; + } - this.description = `${notebookCount} notebook${notebookCount !== 1 ? 's' : ''}`; - } else { - const notebook = this.data as DeepnoteNotebook; + const notebook = item.data as DeepnoteNotebook; + const blockCount = notebook.blocks?.length ?? 0; + + item.contextValue = 'notebook'; + item.label = notebook.name || 'Untitled Notebook'; + item.tooltip = `Notebook: ${notebook.name}\nExecution Mode: ${notebook.executionMode}`; + item.description = `${blockCount} cell${blockCount !== 1 ? 's' : ''}`; + item.iconPath = new ThemeIcon('file-code'); + item.command = { + command: 'deepnote.openNotebook', + title: 'Open Notebook', + arguments: [item.context] + }; +} - this.label = notebook.name || 'Untitled Notebook'; - this.tooltip = `Notebook: ${notebook.name}\nExecution Mode: ${notebook.executionMode}`; +/** + * Tree item representing a Deepnote project group, project file, or in-file notebook in the + * explorer view. + */ +export class DeepnoteTreeItem extends TreeItem { + constructor( + public readonly type: DeepnoteTreeItemType, + public readonly context: DeepnoteTreeItemContext, + public data: DeepnoteProject | DeepnoteNotebook | ProjectGroupData | null, + collapsibleState: TreeItemCollapsibleState + ) { + super('', collapsibleState); - const blockCount = notebook.blocks?.length || 0; + applyVisualFields(this); + } - this.description = `${blockCount} cell${blockCount !== 1 ? 's' : ''}`; - } + /** + * Updates the tree item's visual fields (label, description, tooltip, icon, command, context + * value) based on current data. Call this after updating the data property to ensure the tree + * view reflects changes. + */ + public updateVisualFields(): void { + applyVisualFields(this); } } diff --git a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts index addc75f68a..35e7f9a924 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts @@ -13,6 +13,7 @@ suite('DeepnoteTreeItem', () => { project: { id: 'project-123', name: 'Test Project', + // Two notebooks → a legacy multi-notebook (collapsible) ProjectFile node. notebooks: [ { id: 'notebook-1', @@ -20,6 +21,13 @@ suite('DeepnoteTreeItem', () => { blocks: [], executionMode: 'block', isModule: false + }, + { + id: 'notebook-2', + name: 'Second Notebook', + blocks: [], + executionMode: 'block', + isModule: false } ], settings: {} @@ -62,7 +70,7 @@ suite('DeepnoteTreeItem', () => { assert.deepStrictEqual(item.context, context); assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.Collapsed); assert.strictEqual(item.label, 'Test Project'); - assert.strictEqual(item.description, '1 notebook'); + assert.strictEqual(item.description, '2 notebooks'); }); test('should create notebook item with basic properties', () => { @@ -122,7 +130,7 @@ suite('DeepnoteTreeItem', () => { assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.Collapsed); assert.strictEqual(item.contextValue, 'projectFile'); assert.strictEqual(item.tooltip, 'Deepnote Project: Test Project\nFile: /workspace/my-project.deepnote'); - assert.strictEqual(item.description, '1 notebook'); + assert.strictEqual(item.description, '2 notebooks'); // Should have notebook icon for project files assert.instanceOf(item.iconPath, ThemeIcon); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 9ab328a1e4..84522c38b9 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -104,6 +104,7 @@ import { IntegrationKernelRestartHandler } from './deepnote/integrations/integra import { ISnapshotMetadataService, SnapshotService } from './deepnote/snapshots/snapshotService'; import { EnvironmentCapture, IEnvironmentCapture } from './deepnote/snapshots/environmentCapture.node'; import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; +import { DeepnoteNotebookInfoStatusBar } from './deepnote/deepnoteNotebookInfoStatusBar'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -265,6 +266,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteNewCellLanguageService ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteNotebookInfoStatusBar + ); // Deepnote configuration services serviceManager.addSingleton(DeepnoteEnvironmentStorage, DeepnoteEnvironmentStorage); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 31afe56ca0..28937d6b60 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -56,6 +56,7 @@ import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { IntegrationKernelRestartHandler } from './deepnote/integrations/integrationKernelRestartHandler'; import { FederatedAuthCommandHandlerWeb } from './deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web'; import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; +import { DeepnoteNotebookInfoStatusBar } from './deepnote/deepnoteNotebookInfoStatusBar'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -130,6 +131,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, DeepnoteNewCellLanguageService ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteNotebookInfoStatusBar + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 2d650927e3..fb9b5ef10d 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -223,6 +223,7 @@ export namespace Commands { export const OpenDeepnoteNotebook = 'deepnote.openNotebook'; export const OpenDeepnoteFile = 'deepnote.openFile'; export const RevealInDeepnoteExplorer = 'deepnote.revealInExplorer'; + export const CopyNotebookDetails = 'deepnote.copyNotebookDetails'; export const EnableSnapshots = 'deepnote.enableSnapshots'; export const DisableSnapshots = 'deepnote.disableSnapshots'; export const AuthenticateIntegration = 'deepnote.authenticateIntegration'; From 3e16e205255c00a271431dc3b58a7f33db46fd01 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 00:52:45 +0000 Subject: [PATCH 06/26] refactor(deepnote): key the Jupyter server and environment per notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 6 of single-notebook migration (§8). - Key the server starter maps, the config handle, and the kernel auto-selector by notebook.uri.toString() - the same identity the kernel and controller use - so a notebook's server is 1:1 with its kernel. Sibling notebooks of one project no longer share a server; the working directory and SQL env are taken from each notebook's own file. - Fix environment deletion: stop every server using the environment (including closed notebooks whose server is still running) before removing the mappings, driven from the notebook->environment mapper. Drop the dead environmentServers map that was never populated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- build/mocha-esm-loader.js | 65 ++++++++ .../deepnote/deepnoteServerStarter.node.ts | 116 ++++++------- .../deepnoteServerStarter.unit.test.ts | 135 +++++++++++++++ .../deepnoteEnvironmentManager.node.ts | 15 +- .../deepnoteEnvironmentManager.unit.test.ts | 22 ++- .../deepnoteEnvironmentsView.node.ts | 35 ++-- .../deepnoteEnvironmentsView.unit.test.ts | 157 +++++++++++++++++- .../deepnoteExtensionSidecarWriter.node.ts | 5 +- src/kernels/deepnote/types.ts | 17 +- .../deepnoteKernelAutoSelector.node.ts | 103 ++++-------- ...epnoteKernelAutoSelector.node.unit.test.ts | 12 +- .../deepnoteKernelStatusIndicator.node.ts | 6 +- .../deepnote/deepnoteKernelStatusIndicator.ts | 6 +- .../deepnote/deepnoteServerUtils.node.ts | 4 +- .../deepnoteServerUtils.node.unit.test.ts | 56 +++++++ 15 files changed, 565 insertions(+), 189 deletions(-) create mode 100644 src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index 2fb8fb2d61..ae667068d1 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -113,6 +113,17 @@ export async function resolve(specifier, context, nextResolve) { }; } + // Intercept @deepnote/runtime-core - needed because the real startServer/stopServer + // spawn/kill real Python processes. The mock records calls and returns a fake ServerInfo + // (faithful to the { url, jupyterPort, lspPort, process } contract) so the extension's + // keying/working-directory/lifecycle logic can be tested without a real server. + if (specifier === '@deepnote/runtime-core') { + return { + url: 'vscode-mock:///deepnote-runtime-core', + shortCircuit: true + }; + } + // Intercept @vscode/python-extension - needed because it requires VS Code runtime // Note: Only exact match is needed - no subpath imports (e.g., '@vscode/python-extension/foo') // exist in the codebase. If subpath imports are added, change to startsWith() match. @@ -433,6 +444,60 @@ export async function load(url, context, nextLoad) { }; } + // Handle @deepnote/runtime-core mock - needed because the real startServer/stopServer + // spawn/kill real Python processes. The mock keeps a shared call log so tests can + // assert how many servers were started/stopped and with which working directories. + if (moduleName === 'deepnote-runtime-core') { + return { + format: 'module', + source: ` + const startServerCalls = []; + const stopServerCalls = []; + let nextServerId = 0; + let startServerImpl = null; + + const makeFakeProcess = (id) => ({ + pid: 40000 + id, + stdout: { on() {}, off() {} }, + stderr: { on() {}, off() {} }, + kill() {} + }); + + export const startServer = async (options) => { + startServerCalls.push(options); + if (startServerImpl) { + return startServerImpl(options); + } + const id = nextServerId++; + return { + url: 'http://127.0.0.1:' + (50000 + id), + jupyterPort: 50000 + id, + lspPort: 51000 + id, + process: makeFakeProcess(id) + }; + }; + + export const stopServer = async (info) => { + stopServerCalls.push(info); + }; + + // Test-only helpers (prefixed with __ to signal they are not part of the real API). + export const __getStartServerCalls = () => startServerCalls; + export const __getStopServerCalls = () => stopServerCalls; + export const __setStartServerImpl = (impl) => { + startServerImpl = impl; + }; + export const __resetRuntimeCoreMock = () => { + startServerCalls.length = 0; + stopServerCalls.length = 0; + nextServerId = 0; + startServerImpl = null; + }; + `, + shortCircuit: true + }; + } + // Handle @vscode/python-extension mock - needed because it requires VS Code runtime if (moduleName === 'python-extension') { return { diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 112f7f0e09..26637f8ada 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -93,8 +93,9 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } /** - * Start a server for a kernel environment. - * Serializes concurrent operations on the same environment to prevent race conditions. + * Start a server for a notebook. + * The server is keyed by the notebook URI, so each notebook gets its own server. + * Serializes concurrent operations on the same notebook to prevent race conditions. */ public async startServer( interpreter: PythonEnvironment, @@ -102,14 +103,14 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv: boolean, additionalPackages: string[], environmentId: string, - deepnoteFileUri: Uri, + notebookUri: Uri, token?: CancellationToken ): Promise { - const fileKey = deepnoteFileUri.fsPath; + const notebookKey = notebookUri.toString(); - let pendingOp = this.pendingOperations.get(fileKey); + let pendingOp = this.pendingOperations.get(notebookKey); if (pendingOp) { - logger.info(`Waiting for pending operation on ${fileKey} to complete...`); + logger.info(`Waiting for pending operation on ${notebookKey} to complete...`); try { await pendingOp.promise; } catch { @@ -117,28 +118,28 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } } - let existingContext = this.projectContexts.get(fileKey); + let existingContext = this.projectContexts.get(notebookKey); if (existingContext != null) { const { environmentId: existingEnvironmentId, serverInfo: existingServerInfo } = existingContext; if (existingEnvironmentId === environmentId) { if (existingServerInfo != null && (await this.isServerRunning(existingServerInfo))) { logger.info( - `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (environmentId ${environmentId})` + `Deepnote server already running at ${existingServerInfo.url} for ${notebookKey} (environmentId ${environmentId})` ); return existingServerInfo; } - pendingOp = this.pendingOperations.get(fileKey); + pendingOp = this.pendingOperations.get(notebookKey); if (pendingOp && pendingOp.type === 'start') { return await pendingOp.promise; } } else { logger.info( - `Stopping existing server for ${fileKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` + `Stopping existing server for ${notebookKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` ); - await this.stopServerForEnvironment(existingContext, deepnoteFileUri, token); + await this.stopServerForEnvironment(existingContext, notebookUri, token); existingContext.environmentId = environmentId; } } else { @@ -147,7 +148,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension serverInfo: null }; - this.projectContexts.set(fileKey, newContext); + this.projectContexts.set(notebookKey, newContext); existingContext = newContext; } @@ -160,11 +161,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv, additionalPackages, environmentId, - deepnoteFileUri, + notebookUri, token ) }; - this.pendingOperations.set(fileKey, operation); + this.pendingOperations.set(notebookKey, operation); try { const result = await operation.promise; @@ -172,29 +173,30 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension existingContext.serverInfo = result; return result; } finally { - if (this.pendingOperations.get(fileKey) === operation) { - this.pendingOperations.delete(fileKey); + if (this.pendingOperations.get(notebookKey) === operation) { + this.pendingOperations.delete(notebookKey); } } } /** - * Stop the deepnote-toolkit server for a kernel environment. + * Stop the deepnote-toolkit server for a notebook. + * Safe no-op when the notebook has no running server. */ - public async stopServer(deepnoteFileUri: Uri, token?: CancellationToken): Promise { + public async stopServer(notebookUri: Uri, token?: CancellationToken): Promise { Cancellation.throwIfCanceled(token); - const fileKey = deepnoteFileUri.fsPath; - const projectContext = this.projectContexts.get(fileKey) ?? null; + const notebookKey = notebookUri.toString(); + const projectContext = this.projectContexts.get(notebookKey) ?? null; if (projectContext == null) { - logger.warn(`No project context found for ${fileKey}, skipping stop server...`); + logger.warn(`No project context found for ${notebookKey}, skipping stop server...`); return; } - const pendingOp = this.pendingOperations.get(fileKey); + const pendingOp = this.pendingOperations.get(notebookKey); if (pendingOp) { - logger.info(`Waiting for pending operation on ${fileKey} before stopping...`); + logger.info(`Waiting for pending operation on ${notebookKey} before stopping...`); try { await pendingOp.promise; } catch { @@ -206,15 +208,15 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const operation = { type: 'stop' as const, - promise: this.stopServerForEnvironment(projectContext, deepnoteFileUri, token) + promise: this.stopServerForEnvironment(projectContext, notebookUri, token) }; - this.pendingOperations.set(fileKey, operation); + this.pendingOperations.set(notebookKey, operation); try { await operation.promise; } finally { - if (this.pendingOperations.get(fileKey) === operation) { - this.pendingOperations.delete(fileKey); + if (this.pendingOperations.get(notebookKey) === operation) { + this.pendingOperations.delete(notebookKey); } } } @@ -235,10 +237,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv: boolean, additionalPackages: string[], environmentId: string, - deepnoteFileUri: Uri, + notebookUri: Uri, token?: CancellationToken ): Promise { - const fileKey = deepnoteFileUri.fsPath; + const notebookKey = notebookUri.toString(); Cancellation.throwIfCanceled(token); @@ -258,25 +260,25 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension Cancellation.throwIfCanceled(token); - logger.info(`Starting deepnote-toolkit server for ${fileKey} (environmentId ${environmentId})`); + logger.info(`Starting deepnote-toolkit server for ${notebookKey} (environmentId ${environmentId})`); this.outputChannel.appendLine(l10n.t('Starting Deepnote server...')); - const extraEnv = await this.gatherSqlIntegrationEnvVars(deepnoteFileUri, environmentId, token); + const extraEnv = await this.gatherSqlIntegrationEnvVars(notebookUri, environmentId, token); // Initialize output tracking for error reporting - this.serverOutputByFile.set(fileKey, { stdout: '', stderr: '' }); + this.serverOutputByFile.set(notebookKey, { stdout: '', stderr: '' }); let serverInfo: DeepnoteServerInfo | undefined; try { serverInfo = await startServer({ pythonEnv: venvPath.fsPath, - workingDirectory: path.dirname(deepnoteFileUri.fsPath), + workingDirectory: path.dirname(notebookUri.fsPath), startupTimeoutMs: SERVER_STARTUP_TIMEOUT_MS, env: extraEnv }); } catch (error) { - const capturedOutput = this.serverOutputByFile.get(fileKey); - this.serverOutputByFile.delete(fileKey); + const capturedOutput = this.serverOutputByFile.get(notebookKey); + this.serverOutputByFile.delete(notebookKey); throw new DeepnoteServerStartupError( interpreter.uri.fsPath, @@ -291,17 +293,17 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension projectContext.serverInfo = serverInfo; // Set up output channel logging from the server process - this.monitorServerOutput(fileKey, serverInfo); + this.monitorServerOutput(notebookKey, serverInfo); // Write lock file for orphan-cleanup tracking const serverPid = serverInfo.process.pid; if (serverPid) { await this.writeLockFile(serverPid); } else { - logger.warn(`Could not get PID for server process for ${fileKey}`); + logger.warn(`Could not get PID for server process for ${notebookKey}`); } - logger.info(`Deepnote server started successfully at ${serverInfo.url} for ${fileKey}`); + logger.info(`Deepnote server started successfully at ${serverInfo.url} for ${notebookKey}`); this.outputChannel.appendLine(l10n.t('✓ Deepnote server running at {0}', serverInfo.url)); return serverInfo; @@ -312,10 +314,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension */ private async stopServerForEnvironment( projectContext: ProjectContext, - deepnoteFileUri: Uri, + notebookUri: Uri, token?: CancellationToken ): Promise { - const fileKey = deepnoteFileUri.fsPath; + const notebookKey = notebookUri.toString(); Cancellation.throwIfCanceled(token); @@ -325,9 +327,9 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const serverPid = serverInfo.process.pid; try { - logger.info(`Stopping Deepnote server for ${fileKey}...`); + logger.info(`Stopping Deepnote server for ${notebookKey}...`); await stopServer(serverInfo); - this.outputChannel.appendLine(l10n.t('Deepnote server stopped for {0}', fileKey)); + this.outputChannel.appendLine(l10n.t('Deepnote server stopped for {0}', notebookKey)); } catch (ex) { logger.error('Error stopping Deepnote server', ex); } finally { @@ -341,12 +343,12 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension Cancellation.throwIfCanceled(token); - this.serverOutputByFile.delete(fileKey); + this.serverOutputByFile.delete(notebookKey); - const disposables = this.disposablesByFile.get(fileKey); + const disposables = this.disposablesByFile.get(notebookKey); if (disposables) { disposables.forEach((d) => d.dispose()); - this.disposablesByFile.delete(fileKey); + this.disposablesByFile.delete(notebookKey); } } @@ -366,7 +368,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Gather SQL integration environment variables for the deepnote-toolkit server. */ private async gatherSqlIntegrationEnvVars( - deepnoteFileUri: Uri, + notebookUri: Uri, environmentId: string, token?: CancellationToken ): Promise> { @@ -377,13 +379,13 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension return extraEnv; } - const fileKey = deepnoteFileUri.fsPath; + const notebookKey = notebookUri.toString(); logger.debug( - `DeepnoteServerStarter: Injecting SQL integration env vars for ${fileKey} with environmentId ${environmentId}` + `DeepnoteServerStarter: Injecting SQL integration env vars for ${notebookKey} with environmentId ${environmentId}` ); try { - const sqlEnvVars = await this.sqlIntegrationEnvVars.getEnvironmentVariables(deepnoteFileUri, token); + const sqlEnvVars = await this.sqlIntegrationEnvVars.getEnvironmentVariables(notebookUri, token); if (sqlEnvVars && Object.keys(sqlEnvVars).length > 0) { logger.debug(`DeepnoteServerStarter: Injecting ${Object.keys(sqlEnvVars).length} SQL env vars`); Object.assign(extraEnv, sqlEnvVars); @@ -400,19 +402,19 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension /** * Stream stdout/stderr from the server process to the VSCode output channel. */ - private monitorServerOutput(fileKey: string, serverInfo: DeepnoteServerInfo): void { + private monitorServerOutput(notebookKey: string, serverInfo: DeepnoteServerInfo): void { const proc = serverInfo.process; const disposables: IDisposable[] = []; - this.disposablesByFile.set(fileKey, disposables); + this.disposablesByFile.set(notebookKey, disposables); if (proc.stdout) { const stdout = proc.stdout; const onData = (data: Buffer) => { const text = data.toString(); - logger.trace(`Deepnote server (${fileKey}): ${text}`); + logger.trace(`Deepnote server (${notebookKey}): ${text}`); this.outputChannel.appendLine(text); - const outputTracking = this.serverOutputByFile.get(fileKey); + const outputTracking = this.serverOutputByFile.get(notebookKey); if (outputTracking) { outputTracking.stdout = (outputTracking.stdout + text).slice(-MAX_OUTPUT_TRACKING_LENGTH); } @@ -429,10 +431,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const stderr = proc.stderr; const onData = (data: Buffer) => { const text = data.toString(); - logger.warn(`Deepnote server stderr (${fileKey}): ${text}`); + logger.warn(`Deepnote server stderr (${notebookKey}): ${text}`); this.outputChannel.appendLine(text); - const outputTracking = this.serverOutputByFile.get(fileKey); + const outputTracking = this.serverOutputByFile.get(notebookKey); if (outputTracking) { outputTracking.stderr = (outputTracking.stderr + text).slice(-MAX_OUTPUT_TRACKING_LENGTH); } @@ -485,11 +487,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension await this.deleteLockFile(pid); } - for (const [fileKey, disposables] of this.disposablesByFile.entries()) { + for (const [notebookKey, disposables] of this.disposablesByFile.entries()) { try { disposables.forEach((d) => d.dispose()); } catch (ex) { - logger.error(`Error disposing resources for ${fileKey}`, ex); + logger.error(`Error disposing resources for ${notebookKey}`, ex); } } diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index f90e3a8ec1..0391769b7a 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -1,6 +1,7 @@ import { assert } from 'chai'; import * as fakeTimers from '@sinonjs/fake-timers'; import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri } from 'vscode'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; import { DeepnoteServerStarter } from './deepnoteServerStarter.node'; @@ -8,6 +9,22 @@ import { IProcessServiceFactory } from '../../platform/common/process/types.node import { IAsyncDisposableRegistry, IOutputChannel } from '../../platform/common/types'; import { IDeepnoteToolkitInstaller } from './types'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; + +/** + * Accessor for the @deepnote/runtime-core mock's test-only helpers (see build/mocha-esm-loader.js). + * The real .d.ts does not declare these, so we reach them through a dynamic import + cast. + */ +interface RuntimeCoreMockHelpers { + __getStartServerCalls(): Array<{ workingDirectory: string; pythonEnv: string; env?: Record }>; + __getStopServerCalls(): unknown[]; + __setStartServerImpl(impl: ((options: unknown) => Promise) | null): void; + __resetRuntimeCoreMock(): void; +} + +async function getRuntimeCoreMock(): Promise { + return (await import('@deepnote/runtime-core')) as unknown as RuntimeCoreMockHelpers; +} /** * Unit tests for DeepnoteServerStarter. @@ -101,6 +118,124 @@ suite('DeepnoteServerStarter', () => { }); }); + suite('per-notebook keying (startServer/stopServer)', () => { + const interpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.file('/usr/bin/python3') + } as PythonEnvironment; + const venvPath = Uri.file('/venvs/env1'); + // Two notebooks in the SAME project directory but different files (sibling files). + const uriA = Uri.file('/workspace/project/notebook-a.deepnote'); + const uriB = Uri.file('/workspace/project/notebook-b.deepnote'); + + const start = (notebookUri: Uri, environmentId = 'env1') => + serverStarter.startServer(interpreter, venvPath, true, [], environmentId, notebookUri); + + setup(async () => { + const runtimeCore = await getRuntimeCoreMock(); + runtimeCore.__resetRuntimeCoreMock(); + + // The toolkit install step runs before runtime-core's startServer; stub it so the + // start path reaches startServer. (Un-stubbed ts-mockito methods return null.) + when(mockToolkitInstaller.ensureVenvAndToolkit(anything(), anything(), anything(), anything())).thenResolve( + { + pythonInterpreter: interpreter, + toolkitVersion: '1.0.0' + } + ); + when(mockToolkitInstaller.installAdditionalPackages(anything(), anything(), anything())).thenResolve(); + when(mockAgentSkillsManager.ensureSkillsUpdated(anything(), anything())).thenReturn(); + }); + + test('starts SEPARATE servers for two different notebook URIs in the same dir (catches cross-sibling server reuse)', async () => { + const runtimeCore = await getRuntimeCoreMock(); + + const infoA = await start(uriA); + const infoB = await start(uriB); + + // runtime-core startServer must be invoked once per notebook — NOT reused across siblings. + assert.strictEqual( + runtimeCore.__getStartServerCalls().length, + 2, + 'each distinct notebook URI must spawn its own server process' + ); + + // The two servers are distinct (distinct map entries / distinct ServerInfo). + assert.notStrictEqual(infoA.url, infoB.url, 'sibling notebooks must not share one server'); + + // Two distinct projectContexts keyed by notebook.uri.toString(). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contexts = (serverStarter as any).projectContexts as Map; + assert.strictEqual(contexts.size, 2, 'one project context per notebook URI'); + assert.isTrue(contexts.has(uriA.toString()), 'context keyed by notebook A URI'); + assert.isTrue(contexts.has(uriB.toString()), 'context keyed by notebook B URI'); + }); + + test("each notebook's server uses dirname(its own notebookUri.fsPath) as working directory (catches wrong-cwd capture)", async () => { + const runtimeCore = await getRuntimeCoreMock(); + + await start(uriA); + await start(uriB); + + const calls = runtimeCore.__getStartServerCalls(); + assert.strictEqual(calls.length, 2); + // Both files share the same parent dir, so both servers use that dir as cwd. + assert.strictEqual(calls[0].workingDirectory, '/workspace/project'); + assert.strictEqual(calls[1].workingDirectory, '/workspace/project'); + }); + + test('REUSES the running server when the SAME notebook URI re-requests the same environment (catches redundant respawn)', async () => { + const runtimeCore = await getRuntimeCoreMock(); + + // Make the running-server health probe report "running" so the reuse branch is taken. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (serverStarter as any).isServerRunning = async () => true; + + const first = await start(uriA); + assert.strictEqual(runtimeCore.__getStartServerCalls().length, 1); + + const second = await start(uriA); + + assert.strictEqual( + runtimeCore.__getStartServerCalls().length, + 1, + 'a second start for the same notebook+environment must reuse the server, not respawn' + ); + assert.strictEqual(second.url, first.url, 'the reused server info must be returned'); + }); + + test('stopServer(uriA) tears down ONLY notebook A; B keeps running (catches cross-notebook teardown)', async () => { + const runtimeCore = await getRuntimeCoreMock(); + + await start(uriA); + await start(uriB); + + await serverStarter.stopServer(uriA); + + // runtime-core stopServer invoked exactly once (only A's process was alive and stopped). + assert.strictEqual(runtimeCore.__getStopServerCalls().length, 1, 'only notebook A server stopped'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contexts = (serverStarter as any).projectContexts as Map; + // A's context still exists but its server is cleared; B's server is untouched. + assert.strictEqual(contexts.get(uriA.toString())?.serverInfo, null, "A's server info cleared"); + assert.isNotNull(contexts.get(uriB.toString())?.serverInfo, "B's server must remain running"); + }); + + test('stopServer for a notebook with NO running server is a safe no-op (does not throw, does not call runtime-core stop)', async () => { + const runtimeCore = await getRuntimeCoreMock(); + + // Never started anything for this URI. + await serverStarter.stopServer(Uri.file('/workspace/project/never-started.deepnote')); + + assert.strictEqual( + runtimeCore.__getStopServerCalls().length, + 0, + 'stopping a notebook with no server must not invoke runtime-core stopServer' + ); + }); + }); + suite('dispose', () => { let clock: fakeTimers.InstalledClock; diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts index 4a156d750d..ed318c98b5 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts @@ -10,7 +10,7 @@ import { IProcessServiceFactory } from '../../../platform/common/process/types.n import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; import { generateUuid as uuid } from '../../../platform/common/uuid'; import { logger } from '../../../platform/logging'; -import { IDeepnoteEnvironmentManager, IDeepnoteServerStarter } from '../types'; +import { IDeepnoteEnvironmentManager } from '../types'; import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; @@ -20,11 +20,7 @@ import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; */ @injectable() export class DeepnoteEnvironmentManager implements IExtensionSyncActivationService, IDeepnoteEnvironmentManager { - // Track server handles per notebook URI for cleanup - // private readonly notebookServerHandles = new Map(); - private environments: Map = new Map(); - private environmentServers: Map = new Map(); private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event; private initializationPromise: Promise | undefined; @@ -32,7 +28,6 @@ export class DeepnoteEnvironmentManager implements IExtensionSyncActivationServi constructor( @inject(IExtensionContext) private readonly context: IExtensionContext, @inject(DeepnoteEnvironmentStorage) private readonly storage: DeepnoteEnvironmentStorage, - @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IFileSystem) private readonly fileSystem: IFileSystem, @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory @@ -206,11 +201,9 @@ export class DeepnoteEnvironmentManager implements IExtensionSyncActivationServi throw new Error(`Environment not found: ${id}`); } - // Stop the server if running - for (const fileKey of this.environmentServers.get(id) ?? []) { - await this.serverStarter.stopServer(fileKey, token); - Cancellation.throwIfCanceled(token); - } + // Note: stopping the per-notebook servers that use this environment is the view's + // responsibility (DeepnoteEnvironmentsView.deleteEnvironmentCommand) — it drives the + // stop loop from the notebook-environment mapper before this method runs. Cancellation.throwIfCanceled(token); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts index 4a0fe619ca..ee4453ee4b 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts @@ -11,7 +11,6 @@ import { IFileSystem } from '../../../platform/common/platform/types'; import { IProcessService, IProcessServiceFactory } from '../../../platform/common/process/types.node'; import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { IDeepnoteServerStarter } from '../types'; use(chaiAsPromised); @@ -19,7 +18,6 @@ suite('DeepnoteEnvironmentManager', () => { let manager: DeepnoteEnvironmentManager; let mockContext: IExtensionContext; let mockStorage: DeepnoteEnvironmentStorage; - let mockServerStarter: IDeepnoteServerStarter; let mockOutputChannel: IOutputChannel; let mockFileSystem: IFileSystem; let mockProcessServiceFactory: IProcessServiceFactory; @@ -35,7 +33,6 @@ suite('DeepnoteEnvironmentManager', () => { setup(() => { mockContext = mock(); mockStorage = mock(); - mockServerStarter = mock(); mockOutputChannel = mock(); mockFileSystem = mock(); mockProcessServiceFactory = mock(); @@ -69,7 +66,6 @@ suite('DeepnoteEnvironmentManager', () => { manager = new DeepnoteEnvironmentManager( instance(mockContext), instance(mockStorage), - instance(mockServerStarter), instance(mockOutputChannel), instance(mockFileSystem), instance(mockProcessServiceFactory) @@ -285,6 +281,24 @@ suite('DeepnoteEnvironmentManager', () => { // Verify directory no longer exists assert.isFalse(fs.existsSync(venvDirPath), 'Directory should not exist after deletion'); }); + + test('deletion does NOT reference a server-stop map — the dead environmentServers map is gone (stopping is the view’s job)', async () => { + const config = await manager.createEnvironment({ + name: 'Test', + pythonInterpreter: testInterpreter + }); + + // The manager has no server-starter collaborator and no per-environment server map: + // deletion is purely "delete the env (and managed venv)". Stopping servers is the + // view's responsibility (DeepnoteEnvironmentsView.deleteEnvironmentCommand). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.isUndefined((manager as any).environmentServers, 'the dead environmentServers map must not exist'); + + // Deletion succeeds with no server-stopping collaborator wired in. + await manager.deleteEnvironment(config.id); + + assert.isUndefined(manager.getEnvironment(config.id)); + }); }); suite('updateLastUsed', () => { diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts index 4d6c3ec7fa..5eba14dc16 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts @@ -27,7 +27,8 @@ import { DeepnoteKernelConnectionMetadata, IDeepnoteEnvironmentManager, IDeepnoteKernelAutoSelector, - IDeepnoteNotebookEnvironmentMapper + IDeepnoteNotebookEnvironmentMapper, + IDeepnoteServerStarter } from '../types'; import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; @@ -52,7 +53,8 @@ export class DeepnoteEnvironmentsView implements Disposable { @inject(IDeepnoteNotebookEnvironmentMapper) private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, + @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter ) { // Create tree data provider @@ -300,15 +302,29 @@ export class DeepnoteEnvironmentsView implements Disposable { cancellable: true }, async (_progress, token) => { - // Clean up notebook mappings referencing this env - const notebooks = this.notebookEnvironmentMapper.getNotebooksUsingEnvironment(environmentId); - for (const nb of notebooks) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(nb); + // Resolve every notebook that uses this environment from the persisted + // mapper state BEFORE any entries are removed, so the list is complete. + const uris = this.notebookEnvironmentMapper.getNotebooksUsingEnvironment(environmentId); + + // Stop each notebook's server. Per-notebook keying means this stops the + // server even for notebooks that are currently closed but still running. + // stopServer is a safe no-op when a notebook has no running server. + for (const uri of uris) { + try { + await this.serverStarter.stopServer(uri, token); + } catch (error) { + logger.error(`Failed to stop server for ${getDisplayPath(uri)}`, error); + } } // Dispose kernels from any open notebooks using this environment await this.disposeKernelsUsingEnvironment(environmentId); + // Now remove the mapper entries and delete the environment/venv + for (const uri of uris) { + await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(uri); + } + await this.environmentManager.deleteEnvironment(environmentId, token); logger.info(`Deleted environment: ${environmentId}`); } @@ -370,11 +386,8 @@ export class DeepnoteEnvironmentsView implements Disposable { public async selectEnvironmentForNotebook({ notebook }: { notebook: NotebookDocument }): Promise { logger.info('Selecting environment for notebook:', notebook); - // Get base file URI (without query/fragment) - const baseFileUri = notebook.uri.with({ query: '', fragment: '' }); - // Get current environment selection - const currentEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); + const currentEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); const currentEnvironment = currentEnvironmentId ? this.environmentManager.getEnvironment(currentEnvironmentId) : undefined; @@ -472,7 +485,7 @@ export class DeepnoteEnvironmentsView implements Disposable { }, async (progress, token) => { // Update the notebook-to-environment mapping - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, selectedEnvironmentId); + await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironmentId); // Force rebuild the controller with the new environment // This clears cached metadata and creates a fresh controller. diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts index 0407420162..096d35819b 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts @@ -3,7 +3,12 @@ import * as sinon from 'sinon'; import { anything, capture, instance, mock, when, verify, deepEqual, resetCalls } from 'ts-mockito'; import { CancellationToken, Disposable, NotebookDocument, ProgressOptions, Uri } from 'vscode'; import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { IDeepnoteEnvironmentManager, IDeepnoteKernelAutoSelector, IDeepnoteNotebookEnvironmentMapper } from '../types'; +import { + IDeepnoteEnvironmentManager, + IDeepnoteKernelAutoSelector, + IDeepnoteNotebookEnvironmentMapper, + IDeepnoteServerStarter +} from '../types'; import { IPythonApiProvider } from '../../../platform/api/types'; import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; import { IKernelProvider } from '../../../kernels/types'; @@ -25,6 +30,7 @@ suite('DeepnoteEnvironmentsView', () => { let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; let mockKernelProvider: IKernelProvider; let mockOutputChannel: IOutputChannel; + let mockServerStarter: IDeepnoteServerStarter; let disposables: Disposable[] = []; let pythonEnvironments: PythonExtension['environments']; @@ -43,6 +49,10 @@ suite('DeepnoteEnvironmentsView', () => { mockNotebookEnvironmentMapper = mock(); mockKernelProvider = mock(); mockOutputChannel = mock(); + mockServerStarter = mock(); + + // stopServer is a safe no-op when a notebook has no running server + when(mockServerStarter.stopServer(anything(), anything())).thenResolve(); // Mock onDidChangeEnvironments to return a disposable event when(mockConfigManager.onDidChangeEnvironments).thenReturn((_listener: () => void) => { @@ -61,7 +71,8 @@ suite('DeepnoteEnvironmentsView', () => { instance(mockKernelAutoSelector), instance(mockNotebookEnvironmentMapper), instance(mockKernelProvider), - instance(mockOutputChannel) + instance(mockOutputChannel), + instance(mockServerStarter) ); }); @@ -744,6 +755,148 @@ suite('DeepnoteEnvironmentsView', () => { }); }); + suite('deleteEnvironmentCommand - stop servers before removing mappings (the load-bearing fix)', () => { + const envId = 'env-to-delete'; + const testInterpreter: PythonEnvironment = { + id: 'test-python-id', + uri: Uri.file('/usr/bin/python3.11'), + version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } + } as PythonEnvironment; + + const environment: DeepnoteEnvironment = { + id: envId, + name: 'Environment to Delete', + pythonInterpreter: testInterpreter, + venvPath: Uri.file('/path/to/venv'), + managedVenv: true, + createdAt: new Date(), + lastUsedAt: new Date() + }; + + // One notebook is OPEN; the other is CLOSED but its server is still running. + const openNotebookUri = Uri.file('/workspace/open.deepnote'); + const closedNotebookUri = Uri.file('/workspace/closed-but-running.deepnote'); + + setup(() => { + resetCalls(mockConfigManager); + resetCalls(mockNotebookEnvironmentMapper); + resetCalls(mockServerStarter); + resetCalls(mockedVSCodeNamespaces.window); + resetCalls(mockedVSCodeNamespaces.workspace); + }); + + // Wire a shared, ordered call log so we can assert that every stopServer happens BEFORE + // any removeEnvironmentForNotebook, and both happen before deleteEnvironment. + const wireOrderedDeletion = (callLog: string[], options?: { stopRejectsFor?: Uri }): void => { + when(mockConfigManager.getEnvironment(envId)).thenReturn(environment); + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(envId)).thenReturn([ + openNotebookUri, + closedNotebookUri + ]); + + // Only the OPEN notebook is present in workspace.notebookDocuments — the other is closed. + const openNotebookDoc = { + uri: openNotebookUri, + notebookType: 'deepnote', + isClosed: false + } as any; + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([openNotebookDoc]); + when(mockKernelProvider.get(openNotebookDoc)).thenReturn(undefined); + + when(mockServerStarter.stopServer(anything(), anything())).thenCall((uri: Uri) => { + callLog.push(`stop:${uri.toString()}`); + if (options?.stopRejectsFor && uri.toString() === options.stopRejectsFor.toString()) { + return Promise.reject(new Error('stop failed')); + } + return Promise.resolve(); + }); + when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { + callLog.push(`remove:${uri.toString()}`); + return Promise.resolve(); + }); + when(mockConfigManager.deleteEnvironment(envId, anything())).thenCall(() => { + callLog.push('deleteEnvironment'); + return Promise.resolve(); + }); + + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( + (_options: ProgressOptions, callback: Function) => { + const mockProgress = { report: () => undefined }; + const mockToken: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => undefined }) + }; + return callback(mockProgress, mockToken); + } + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); + }; + + test('stops BOTH notebooks (open + closed-but-running) via getNotebooksUsingEnvironment (catches "never stops closed servers")', async () => { + const callLog: string[] = []; + wireOrderedDeletion(callLog); + + await view.deleteEnvironmentCommand(envId); + + // Servers are actually STOPPED (stopServer invoked) for both URIs — not merely controllers disposed. + verify(mockServerStarter.stopServer(openNotebookUri, anything())).once(); + verify(mockServerStarter.stopServer(closedNotebookUri, anything())).once(); + assert.include(callLog, `stop:${openNotebookUri.toString()}`); + assert.include(callLog, `stop:${closedNotebookUri.toString()}`); + }); + + test('every stopServer precedes every removeEnvironmentForNotebook, and both precede deleteEnvironment (catches inverted order)', async () => { + const callLog: string[] = []; + wireOrderedDeletion(callLog); + + await view.deleteEnvironmentCommand(envId); + + const stopIndexes = callLog.map((entry, i) => (entry.startsWith('stop:') ? i : -1)).filter((i) => i >= 0); + const removeIndexes = callLog + .map((entry, i) => (entry.startsWith('remove:') ? i : -1)) + .filter((i) => i >= 0); + const deleteIndex = callLog.indexOf('deleteEnvironment'); + + assert.strictEqual(stopIndexes.length, 2, 'both servers stopped'); + assert.strictEqual(removeIndexes.length, 2, 'both mappings removed'); + assert.isAtLeast(deleteIndex, 0, 'environment deleted'); + + const lastStop = Math.max(...stopIndexes); + const firstRemove = Math.min(...removeIndexes); + const lastRemove = Math.max(...removeIndexes); + + assert.isBelow( + lastStop, + firstRemove, + 'all stopServer calls must come BEFORE any removeEnvironmentForNotebook (else the mapper list would already be empty when stopping)' + ); + assert.isBelow(lastRemove, deleteIndex, 'mappings must be removed before the environment is deleted'); + }); + + test('a stopServer rejection for one URI does NOT abort the deletion — the other still stops and deletion proceeds (per-iteration try/catch)', async () => { + const callLog: string[] = []; + wireOrderedDeletion(callLog, { stopRejectsFor: openNotebookUri }); + + await view.deleteEnvironmentCommand(envId); + + // The failing stop was attempted, the other still ran, and deletion completed. + verify(mockServerStarter.stopServer(openNotebookUri, anything())).once(); + verify(mockServerStarter.stopServer(closedNotebookUri, anything())).once(); + assert.include( + callLog, + `stop:${closedNotebookUri.toString()}`, + 'second server still stopped after first threw' + ); + verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(openNotebookUri)).once(); + verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(closedNotebookUri)).once(); + verify(mockConfigManager.deleteEnvironment(envId, anything())).once(); + assert.include(callLog, 'deleteEnvironment', 'environment deletion still proceeds after a stop failure'); + }); + }); + suite('selectEnvironmentForNotebook', () => { const testInterpreter1: PythonEnvironment = { id: 'python-1', diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts index 20a39162c0..4f2038d8e1 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts @@ -116,7 +116,7 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS } try { - const notebookUri = doc.uri.with({ query: '', fragment: '' }); + const notebookUri = doc.uri; const projectId = doc.metadata?.deepnoteProjectId as string | undefined; if (!projectId) { return; @@ -335,8 +335,7 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS private resolveProjectId(notebookUri: Uri): string | undefined { const doc = workspace.notebookDocuments.find( - (d) => - d.notebookType === 'deepnote' && d.uri.with({ query: '', fragment: '' }).fsPath === notebookUri.fsPath + (d) => d.notebookType === 'deepnote' && d.uri.fsPath === notebookUri.fsPath ); return doc?.metadata?.deepnoteProjectId as string | undefined; } diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index a0c17a31ba..f1a8c89121 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -151,13 +151,14 @@ export interface IDeepnoteToolkitInstaller { export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter'); export interface IDeepnoteServerStarter { /** - * Starts a deepnote-toolkit Jupyter server for a kernel environment. - * Environment-based method. + * Starts a deepnote-toolkit Jupyter server for a notebook. + * The server is keyed by the notebook URI, so each notebook gets its own server. * @param interpreter The Python interpreter to use * @param venvPath The path to the venv * @param managedVenv Whether the venv is managed by this extension (created by us) * @param environmentId The environment ID (for server management) - * @param deepnoteFileUri The URI of the .deepnote file + * @param notebookUri The URI of the notebook (used both as the server key and to derive + * the working directory and SQL integration environment) * @param token Cancellation token to cancel the operation * @returns Connection information (URL, port, etc.) */ @@ -167,17 +168,17 @@ export interface IDeepnoteServerStarter { managedVenv: boolean, additionalPackages: string[], environmentId: string, - deepnoteFileUri: vscode.Uri, + notebookUri: vscode.Uri, token?: vscode.CancellationToken ): Promise; /** - * Stops the deepnote-toolkit server for a kernel environment. - * @param environmentId The environment ID + * Stops the deepnote-toolkit server for a notebook. + * Safe no-op when the notebook has no running server. + * @param notebookUri The URI of the notebook * @param token Cancellation token to cancel the operation */ - // stopServer(environmentId: string, token?: vscode.CancellationToken): Promise; - stopServer(deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken): Promise; + stopServer(notebookUri: vscode.Uri, token?: vscode.CancellationToken): Promise; /** * Disposes all server processes and resources. diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index a0205555a4..ec1a57a1b3 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -69,15 +69,15 @@ const NOTEBOOK_EDITOR_RETRY_DELAY_MS = 100; */ @injectable() export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, IExtensionSyncActivationService { - // Track connection metadata per NOTEBOOK for reuse + // Track connection metadata per NOTEBOOK (keyed by notebook.uri.toString()) for reuse private readonly notebookConnectionMetadata = new Map(); - // Track registered controllers per NOTEBOOK (full URI with query) - one controller per notebook + // Track registered controllers per NOTEBOOK (keyed by notebook.uri.toString()) - one controller per notebook private readonly notebookControllers = new Map(); - // Track environment for each notebook + // Track environment for each notebook (keyed by notebook.uri.toString()) private readonly notebookEnvironmentsIds = new Map(); // Track per-notebook placeholder controllers for notebooks without configured environments private readonly placeholderControllers = new Map(); - // Track server handles per PROJECT (baseFileUri) - one server per project + // Track server handles per NOTEBOOK (keyed by notebook.uri.toString()) - one server per notebook private readonly projectServerHandles = new Map(); // Track projects where we need to run init notebook (set during controller setup) private readonly projectsPendingInitNotebook = new Map< @@ -280,8 +280,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } - // const baseFileUri = event.notebook.uri.with({ query: '', fragment: '' }); - // const notebookKey = baseFileUri.fsPath; + // const notebookKey = event.notebook.uri.toString(); // // If the Deepnote controller for this notebook was deselected, try to reselect it // // Since controllers are now protected from disposal, this should rarely happen @@ -389,9 +388,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress: { report(value: { message?: string; increment?: number }): void }, token: CancellationToken ): Promise { - const baseFileUri = notebook.uri.with({ query: '', fragment: '' }); const notebookKey = notebook.uri.toString(); - const projectKey = baseFileUri.fsPath; logger.info(`Switching controller environment for ${getDisplayPath(notebook.uri)}`); @@ -411,10 +408,10 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.notebookConnectionMetadata.delete(notebookKey); // Clear old server handle - new environment will register a new handle - const oldServerHandle = this.projectServerHandles.get(projectKey); + const oldServerHandle = this.projectServerHandles.get(notebookKey); if (oldServerHandle) { logger.info(`Clearing old server handle from tracking: ${oldServerHandle}`); - this.projectServerHandles.delete(projectKey); + this.projectServerHandles.delete(notebookKey); } // Stop existing LSP clients so new ones can be created with fresh environment @@ -425,24 +422,16 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Update the controller with new environment's metadata // Because we use notebook-based controller IDs, addOrUpdate will call updateConnection() // on the existing controller instead of creating a new one - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); + const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); const environment = environmentId ? this.environmentManager.getEnvironment(environmentId) : undefined; if (environment == null) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); + await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); logger.error(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); return; } - await this.ensureKernelSelectedWithConfiguration( - notebook, - environment, - baseFileUri, - notebookKey, - projectKey, - progress, - token - ); + await this.ensureKernelSelectedWithConfiguration(notebook, environment, notebookKey, progress, token); logger.info(`Controller successfully switched to new environment`); } @@ -452,14 +441,10 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress: { report(value: { message?: string; increment?: number }): void }, token: CancellationToken ): Promise { - // baseFileUri identifies the PROJECT (without query/fragment) - const baseFileUri = notebook.uri.with({ query: '', fragment: '' }); - // notebookKey uniquely identifies THIS NOTEBOOK (includes query with notebook ID) + // notebookKey uniquely identifies THIS NOTEBOOK - the same identity the controller/server use const notebookKey = notebook.uri.toString(); - // projectKey identifies the PROJECT for server tracking - const projectKey = baseFileUri.fsPath; - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); + const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); if (environmentId == null) { await this.selectPlaceholderController(notebook); @@ -471,21 +456,13 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, if (environment == null) { logger.info(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); + await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); await this.selectPlaceholderController(notebook); return false; } - await this.ensureKernelSelectedWithConfiguration( - notebook, - environment, - baseFileUri, - notebookKey, - projectKey, - progress, - token - ); + await this.ensureKernelSelectedWithConfiguration(notebook, environment, notebookKey, progress, token); return true; } @@ -493,9 +470,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, public async ensureKernelSelectedWithConfiguration( notebook: NotebookDocument, configuration: DeepnoteEnvironment, - baseFileUri: Uri, notebookKey: string, - projectKey: string, progress: { report(value: { message?: string; increment?: number }): void }, progressToken: CancellationToken ): Promise { @@ -553,7 +528,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, configuration.managedVenv, configuration.packages ?? [], configuration.id, - baseFileUri, + notebook.uri, progressToken ); @@ -568,12 +543,12 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const serverProviderHandle: JupyterServerProviderHandle = { extensionId: JVSC_EXTENSION_ID, id: 'deepnote-server', - handle: createDeepnoteServerConfigHandle(configuration.id, baseFileUri) + handle: createDeepnoteServerConfigHandle(configuration.id, notebook.uri) }; - // Register the server with the provider (one server per PROJECT) + // Register the server with the provider (one server per NOTEBOOK) this.serverProvider.registerServer(serverProviderHandle.handle, serverInfo); - this.projectServerHandles.set(projectKey, serverProviderHandle.handle); + this.projectServerHandles.set(notebookKey, serverProviderHandle.handle); const lspInterpreterUri = this.getVenvInterpreterUri(configuration.venvPath); @@ -603,7 +578,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.requestCreator, this.requestAgentCreator, this.configService, - baseFileUri + notebook.uri ); const sessionManager = JupyterLabHelper.create(connectionInfo.settings); @@ -641,7 +616,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, kernelSpec, baseUrl: serverInfo.url, id: controllerId, - projectFilePath: baseFileUri.toString(), + projectFilePath: notebook.uri.toString(), serverProviderHandle, serverInfo, environmentName: configuration.name, @@ -791,15 +766,13 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, ): Promise { Cancellation.throwIfCanceled(token); - const baseFileUri = notebook.uri.with({ query: '', fragment: '' }); const notebookKey = notebook.uri.toString(); - const projectKey = baseFileUri.fsPath; - const existingEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); + const existingEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); // No environment configured - need to pick one if (!existingEnvironmentId) { - return this.pickAndSetupEnvironment(notebook, baseFileUri, notebookKey, projectKey, token); + return this.pickAndSetupEnvironment(notebook, notebookKey, token); } const environment = this.environmentManager.getEnvironment(existingEnvironmentId); @@ -807,9 +780,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Environment no longer exists - remove stale mapping and pick a new one if (!environment) { logger.info(`Removing stale environment mapping for ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); + await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(notebook.uri); - return this.pickAndSetupEnvironment(notebook, baseFileUri, notebookKey, projectKey, token); + return this.pickAndSetupEnvironment(notebook, notebookKey, token); } const existingController = this.notebookControllers.get(notebookKey); @@ -825,14 +798,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, existingController.dispose(); this.notebookControllers.delete(notebookKey); - return this.setupKernelForEnvironment( - notebook, - environment, - baseFileUri, - notebookKey, - projectKey, - token - ); + return this.setupKernelForEnvironment(notebook, environment, notebookKey, token); } logger.info(`Environment "${environment.name}" already configured for ${getDisplayPath(notebook.uri)}`); @@ -847,7 +813,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, )}, triggering setup` ); - return this.setupKernelForEnvironment(notebook, environment, baseFileUri, notebookKey, projectKey, token); + return this.setupKernelForEnvironment(notebook, environment, notebookKey, token); } /** @@ -855,9 +821,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, */ private async pickAndSetupEnvironment( notebook: NotebookDocument, - baseFileUri: Uri, notebookKey: string, - projectKey: string, token: CancellationToken ): Promise { Cancellation.throwIfCanceled(token); @@ -873,16 +837,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, Cancellation.throwIfCanceled(token); - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, selectedEnvironment.id); + await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironment.id); - const result = await this.setupKernelForEnvironment( - notebook, - selectedEnvironment, - baseFileUri, - notebookKey, - projectKey, - token - ); + const result = await this.setupKernelForEnvironment(notebook, selectedEnvironment, notebookKey, token); if (result) { logger.info(`Environment "${selectedEnvironment.name}" configured for ${getDisplayPath(notebook.uri)}`); @@ -897,9 +854,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private async setupKernelForEnvironment( notebook: NotebookDocument, environment: DeepnoteEnvironment, - baseFileUri: Uri, notebookKey: string, - projectKey: string, token: CancellationToken ): Promise { try { @@ -913,9 +868,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, await this.ensureKernelSelectedWithConfiguration( notebook, environment, - baseFileUri, notebookKey, - projectKey, progress, progressToken ); diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index 824635343c..f682816f65 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -288,7 +288,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { 'ensureKernelSelected should be called with the notebook' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[6], + ensureKernelSelectedWithConfigurationStub.firstCall.args[4], instance(mockCancellationToken), 'ensureKernelSelected should be called with the cancellation token' ); @@ -410,9 +410,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { test('should return true and call ensureKernelSelectedWithConfiguration when environment is found', async () => { // Arrange - const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); const notebookKey = mockNotebook.uri.toString(); - const projectKey = baseFileUri.fsPath; const environmentId = 'test-env-id'; const mockEnvironment = createMockEnvironment(environmentId, 'Test Environment'); @@ -447,11 +445,9 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { const callArgs = ensureKernelSelectedStub.firstCall.args; assert.strictEqual(callArgs[0], mockNotebook, 'First arg should be notebook'); assert.strictEqual(callArgs[1], mockEnvironment, 'Second arg should be environment'); - assert.strictEqual(callArgs[2].toString(), baseFileUri.toString(), 'Third arg should be baseFileUri'); - assert.strictEqual(callArgs[3], notebookKey, 'Fourth arg should be notebookKey'); - assert.strictEqual(callArgs[4], projectKey, 'Fifth arg should be projectKey'); - assert.strictEqual(callArgs[5], mockProgress, 'Sixth arg should be progress'); - assert.strictEqual(callArgs[6], instance(mockCancellationToken), 'Seventh arg should be token'); + assert.strictEqual(callArgs[2], notebookKey, 'Third arg should be notebookKey'); + assert.strictEqual(callArgs[3], mockProgress, 'Fourth arg should be progress'); + assert.strictEqual(callArgs[4], instance(mockCancellationToken), 'Fifth arg should be token'); verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); verify(mockEnvironmentManager.getEnvironment(environmentId)).once(); diff --git a/src/notebooks/deepnote/deepnoteKernelStatusIndicator.node.ts b/src/notebooks/deepnote/deepnoteKernelStatusIndicator.node.ts index af1586f136..cca297c06d 100644 --- a/src/notebooks/deepnote/deepnoteKernelStatusIndicator.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelStatusIndicator.node.ts @@ -46,9 +46,8 @@ export interface IDeepnoteKernelStatusService { } function normalizeNotebookBaseUri(uri: Uri): string { - const normalized = uri.with({ query: '', fragment: '' }); // toString(true) keeps the URI unencoded, matching how fsPath-based keys are generated elsewhere - return normalized.toString(true); + return uri.toString(true); } export function getNotebookStatusKeyFromNotebook(notebook: NotebookDocument): string { @@ -283,8 +282,7 @@ export class DeepnoteKernelStatusIndicator return undefined; } - const baseUri = notebook.uri.with({ query: '', fragment: '' }); - const environmentId = this.environmentMapper.getEnvironmentForNotebook(baseUri); + const environmentId = this.environmentMapper.getEnvironmentForNotebook(notebook.uri); const environmentName = environmentId ? this.environmentManager.getEnvironment(environmentId)?.name : undefined; const connection = controller.connection; diff --git a/src/notebooks/deepnote/deepnoteKernelStatusIndicator.ts b/src/notebooks/deepnote/deepnoteKernelStatusIndicator.ts index 6bc389b9dd..686375c5dd 100644 --- a/src/notebooks/deepnote/deepnoteKernelStatusIndicator.ts +++ b/src/notebooks/deepnote/deepnoteKernelStatusIndicator.ts @@ -48,9 +48,8 @@ export interface IDeepnoteKernelStatusService { } function normalizeNotebookBaseUri(uri: Uri): string { - const normalized = uri.with({ query: '', fragment: '' }); // toString(true) keeps the URI unencoded, matching how fsPath-based keys are generated elsewhere - return normalized.toString(true); + return uri.toString(true); } export function getNotebookStatusKeyFromNotebook(notebook: NotebookDocument): string { @@ -290,8 +289,7 @@ export class DeepnoteKernelStatusIndicator return undefined; } - const baseUri = notebook.uri.with({ query: '', fragment: '' }); - const environmentId = this.environmentMapper.getEnvironmentForNotebook(baseUri); + const environmentId = this.environmentMapper.getEnvironmentForNotebook(notebook.uri); const environmentName = environmentId ? this.environmentManager.getEnvironment(environmentId)?.name : undefined; const connection = controller.connection; diff --git a/src/platform/deepnote/deepnoteServerUtils.node.ts b/src/platform/deepnote/deepnoteServerUtils.node.ts index e8fea3659e..62c839e7ce 100644 --- a/src/platform/deepnote/deepnoteServerUtils.node.ts +++ b/src/platform/deepnote/deepnoteServerUtils.node.ts @@ -1,5 +1,5 @@ import { Uri } from 'vscode'; -export function createDeepnoteServerConfigHandle(environmentId: string, deepnoteFileUri: Uri): string { - return `deepnote-config-server-${environmentId}-${deepnoteFileUri.fsPath}`; +export function createDeepnoteServerConfigHandle(environmentId: string, notebookUri: Uri): string { + return `deepnote-config-server-${environmentId}-${notebookUri.toString()}`; } diff --git a/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts b/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts new file mode 100644 index 0000000000..f4bc2825f6 --- /dev/null +++ b/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts @@ -0,0 +1,56 @@ +import { assert } from 'chai'; +import { Uri } from 'vscode'; + +import { createDeepnoteServerConfigHandle } from './deepnoteServerUtils.node'; + +/** + * Unit tests for createDeepnoteServerConfigHandle. + * + * The handle is the producer/consumer match invariant for per-notebook servers: the kernel + * selector PRODUCES it and two consumers (clearControllerForEnvironment, + * disposeKernelsUsingEnvironment) COMPARE against `serverProviderHandle.handle`. If the formula + * or its inputs ever drift between the producer and a consumer, the compared handle no longer + * matches and the deletion/clear silently fails. These tests pin the exact format and the + * per-notebook uniqueness/byte-stability the contract relies on. + */ +suite('DeepnoteServerUtils - createDeepnoteServerConfigHandle', () => { + test('returns deepnote-config-server-${environmentId}-${notebookUri.toString()} (catches handle-format drift)', () => { + const uri = Uri.file('/workspace/project/notebook.deepnote'); + + assert.strictEqual( + createDeepnoteServerConfigHandle('env-123', uri), + `deepnote-config-server-env-123-${uri.toString()}` + ); + }); + + test('two different notebook URIs produce DIFFERENT handles (catches sibling collision)', () => { + const uriA = Uri.file('/workspace/project/notebook-a.deepnote'); + const uriB = Uri.file('/workspace/project/notebook-b.deepnote'); + + assert.notStrictEqual( + createDeepnoteServerConfigHandle('env-1', uriA), + createDeepnoteServerConfigHandle('env-1', uriB), + 'sibling notebooks sharing one environment must still get distinct server handles' + ); + }); + + test('same env + same URI yields a BYTE-IDENTICAL handle (producer/consumer match invariant)', () => { + const uri = Uri.file('/workspace/project/notebook.deepnote'); + + // Build two Uri instances for the same path to mimic producer vs. consumer building it + // independently from `notebook.uri`. + const produced = createDeepnoteServerConfigHandle('env-9', uri); + const compared = createDeepnoteServerConfigHandle('env-9', Uri.file('/workspace/project/notebook.deepnote')); + + assert.strictEqual(produced, compared, 'the produced and compared handle must be byte-for-byte identical'); + }); + + test('different environmentId for the same notebook produces different handles', () => { + const uri = Uri.file('/workspace/project/notebook.deepnote'); + + assert.notStrictEqual( + createDeepnoteServerConfigHandle('env-1', uri), + createDeepnoteServerConfigHandle('env-2', uri) + ); + }); +}); From 83d4e913fe4321bda3361ade3521a0516ac47cbd Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 01:32:21 +0000 Subject: [PATCH 07/26] feat(deepnote): scope snapshots per notebook with a backward-compatible reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 7a of single-notebook migration (§9). - Write snapshots with notebook-scoped filenames via @deepnote/convert (generateSnapshotFilename / parseSnapshotFilename), replacing the local slug and filename regex. - readSnapshot resolves snapshots path-free (it runs at deserialize, which has no URI): glob by project id, rank the notebook-scoped match first and keep legacy project-scoped snapshots as a fallback, and skip an empty-output "latest" (save race) or a corrupt file while walking candidates. Legacy snapshots are read, never migrated or deleted. - Defer the execution snapshot save until outputs settle (quiet window with a max wait) and cancel it on re-execute / close. - Use convert's computeSnapshotHash on the save path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- build/mocha-esm-loader.js | 140 ++++++ .../deepnote/deepnoteFileChangeWatcher.ts | 3 +- .../deepnoteFileChangeWatcher.unit.test.ts | 101 ++-- src/notebooks/deepnote/deepnoteSerializer.ts | 46 +- .../deepnote/snapshots/snapshotFiles.ts | 60 ++- .../snapshots/snapshotFiles.unit.test.ts | 140 ++++-- .../deepnote/snapshots/snapshotService.ts | 372 +++++++++++---- .../snapshots/snapshotService.unit.test.ts | 436 ++++++++++++++++-- 8 files changed, 1053 insertions(+), 245 deletions(-) diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index ae667068d1..b45dad110c 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -369,6 +369,8 @@ export async function load(url, context, nextLoad) { return { format: 'module', source: ` + import { createHash } from 'node:crypto'; + export const convertIpynbFilesToDeepnoteFile = async () => { // Mock implementation - does nothing in tests }; @@ -439,6 +441,144 @@ export async function load(url, context, nextLoad) { outputFilename: allocate(slugifyProjectName(nb.name)) })); }; + + // --- Snapshot filename helpers (faithful to @deepnote/convert dist) --- + + const FILENAME_SAFE_NOTEBOOK_ID_CHAR = /[A-Za-z0-9_-]/; + const utf8Encoder = new TextEncoder(); + const utf8Decoder = new TextDecoder(); + + const sanitizeFilenameComponent = (value) => value.replace(/[^a-zA-Z0-9_-]/g, '_'); + + export const encodeNotebookIdForFilename = (notebookId) => { + let encoded = ''; + for (const char of notebookId) { + if (FILENAME_SAFE_NOTEBOOK_ID_CHAR.test(char)) { + encoded += char; + continue; + } + for (const byte of utf8Encoder.encode(char)) { + encoded += '%' + byte.toString(16).toUpperCase().padStart(2, '0'); + } + } + return encoded; + }; + + export const decodeNotebookIdFromFilename = (encoded) => { + const bytes = []; + let i = 0; + while (i < encoded.length) { + const hex = encoded.slice(i + 1, i + 3); + if (encoded[i] === '%' && /^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(Number.parseInt(hex, 16)); + i += 3; + } else { + bytes.push(encoded.charCodeAt(i)); + i += 1; + } + } + return utf8Decoder.decode(new Uint8Array(bytes)); + }; + + export const generateSnapshotFilename = (params) => { + const { slug, projectId, notebookId, timestamp = 'latest' } = params; + const safeSlug = sanitizeFilenameComponent(slug); + const safeProjectId = sanitizeFilenameComponent(projectId); + if (notebookId) { + return safeSlug + '_' + safeProjectId + '_' + encodeNotebookIdForFilename(notebookId) + '_' + timestamp + '.snapshot.deepnote'; + } + return safeSlug + '_' + safeProjectId + '_' + timestamp + '.snapshot.deepnote'; + }; + + const SNAPSHOT_SINGLE_NOTEBOOK_FILENAME_PATTERN = new RegExp('^(.+)_([0-9a-f-]{36})_([0-9a-f]{32}|[0-9a-f-]{36}|[A-Za-z0-9%][A-Za-z0-9_%-]*)_(latest|[\\\\dT:-]+)\\\\.snapshot\\\\.deepnote$'); + const SNAPSHOT_MULTI_NOTEBOOK_FILENAME_PATTERN = new RegExp('^(.+)_([0-9a-f-]{36})_(latest|[\\\\dT:-]+)\\\\.snapshot\\\\.deepnote$'); + + export const parseSnapshotFilename = (filename) => { + const match = SNAPSHOT_SINGLE_NOTEBOOK_FILENAME_PATTERN.exec(filename); + if (match) { + return { + slug: match[1], + projectId: match[2], + notebookId: decodeNotebookIdFromFilename(match[3]), + timestamp: match[4] + }; + } + const legacy = SNAPSHOT_MULTI_NOTEBOOK_FILENAME_PATTERN.exec(filename); + if (!legacy) { + return null; + } + return { + slug: legacy[1], + projectId: legacy[2], + timestamp: legacy[3] + }; + }; + + export const resolveSnapshotNotebookId = (file) => { + const project = file?.project || {}; + const notebooks = project.notebooks || []; + const initNotebookId = project.initNotebookId; + if (notebooks.length === 1) { + return notebooks[0].id; + } + if (notebooks.length === 2 && initNotebookId !== undefined) { + const initNotebook = notebooks.find((nb) => nb.id === initNotebookId); + const nonInit = notebooks.find((nb) => nb.id !== initNotebookId); + if (initNotebook !== undefined && nonInit !== undefined) { + return nonInit.id; + } + } + return undefined; + }; + + // --- Snapshot output/hash helpers (faithful to @deepnote/convert dist) --- + + const EXECUTABLE_BLOCK_TYPES = new Set(['code', 'sql', 'chart', 'input', 'button']); + const isExecutableBlockTypeMock = (type) => + EXECUTABLE_BLOCK_TYPES.has(type) || (typeof type === 'string' && type.startsWith('sql')); + + export const hasOutputs = (file) => { + for (const notebook of file?.project?.notebooks || []) { + for (const block of notebook.blocks || []) { + if (!isExecutableBlockTypeMock(block.type)) continue; + if (block.outputs && block.outputs.length > 0) return true; + } + } + return false; + }; + + export const countBlocksWithOutputs = (file) => { + let count = 0; + for (const notebook of file?.project?.notebooks || []) { + for (const block of notebook.blocks || []) { + if (block.outputs && block.outputs.length > 0) count++; + } + } + return count; + }; + + export const computeSnapshotHash = (file) => { + const parts = []; + parts.push('version:' + file.version); + if (file.environment && file.environment.hash) { + parts.push('env:' + file.environment.hash); + } + const sortedIntegrations = [...(file.project.integrations || [])].sort((a, b) => + a.id.localeCompare(b.id) + ); + for (const integration of sortedIntegrations) { + parts.push('integration:' + integration.id + ':' + integration.type); + } + for (const notebook of file.project.notebooks) { + for (const block of notebook.blocks) { + if (block.contentHash) { + parts.push('block:' + block.id + ':' + block.contentHash); + } + } + } + const combined = parts.join('\\n'); + return 'sha256:' + createHash('sha256').update(combined, 'utf-8').digest('hex'); + }; `, shortCircuit: true }; diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index f4f36372d4..cbeee3a8a2 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -353,7 +353,8 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic return; } - const snapshotOutputs = await this.snapshotService.readSnapshot(projectId); + const notebookIdForSnapshot = notebook.metadata?.deepnoteNotebookId as string | undefined; + const snapshotOutputs = await this.snapshotService.readSnapshot(projectId, notebookIdForSnapshot); if (!snapshotOutputs || snapshotOutputs.size === 0) { return; } diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index 562374185a..c3f8cef18f 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -114,7 +114,7 @@ suite('DeepnoteFileChangeWatcher', () => { notebookType: opts.notebookType ?? 'deepnote', cellCount: opts.cellCount ?? (cells.length || 1), metadata: opts.metadata ?? { - deepnoteProjectId: 'project-1', + deepnoteProjectId: 'e132b172-b114-410e-8331-011517db664f', deepnoteNotebookId: 'notebook-1' }, getCells: () => cells @@ -137,7 +137,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: project-1 + id: e132b172-b114-410e-8331-011517db664f name: Test Project notebooks: - id: notebook-1 @@ -336,7 +336,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: project-1 + id: e132b172-b114-410e-8331-011517db664f name: Test Project notebooks: - id: notebook-1 @@ -408,7 +408,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: project-1 + id: e132b172-b114-410e-8331-011517db664f name: Test Project notebooks: - id: notebook-1 @@ -426,7 +426,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: project-1 + id: e132b172-b114-410e-8331-011517db664f name: Test Project notebooks: - id: n1 @@ -490,13 +490,13 @@ project: uri: baseUri.with({ query: 'notebook=n1' }), cellCount: 0, cells: [], - metadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'n1' } + metadata: { deepnoteProjectId: 'e132b172-b114-410e-8331-011517db664f', deepnoteNotebookId: 'n1' } }); const nb2 = createMockNotebook({ uri: baseUri.with({ query: 'notebook=n2' }), cellCount: 0, cells: [], - metadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'n2' } + metadata: { deepnoteProjectId: 'e132b172-b114-410e-8331-011517db664f', deepnoteNotebookId: 'n2' } }); // Genuine change: same two-notebook structure (so n1/n2 still resolve), but with @@ -506,7 +506,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: project-1 + id: e132b172-b114-410e-8331-011517db664f name: Test Project notebooks: - id: n1 @@ -639,7 +639,7 @@ project: mockSnapshotService = mock(); when(mockSnapshotService.isSnapshotsEnabled()).thenReturn(true); - when(mockSnapshotService.readSnapshot(anything())).thenCall(() => { + when(mockSnapshotService.readSnapshot(anything(), anything())).thenCall(() => { readSnapshotCallCount++; return Promise.resolve(snapshotOutputs); }); @@ -687,7 +687,9 @@ project: }); test('should update outputs when snapshot file changes', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -716,7 +718,9 @@ project: const noSnapshotWatcher = new DeepnoteFileChangeWatcher(noSnapshotDisposables, mockNotebookManager); noSnapshotWatcher.activate(); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); @@ -733,7 +737,9 @@ project: }); test('should skip self-triggered snapshot writes via onFileWritten', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [{ metadata: { id: 'block-1', type: 'code' }, outputs: [] }] @@ -756,7 +762,9 @@ project: test('should skip when snapshots are disabled', async () => { when(mockSnapshotService.isSnapshotsEnabled()).thenReturn(false); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); snapshotOnDidChange.fire(snapshotUri); @@ -767,9 +775,11 @@ project: test('should debounce rapid snapshot changes for same project', async () => { const snapshotUri1 = Uri.file( - '/workspace/snapshots/my-project_project-1_2025-01-15T10-31-48.snapshot.deepnote' + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_2025-01-15T10-31-48.snapshot.deepnote' + ); + const snapshotUri2 = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' ); - const snapshotUri2 = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -794,7 +804,9 @@ project: }); test('should handle onDidCreate for new snapshot files', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -818,7 +830,9 @@ project: }); test('should skip update when snapshot outputs match live state', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -864,7 +878,9 @@ project: }); test('should update outputs when content changed but count is the same', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const existingOutput = { items: [{ mime: 'text/plain', data: new Uint8Array([72]) }] }; const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), @@ -889,7 +905,9 @@ project: }); test('should skip main-file reload after snapshot update via self-write tracking', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebookUri = Uri.file('/workspace/test.deepnote'); const notebook = createMockNotebook({ uri: notebookUri, @@ -933,7 +951,9 @@ project: }); test('should use two-phase edit for snapshot updates (replaceCells + metadata restore)', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -961,7 +981,9 @@ project: }); test('should call workspace.save after snapshot fallback output update', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -985,7 +1007,9 @@ project: }); test('should preserve outputs for cells not covered by snapshot', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const existingOutput = { items: [{ mime: 'text/plain', data: new Uint8Array([72]) }] }; const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), @@ -1020,11 +1044,11 @@ project: test('should apply snapshot outputs using original blocks when metadata is lost', async () => { // Create a mock notebook manager that returns an original project const mockedManager = mock(); - when(mockedManager.getAnyProjectEntry('project-1')).thenReturn({ + when(mockedManager.getAnyProjectEntry('e132b172-b114-410e-8331-011517db664f')).thenReturn({ version: '1.0', metadata: { createdAt: '2025-01-01T00:00:00Z' }, project: { - id: 'project-1', + id: 'e132b172-b114-410e-8331-011517db664f', name: 'Test Project', notebooks: [ { @@ -1060,11 +1084,16 @@ project: ); fallbackWatcher.activate(); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); // Cell has NO id in metadata — simulates VS Code losing metadata after replaceCells const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), - metadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }, + metadata: { + deepnoteProjectId: 'e132b172-b114-410e-8331-011517db664f', + deepnoteNotebookId: 'notebook-1' + }, cells: [ { metadata: { type: 'code' }, // No id! @@ -1090,7 +1119,7 @@ project: ] ] ]); - when(mockSnapshotService.readSnapshot(anything())).thenReturn(Promise.resolve(newOutputs)); + when(mockSnapshotService.readSnapshot(anything(), anything())).thenReturn(Promise.resolve(newOutputs)); fallbackOnDidChange.fire(snapshotUri); @@ -1107,7 +1136,9 @@ project: }); test('should only update cells whose outputs changed (per-cell updates)', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); // Two cells: block-1 has no outputs (will get updated), block-2 already has matching outputs const outputItem = { @@ -1155,7 +1186,7 @@ project: ] ] ]); - when(mockSnapshotService.readSnapshot(anything())).thenCall(() => { + when(mockSnapshotService.readSnapshot(anything(), anything())).thenCall(() => { readSnapshotCallCount++; return Promise.resolve(multiOutputs); }); @@ -1227,7 +1258,9 @@ project: ); execWatcher.activate(); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -1260,7 +1293,9 @@ project: }); test('should not apply updates when cells have no block IDs and no fallback', async () => { - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ @@ -1316,7 +1351,9 @@ project: ); fbWatcher.activate(); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); + const snapshotUri = Uri.file( + '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' + ); const notebook = createMockNotebook({ uri: Uri.file('/workspace/test.deepnote'), cells: [ diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index 0cae4e8dd6..a817e3a1e5 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -1,5 +1,6 @@ import type { DeepnoteBlock, DeepnoteFile, DeepnoteSnapshot } from '@deepnote/blocks'; import { deserializeDeepnoteFile, isExecutableBlock, serializeDeepnoteSnapshot } from '@deepnote/blocks'; +import { computeSnapshotHash } from '@deepnote/convert'; import { inject, injectable, optional } from 'inversify'; import { workspace, type CancellationToken, type NotebookData, type NotebookSerializer } from 'vscode'; @@ -125,7 +126,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { if (this.snapshotService?.isSnapshotsEnabled()) { logger.debug(`[Snapshot] Snapshots enabled, reading snapshot for project ${projectId}`); try { - const snapshotOutputs = await this.snapshotService.readSnapshot(projectId); + const snapshotOutputs = await this.snapshotService.readSnapshot(projectId, selectedNotebook.id); if (snapshotOutputs && snapshotOutputs.size > 0) { logger.debug(`[Snapshot] Merging ${snapshotOutputs.size} block outputs from snapshot`); @@ -286,10 +287,10 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { logger.debug('SerializeNotebook: Cloned blocks, computing snapshotHash'); - // Compute snapshot hash from all execution-affecting factors - (originalProject.metadata as { snapshotHash?: string }).snapshotHash = await this.computeSnapshotHash( - originalProject - ); + // Compute snapshot hash from all execution-affecting factors. convert's computeSnapshotHash + // is synchronous; a one-time hash-value change vs the prior local impl is acceptable (the + // field is stripped on serialize and recomputed each save). + (originalProject.metadata as { snapshotHash?: string }).snapshotHash = computeSnapshotHash(originalProject); // Update modifiedAt conditionally based on snapshot mode if (this.snapshotService?.isSnapshotsEnabled()) { @@ -418,41 +419,6 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { } } - /** - * Computes a deterministic hash of all factors that affect notebook execution and outputs. - * Includes contentHashes from all blocks, environment hash, version, and integrations. - * Excludes temporal fields to ensure identical snapshots produce identical hashes. - */ - private async computeSnapshotHash(project: DeepnoteFile): Promise { - // Collect all block contentHashes (sorted for determinism) - const contentHashes: string[] = []; - - for (const notebook of project.project.notebooks) { - for (const block of notebook.blocks ?? []) { - if (block.contentHash) { - contentHashes.push(block.contentHash); - } - } - } - - contentHashes.sort(); - - // Build deterministic hash input - const hashInput = { - contentHashes, - environmentHash: project.environment?.hash ?? null, - integrations: (project.project.integrations ?? []) - .map((i) => ({ id: i.id, name: i.name, type: i.type })) - .sort((a, b) => a.id.localeCompare(b.id)), - version: project.version - }; - - const hashData = JSON.stringify(hashInput); - const hash = await computeHash(hashData, 'SHA-256'); - - return `sha256:${hash}`; - } - /** * Detects whether actual content has changed between two project versions. * A .deepnote file holds a single notebook, so this compares that one notebook's diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.ts index 6f6ff9f5fd..381a038390 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.ts @@ -1,12 +1,30 @@ +import { + decodeNotebookIdFromFilename, + encodeNotebookIdForFilename, + generateSnapshotFilename, + parseSnapshotFilename, + resolveSnapshotNotebookId, + slugifyProjectName +} from '@deepnote/convert'; import { Uri } from 'vscode'; -import { InvalidProjectNameError } from '../../../platform/errors/invalidProjectNameError'; - /** File suffix for snapshot files */ export const SNAPSHOT_FILE_SUFFIX = '.snapshot.deepnote'; -/** Regex pattern for extracting project ID from snapshot filenames. */ -const SNAPSHOT_FILENAME_PATTERN = new RegExp(`^[a-z0-9-]+_(.+)_[^_]+${SNAPSHOT_FILE_SUFFIX.replace(/\./g, '\\.')}$`); +/** + * Re-export the snapshot filename helpers from `@deepnote/convert` so the snapshot service + * and tests share a single, CLI-compatible implementation of the filename grammar (which + * percent-encodes the notebook id and NFD-normalizes accents). The local hand-written + * regex/slugify previously diverged from the CLI and is gone. + */ +export { + decodeNotebookIdFromFilename, + encodeNotebookIdForFilename, + generateSnapshotFilename, + parseSnapshotFilename, + resolveSnapshotNotebookId, + slugifyProjectName +}; /** * Checks if a URI represents a snapshot file @@ -17,36 +35,16 @@ export function isSnapshotFile(uri: Uri): boolean { /** * Extracts the project ID from a snapshot file URI. - * Snapshot filenames follow: `${slug}_${projectId}_${variant}.snapshot.deepnote` + * + * Snapshot filenames follow the notebook-scoped form + * `${slug}_${projectId}_${encodedNotebookId}_${variant}.snapshot.deepnote` or the legacy + * project-scoped form `${slug}_${projectId}_${variant}.snapshot.deepnote`. Both are handled + * by convert's `parseSnapshotFilename` (which also decodes the percent-encoded notebook id). * @returns The project ID, or undefined if the URI is not a valid snapshot file */ export function extractProjectIdFromSnapshotUri(uri: Uri): string | undefined { const basename = uri.path.split('/').pop() ?? ''; - const match = basename.match(SNAPSHOT_FILENAME_PATTERN); - - return match?.[1]; -} - -/** - * Slugifies a project name for use in filenames. - * Converts to lowercase, replaces spaces with hyphens, removes non-alphanumeric chars. - * @throws Error if the result is empty after transformation - */ -export function slugifyProjectName(name: string): string { - if (typeof name !== 'string' || !name.trim()) { - throw new InvalidProjectNameError(); - } - - const slug = name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - if (!slug) { - throw new InvalidProjectNameError(); - } + const parsed = parseSnapshotFilename(basename); - return slug; + return parsed?.projectId; } diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts index 8acb26f385..fedae3936e 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts @@ -3,7 +3,9 @@ import { Uri } from 'vscode'; import { extractProjectIdFromSnapshotUri, + generateSnapshotFilename, isSnapshotFile, + parseSnapshotFilename, slugifyProjectName, SNAPSHOT_FILE_SUFFIX } from './snapshotFiles'; @@ -48,16 +50,32 @@ suite('snapshotFiles', () => { }); suite('extractProjectIdFromSnapshotUri', () => { - test('should extract project ID from latest snapshot URI', () => { - const uri = Uri.file('/path/to/snapshots/my-project_abc-123_latest.snapshot.deepnote'); + const projectId = 'e132b172-b114-410e-8331-011517db664f'; - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), 'abc-123'); + test('should extract project ID from legacy latest snapshot URI', () => { + const uri = Uri.file(`/path/to/snapshots/my-project_${projectId}_latest.snapshot.deepnote`); + + assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); + }); + + test('should extract project ID from legacy timestamped snapshot URI', () => { + const uri = Uri.file(`/path/to/snapshots/my-project_${projectId}_2025-01-15T10-31-48.snapshot.deepnote`); + + assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); }); - test('should extract project ID from timestamped snapshot URI', () => { - const uri = Uri.file('/path/to/snapshots/my-project_abc-123_2025-01-15T10-31-48.snapshot.deepnote'); + test('should extract project ID from notebook-scoped latest snapshot URI', () => { + const uri = Uri.file(`/path/to/snapshots/my-project_${projectId}_notebook-1_latest.snapshot.deepnote`); + + assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); + }); + + test('should extract project ID from notebook-scoped timestamped snapshot URI', () => { + const uri = Uri.file( + `/path/to/snapshots/my-project_${projectId}_notebook-1_2025-01-15T10-31-48.snapshot.deepnote` + ); - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), 'abc-123'); + assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); }); test('should return undefined for non-snapshot files', () => { @@ -72,16 +90,87 @@ suite('snapshotFiles', () => { assert.isUndefined(extractProjectIdFromSnapshotUri(uri)); }); - test('should return undefined for filenames with a single underscore', () => { - const uri = Uri.file('/path/to/slug_only.snapshot.deepnote'); + test('should return undefined when the project id is not a UUID', () => { + const uri = Uri.file('/path/to/snapshots/slug_not-a-uuid_latest.snapshot.deepnote'); assert.isUndefined(extractProjectIdFromSnapshotUri(uri)); }); + }); + + // Use real UUIDs: convert's parseSnapshotFilename only matches a 36-char projectId, so any + // fixture with a short/non-UUID projectId would fail to parse and silently weaken the test. + suite('generateSnapshotFilename / parseSnapshotFilename round-trip', () => { + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + const notebookId = '11111111-2222-3333-4444-555555555555'; + + test('round-trips projectId, notebookId, and timestamp for the notebook-scoped form (catches an encoding drift that would lose the notebook id on parse)', () => { + const filename = generateSnapshotFilename({ + slug: 'my-project', + projectId, + notebookId, + timestamp: '2025-01-02T10-31-48' + }); + + const parsed = parseSnapshotFilename(filename); + + assert.deepStrictEqual(parsed, { + slug: 'my-project', + projectId, + notebookId, + timestamp: '2025-01-02T10-31-48' + }); + }); + + test('round-trips the latest variant for the notebook-scoped form (catches losing the "latest" pointer marker)', () => { + const filename = generateSnapshotFilename({ + slug: 'my-project', + projectId, + notebookId, + timestamp: 'latest' + }); + + const parsed = parseSnapshotFilename(filename); + + assert.deepStrictEqual(parsed, { + slug: 'my-project', + projectId, + notebookId, + timestamp: 'latest' + }); + }); + + test('percent-encodes a notebook id with non-filename-safe characters and decodes it back unchanged (catches a path-unsafe filename or a lossy decode)', () => { + const trickyNotebookId = 'naïve/notebook id'; + const filename = generateSnapshotFilename({ + slug: 'my-project', + projectId, + notebookId: trickyNotebookId, + timestamp: 'latest' + }); - test('should handle project IDs containing underscores', () => { - const uri = Uri.file('/path/to/snapshots/slug_proj_id_with_parts_latest.snapshot.deepnote'); + // The on-disk filename must be path-safe: no raw slash or space leaks into the basename. + assert.notInclude(filename, '/notebook'); + assert.notInclude(filename, ' '); + assert.include(filename, '%2F'); + assert.include(filename, '%20'); - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), 'proj_id_with_parts'); + const parsed = parseSnapshotFilename(filename); + + assert.strictEqual(parsed?.notebookId, trickyNotebookId); + }); + + test('parses the legacy no-notebook-id form with notebookId === undefined (catches treating a legacy snapshot as notebook-scoped)', () => { + const filename = generateSnapshotFilename({ slug: 'my-project', projectId, timestamp: 'latest' }); + + // The legacy form must NOT embed a notebook id between the project id and the timestamp. + assert.strictEqual(filename, `my-project_${projectId}_latest.snapshot.deepnote`); + + const parsed = parseSnapshotFilename(filename); + + assert.isDefined(parsed); + assert.strictEqual(parsed!.projectId, projectId); + assert.strictEqual(parsed!.timestamp, 'latest'); + assert.isUndefined(parsed!.notebookId); }); }); @@ -98,8 +187,12 @@ suite('snapshotFiles', () => { assert.strictEqual(slugifyProjectName('Customer Churn ML Playbook!'), 'customer-churn-ml-playbook'); }); - test('should handle project names with special characters', () => { - assert.strictEqual(slugifyProjectName('Test@#$%Project'), 'testproject'); + test('should treat runs of special characters as a single hyphen', () => { + assert.strictEqual(slugifyProjectName('Test@#$%Project'), 'test-project'); + }); + + test('should normalize accented characters to ASCII', () => { + assert.strictEqual(slugifyProjectName('Café Résumé'), 'cafe-resume'); }); test('should collapse multiple spaces into single hyphen', () => { @@ -114,25 +207,16 @@ suite('snapshotFiles', () => { assert.strictEqual(slugifyProjectName('-project-'), 'project'); }); - test('should throw error for empty project name', () => { - assert.throws( - () => slugifyProjectName(''), - 'Project name cannot be empty or contain only special characters' - ); + test('should return an empty string for an empty project name', () => { + assert.strictEqual(slugifyProjectName(''), ''); }); - test('should throw error for project name with only special characters', () => { - assert.throws( - () => slugifyProjectName('@#$%^&*()'), - 'Project name cannot be empty or contain only special characters' - ); + test('should return an empty string for a name with only special characters', () => { + assert.strictEqual(slugifyProjectName('@#$%^&*()'), ''); }); - test('should throw error for project name with only whitespace', () => { - assert.throws( - () => slugifyProjectName(' '), - 'Project name cannot be empty or contain only special characters' - ); + test('should return an empty string for a name with only whitespace', () => { + assert.strictEqual(slugifyProjectName(' '), ''); }); }); }); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 4f4aacab42..27b696496a 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -8,16 +8,32 @@ import { type Execution, type ExecutionError } from '@deepnote/blocks'; +import { countBlocksWithOutputs, hasOutputs } from '@deepnote/convert'; import fastDeepEqual from 'fast-deep-equal'; import { inject, injectable, optional } from 'inversify'; -import { Disposable, FileType, NotebookCell, NotebookCellKind, RelativePattern, Uri, window, workspace } from 'vscode'; +import { + Disposable, + FileType, + NotebookCell, + NotebookCellKind, + NotebookDocumentChangeEvent, + RelativePattern, + Uri, + window, + workspace +} from 'vscode'; import { Utils } from 'vscode-uri'; import { IExtensionSyncActivationService } from '../../../platform/activation/types'; import { IDisposableRegistry } from '../../../platform/common/types'; import type { DeepnoteOutput } from '../../../platform/deepnote/deepnoteTypes'; import { InvalidProjectNameError } from '../../../platform/errors/invalidProjectNameError'; -import { slugifyProjectName } from './snapshotFiles'; +import { + generateSnapshotFilename, + parseSnapshotFilename, + resolveSnapshotNotebookId, + slugifyProjectName +} from './snapshotFiles'; import { logger } from '../../../platform/logging'; import { notebookCellExecutions, @@ -101,6 +117,22 @@ interface NotebookExecutionState { /** How long a written URI is considered "recent" and suppressed from file-change processing. */ const recentWriteExpirationMs = 2000; +/** + * After execution completes, wait for outputs to settle before writing a snapshot. The timer is + * (re)armed on each output/metadata-bearing notebook change and only flushes once this quiet + * window elapses with no further changes. + */ +const outputSettleQuietPeriodMs = 150; + +/** + * Upper bound, measured from the first arm, on how long the deferred snapshot save can be pushed + * out by repeated output/metadata changes. Once exceeded, the save flushes regardless. + */ +const outputSettleMaxWaitMs = 2000; + +/** Maximum number of snapshot files the open-time (path-free) reader inspects per workspace folder. */ +const maxSnapshotFilesPerFolder = 200; + class TimeoutError extends Error { constructor(message: string) { super(message); @@ -126,6 +158,10 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync private readonly converter = new DeepnoteDataConverter(); private readonly executionStates = new Map(); private readonly fileWrittenCallbacks: ((uri: Uri) => void)[] = []; + private readonly pendingSnapshotSaves = new Map< + string, + { armedAt: number; timer: ReturnType } + >(); private readonly recentlyWrittenTimers = new Map>(); private readonly recentlyWrittenUris = new Set(); @@ -144,12 +180,29 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync clearTimeout(timer); } this.recentlyWrittenTimers.clear(); + + // Cancel any in-flight deferred snapshot saves so timers don't fire after disposal. + for (const pending of this.pendingSnapshotSaves.values()) { + clearTimeout(pending.timer); + } + this.pendingSnapshotSaves.clear(); } }); workspace.onDidCloseNotebookDocument( (notebook) => { - this.clearExecutionState(notebook.uri.toString()); + const notebookUri = notebook.uri.toString(); + + this.cancelPendingSnapshotSave(notebookUri); + this.clearExecutionState(notebookUri); + }, + this, + this.disposables + ); + + workspace.onDidChangeNotebookDocument( + (e) => { + this.handleNotebookDocumentChange(e); }, this, this.disposables @@ -165,9 +218,9 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync ); notebookCellExecutions.onDidCompleteQueueExecution( - async (e) => { + (e) => { logger.debug(`[Snapshot] Queue execution complete for ${e.notebookUri}`); - await this.onExecutionComplete(e.notebookUri); + this.onExecutionComplete(e.notebookUri); }, this, this.disposables @@ -202,9 +255,17 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectId: string, projectName: string, projectData: DeepnoteFile, + notebookId?: string, notebookUri?: string ): Promise { - const prepared = await this.prepareSnapshotData(projectUri, projectId, projectName, projectData, notebookUri); + const prepared = await this.prepareSnapshotData( + projectUri, + projectId, + projectName, + projectData, + notebookId, + notebookUri + ); if (!prepared) { logger.debug(`[Snapshot] No changes detected, skipping snapshot creation`); @@ -214,7 +275,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync const { latestPath, content } = prepared; const timestamp = generateTimestamp(); - const timestampedPath = this.buildSnapshotPath(projectUri, projectId, projectName, timestamp); + const timestampedPath = this.buildSnapshotPath(projectUri, projectId, projectName, timestamp, notebookId); // Write to timestamped file first (safe - doesn't touch existing files) try { @@ -390,8 +451,18 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync }; } - async readSnapshot(projectId: string): Promise | undefined> { - logger.debug(`[Snapshot] readSnapshot called for projectId=${projectId}`); + /** + * Reads the best available snapshot for a project/notebook WITHOUT a file path. + * + * `deserializeNotebook` only receives the file bytes (no URI), so this resolver globs the + * workspace for snapshot files keyed on `projectId` ONLY, parses each basename with convert's + * pure `parseSnapshotFilename`, ranks them (notebook-scoped match first, legacy no-notebook-id + * entries kept as a fallback, then `latest`, then newest timestamp), and walks the ranked + * candidates returning the first one with real outputs. It never calls convert's path-bound + * disk helpers (loadLatestSnapshot/findSnapshotsForProject/loadSnapshotFile/getSnapshotPath). + */ + async readSnapshot(projectId: string, notebookId?: string): Promise | undefined> { + logger.debug(`[Snapshot] readSnapshot called for projectId=${projectId}, notebookId=${notebookId}`); const workspaceFolders = workspace.workspaceFolders; if (!workspaceFolders || workspaceFolders.length === 0) { @@ -404,69 +475,86 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync logger.debug(`[Snapshot] Searching ${workspaceFolders.length} workspace folder(s) for snapshots`); - // 1. Try to find a 'latest' snapshot file - const latestGlob = `**/snapshots/*_${projectId}_latest.snapshot.deepnote`; + // Key the glob on projectId only — the percent-encoded notebook id must NOT be embedded so + // that legacy (no-notebook-id) snapshots are still discovered and can serve as a fallback. + const glob = `**/snapshots/*_${projectId}_*.snapshot.deepnote`; + const candidates: Array<{ uri: Uri; notebookId?: string; timestamp: string }> = []; for (const folder of workspaceFolders) { - logger.debug(`[Snapshot] Searching for latest snapshot with glob: ${latestGlob} in ${folder.uri.path}`); - const latestPattern = new RelativePattern(folder, latestGlob); - const latestFiles = await workspace.findFiles(latestPattern, null, 1); - - if (latestFiles.length > 0) { - logger.debug(`[Snapshot] Found latest snapshot: ${latestFiles[0].path}`); + const pattern = new RelativePattern(folder, glob); + const files = await workspace.findFiles(pattern, null, maxSnapshotFilesPerFolder); - try { - return await this.parseSnapshotFile(latestFiles[0]); - } catch (error) { - logger.error(`[Snapshot] Failed to parse snapshot: ${Utils.basename(latestFiles[0])}`, error); + for (const uri of files) { + const basename = Utils.basename(uri); + const parsed = parseSnapshotFilename(basename); - await window.showErrorMessage(`Failed to read latest snapshot: ${Utils.basename(latestFiles[0])}`); + if (!parsed || parsed.projectId !== projectId) { + continue; + } - return; + // Drop OTHER notebooks' scoped files; keep this notebook's scoped files and all + // legacy no-notebook-id files (the latter act as a backward-compatible fallback). + if (notebookId && parsed.notebookId && parsed.notebookId !== notebookId) { + continue; } + + candidates.push({ uri, notebookId: parsed.notebookId, timestamp: parsed.timestamp }); } } - logger.debug(`[Snapshot] No latest snapshot found, looking for timestamped files`); + if (candidates.length === 0) { + logger.debug(`[Snapshot] No snapshot files found for project ${projectId}`); + + return; + } - // 2. Find timestamped snapshots across all workspace folders - const timestampedGlob = `**/snapshots/*_${projectId}_*.snapshot.deepnote`; - let allTimestampedFiles: Uri[] = []; + candidates.sort((a, b) => this.compareSnapshotCandidates(a, b, notebookId)); - for (const folder of workspaceFolders) { - const timestampedPattern = new RelativePattern(folder, timestampedGlob); - const files = await workspace.findFiles(timestampedPattern, null, 100); + // Walk the ranked candidates: skip a corrupt file or an empty-output `latest` (a block→[] + // mapping signals a save race) and continue; return the first candidate with real outputs. + for (const candidate of candidates) { + let file: DeepnoteFile; - allTimestampedFiles = allTimestampedFiles.concat(files); - } + try { + const content = await workspace.fs.readFile(candidate.uri); + const contentString = new TextDecoder('utf-8').decode(content); - // Filter out 'latest' files and sort by filename descending - const sortedFiles = allTimestampedFiles - .filter((uri) => !Utils.basename(uri).endsWith('_latest.snapshot.deepnote')) - .sort((a, b) => { - const nameA = Utils.basename(a); - const nameB = Utils.basename(b); + file = deserializeDeepnoteFile(contentString); + } catch (error) { + logger.warn( + `[Snapshot] Failed to read/parse snapshot candidate: ${Utils.basename(candidate.uri)}`, + error + ); - return nameB.localeCompare(nameA); - }); + continue; + } - if (sortedFiles.length === 0) { - logger.debug(`[Snapshot] No timestamped snapshots found`); + if (candidate.timestamp === 'latest' && countBlocksWithOutputs(file) === 0) { + logger.debug( + `[Snapshot] Skipping empty-output latest snapshot (possible save race): ${Utils.basename( + candidate.uri + )}` + ); - return; - } + continue; + } - const newestFile = sortedFiles[0]; + if (!hasOutputs(file)) { + logger.debug( + `[Snapshot] Snapshot candidate has no outputs, trying next: ${Utils.basename(candidate.uri)}` + ); - logger.debug(`[Snapshot] Using timestamped snapshot: ${Utils.basename(newestFile)}`); + continue; + } - try { - return await this.parseSnapshotFile(newestFile); - } catch (error) { - logger.error(`[Snapshot] Failed to parse snapshot: ${Utils.basename(newestFile)}`, error); + logger.debug(`[Snapshot] Using snapshot: ${Utils.basename(candidate.uri)}`); - return; + return this.extractOutputsFromFile(file); } + + logger.debug(`[Snapshot] No snapshot candidate with real outputs found for project ${projectId}`); + + return; } stripOutputsFromBlocks(blocks: DeepnoteBlock[]): DeepnoteBlock[] { @@ -494,11 +582,25 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectUri: Uri, projectId: string, projectName: string, - variant: 'latest' | string + variant: 'latest' | string, + notebookId?: string ): Uri { const parentDir = Uri.joinPath(projectUri, '..'); + + // convert's slugifyProjectName returns '' (rather than throwing) for names with no + // slug-safe characters; preserve the existing "skip snapshots for invalid names" contract + // by validating here so prepareSnapshotData can catch InvalidProjectNameError. + if (typeof projectName !== 'string' || !projectName.trim()) { + throw new InvalidProjectNameError(); + } + const slug = slugifyProjectName(projectName); - const filename = `${slug}_${projectId}_${variant}.snapshot.deepnote`; + + if (!slug) { + throw new InvalidProjectNameError(); + } + + const filename = generateSnapshotFilename({ slug, projectId, notebookId, timestamp: variant }); return Uri.joinPath(parentDir, 'snapshots', filename); } @@ -586,7 +688,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync (doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId ); - return notebookDoc?.uri.with({ query: '' }); + return notebookDoc?.uri; } private getComparableProjectContent(data: DeepnoteFile): object { @@ -629,6 +731,9 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync if (state === NotebookCellExecutionState.Executing) { const startTime = Date.now(); + // A new execution invalidates any deferred save armed by the previous run; the new + // run will re-arm on completion. This prevents writing a snapshot mid-execution. + this.cancelPendingSnapshotSave(notebookUri); this.recordCellExecutionStart(notebookUri, cellId, startTime); } else if (state === NotebookCellExecutionState.Idle) { const endTime = Date.now(); @@ -709,6 +814,62 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync }); } + /** + * Arms (or re-arms) the output-settled deferred snapshot save for a notebook. The save flushes + * once `outputSettleQuietPeriodMs` elapses with no further output/metadata-bearing change, but + * never later than `outputSettleMaxWaitMs` from the first arm. + */ + private armSnapshotSave(notebookUri: string): void { + const now = Date.now(); + const existing = this.pendingSnapshotSaves.get(notebookUri); + const armedAt = existing ? existing.armedAt : now; + + if (existing) { + clearTimeout(existing.timer); + } + + // Bound the quiet-period reset by the max wait measured from the first arm. + const remainingMaxWait = Math.max(0, armedAt + outputSettleMaxWaitMs - now); + const delay = Math.min(outputSettleQuietPeriodMs, remainingMaxWait); + + const timer = setTimeout(() => { + this.pendingSnapshotSaves.delete(notebookUri); + void this.performSnapshotSave(notebookUri); + }, delay); + + this.pendingSnapshotSaves.set(notebookUri, { armedAt, timer }); + } + + private cancelPendingSnapshotSave(notebookUri: string): void { + const existing = this.pendingSnapshotSaves.get(notebookUri); + + if (existing) { + clearTimeout(existing.timer); + this.pendingSnapshotSaves.delete(notebookUri); + + logger.debug(`[Snapshot] Cancelled pending snapshot save for ${notebookUri}`); + } + } + + private handleNotebookDocumentChange(event: NotebookDocumentChangeEvent): void { + const notebookUri = event.notebook.uri.toString(); + + // Only matters while a save is pending; re-arm the settle timer when outputs/metadata change. + if (!this.pendingSnapshotSaves.has(notebookUri)) { + return; + } + + const bearsOutputOrMetadata = event.cellChanges.some( + (change) => + change.outputs !== undefined || change.executionSummary !== undefined || change.metadata !== undefined + ); + + if (bearsOutputOrMetadata) { + logger.trace(`[Snapshot] Output/metadata change while save pending — re-arming settle timer`); + this.armSnapshotSave(notebookUri); + } + } + private async onExecutionComplete(notebookUri: string): Promise { logger.debug(`[Snapshot] onExecutionComplete called for ${notebookUri}`); @@ -723,6 +884,24 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } + // Defer the actual write until outputs settle (see armSnapshotSave). A new execution + // re-entering the executing state, notebook close, or disposal cancels the pending save. + this.armSnapshotSave(notebookUri); + } + + /** + * Performs the deferred snapshot save: rebuilds the notebook's blocks, detects Run All vs + * partial run, and writes the snapshot. "Run all" → timestamped + latest; partial → latest only. + */ + private async performSnapshotSave(notebookUri: string): Promise { + logger.debug(`[Snapshot] performSnapshotSave for ${notebookUri}`); + + if (!this.isSnapshotsEnabled()) { + logger.debug(`[Snapshot] Snapshots not enabled, skipping`); + + return; + } + const notebook = workspace.notebookDocuments.find((n) => n.uri.toString() === notebookUri); if (!notebook) { @@ -787,6 +966,11 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync snapshotNotebook.blocks = blocks as DeepnoteBlock[]; } + // The snapshot filename is scoped by the file's snapshot notebook id (single-notebook file → + // its one notebook; [init, main] → the main notebook), falling back to the rendered + // notebook's metadata id for any unexpected multi-notebook shape. + const snapshotNotebookId = resolveSnapshotNotebookId(snapshotProject) ?? notebookId; + // Detect "Run All" by checking if all code cells in the notebook were executed const state = this.executionStates.get(notebookUri); const totalCodeCells = notebook.getCells().filter((cell) => cell.kind === NotebookCellKind.Code).length; @@ -800,6 +984,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectId, originalProject.project.name, snapshotProject, + snapshotNotebookId, notebookUri ); @@ -814,6 +999,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectId, originalProject.project.name, snapshotProject, + snapshotNotebookId, notebookUri ); @@ -826,38 +1012,53 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync this.clearExecutionState(notebookUri); } - private async parseSnapshotFile(path: Uri): Promise> { - const outputsMap = new Map(); + /** + * Ranking comparator for open-time snapshot candidates (reimplemented locally from convert's + * `findSnapshotsForProject` ordering, since the path-bound convert helper cannot run here): + * the requested notebook's scoped match first, then `latest`, then newest timestamp. Legacy + * no-notebook-id entries are NOT dropped — they sort after the scoped match as a fallback. + */ + private compareSnapshotCandidates( + a: { notebookId?: string; timestamp: string }, + b: { notebookId?: string; timestamp: string }, + notebookId?: string + ): number { + if (notebookId) { + const aMatches = a.notebookId === notebookId; + + if (aMatches !== (b.notebookId === notebookId)) { + return aMatches ? -1 : 1; + } + } - logger.debug(`[Snapshot] Parsing snapshot file: ${path.path}`); + const aLatest = a.timestamp === 'latest'; - try { - const content = await workspace.fs.readFile(path); - const contentString = new TextDecoder('utf-8').decode(content); - - logger.debug(`[Snapshot] Read ${content.byteLength} bytes from snapshot file`); - - const data = deserializeDeepnoteFile(contentString); - let totalBlocks = 0; - - for (const notebook of data.project.notebooks) { - for (const block of notebook.blocks) { - totalBlocks++; - try { - if (isExecutableBlock(block) && block.outputs) { - outputsMap.set(block.id, block.outputs as DeepnoteOutput[]); - } - } catch (blockError) { - logger.warn(`[Snapshot] Failed to extract outputs for block ${block.id}`, blockError); + if (aLatest !== (b.timestamp === 'latest')) { + return aLatest ? -1 : 1; + } + + return b.timestamp.localeCompare(a.timestamp); + } + + private extractOutputsFromFile(file: DeepnoteFile): Map { + const outputsMap = new Map(); + let totalBlocks = 0; + + for (const notebook of file.project.notebooks) { + for (const block of notebook.blocks) { + totalBlocks++; + try { + if (isExecutableBlock(block) && block.outputs) { + outputsMap.set(block.id, block.outputs as DeepnoteOutput[]); } + } catch (blockError) { + logger.warn(`[Snapshot] Failed to extract outputs for block ${block.id}`, blockError); } } - - logger.debug(`[Snapshot] Extracted ${outputsMap.size} block outputs from ${totalBlocks} total blocks`); - } catch (error) { - logger.error(`[Snapshot] Failed to parse snapshot file: ${Utils.basename(path)}`, error); } + logger.debug(`[Snapshot] Extracted ${outputsMap.size} block outputs from ${totalBlocks} total blocks`); + return outputsMap; } @@ -866,12 +1067,13 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectId: string, projectName: string, projectData: DeepnoteFile, + notebookId?: string, notebookUri?: string ): Promise<{ latestPath: Uri; content: Uint8Array } | undefined> { let latestPath: Uri; try { - latestPath = this.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + latestPath = this.buildSnapshotPath(projectUri, projectId, projectName, 'latest', notebookId); } catch (error) { if (error instanceof InvalidProjectNameError) { logger.warn('[Snapshot] Skipping snapshots due to invalid project name', error); @@ -1011,9 +1213,17 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync projectId: string, projectName: string, projectData: DeepnoteFile, + notebookId?: string, notebookUri?: string ): Promise { - const prepared = await this.prepareSnapshotData(projectUri, projectId, projectName, projectData, notebookUri); + const prepared = await this.prepareSnapshotData( + projectUri, + projectId, + projectName, + projectData, + notebookId, + notebookUri + ); if (!prepared) { logger.debug(`[Snapshot] No changes detected, skipping latest snapshot update`); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index 41f5ba8c94..e495a75b0a 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -1,10 +1,12 @@ +import * as fakeTimers from '@sinonjs/fake-timers'; import { assert } 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 { FileType, NotebookCellKind, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import type { DeepnoteBlock, DeepnoteFile, ExecutableBlock } from '@deepnote/blocks'; +import { NotebookCellExecutionState } from '../../../platform/notebooks/cellExecutionStateService'; import { IEnvironmentCapture } from './environmentCapture.node'; import { SnapshotService } from './snapshotService'; import type { DeepnoteOutput } from '../../../platform/deepnote/deepnoteTypes'; @@ -105,7 +107,19 @@ suite('SnapshotService', () => { const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); - assert.include(result.fsPath, 'testproject'); + // convert's slugifyProjectName collapses a run of special characters to a single hyphen. + assert.include(result.fsPath, 'test-project'); + }); + + test('should embed the notebook id when provided', () => { + const projectUri = Uri.file('/path/to/my-project.deepnote'); + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + const projectName = 'My Project'; + const notebookId = 'notebook-1'; + + const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest', notebookId); + + assert.include(result.fsPath, `${projectId}_notebook-1_latest.snapshot.deepnote`); }); test('should handle project names with multiple spaces', () => { @@ -498,7 +512,7 @@ suite('SnapshotService', () => { }); suite('readSnapshot', () => { - const projectId = 'test-project-id-123'; + const projectId = 'e132b172-b114-410e-8331-011517db664f'; test('should return undefined when no workspace folders exist', async () => { when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); @@ -524,7 +538,7 @@ suite('SnapshotService', () => { }; when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - const snapshotUri = Uri.file('/workspace/snapshots/project_test-project-id-123_latest.snapshot.deepnote'); + const snapshotUri = Uri.file(`/workspace/snapshots/project_${projectId}_latest.snapshot.deepnote`); when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ snapshotUri ] as any); @@ -534,7 +548,7 @@ version: '1.0.0' metadata: createdAt: '2025-01-01T00:00:00Z' project: - id: test-project-id-123 + id: ${projectId} name: Test Project notebooks: - id: notebook-1 @@ -588,31 +602,24 @@ project: }; when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - // First call for latest - returns empty - // Second call for timestamped - returns files const timestampedUri1 = Uri.file( - '/workspace/snapshots/project_test-project-id-123_2025-01-01T10-00-00.snapshot.deepnote' + `/workspace/snapshots/project_${projectId}_2025-01-01T10-00-00.snapshot.deepnote` ); const timestampedUri2 = Uri.file( - '/workspace/snapshots/project_test-project-id-123_2025-01-02T10-00-00.snapshot.deepnote' + `/workspace/snapshots/project_${projectId}_2025-01-02T10-00-00.snapshot.deepnote` ); - let callCount = 0; - when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenCall(() => { - callCount++; - if (callCount === 1) { - return Promise.resolve([]); - } - - return Promise.resolve([timestampedUri1, timestampedUri2]); - }); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ + timestampedUri1, + timestampedUri2 + ] as any); const snapshotYaml = ` version: '1.0.0' metadata: createdAt: '2025-01-02T00:00:00Z' project: - id: test-project-id-123 + id: ${projectId} name: Test Project notebooks: - id: notebook-1 @@ -637,7 +644,7 @@ project: assert.strictEqual(result!.size, 1); }); - test('should return empty map when snapshot file read fails', async () => { + test('should return undefined when the only snapshot file read fails', async () => { const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', @@ -645,7 +652,7 @@ project: }; when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - const snapshotUri = Uri.file('/workspace/snapshots/project_test-project-id-123_latest.snapshot.deepnote'); + const snapshotUri = Uri.file(`/workspace/snapshots/project_${projectId}_latest.snapshot.deepnote`); when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ snapshotUri ] as any); @@ -656,12 +663,12 @@ project: const result = await service.readSnapshot(projectId); - // parseSnapshotFile catches read errors and returns empty map - assert.isDefined(result); - assert.strictEqual(result!.size, 0); + // A corrupt/unreadable candidate is skipped during the safe-restore walk; with no other + // candidate the lookup resolves to undefined (the open-time merge becomes a no-op). + assert.isUndefined(result); }); - test('should return empty map when snapshot has invalid structure', async () => { + test('should return undefined when the only snapshot candidate has no outputs', async () => { const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', @@ -669,20 +676,384 @@ project: }; when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - const snapshotUri = Uri.file('/workspace/snapshots/project_test-project-id-123_latest.snapshot.deepnote'); + const snapshotUri = Uri.file(`/workspace/snapshots/project_${projectId}_latest.snapshot.deepnote`); when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ snapshotUri ] as any); - const invalidYaml = 'not_an_object'; + // A `latest` snapshot whose blocks carry no real outputs signals a save race and is + // skipped by the safe-restore walk. + const emptyOutputsYaml = ` +version: '1.0.0' +metadata: + createdAt: '2025-01-01T00:00:00Z' +project: + id: ${projectId} + name: Test Project + notebooks: + - id: notebook-1 + name: Notebook 1 + blocks: + - id: block-1 + blockGroup: group-1 + type: code + sortingKey: 'a0' + content: print(1) + outputs: [] +`; const mockFs = mock(); - when(mockFs.readFile(anything())).thenResolve(new TextEncoder().encode(invalidYaml) as any); + when(mockFs.readFile(anything())).thenResolve(new TextEncoder().encode(emptyOutputsYaml) as any); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); const result = await service.readSnapshot(projectId); - assert.isDefined(result); - assert.strictEqual(result!.size, 0); + assert.isUndefined(result); + }); + }); + + suite('readSnapshot backward-compatible ranking', () => { + // UUIDs are required: convert's (faithfully mocked) parseSnapshotFilename only matches a + // 36-char projectId AND a UUID-shaped notebook id, so non-UUID ids would never parse. + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + const notebookId = '11111111-2222-3333-4444-555555555555'; + const otherNotebookId = '99999999-8888-7777-6666-555555555555'; + + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('/workspace'), + name: 'workspace', + index: 0 + }; + + /** A snapshot whose single block carries one stream output tagged with `marker`. */ + function snapshotYamlWithOutput(marker: string): string { + return ` +version: '1.0.0' +metadata: + createdAt: '2025-01-01T00:00:00Z' +project: + id: ${projectId} + name: Test Project + notebooks: + - id: notebook-1 + name: Notebook 1 + blocks: + - id: block-1 + blockGroup: group-1 + type: code + sortingKey: 'a0' + content: print(1) + outputs: + - output_type: stream + name: stdout + text: '${marker}' +`; + } + + /** + * Stub findFiles to return the given URIs and readFile to dispatch bytes per-URI by fsPath. + * ts-mockito's single-arg matcher returns one value for every call, so per-URI content must + * be dispatched explicitly — otherwise every candidate would read identical bytes and the + * "which file won" assertions would be meaningless. + */ + function stubSnapshotFiles(filesByUri: Array<{ uri: Uri; yaml: string }>): void { + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve( + filesByUri.map((f) => f.uri) as any + ); + + const byPath = new Map(filesByUri.map((f) => [f.uri.fsPath, f.yaml])); + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const yaml = byPath.get(uri.fsPath); + if (yaml === undefined) { + return Promise.reject(new Error(`Unexpected readFile for ${uri.fsPath}`)); + } + + return Promise.resolve(new TextEncoder().encode(yaml)); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return; + } + + function markerOf(result: Map | undefined): string | undefined { + const outputs = result?.get('block-1'); + const first = outputs?.[0] as { text?: string } | undefined; + + return first?.text; + } + + test('still loads a legacy project-scoped snapshot when a notebookId is requested but only the legacy file exists (catches dropping the legacy fallback)', async () => { + const legacyUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + stubSnapshotFiles([{ uri: legacyUri, yaml: snapshotYamlWithOutput('from-legacy') }]); + + const result = await service.readSnapshot(projectId, notebookId); + + assert.strictEqual(markerOf(result), 'from-legacy'); + }); + + test('prefers the notebook-scoped snapshot over a legacy one for the requested notebookId (catches ranking legacy ahead of the notebook-scoped match)', async () => { + const scopedUri = Uri.file( + `/workspace/snapshots/test-project_${projectId}_${notebookId}_latest.snapshot.deepnote` + ); + const legacyUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + + stubSnapshotFiles([ + { uri: legacyUri, yaml: snapshotYamlWithOutput('from-legacy') }, + { uri: scopedUri, yaml: snapshotYamlWithOutput('from-scoped') } + ]); + + const result = await service.readSnapshot(projectId, notebookId); + + assert.strictEqual(markerOf(result), 'from-scoped'); + }); + + test('ignores a different notebook scoped snapshot and falls back to the legacy one (catches reading another notebook outputs into this notebook)', async () => { + const otherScopedUri = Uri.file( + `/workspace/snapshots/test-project_${projectId}_${otherNotebookId}_latest.snapshot.deepnote` + ); + const legacyUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + + stubSnapshotFiles([ + { uri: otherScopedUri, yaml: snapshotYamlWithOutput('from-other-notebook') }, + { uri: legacyUri, yaml: snapshotYamlWithOutput('from-legacy') } + ]); + + const result = await service.readSnapshot(projectId, notebookId); + + assert.strictEqual(markerOf(result), 'from-legacy'); + }); + + test('never deletes or renames the legacy snapshot file while reading it (catches a destructive migration on open)', async () => { + const legacyUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ + legacyUri + ] as any); + + const mockFs = mock(); + when(mockFs.readFile(anything())).thenResolve( + new TextEncoder().encode(snapshotYamlWithOutput('from-legacy')) as any + ); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + const result = await service.readSnapshot(projectId, notebookId); + + // The read must succeed AND leave the file untouched: no delete/rename/write of the legacy. + assert.strictEqual(markerOf(result), 'from-legacy'); + verify(mockFs.delete(anything())).never(); + verify(mockFs.delete(anything(), anything())).never(); + verify(mockFs.rename(anything(), anything())).never(); + verify(mockFs.rename(anything(), anything(), anything())).never(); + verify(mockFs.writeFile(anything(), anything())).never(); + }); + }); + + suite('readSnapshot safe restore', () => { + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('/workspace'), + name: 'workspace', + index: 0 + }; + + function snapshotYaml(blockContent: string, outputsYaml: string): string { + return ` +version: '1.0.0' +metadata: + createdAt: '2025-01-01T00:00:00Z' +project: + id: ${projectId} + name: Test Project + notebooks: + - id: notebook-1 + name: Notebook 1 + blocks: + - id: block-1 + blockGroup: group-1 + type: code + sortingKey: 'a0' + content: ${blockContent} + outputs:${outputsYaml} +`; + } + + function stubFiles(filesByUri: Array<{ uri: Uri; yaml: string }>): void { + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve( + filesByUri.map((f) => f.uri) as any + ); + + const byPath = new Map(filesByUri.map((f) => [f.uri.fsPath, f.yaml])); + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const yaml = byPath.get(uri.fsPath); + if (yaml === undefined) { + return Promise.reject(new Error(`Unexpected readFile for ${uri.fsPath}`)); + } + + return Promise.resolve(new TextEncoder().encode(yaml)); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return; + } + + test('skips an empty-output latest and falls through to a timestamped candidate that has outputs (catches restoring a save-race empty latest)', async () => { + const latestUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + const timestampedUri = Uri.file( + `/workspace/snapshots/test-project_${projectId}_2025-01-02T10-00-00.snapshot.deepnote` + ); + + stubFiles([ + // `latest` ranks first but has empty outputs (a save race) and must be skipped. + { uri: latestUri, yaml: snapshotYaml('print(1)', ' []') }, + // The timestamped candidate has real outputs and must be the one returned. + { + uri: timestampedUri, + yaml: snapshotYaml( + 'print(1)', + `\n - output_type: stream\n name: stdout\n text: 'from-timestamped'` + ) + } + ]); + + const result = await service.readSnapshot(projectId); + + const first = result?.get('block-1')?.[0] as { text?: string } | undefined; + assert.strictEqual(first?.text, 'from-timestamped'); + }); + + test('skips a corrupt/unparseable candidate and uses the next valid one (catches aborting the lookup on one bad file)', async () => { + // `latest` sorts first; make it unparseable so the walk must continue to the timestamped one. + const latestUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); + const timestampedUri = Uri.file( + `/workspace/snapshots/test-project_${projectId}_2025-01-02T10-00-00.snapshot.deepnote` + ); + + stubFiles([ + { uri: latestUri, yaml: 'this: is: not: valid: deepnote: [[[' }, + { + uri: timestampedUri, + yaml: snapshotYaml( + 'print(1)', + `\n - output_type: stream\n name: stdout\n text: 'from-valid'` + ) + } + ]); + + const result = await service.readSnapshot(projectId); + + const first = result?.get('block-1')?.[0] as { text?: string } | undefined; + assert.strictEqual(first?.text, 'from-valid'); + }); + }); + + suite('deferred snapshot save timing', () => { + const notebookUri = 'file:///workspace/notebook.deepnote'; + let clock: fakeTimers.InstalledClock; + let performSaveStub: sinon.SinonStub; + + setup(() => { + // install() patches Date.now AND setTimeout/clearTimeout, both of which armSnapshotSave + // relies on (armedAt = Date.now(); the quiet/max-wait delays are real setTimeout calls). + clock = fakeTimers.install(); + + // Replace the flush so the only thing under test is *whether/when* it is invoked — never + // real file I/O. The arm timer reads this.performSnapshotSave at fire time, so stubbing + // the instance property is observed by the already-armed timer. + performSaveStub = sinon.stub(serviceAny, 'performSnapshotSave').resolves(); + }); + + teardown(() => { + clock.uninstall(); + performSaveStub.restore(); + }); + + /** Drives the same "output/metadata changed" event the service listens to while a save is pending. */ + function fireOutputChange(): void { + serviceAny.handleNotebookDocumentChange({ + notebook: { uri: Uri.parse(notebookUri) }, + cellChanges: [{ outputs: [] }], + contentChanges: [], + metadata: undefined + }); + } + + test('does NOT save immediately when execution completes — only after the quiet period elapses (catches writing a snapshot before outputs settle)', () => { + serviceAny.armSnapshotSave(notebookUri); + + // Just before the quiet window closes: nothing flushed yet. + clock.tick(149); + assert.isFalse(performSaveStub.called, 'save must not flush before the quiet period elapses'); + + // Crossing the quiet window with no further changes flushes exactly once. + clock.tick(1); + assert.isTrue(performSaveStub.calledOnce, 'save must flush once the quiet period elapses'); + }); + + test('re-arms (delays) the save when an output change arrives within the quiet window (catches flushing mid-output-stream)', () => { + serviceAny.armSnapshotSave(notebookUri); + + clock.tick(100); + assert.isFalse(performSaveStub.called); + + // An output change at t=100 resets the 150ms quiet window. + fireOutputChange(); + + // t=200: would have fired under the original arm (100+? ) but the re-arm pushed it out. + clock.tick(100); + assert.isFalse(performSaveStub.called, 'an in-window change must re-arm and delay the save'); + + // t=250: 150ms after the re-arm — now it flushes. + clock.tick(50); + assert.isTrue(performSaveStub.calledOnce, 'save flushes one quiet period after the last change'); + }); + + test('forces a flush at the max-wait bound even under continuous output changes (catches an unbounded deferral starving the save)', () => { + serviceAny.armSnapshotSave(notebookUri); + + // Hammer an output change every 100ms; each one would reset the 150ms quiet window, but the + // 2000ms max-wait measured from the first arm must force a flush regardless. + for (let elapsed = 0; elapsed < 2000; elapsed += 100) { + clock.tick(100); + fireOutputChange(); + } + + // By t=2000 the max-wait bound has forced exactly one flush despite the continuous churn. + assert.isTrue(performSaveStub.called, 'max-wait must force a flush under continuous changes'); + }); + + test('cancels a pending save when a cell re-enters the executing state (catches writing a stale snapshot mid re-execution)', () => { + serviceAny.armSnapshotSave(notebookUri); + + clock.tick(100); + + // Drive the real cell-state handler: an Executing transition must cancel the armed save + // (otherwise a snapshot from the *previous* run would be written during the new run). + const cell = { + notebook: { uri: Uri.parse(notebookUri) }, + metadata: { id: 'cell-1' } + }; + serviceAny.handleCellExecutionStateChange(cell, NotebookCellExecutionState.Executing); + + clock.tick(1000); + assert.isFalse(performSaveStub.called, 're-execution must cancel the pending deferred save'); + }); + + test('cancels a pending save when the notebook is closed (catches a flush firing after the document is gone)', () => { + serviceAny.armSnapshotSave(notebookUri); + + clock.tick(100); + + // The close handler registered in activate() cancels the pending save via this primitive; + // once cancelled the timer must never flush even after the full quiet/max-wait window. + serviceAny.cancelPendingSnapshotSave(notebookUri); + + clock.tick(2000); + assert.isFalse(performSaveStub.called, 'closing the notebook must cancel the pending deferred save'); }); }); @@ -1149,8 +1520,9 @@ project: when(mockFs.copy(anything(), anything(), anything())).thenResolve(); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - // Call onExecutionComplete (which should auto-detect Run All) - await testServiceAny.onExecutionComplete(notebookUri); + // onExecutionComplete arms a deferred (output-settled) save; invoke the flush body + // directly to assert the Run-All-vs-partial routing without waiting on the timer. + await testServiceAny.performSnapshotSave(notebookUri); // ASSERT: createSnapshot should be called (full snapshot, not just latest) assert.isTrue( From faa8432ee93d6d5163f520c9bb256c784d926830 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 01:54:37 +0000 Subject: [PATCH 08/26] feat(deepnote): run the init notebook per kernel from its sibling file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 7b of single-notebook migration (§10). - The init runner now subscribes to kernel start and restart events and runs the init notebook found in its own sibling .deepnote file (matched by project id + initNotebookId via isValidSiblingInitCandidate), instead of looking it up in the main file's notebooks. - Track "init has run" per kernel in a WeakSet: a fresh kernel runs init once, and an in-place restart (which fires onDidRestartKernel) re-runs it so the kernel is re-initialized before the next user cell. A missing sibling is logged and skipped without permanently marking the project. - Remove the manager's persistent init-run tracking and the selector's init staging; the runner owns init triggering. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- build/mocha-esm-loader.js | 33 ++ .../deepnoteInitNotebookRunner.node.ts | 217 ++++++---- ...epnoteInitNotebookRunner.node.unit.test.ts | 388 ++++++++++++++++++ .../deepnoteKernelAutoSelector.node.ts | 77 +--- ...epnoteKernelAutoSelector.node.unit.test.ts | 24 -- .../deepnote/deepnoteNotebookManager.ts | 18 - src/notebooks/serviceRegistry.node.ts | 1 + src/notebooks/types.ts | 3 - 8 files changed, 569 insertions(+), 192 deletions(-) create mode 100644 src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index b45dad110c..a6eb4a7255 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -442,6 +442,39 @@ export async function load(url, context, nextLoad) { })); }; + export const isValidSiblingInitCandidate = (candidate, expectedProjectId, initNotebookId) => { + if (candidate?.project?.id !== expectedProjectId) { + return { + valid: false, + reason: + 'project.id mismatch (expected ' + + expectedProjectId + + ', got ' + + candidate?.project?.id + + ')' + }; + } + const candidateNotebooks = candidate?.project?.notebooks || []; + if (candidateNotebooks.length !== 1) { + return { + valid: false, + reason: 'expected exactly 1 notebook, found ' + candidateNotebooks.length + }; + } + const onlyNotebook = candidateNotebooks[0]; + if (onlyNotebook.id !== initNotebookId) { + return { + valid: false, + reason: + 'single notebook id ' + + onlyNotebook.id + + ' does not match initNotebookId ' + + initNotebookId + }; + } + return { valid: true }; + }; + // --- Snapshot filename helpers (faithful to @deepnote/convert dist) --- const FILENAME_SAFE_NOTEBOOK_ID_CHAR = /[A-Za-z0-9_-]/; diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index fc29c87409..29c9483c77 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts @@ -1,18 +1,38 @@ +import type { DeepnoteFile } from '@deepnote/blocks'; +import { isValidSiblingInitCandidate } from '@deepnote/convert'; import { inject, injectable } from 'inversify'; import { type NotebookDocument, ProgressLocation, + Uri, window, + workspace, CancellationTokenSource, type CancellationToken, l10n } from 'vscode'; import { logger } from '../../platform/logging'; -import { IDeepnoteNotebookManager } from '../types'; -import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; -import { IKernelProvider } from '../../kernels/types'; +import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; +import { DEEPNOTE_NOTEBOOK_TYPE } from '../../kernels/deepnote/types'; +import { IKernel, IKernelProvider } from '../../kernels/types'; +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IDisposableRegistry } from '../../platform/common/types'; import { getDisplayPath } from '../../platform/common/platform/fs-paths.node'; +import { readDeepnoteProjectFile } from '../../platform/deepnote/deepnoteProjectFileReader'; +import { resolveProjectIdForNotebook } from '../../platform/deepnote/deepnoteProjectIdResolver'; +import { IDeepnoteNotebookManager } from '../types'; + +const DEEPNOTE_FILE_EXTENSION = '.deepnote'; +const SNAPSHOT_FILE_SUFFIX = '.snapshot.deepnote'; + +// How long to keep the "initialization complete" message visible before resolving. +const INIT_COMPLETE_DISPLAY_DELAY_MS = 1000; + +// Progress weighting for the init run (sums to 100 across start + per-block + finish). +const INIT_PROGRESS_START_INCREMENT = 5; +const INIT_PROGRESS_BLOCKS_INCREMENT = 90; +const INIT_PROGRESS_FINISH_INCREMENT = 5; const DEEPNOTE_CLOUD_INIT_NOTEBOOK_BLOCK_CONTENT = `%%bash # If your project has a 'requirements.txt' file, we'll install it here. @@ -31,100 +51,155 @@ else: print("There's no requirements.txt, so nothing to install.")`.trim(); /** - * Service responsible for running init notebooks before the main notebook starts. - * Init notebooks typically contain setup code like pip installs. + * Service responsible for running a project's init notebook in a kernel. + * + * The init notebook lives in its own sibling `.deepnote` file (referenced by the main + * file's `project.initNotebookId`). Its setup blocks (typically pip installs) are run in + * the notebook's kernel on kernel start, and re-run after a kernel restart (a restart loses + * all in-kernel state). "Has the init already run" is tracked per kernel — not per project + * or per notebook URI — so the same-environment restart case re-initializes correctly. */ @injectable() -export class DeepnoteInitNotebookRunner { +export class DeepnoteInitNotebookRunner implements IDeepnoteInitNotebookRunner, IExtensionSyncActivationService { + // Tracks kernels that have already run init in their current lifetime. A fresh kernel is + // not in the set (init runs, then it is added); a restart re-runs unconditionally and + // re-marks; on kernel dispose the entry is collected automatically. + private readonly initRunByKernel = new WeakSet(); + constructor( @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, - @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider + @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, + @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry ) {} + public activate(): void { + // A fresh kernel start runs init once (gated by the WeakSet); an in-place restart + // fires onDidRestartKernel (not onDidStartKernel) and must always re-run init. + this.kernelProvider.onDidStartKernel(this.onDidStartKernel, this, this.disposables); + this.kernelProvider.onDidRestartKernel(this.onDidRestartKernel, this, this.disposables); + } + + private async onDidStartKernel(kernel: IKernel): Promise { + if (this.initRunByKernel.has(kernel)) { + return; + } + + await this.runInitForKernel(kernel); + + // Mark this kernel as initialized even when no valid sibling init was found — that + // only affects THIS kernel; a new kernel re-scans, so a later-added/fixed sibling is + // still picked up. + this.initRunByKernel.add(kernel); + } + + private async onDidRestartKernel(kernel: IKernel): Promise { + // A restart loses all in-kernel state, so re-run init unconditionally and re-mark. + await this.runInitForKernel(kernel); + this.initRunByKernel.add(kernel); + } + /** - * Runs the init notebook if it exists and hasn't been run yet for this project. - * This should be called after the kernel is started but before user code executes. - * @param notebook The notebook document - * @param projectId The Deepnote project ID - * @param token Optional cancellation token to stop execution if notebook is closed + * Runs the init notebook for a kernel, sourcing it from the project's sibling init file. + * Never throws — failures are logged so the user can continue. */ - async runInitNotebookIfNeeded( - projectId: string, - notebook: NotebookDocument, - token?: CancellationToken - ): Promise { - try { - // Check for cancellation before starting - if (token?.isCancellationRequested) { - logger.info(`Init notebook cancelled before start for project ${projectId}`); - return; - } - - // Check if init notebook has already run for this project - if (this.notebookManager.hasInitNotebookBeenRun(projectId)) { - logger.info(`Init notebook already ran for project ${projectId}, skipping`); - return; - } + private async runInitForKernel(kernel: IKernel): Promise { + const notebook = kernel.notebook; - if (token?.isCancellationRequested) { - logger.info(`Init notebook cancelled for project ${projectId}`); - return; - } + // Only Deepnote notebooks have init notebooks. + if (notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { + return; + } - // Get the project data - const project = this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteProject | undefined; - if (!project) { - logger.warn(`Project ${projectId} not found, cannot run init notebook`); + try { + const projectId = await resolveProjectIdForNotebook(notebook); + if (!projectId) { + logger.info( + `No Deepnote project id resolved for ${getDisplayPath(notebook.uri)}, skipping init notebook` + ); return; } - // Check if project has an init notebook ID - const initNotebookId = (project.project as { initNotebookId?: string }).initNotebookId; + const initNotebookId = this.notebookManager.getAnyProjectEntry(projectId)?.project.initNotebookId; if (!initNotebookId) { - logger.info(`No init notebook configured for project ${projectId}`); - // Mark as run so we don't check again - this.notebookManager.markInitNotebookAsRun(projectId); + logger.info(`No init notebook configured for project ${projectId}, skipping init`); return; } - // Find the init notebook - const initNotebook = project.project.notebooks.find((nb) => nb.id === initNotebookId); + const initNotebook = await this.findSiblingInitNotebook(notebook, projectId, initNotebookId); if (!initNotebook) { + // No valid sibling init found — log and skip. Do NOT permanently mark init as + // run beyond this kernel, so a later-added/fixed sibling is picked up next time. logger.warn( - `Init notebook ${initNotebookId} not found in project ${projectId}, skipping initialization` + `No valid sibling init file found for project ${projectId} (initNotebookId ${initNotebookId}), skipping init` ); - this.notebookManager.markInitNotebookAsRun(projectId); return; } - if (token?.isCancellationRequested) { - logger.info(`Init notebook cancelled before execution for project ${projectId}`); - return; - } - - logger.info(`Running init notebook "${initNotebook.name}" (${initNotebookId}) for project ${projectId}`); + logger.info( + `Running init notebook "${ + initNotebook.name + }" (${initNotebookId}) for project ${projectId} in kernel for ${getDisplayPath(notebook.uri)}` + ); - // Execute the init notebook with progress - const success = await this.executeInitNotebook(notebook, initNotebook, token); + const success = await this.executeInitNotebook(notebook, initNotebook); if (success) { - // Mark as run so we don't run it again - this.notebookManager.markInitNotebookAsRun(projectId); logger.info(`Init notebook completed successfully for project ${projectId}`); } else { logger.warn(`Init notebook did not execute for project ${projectId} - kernel not available`); } } catch (error) { - // Check if this is a cancellation error - if (error instanceof Error && error.message === 'Cancelled') { - logger.info(`Init notebook cancelled for project ${projectId}`); - return; + // Log error but don't throw - we want to let the user continue anyway. + logger.error(`Error running init notebook for ${getDisplayPath(notebook.uri)}:`, error); + } + } + + /** + * Finds the init notebook in its sibling `.deepnote` file. + * + * Scans the directory containing `notebook.uri` for `.deepnote` files (ignoring snapshot + * files), parses each, and returns the single notebook of the first file that is a valid + * init source for this project (same `project.id`, exactly one notebook whose `id` + * matches `initNotebookId`). + * + * @returns The init notebook, or undefined when no valid sibling init is found. + */ + private async findSiblingInitNotebook( + notebook: NotebookDocument, + projectId: string, + initNotebookId: string + ): Promise { + const dirUri = Uri.joinPath(notebook.uri, '..'); + + let entries: [string, number][]; + try { + entries = await workspace.fs.readDirectory(dirUri); + } catch (error) { + logger.warn(`Failed to read directory ${getDisplayPath(dirUri)} while looking for init notebook:`, error); + return undefined; + } + + for (const [name] of entries) { + if (!name.endsWith(DEEPNOTE_FILE_EXTENSION) || name.endsWith(SNAPSHOT_FILE_SUFFIX)) { + continue; + } + + const candidateUri = Uri.joinPath(dirUri, name); + try { + const candidate: DeepnoteFile = await readDeepnoteProjectFile(candidateUri); + const validation = isValidSiblingInitCandidate(candidate, projectId, initNotebookId); + + if (validation.valid) { + return candidate.project.notebooks[0]; + } + } catch (error) { + // Per-iteration error handling: a single unreadable/invalid file must not + // stop the scan of the rest. + logger.warn(`Failed to read candidate init file ${getDisplayPath(candidateUri)}:`, error); } - // Log error but don't throw - we want to let user continue anyway - logger.error(`Error running init notebook for project ${projectId}:`, error); - // Still mark as run to avoid retrying on every notebook open - this.notebookManager.markInitNotebookAsRun(projectId); } + + return undefined; } /** @@ -211,13 +286,13 @@ export class DeepnoteInitNotebookRunner { progress(`Running init notebook "${initNotebook.name}"...`, 0); // Get the kernel for this notebook - // Note: This should always exist because onKernelStarted already fired + // Note: This should always exist because the kernel start/restart event already fired const kernel = this.kernelProvider.get(notebook); if (!kernel) { logger.error( `No kernel found for ${getDisplayPath( notebook.uri - )} even after onDidStartKernel fired - this should not happen` + )} even after the kernel start/restart event fired - this should not happen` ); return false; } @@ -237,7 +312,7 @@ export class DeepnoteInitNotebookRunner { `Preparing to execute ${codeBlocks.length} initialization ${ codeBlocks.length === 1 ? 'block' : 'blocks' }...`, - 5 + INIT_PROGRESS_START_INCREMENT ); // Check for cancellation @@ -263,7 +338,7 @@ export class DeepnoteInitNotebookRunner { // Show more detailed progress with percentage progress( `[${percentComplete}%] Executing block ${i + 1} of ${codeBlocks.length}...`, - 90 / codeBlocks.length // Reserve 5% for start, 5% for finish + INIT_PROGRESS_BLOCKS_INCREMENT / codeBlocks.length ); logger.info(`Executing init notebook block ${i + 1}/${codeBlocks.length}`); @@ -303,10 +378,10 @@ export class DeepnoteInitNotebookRunner { } logger.info(`Completed executing all init notebook blocks`); - progress(`✓ Initialization complete! Environment ready.`, 5); + progress(`✓ Initialization complete! Environment ready.`, INIT_PROGRESS_FINISH_INCREMENT); // Give user a moment to see the completion message - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, INIT_COMPLETE_DISPLAY_DELAY_MS)); return true; } catch (error) { @@ -318,5 +393,5 @@ export class DeepnoteInitNotebookRunner { export const IDeepnoteInitNotebookRunner = Symbol('IDeepnoteInitNotebookRunner'); export interface IDeepnoteInitNotebookRunner { - runInitNotebookIfNeeded(projectId: string, notebook: NotebookDocument, token?: CancellationToken): Promise; + activate(): void; } diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts new file mode 100644 index 0000000000..96253d93fa --- /dev/null +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts @@ -0,0 +1,388 @@ +import { serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter, FileType, NotebookDocument, Uri } from 'vscode'; + +import { IKernel, IKernelProvider } from '../../kernels/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { IDeepnoteNotebookManager } from '../types'; +import { DeepnoteInitNotebookRunner } from './deepnoteInitNotebookRunner.node'; + +const PROJECT_ID = '11111111-1111-1111-1111-111111111111'; +const OTHER_PROJECT_ID = '22222222-2222-2222-2222-222222222222'; +const INIT_NOTEBOOK_ID = 'init-notebook-id'; +const MAIN_NOTEBOOK_ID = 'main-notebook-id'; + +const DIR_PATH = '/workspace/project'; +const MAIN_FILE_NAME = 'main.deepnote'; +const SIBLING_INIT_FILE_NAME = 'init.deepnote'; + +// The init's single CODE block content — the marker we assert flows through to executeHidden. +const SIBLING_INIT_CODE = 'pip install sibling-init-package'; +// Content that ONLY lives in the main file's notebooks — must NEVER be executed. +const MAIN_FILE_BLOCK_CODE = 'print("this is a main-file block, not init")'; + +const waitTimeoutMs = 5000; +const waitIntervalMs = 5; + +// The runner adds a kernel to its WeakSet only AFTER runInitForKernel fully returns, which +// includes a ~1000ms "init complete" display delay (INIT_COMPLETE_DISPLAY_DELAY_MS in the +// runner). Tests that fire a *second* start for the same kernel must wait past this so the +// gate has actually been set; we use a margin above the production delay. +const INIT_COMPLETE_DISPLAY_DELAY_MS = 1000; +const RUN_FULLY_SETTLED_MS = INIT_COMPLETE_DISPLAY_DELAY_MS + 300; + +/** Poll until `condition` is true (used to await the async, event-driven init run). */ +async function waitFor(condition: () => boolean, timeoutMs = waitTimeoutMs): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor timed out after ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, waitIntervalMs)); + } +} + +/** A short settle window used to PROVE that nothing further happened (no executeHidden, no scan). */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 120)); +} + +/** Wait long enough that a started init run has fully returned and its kernel is WeakSet-marked. */ +function waitForRunFullySettled(): Promise { + return new Promise((resolve) => setTimeout(resolve, RUN_FULLY_SETTLED_MS)); +} + +function basename(uri: Uri): string { + return uri.path.split('/').pop() ?? ''; +} + +/** + * Build a single-notebook DeepnoteFile whose one notebook carries the given code block. + * Adds a trailing markdown block so the runner's `type === 'code'` filter is exercised + * (a 1-code + 1-markdown init must produce exactly ONE executeHidden call per run). + */ +function makeSingleNotebookFile(projectId: string, notebookId: string, codeContent: string): DeepnoteFile { + return { + version: '1.0.0', + metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, + project: { + id: projectId, + name: 'Proj', + notebooks: [ + { + id: notebookId, + name: 'Init', + blocks: [ + { + id: `${notebookId}-code`, + type: 'code', + sortingKey: 'a0', + blockGroup: 'g', + content: codeContent + }, + { + id: `${notebookId}-md`, + type: 'markdown', + sortingKey: 'a1', + blockGroup: 'g', + content: '# notes' + } + ] + } + ] + } + } as unknown as DeepnoteFile; +} + +/** The cached project entry the manager returns for `getAnyProjectEntry` (carries initNotebookId). */ +function makeMainProjectEntry(projectId: string, initNotebookId: string | undefined): DeepnoteProject { + return { + version: '1.0.0', + metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, + project: { + id: projectId, + name: 'Proj', + ...(initNotebookId ? { initNotebookId } : {}), + notebooks: [ + { + id: MAIN_NOTEBOOK_ID, + name: 'Main', + blocks: [ + { id: 'main-b', type: 'code', sortingKey: 'a0', blockGroup: 'g', content: MAIN_FILE_BLOCK_CODE } + ] + } + ] + } + } as unknown as DeepnoteProject; +} + +suite('DeepnoteInitNotebookRunner', () => { + let runner: DeepnoteInitNotebookRunner; + + let mockNotebookManager: IDeepnoteNotebookManager; + let mockKernelProvider: IKernelProvider; + let mockDisposables: IDisposableRegistry; + + let onDidStartKernel: EventEmitter; + let onDidRestartKernel: EventEmitter; + + // Spy capturing every executeHidden(code) call across all kernels. + let executeHiddenSpy: sinon.SinonStub; + + // Directory listing returned by workspace.fs.readDirectory (basename → present on disk). + let directoryEntries: [string, FileType][]; + // basename → serialized .deepnote bytes for workspace.fs.readFile. + let fileBytesByName: Map; + // Counts readDirectory invocations, so we can prove a missing sibling is NOT permanently marked. + let readDirectoryCount: number; + + function putFile(name: string, file: DeepnoteFile): void { + fileBytesByName.set(name, new TextEncoder().encode(serializeDeepnoteFile(file))); + if (!directoryEntries.some(([n]) => n === name)) { + directoryEntries.push([name, FileType.File]); + } + } + + /** + * Build an IKernel whose `.notebook` points at the given file URI. Each kernel is a + * DISTINCT object identity, so the runner's WeakSet gate treats them separately. + */ + function makeKernel(fileName: string, opts?: { notebookType?: string; projectId?: string }): IKernel { + const uri = Uri.file(`${DIR_PATH}/${fileName}`); + const notebook = { + uri, + notebookType: opts?.notebookType ?? 'deepnote', + metadata: { deepnoteProjectId: opts?.projectId ?? PROJECT_ID } + } as unknown as NotebookDocument; + + return { notebook } as unknown as IKernel; + } + + setup(() => { + resetVSCodeMocks(); + + mockNotebookManager = mock(); + mockKernelProvider = mock(); + mockDisposables = mock(); + + onDidStartKernel = new EventEmitter(); + onDidRestartKernel = new EventEmitter(); + when(mockKernelProvider.onDidStartKernel).thenReturn(onDidStartKernel.event); + when(mockKernelProvider.onDidRestartKernel).thenReturn(onDidRestartKernel.event); + + // get(notebook) must return a kernel — the runner re-fetches it inside executeInitNotebookImpl. + // Return any non-undefined kernel; the impl only uses it to call getKernelExecution. + when(mockKernelProvider.get(anything())).thenReturn({} as unknown as IKernel); + + executeHiddenSpy = sinon.stub().callsFake(() => Promise.resolve([])); + when(mockKernelProvider.getKernelExecution(anything())).thenReturn({ + executeHidden: executeHiddenSpy + } as never); + + // Default cached project: has an init notebook configured. + when(mockNotebookManager.getAnyProjectEntry(PROJECT_ID)).thenReturn( + makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) + ); + + directoryEntries = []; + fileBytesByName = new Map(); + readDirectoryCount = 0; + + const mockFs = mock(); + when(mockFs.readDirectory(anything())).thenCall(() => { + readDirectoryCount++; + return Promise.resolve(directoryEntries.map(([n, t]) => [n, t] as [string, FileType])); + }); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + const bytes = fileBytesByName.get(basename(uri)); + if (!bytes) { + return Promise.reject(new Error(`no such file: ${basename(uri)}`)); + } + return Promise.resolve(bytes); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + runner = new DeepnoteInitNotebookRunner( + instance(mockNotebookManager), + instance(mockKernelProvider), + instance(mockDisposables) + ); + runner.activate(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('runs init from the SIBLING file (not the main file) — post-migration the init is not in main.project.notebooks', async () => { + // Main file's cached project references INIT_NOTEBOOK_ID but does NOT contain it; the + // init lives in a sibling .deepnote in the same directory. + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernel = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernel); + + // One code block in the sibling init → exactly one executeHidden call. + await waitFor(() => executeHiddenSpy.callCount >= 1); + + assert.strictEqual(executeHiddenSpy.callCount, 1, 'exactly the sibling init code block should run'); + assert.strictEqual( + executeHiddenSpy.firstCall.args[0], + SIBLING_INIT_CODE, + 'must execute the SIBLING init block content' + ); + // The main file's own block content must never be executed. + assert.isFalse( + executeHiddenSpy.getCalls().some((c) => c.args[0] === MAIN_FILE_BLOCK_CODE), + 'must NOT run anything from the main file notebooks' + ); + }); + + test('missing sibling → logged and NOT permanently marked: a later NEW kernel re-scans the directory', async () => { + // initNotebookId is configured, but NO valid sibling exists on disk (only the main file). + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + + const kernelA = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernelA); + await settle(); + + assert.strictEqual(executeHiddenSpy.callCount, 0, 'no init should run when the sibling is missing'); + const scansAfterFirst = readDirectoryCount; + assert.isAtLeast(scansAfterFirst, 1, 'the first start must attempt a directory scan'); + + // A brand-new kernel (different IKernel identity) must re-scan — the project was NOT + // permanently marked, so a later-added/fixed sibling would be picked up. + const kernelB = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernelB); + await waitFor(() => readDirectoryCount > scansAfterFirst); + + assert.isAbove(readDirectoryCount, scansAfterFirst, 'a new kernel must re-scan (not permanently marked)'); + assert.strictEqual(executeHiddenSpy.callCount, 0, 'still nothing to run while the sibling is absent'); + }); + + test('per-kernel A/B: two different kernels each run init when their onDidStartKernel fires', async () => { + // Two siblings of ONE project, each opened in its own kernel. Both kernels' starts run init. + putFile('a.deepnote', makeSingleNotebookFile(PROJECT_ID, MAIN_NOTEBOOK_ID, 'print("a main")')); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernelA = makeKernel('a.deepnote'); + const kernelB = makeKernel('b.deepnote'); + + onDidStartKernel.fire(kernelA); + onDidStartKernel.fire(kernelB); + + // Each kernel runs the single init code block once → two calls total. + await waitFor(() => executeHiddenSpy.callCount >= 2); + + assert.strictEqual(executeHiddenSpy.callCount, 2, 'both kernels A and B must each run init exactly once'); + assert.deepStrictEqual( + executeHiddenSpy.getCalls().map((c) => c.args[0]), + [SIBLING_INIT_CODE, SIBLING_INIT_CODE], + 'both runs execute the sibling init block' + ); + }); + + test('same kernel start fires twice → init runs only once (WeakSet gate prevents doubling)', async () => { + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernel = makeKernel(MAIN_FILE_NAME); + + onDidStartKernel.fire(kernel); + await waitFor(() => executeHiddenSpy.callCount >= 1); + // The WeakSet marker is set only after the run fully returns (past the display delay); + // wait for that so the second start actually exercises the gate (not a race before marking). + await waitForRunFullySettled(); + + // Fire start AGAIN for the same kernel instance — the WeakSet gate must short-circuit it. + onDidStartKernel.fire(kernel); + await settle(); + + assert.strictEqual( + executeHiddenSpy.callCount, + 1, + 'a repeated start for the same kernel must NOT run init a second time' + ); + }); + + test('RESTART re-runs init even though the kernel already ran it (onDidRestartKernel is unconditional)', async () => { + // This is the key fix: an in-place restart fires onDidRestartKernel (NOT onDidStartKernel) + // and loses all in-kernel state, so init MUST re-run before the next user cell. + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernel = makeKernel(MAIN_FILE_NAME); + + // First start runs init once and (after the run fully settles) marks the kernel in the + // WeakSet — so the restart below proves re-run despite an ALREADY-SET gate, not a race. + onDidStartKernel.fire(kernel); + await waitFor(() => executeHiddenSpy.callCount >= 1); + assert.strictEqual(executeHiddenSpy.callCount, 1, 'start runs init once'); + await waitForRunFullySettled(); + + // Sanity: a repeated START would now be gated (kernel is marked) — so the second run + // below can only come from the restart path being unconditional. + onDidStartKernel.fire(kernel); + await settle(); + assert.strictEqual(executeHiddenSpy.callCount, 1, 'a repeated start is gated once the kernel is marked'); + + // Restart the SAME (already-marked) kernel — init MUST run a SECOND time regardless. + onDidRestartKernel.fire(kernel); + await waitFor(() => executeHiddenSpy.callCount >= 2); + + assert.strictEqual(executeHiddenSpy.callCount, 2, 'restart must re-run init (a second executeHidden pass)'); + assert.strictEqual( + executeHiddenSpy.secondCall.args[0], + SIBLING_INIT_CODE, + 'the restart re-run executes the sibling init block again' + ); + }); + + test('non-deepnote kernel is ignored: onDidStartKernel for a non-deepnote notebook does nothing', async () => { + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernel = makeKernel(MAIN_FILE_NAME, { notebookType: 'jupyter-notebook' }); + onDidStartKernel.fire(kernel); + await settle(); + + assert.strictEqual(executeHiddenSpy.callCount, 0, 'a non-deepnote kernel must not trigger init'); + assert.strictEqual(readDirectoryCount, 0, 'a non-deepnote kernel must not even scan for siblings'); + }); + + test('no init configured: undefined initNotebookId runs nothing and never scans the directory', async () => { + // The cached project has NO initNotebookId. + when(mockNotebookManager.getAnyProjectEntry(PROJECT_ID)).thenReturn( + makeMainProjectEntry(PROJECT_ID, undefined) + ); + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, undefined) as unknown as DeepnoteFile); + + const kernel = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernel); + await settle(); + + assert.strictEqual(executeHiddenSpy.callCount, 0, 'no init must run when initNotebookId is undefined'); + assert.strictEqual(readDirectoryCount, 0, 'with no init configured there is no need to scan the directory'); + }); + + test('sibling of a DIFFERENT project is not a valid init source (project.id must match)', async () => { + // A sibling exists with the right initNotebookId-shaped notebook but a different project.id. + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(OTHER_PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + + const kernel = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernel); + await settle(); + + assert.strictEqual( + executeHiddenSpy.callCount, + 0, + 'a sibling whose project.id does not match must be rejected as an init source' + ); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index ec1a57a1b3..f58d2ccef9 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -7,7 +7,6 @@ import { inject, injectable, named, optional } from 'inversify'; import { CancellationToken, CancellationTokenSource, - Disposable, NotebookController, NotebookControllerAffinity, NotebookDocument, @@ -42,7 +41,7 @@ import { IJupyterRequestCreator, JupyterServerProviderHandle } from '../../kernels/jupyter/types'; -import { IJupyterKernelSpec, IKernel, IKernelProvider } from '../../kernels/types'; +import { IJupyterKernelSpec, IKernelProvider } from '../../kernels/types'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IPythonExtensionChecker } from '../../platform/api/types'; import { Cancellation, isCancellationError } from '../../platform/common/cancellation'; @@ -56,7 +55,6 @@ import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { IControllerRegistration, IVSCodeNotebookController } from '../controllers/types'; import { IDeepnoteNotebookManager } from '../types'; -import { IDeepnoteInitNotebookRunner } from './deepnoteInitNotebookRunner.node'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; @@ -79,11 +77,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly placeholderControllers = new Map(); // Track server handles per NOTEBOOK (keyed by notebook.uri.toString()) - one server per notebook private readonly projectServerHandles = new Map(); - // Track projects where we need to run init notebook (set during controller setup) - private readonly projectsPendingInitNotebook = new Map< - string, - { notebook: NotebookDocument; project: DeepnoteFile } - >(); constructor( @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @@ -96,7 +89,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, @optional() private readonly requestAgentCreator: IJupyterRequestAgentCreator | undefined, @inject(IConfigurationService) private readonly configService: IConfigurationService, - @inject(IDeepnoteInitNotebookRunner) private readonly initNotebookRunner: IDeepnoteInitNotebookRunner, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, @inject(IDeepnoteRequirementsHelper) private readonly requirementsHelper: IDeepnoteRequirementsHelper, @@ -123,10 +115,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.disposables ); - // Listen to kernel starts to run init notebooks - // Kernels are created lazily when cells are executed, so this is the right time to run init notebook - this.kernelProvider.onDidStartKernel(this.onKernelStarted, this, this.disposables); - // Handle currently open notebooks - await all async operations Promise.all(workspace.notebookDocuments.map((d) => this.onDidOpenNotebook(d))).catch((error) => { logger.error(`Error handling open notebooks during activation: ${error}`); @@ -319,64 +307,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } } - public async onKernelStarted(kernel: IKernel) { - // Only handle deepnote notebooks - if (kernel.notebook?.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { - return; - } - - const notebook = kernel.notebook; - const projectId = notebook.metadata?.deepnoteProjectId; - - if (!projectId) { - return; - } - - // Check if we have a pending init notebook for this project - const pendingInit = this.projectsPendingInitNotebook.get(projectId); - if (!pendingInit) { - return; // No init notebook to run - } - - logger.info(`Kernel started for Deepnote notebook, running init notebook for project ${projectId}`); - - // Remove from pending list - this.projectsPendingInitNotebook.delete(projectId); - - // Create a CancellationTokenSource tied to the notebook lifecycle - const cts = new CancellationTokenSource(); - const disposables: Disposable[] = []; - - try { - // Register handler to cancel the token if the notebook is closed - // Note: We check the URI to ensure we only cancel for the specific notebook that closed - const closeListener = workspace.onDidCloseNotebookDocument((closedNotebook) => { - if (closedNotebook.uri.toString() === notebook.uri.toString()) { - logger.info(`Notebook closed while init notebook was running, cancelling for project ${projectId}`); - cts.cancel(); - } - }); - disposables.push(closeListener); - - // Run init notebook with cancellation support - await this.initNotebookRunner.runInitNotebookIfNeeded(projectId, notebook, cts.token); - } catch (error) { - // Check if this is a cancellation error - if so, just log and continue - if (error instanceof Error && error.message === 'Cancelled') { - logger.info(`Init notebook cancelled for project ${projectId}`); - - return; - } - - logger.error('Error running init notebook', error); - // Continue anyway - don't block user if init fails - } finally { - // Always clean up the CTS and event listeners - cts.dispose(); - disposables.forEach((d) => d.dispose()); - } - } - /** * Switch controller to use a different environment by updating the existing controller's connection. * Because we use notebook-based controller IDs (not environment-based), the controller ID stays the same @@ -662,11 +592,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } else { logger.info(`Skipping requirements.txt creation for project ${projectId} (no changes detected)`); } - - if (project.project.initNotebookId && !this.notebookManager.hasInitNotebookBeenRun(projectId!)) { - this.projectsPendingInitNotebook.set(projectId!, { notebook, project }); - logger.info(`Init notebook will run automatically when kernel starts for project ${projectId}`); - } } // Mark controller as protected diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index f682816f65..7f9641fed8 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -16,7 +16,6 @@ import { IDisposableRegistry, IOutputChannel } from '../../platform/common/types import { IPythonExtensionChecker } from '../../platform/api/types'; import { IJupyterRequestCreator } from '../../kernels/jupyter/types'; import { IConfigurationService } from '../../platform/common/types'; -import { IDeepnoteInitNotebookRunner } from './deepnoteInitNotebookRunner.node'; import { IDeepnoteNotebookManager } from '../types'; import { IKernelProvider, IKernel, IJupyterKernelSpec } from '../../kernels/types'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; @@ -35,7 +34,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { let mockLspClientManager: IDeepnoteLspClientManager; let mockRequestCreator: IJupyterRequestCreator; let mockConfigService: IConfigurationService; - let mockInitNotebookRunner: IDeepnoteInitNotebookRunner; let mockNotebookManager: IDeepnoteNotebookManager; let mockKernelProvider: IKernelProvider; let mockRequirementsHelper: IDeepnoteRequirementsHelper; @@ -65,7 +63,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockLspClientManager = mock(); mockRequestCreator = mock(); mockConfigService = mock(); - mockInitNotebookRunner = mock(); mockNotebookManager = mock(); mockKernelProvider = mock(); mockRequirementsHelper = mock(); @@ -131,7 +128,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockRequestCreator), undefined, // requestAgentCreator is optional instance(mockConfigService), - instance(mockInitNotebookRunner), instance(mockNotebookManager), instance(mockKernelProvider), instance(mockRequirementsHelper), @@ -322,26 +318,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); - suite('onKernelStarted', () => { - test('should return early and not call initNotebookRunner for non-deepnote notebooks', async () => { - // Arrange - const mockKernel = mock(); - const mockJupyterNotebook = mock(); - - when(mockJupyterNotebook.notebookType).thenReturn('jupyter-notebook'); - when(mockKernel.notebook).thenReturn(instance(mockJupyterNotebook)); - - // Mock initNotebookRunner to track if it gets called - when(mockInitNotebookRunner.runInitNotebookIfNeeded(anything(), anything(), anything())).thenResolve(); - - // Act - await selector.onKernelStarted(instance(mockKernel)); - - // Assert - verify initNotebookRunner was never called - verify(mockInitNotebookRunner.runInitNotebookIfNeeded(anything(), anything(), anything())).never(); - }); - }); - suite('ensureKernelSelected', () => { test('should return false when no environment ID is assigned to the notebook', async () => { // Mock environment mapper to return null (no environment assigned) diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index dfe8269215..9e30652e10 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -12,7 +12,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { // Cached originals are keyed by projectId, then by notebookId, so sibling files // that share a single project.id do not clobber each other's cached project data. private readonly originalProjects = new Map>(); - private readonly projectsWithInitNotebookRun = new Set(); /** * Returns any cached project entry for the given project id. @@ -49,23 +48,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { return this.originalProjects.get(projectId)?.get(notebookId); } - /** - * Checks if the init notebook has already been run for a project. - * @param projectId Project identifier - * @returns True if init notebook has been run, false otherwise - */ - hasInitNotebookBeenRun(projectId: string): boolean { - return this.projectsWithInitNotebookRun.has(projectId); - } - - /** - * Marks the init notebook as having been run for a project. - * @param projectId Project identifier - */ - markInitNotebookAsRun(projectId: string): void { - this.projectsWithInitNotebookRun.add(projectId); - } - /** * Stores the original project data for an exact (projectId, notebookId) pair. * This is used during deserialization to cache project data. diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 84522c38b9..200901c0b5 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -253,6 +253,7 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IDeepnoteLspClientManager, DeepnoteLspClientManager); serviceManager.addBinding(IDeepnoteLspClientManager, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteInitNotebookRunner, DeepnoteInitNotebookRunner); + serviceManager.addBinding(IDeepnoteInitNotebookRunner, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteRequirementsHelper, DeepnoteRequirementsHelper); serviceManager.addSingleton( IExtensionSyncActivationService, diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index eab2f228ba..18a097beeb 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -64,7 +64,4 @@ export interface IDeepnoteNotebookManager { * @returns `true` if at least one cached entry was found and updated, `false` otherwise */ updateProjectIntegrations(projectId: string, integrations: ProjectIntegration[]): boolean; - - hasInitNotebookBeenRun(projectId: string): boolean; - markInitNotebookAsRun(projectId: string): void; } From 4a43a7df73b2f3697ca51143d96d329842f2c63b Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 06:54:48 +0000 Subject: [PATCH 09/26] fix(deepnote): satisfy lint and spell-check on the migration branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - void the fire-and-forget onExecutionComplete call (no-floating-promises), matching the existing void performSnapshotSave pattern. - Use American "behavior" in a comment. - Add test-only technical words (basenames, initmain, Résumé, unparseable) to cspell. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- cspell.json | 4 ++++ src/notebooks/deepnote/deepnoteExplorerView.ts | 2 +- src/notebooks/deepnote/snapshots/snapshotService.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cspell.json b/cspell.json index 8aa54f8759..ee978b2ea9 100644 --- a/cspell.json +++ b/cspell.json @@ -27,6 +27,7 @@ "altuser", "anyio", "artmann", + "basenames", "blockgroup", "boto", "channeldef", @@ -50,6 +51,7 @@ "findstr", "getsitepackages", "IMAGENAME", + "initmain", "ipykernel", "ipynb", "jupyter", @@ -78,6 +80,7 @@ "PYTHONHOME", "pyyaml", "Reselecting", + "Résumé", "rootpass", "scikit", "scipy", @@ -95,6 +98,7 @@ "trino", "Trino", "unconfigured", + "unparseable", "Unconfigured", "unuse", "unittests", diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 1bf3fe591c..c07f4b56e0 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -241,7 +241,7 @@ export class DeepnoteExplorerView { return; } - // Legacy multi-notebook file: append the duplicate in place (existing behaviour). + // Legacy multi-notebook file: append the duplicate in place (existing behavior). projectData.project.notebooks.push(newNotebook); await this.writeProjectFile(fileUri, projectData); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 27b696496a..54246bdbcc 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -220,7 +220,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync notebookCellExecutions.onDidCompleteQueueExecution( (e) => { logger.debug(`[Snapshot] Queue execution complete for ${e.notebookUri}`); - this.onExecutionComplete(e.notebookUri); + void this.onExecutionComplete(e.notebookUri); }, this, this.disposables From f92d1733a0c9f7bc9ce092537cc54b8a03052944 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 08:04:14 +0000 Subject: [PATCH 10/26] test(deepnote): use the real @deepnote/convert in unit tests instead of duplicating it The mocha ESM loader wholesale-mocked @deepnote/convert and reimplemented its pure helpers (resolveSnapshotNotebookId, splitByNotebooks, isValidSiblingInitCandidate, snapshot filename generate/parse, hashing, etc.). That duplicated upstream logic with no drift detection: if convert changed, the mock silently kept the old behavior and tests stayed green against a fiction. - Remove the @deepnote/convert interception from build/mocha-esm-loader.js so unit tests exercise the real package's pure functions (and now track its actual API). - Mock only the one genuinely side-effecting export, convertIpynbFilesToDeepnoteFile (real node:fs I/O), via esmock in the explorer import suites where it is used. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- build/mocha-esm-loader.js | 262 ------------------ .../deepnoteExplorerView.unit.test.ts | 33 +++ 2 files changed, 33 insertions(+), 262 deletions(-) diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index a6eb4a7255..64a610dd08 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -104,15 +104,6 @@ export async function resolve(specifier, context, nextResolve) { }; } - // 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', - shortCircuit: true - }; - } - // Intercept @deepnote/runtime-core - needed because the real startServer/stopServer // spawn/kill real Python processes. The mock records calls and returns a fake ServerInfo // (faithful to the { url, jupyterPort, lspPort, process } contract) so the extension's @@ -364,259 +355,6 @@ export async function load(url, context, nextLoad) { }; } - // Handle deepnote convert mock - needed because the real package performs file I/O - if (moduleName === 'deepnote-convert') { - return { - format: 'module', - source: ` - import { createHash } from 'node:crypto'; - - 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 - } - })); - }; - - export const slugifyProjectName = (name) => { - return (name || '') - .normalize('NFD') - .replace(/[\\u0300-\\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - }; - - export const isSingleNotebookDeepnoteFile = (file) => { - return (file?.project?.notebooks?.length || 0) === 1; - }; - - export const splitByNotebooks = (file, sourceFileStem) => { - const notebooks = file?.project?.notebooks || []; - const initNotebookId = file?.project?.initNotebookId; - const used = new Set(); - const allocate = (slug) => { - const base = sourceFileStem + '-' + (slug || 'notebook'); - let candidate = base + '.deepnote'; - let n = 2; - while (used.has(candidate)) { - candidate = base + '-' + n + '.deepnote'; - n++; - } - used.add(candidate); - return candidate; - }; - const ordered = [...notebooks].sort((a, b) => { - const aInit = a.id === initNotebookId ? 0 : 1; - const bInit = b.id === initNotebookId ? 0 : 1; - return aInit - bInit; - }); - return ordered.map((nb) => ({ - notebook: { id: nb.id, name: nb.name }, - file: { ...file, project: { ...file.project, notebooks: [nb] } }, - outputFilename: allocate(slugifyProjectName(nb.name)) - })); - }; - - export const isValidSiblingInitCandidate = (candidate, expectedProjectId, initNotebookId) => { - if (candidate?.project?.id !== expectedProjectId) { - return { - valid: false, - reason: - 'project.id mismatch (expected ' + - expectedProjectId + - ', got ' + - candidate?.project?.id + - ')' - }; - } - const candidateNotebooks = candidate?.project?.notebooks || []; - if (candidateNotebooks.length !== 1) { - return { - valid: false, - reason: 'expected exactly 1 notebook, found ' + candidateNotebooks.length - }; - } - const onlyNotebook = candidateNotebooks[0]; - if (onlyNotebook.id !== initNotebookId) { - return { - valid: false, - reason: - 'single notebook id ' + - onlyNotebook.id + - ' does not match initNotebookId ' + - initNotebookId - }; - } - return { valid: true }; - }; - - // --- Snapshot filename helpers (faithful to @deepnote/convert dist) --- - - const FILENAME_SAFE_NOTEBOOK_ID_CHAR = /[A-Za-z0-9_-]/; - const utf8Encoder = new TextEncoder(); - const utf8Decoder = new TextDecoder(); - - const sanitizeFilenameComponent = (value) => value.replace(/[^a-zA-Z0-9_-]/g, '_'); - - export const encodeNotebookIdForFilename = (notebookId) => { - let encoded = ''; - for (const char of notebookId) { - if (FILENAME_SAFE_NOTEBOOK_ID_CHAR.test(char)) { - encoded += char; - continue; - } - for (const byte of utf8Encoder.encode(char)) { - encoded += '%' + byte.toString(16).toUpperCase().padStart(2, '0'); - } - } - return encoded; - }; - - export const decodeNotebookIdFromFilename = (encoded) => { - const bytes = []; - let i = 0; - while (i < encoded.length) { - const hex = encoded.slice(i + 1, i + 3); - if (encoded[i] === '%' && /^[0-9A-Fa-f]{2}$/.test(hex)) { - bytes.push(Number.parseInt(hex, 16)); - i += 3; - } else { - bytes.push(encoded.charCodeAt(i)); - i += 1; - } - } - return utf8Decoder.decode(new Uint8Array(bytes)); - }; - - export const generateSnapshotFilename = (params) => { - const { slug, projectId, notebookId, timestamp = 'latest' } = params; - const safeSlug = sanitizeFilenameComponent(slug); - const safeProjectId = sanitizeFilenameComponent(projectId); - if (notebookId) { - return safeSlug + '_' + safeProjectId + '_' + encodeNotebookIdForFilename(notebookId) + '_' + timestamp + '.snapshot.deepnote'; - } - return safeSlug + '_' + safeProjectId + '_' + timestamp + '.snapshot.deepnote'; - }; - - const SNAPSHOT_SINGLE_NOTEBOOK_FILENAME_PATTERN = new RegExp('^(.+)_([0-9a-f-]{36})_([0-9a-f]{32}|[0-9a-f-]{36}|[A-Za-z0-9%][A-Za-z0-9_%-]*)_(latest|[\\\\dT:-]+)\\\\.snapshot\\\\.deepnote$'); - const SNAPSHOT_MULTI_NOTEBOOK_FILENAME_PATTERN = new RegExp('^(.+)_([0-9a-f-]{36})_(latest|[\\\\dT:-]+)\\\\.snapshot\\\\.deepnote$'); - - export const parseSnapshotFilename = (filename) => { - const match = SNAPSHOT_SINGLE_NOTEBOOK_FILENAME_PATTERN.exec(filename); - if (match) { - return { - slug: match[1], - projectId: match[2], - notebookId: decodeNotebookIdFromFilename(match[3]), - timestamp: match[4] - }; - } - const legacy = SNAPSHOT_MULTI_NOTEBOOK_FILENAME_PATTERN.exec(filename); - if (!legacy) { - return null; - } - return { - slug: legacy[1], - projectId: legacy[2], - timestamp: legacy[3] - }; - }; - - export const resolveSnapshotNotebookId = (file) => { - const project = file?.project || {}; - const notebooks = project.notebooks || []; - const initNotebookId = project.initNotebookId; - if (notebooks.length === 1) { - return notebooks[0].id; - } - if (notebooks.length === 2 && initNotebookId !== undefined) { - const initNotebook = notebooks.find((nb) => nb.id === initNotebookId); - const nonInit = notebooks.find((nb) => nb.id !== initNotebookId); - if (initNotebook !== undefined && nonInit !== undefined) { - return nonInit.id; - } - } - return undefined; - }; - - // --- Snapshot output/hash helpers (faithful to @deepnote/convert dist) --- - - const EXECUTABLE_BLOCK_TYPES = new Set(['code', 'sql', 'chart', 'input', 'button']); - const isExecutableBlockTypeMock = (type) => - EXECUTABLE_BLOCK_TYPES.has(type) || (typeof type === 'string' && type.startsWith('sql')); - - export const hasOutputs = (file) => { - for (const notebook of file?.project?.notebooks || []) { - for (const block of notebook.blocks || []) { - if (!isExecutableBlockTypeMock(block.type)) continue; - if (block.outputs && block.outputs.length > 0) return true; - } - } - return false; - }; - - export const countBlocksWithOutputs = (file) => { - let count = 0; - for (const notebook of file?.project?.notebooks || []) { - for (const block of notebook.blocks || []) { - if (block.outputs && block.outputs.length > 0) count++; - } - } - return count; - }; - - export const computeSnapshotHash = (file) => { - const parts = []; - parts.push('version:' + file.version); - if (file.environment && file.environment.hash) { - parts.push('env:' + file.environment.hash); - } - const sortedIntegrations = [...(file.project.integrations || [])].sort((a, b) => - a.id.localeCompare(b.id) - ); - for (const integration of sortedIntegrations) { - parts.push('integration:' + integration.id + ':' + integration.type); - } - for (const notebook of file.project.notebooks) { - for (const block of notebook.blocks) { - if (block.contentHash) { - parts.push('block:' + block.id + ':' + block.contentHash); - } - } - } - const combined = parts.join('\\n'); - return 'sha256:' + createHash('sha256').update(combined, 'utf-8').digest('hex'); - }; - `, - shortCircuit: true - }; - } - // Handle @deepnote/runtime-core mock - needed because the real startServer/stopServer // spawn/kill real Python processes. The mock keeps a shared call log so tests can // assert how many servers were started/stopped and with which working directories. diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 5936f7ea93..14cde40189 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -1,5 +1,6 @@ import { deserializeDeepnoteFile, ExecutableBlock, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; import { assert, expect } from 'chai'; +import esmock from 'esmock'; import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { Uri, workspace } from 'vscode'; @@ -446,6 +447,23 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); suite('importNotebook', () => { + // `convertIpynbFilesToDeepnoteFile` does real node:fs I/O, so stub just that one + // @deepnote/convert export (all other exports stay the real implementation) while the + // import flow is exercised. This mocks the side-effecting function where it matters, + // instead of reimplementing the whole package. + let importModule: typeof import('./deepnoteExplorerView'); + + setup(async () => { + importModule = await esmock('./deepnoteExplorerView', { + '@deepnote/convert': { convertIpynbFilesToDeepnoteFile: async () => {} } + }); + explorerView = new importModule.DeepnoteExplorerView(mockContext, createMockLogger()); + }); + + teardown(() => { + esmock.purge(importModule); + }); + test('should import deepnote files', async () => { const workspaceFolder = { uri: Uri.file('/workspace') }; const sourceUri = Uri.file('/external/test.deepnote'); @@ -616,6 +634,21 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); suite('importJupyterNotebook', () => { + // Stub only the side-effecting `convertIpynbFilesToDeepnoteFile` (real node:fs I/O); + // all other @deepnote/convert exports remain the real implementation. + let importModule: typeof import('./deepnoteExplorerView'); + + setup(async () => { + importModule = await esmock('./deepnoteExplorerView', { + '@deepnote/convert': { convertIpynbFilesToDeepnoteFile: async () => {} } + }); + explorerView = new importModule.DeepnoteExplorerView(mockContext, createMockLogger()); + }); + + teardown(() => { + esmock.purge(importModule); + }); + test('should import jupyter notebook with correct naming', async () => { const workspaceFolder = { uri: Uri.file('/workspace') }; const sourceUri = Uri.file('/external/my-analysis.ipynb'); From 6532d96b5017895878747c6fad9fef562b0e87e5 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 24 Jun 2026 12:27:06 +0000 Subject: [PATCH 11/26] fix(deepnote): address code-review findings on the snapshot/serializer paths From the Codex review of the PR (F1/F3/F4/F5), all independently verified: - F1 (P1): snapshot save fetched the cached project with getAnyProjectEntry(projectId), which can return the wrong sibling when multiple single-notebook siblings of one project are open, silently skipping the snapshot write. Use the exact getOriginalProject(projectId, notebookId) lookup instead. - F3: collectNotebookNamesForProject globbed **/*.deepnote without skipping snapshot sidecars, so stale snapshot notebook names polluted the name-uniqueness set. Filter snapshot files (matching the tree provider and propagator). - F4: detectContentChanges compared notebooks[0]; for a legacy [init, main] file the edited notebook is not at index 0, so edits were missed and modifiedAt preserved. Match the notebook by id. - F5: the deferred-save timer fired performSnapshotSave as a floating promise; wrap the save body in try/catch/finally so a build/write failure is logged (not an unhandled rejection) and execution state is always cleared. Adds regression tests for F1 (exact lookup), F3 (snapshot exclusion), and F4 (match by id). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- .../deepnote/deepnoteExplorerView.ts | 8 + .../deepnoteExplorerView.unit.test.ts | 36 +++++ src/notebooks/deepnote/deepnoteSerializer.ts | 11 +- .../deepnote/deepnoteSerializer.unit.test.ts | 56 +++++-- .../deepnote/snapshots/snapshotService.ts | 138 ++++++++++-------- .../snapshots/snapshotService.unit.test.ts | 15 +- 6 files changed, 186 insertions(+), 78 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index c07f4b56e0..9618d4416d 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -20,6 +20,7 @@ import { ILogger } from '../../platform/logging/types'; import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { buildSingleNotebookFile, buildSiblingNotebookFileUri } from './deepnoteNotebookFileFactory'; import { deepnoteFileExists } from './deepnoteSiblingFileAllocator'; +import { isSnapshotFile } from './snapshots/snapshotFiles'; /** * Manages the Deepnote explorer tree view and related commands. @@ -463,6 +464,13 @@ export class DeepnoteExplorerView { } for (const fileUri of files) { + // Skip snapshot sidecars (`*.snapshot.deepnote`): they are full project clones, so + // their stale notebook names would otherwise pollute the uniqueness set. The tree + // provider and metadata propagator filter them the same way. + if (isSnapshotFile(fileUri)) { + continue; + } + try { const projectData = await readDeepnoteProjectFile(fileUri); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 14cde40189..24f626d483 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -3096,5 +3096,41 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { assert.isFalse(names.has('Alpha'), 'the excluded current name must not be in the set'); assert.strictEqual(names.size, 0); }); + + test('excludes snapshot sidecar files so stale snapshot notebook names cannot pollute the set', async () => { + const projectId = 'group-project'; + const pathA = '/workspace/a.deepnote'; + // A snapshot sidecar is a full project clone (same project.id) ending in `.snapshot.deepnote`, + // carrying a notebook name that may be stale (e.g. a since-renamed/deleted notebook). + const snapshotPath = '/workspace/snapshots/test_group-project_latest.snapshot.deepnote'; + const fileA = singleNotebookFile(projectId, 'nb-a', 'Alpha'); + const snapshotFile = singleNotebookFile(projectId, 'nb-stale', 'StaleSnapshotName'); + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + { uri: Uri.file('/workspace') } as any + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn( + Promise.resolve([Uri.file(pathA), Uri.file(snapshotPath)]) + ); + + const byPath: Record = { + [Uri.file(pathA).fsPath]: fileA, + [Uri.file(snapshotPath).fsPath]: snapshotFile + }; + const mockFS = mock(); + when(mockFS.readFile(anything())).thenCall((uri: Uri) => + Promise.resolve(Buffer.from(serializeDeepnoteFile(byPath[uri.fsPath]))) + ); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + const names: Set = await (explorerView as any).collectNotebookNamesForProject(projectId); + + assert.isTrue(names.has('Alpha'), 'the real sibling name must be collected'); + assert.isFalse( + names.has('StaleSnapshotName'), + 'names from snapshot sidecars must be excluded from the uniqueness set' + ); + assert.deepStrictEqual([...names].sort(), ['Alpha']); + }); }); }); diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index a817e3a1e5..e7473633cb 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -295,7 +295,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { // Update modifiedAt conditionally based on snapshot mode if (this.snapshotService?.isSnapshotsEnabled()) { // In snapshot mode, only update modifiedAt if content actually changed - const hasContentChanges = this.detectContentChanges(originalProject, storedProject); + const hasContentChanges = this.detectContentChanges(originalProject, storedProject, notebookId); if (hasContentChanges) { originalProject.metadata.modifiedAt = new Date().toISOString(); @@ -428,9 +428,12 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { * @param originalProject The stored original project * @returns true if content has changed, false otherwise */ - private detectContentChanges(newProject: DeepnoteFile, originalProject: DeepnoteFile): boolean { - const newNotebook = newProject.project.notebooks[0]; - const originalNotebook = originalProject.project.notebooks[0]; + private detectContentChanges(newProject: DeepnoteFile, originalProject: DeepnoteFile, notebookId: string): boolean { + // Match the edited notebook by id rather than a fixed [0] slot. For a single-notebook file + // these coincide, but for a legacy [init, main] file the rendered/edited notebook is not at + // index 0, so comparing [0] (the init) would miss real edits and wrongly preserve modifiedAt. + const newNotebook = newProject.project.notebooks.find((nb) => nb.id === notebookId); + const originalNotebook = originalProject.project.notebooks.find((nb) => nb.id === notebookId); if (!newNotebook || !originalNotebook) { return newNotebook !== originalNotebook; diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index 6b909c259a..633e7bff11 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -1185,7 +1185,7 @@ project: const serializerAny = serializer as any; const projectCopy = structuredClone(project); - const result = serializerAny.detectContentChanges(project, projectCopy); + const result = serializerAny.detectContentChanges(project, projectCopy, 'nb-1'); assert.isFalse(result); }); @@ -1242,7 +1242,7 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); @@ -1299,7 +1299,7 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); @@ -1364,7 +1364,7 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); @@ -1422,7 +1422,7 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isFalse(result); }); @@ -1482,7 +1482,7 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isFalse(result); }); @@ -1533,7 +1533,7 @@ project: const newProject = singleNotebookFile({ [field]: changed }); const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result, `change to notebook-level field '${field}' should be detected`); }); @@ -1544,7 +1544,7 @@ project: const newProject = structuredClone(originalProject); const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isFalse(result); }); @@ -1555,10 +1555,48 @@ project: newProject.project.notebooks[0].blocks[0].id = 'b1-renamed'; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); + + test('matches the edited notebook by id, not the [0] slot (legacy [init, main] file)', () => { + // Legacy shape: init at index 0, the edited/rendered notebook (main) at index 1. Comparing + // a fixed [0] slot would compare the (unchanged) init and miss real edits to main. + const makeFile = (mainContent: string): DeepnoteFile => ({ + version: '1.0.0', + metadata: { createdAt: '2023-01-01T00:00:00Z' }, + project: { + id: 'project-1', + name: 'Test', + initNotebookId: 'init-1', + notebooks: [ + { id: 'init-1', name: 'Init', blocks: [] }, + { + id: 'main-1', + name: 'Main', + blocks: [ + { + id: 'b1', + type: 'code', + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + content: mainContent + } + ] + } + ] + } + }); + + const serializerAny = serializer as any; + + // Editing main (index 1) IS detected when matching by id; the old [0] comparison missed it. + assert.isTrue(serializerAny.detectContentChanges(makeFile('print(2)'), makeFile('print(1)'), 'main-1')); + // Identical main → no content change. + assert.isFalse(serializerAny.detectContentChanges(makeFile('print(1)'), makeFile('print(1)'), 'main-1')); + }); }); suite('snapshotHash', () => { diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 54246bdbcc..4a64be0373 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -918,26 +918,30 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } - const originalProject = this.notebookManager?.getAnyProjectEntry(projectId); + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; - if (!originalProject) { - logger.warn(`[Snapshot] No original project found for ${projectId}`); + if (!notebookId) { + logger.warn(`[Snapshot] No notebook ID in notebook metadata`); return; } - const projectUri = this.findProjectUriFromId(projectId); + // Fetch the cached project with an exact (projectId, notebookId) lookup. Sibling files share a + // project.id, so a project-only lookup (getAnyProjectEntry) can return a different sibling's + // project whose notebooks do not contain this notebookId — which would silently skip the + // snapshot write for every sibling but the first one cached. + const originalProject = this.notebookManager?.getOriginalProject(projectId, notebookId); - if (!projectUri) { - logger.warn(`[Snapshot] Could not find project URI for ${projectId}`); + if (!originalProject) { + logger.warn(`[Snapshot] No original project found for ${projectId}/${notebookId}`); return; } - const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; + const projectUri = this.findProjectUriFromId(projectId); - if (!notebookId) { - logger.warn(`[Snapshot] No notebook ID in notebook metadata`); + if (!projectUri) { + logger.warn(`[Snapshot] Could not find project URI for ${projectId}`); return; } @@ -950,66 +954,72 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } - const cellData = notebook.getCells().map((cell) => ({ - kind: cell.kind, - value: cell.document.getText(), - languageId: cell.document.languageId, - metadata: cell.metadata, - outputs: [...cell.outputs] - })); - const blocks = this.converter.convertCellsToBlocks(cellData); - - const snapshotProject = structuredClone(originalProject) as DeepnoteFile; - const snapshotNotebook = snapshotProject.project.notebooks?.find((nb) => nb.id === notebookId); - - if (snapshotNotebook) { - snapshotNotebook.blocks = blocks as DeepnoteBlock[]; - } - - // The snapshot filename is scoped by the file's snapshot notebook id (single-notebook file → - // its one notebook; [init, main] → the main notebook), falling back to the rendered - // notebook's metadata id for any unexpected multi-notebook shape. - const snapshotNotebookId = resolveSnapshotNotebookId(snapshotProject) ?? notebookId; + try { + const cellData = notebook.getCells().map((cell) => ({ + kind: cell.kind, + value: cell.document.getText(), + languageId: cell.document.languageId, + metadata: cell.metadata, + outputs: [...cell.outputs] + })); + const blocks = this.converter.convertCellsToBlocks(cellData); + + const snapshotProject = structuredClone(originalProject) as DeepnoteFile; + const snapshotNotebook = snapshotProject.project.notebooks?.find((nb) => nb.id === notebookId); + + if (snapshotNotebook) { + snapshotNotebook.blocks = blocks as DeepnoteBlock[]; + } - // Detect "Run All" by checking if all code cells in the notebook were executed - const state = this.executionStates.get(notebookUri); - const totalCodeCells = notebook.getCells().filter((cell) => cell.kind === NotebookCellKind.Code).length; - const isRunAll = state && state.blocksExecuted === totalCodeCells; - - if (isRunAll) { - logger.debug(`[Snapshot] Creating full snapshot (Run All mode)`); - - const snapshotUri = await this.createSnapshot( - projectUri, - projectId, - originalProject.project.name, - snapshotProject, - snapshotNotebookId, - notebookUri - ); + // The snapshot filename is scoped by the file's snapshot notebook id (single-notebook file → + // its one notebook; [init, main] → the main notebook), falling back to the rendered + // notebook's metadata id for any unexpected multi-notebook shape. + const snapshotNotebookId = resolveSnapshotNotebookId(snapshotProject) ?? notebookId; + + // Detect "Run All" by checking if all code cells in the notebook were executed + const state = this.executionStates.get(notebookUri); + const totalCodeCells = notebook.getCells().filter((cell) => cell.kind === NotebookCellKind.Code).length; + const isRunAll = state && state.blocksExecuted === totalCodeCells; + + if (isRunAll) { + logger.debug(`[Snapshot] Creating full snapshot (Run All mode)`); + + const snapshotUri = await this.createSnapshot( + projectUri, + projectId, + originalProject.project.name, + snapshotProject, + snapshotNotebookId, + notebookUri + ); - if (snapshotUri) { - logger.info(`[Snapshot] Created full snapshot: ${snapshotUri.toString()}`); - } - } else { - logger.debug(`[Snapshot] Updating latest snapshot only (partial run)`); - - const snapshotUri = await this.updateLatestSnapshot( - projectUri, - projectId, - originalProject.project.name, - snapshotProject, - snapshotNotebookId, - notebookUri - ); + if (snapshotUri) { + logger.info(`[Snapshot] Created full snapshot: ${snapshotUri.toString()}`); + } + } else { + logger.debug(`[Snapshot] Updating latest snapshot only (partial run)`); + + const snapshotUri = await this.updateLatestSnapshot( + projectUri, + projectId, + originalProject.project.name, + snapshotProject, + snapshotNotebookId, + notebookUri + ); - if (snapshotUri) { - logger.info(`[Snapshot] Updated latest snapshot: ${snapshotUri.toString()}`); + if (snapshotUri) { + logger.info(`[Snapshot] Updated latest snapshot: ${snapshotUri.toString()}`); + } } + } catch (error) { + // Deferred fire-and-forget save: log and swallow so a snapshot build/write failure never + // becomes an unhandled rejection. + logger.error(`[Snapshot] Failed to save deferred snapshot for ${notebookUri}`, error); + } finally { + // Clear execution state so the next run starts fresh (even if the save above failed). + this.clearExecutionState(notebookUri); } - - // Clear execution state so the next run starts fresh - this.clearExecutionState(notebookUri); } /** diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index e495a75b0a..2998a7de07 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -1488,7 +1488,8 @@ project: }; const mockNotebookManager = { - getAnyProjectEntry: sinon.stub().returns(originalProject) + getAnyProjectEntry: sinon.stub().returns(originalProject), + getOriginalProject: sinon.stub().returns(originalProject) }; // Create a new service with the mock notebook manager @@ -1533,6 +1534,18 @@ project: updateLatestSnapshotSpy.called, 'updateLatestSnapshot should NOT be called when all code cells are executed' ); + + // F1 regression: the save path must resolve the project via the EXACT + // (projectId, notebookId) lookup, not the project-only getAnyProjectEntry (which can + // return a different open sibling and silently skip the snapshot write). + assert.isTrue( + mockNotebookManager.getOriginalProject.calledWith(projectId, notebookId), + 'performSnapshotSave must fetch via getOriginalProject(projectId, notebookId)' + ); + assert.isFalse( + mockNotebookManager.getAnyProjectEntry.called, + 'performSnapshotSave must NOT use the project-only getAnyProjectEntry' + ); }); }); From 6d4672546346afa3d8fc76a47912c28fb7b64f11 Mon Sep 17 00:00:00 2001 From: tomas Date: Fri, 26 Jun 2026 09:32:16 +0000 Subject: [PATCH 12/26] fix(deepnote): exact snapshot lookup in the file watcher + close-cancel init runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses round-2 code-review findings G2 and G3 (both verified P2). - G2: deepnoteFileChangeWatcher's snapshot block-id recovery used the project-only getAnyProjectEntry(projectId), which can return a different open sibling's cached project (siblings share project.id), leaving originalBlocks undefined and silently skipping recovered outputs. Use the exact getOriginalProject(projectId, notebookId) — the same fix already applied to snapshotService (F1), here in the watcher path that was missed. - G3: moving init execution to the event-driven runner dropped the notebook-close cancellation that the kernel auto-selector used to provide, so closing a notebook mid-init left the remaining init blocks executing against a closed notebook. Tie the init run to a CancellationTokenSource cancelled on notebook close and dispose it in a finally. Adds regression tests for both (each fails on the pre-fix code). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01URccsVKXeNKZqqPi89L4ro --- .../deepnote/deepnoteFileChangeWatcher.ts | 13 +- .../deepnoteFileChangeWatcher.unit.test.ts | 132 +++++++++++++++++- .../deepnoteInitNotebookRunner.node.ts | 26 +++- ...epnoteInitNotebookRunner.node.unit.test.ts | 91 ++++++++++++ 4 files changed, 249 insertions(+), 13 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index cbeee3a8a2..86d6392da2 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -353,14 +353,18 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic return; } - const notebookIdForSnapshot = notebook.metadata?.deepnoteNotebookId as string | undefined; - const snapshotOutputs = await this.snapshotService.readSnapshot(projectId, notebookIdForSnapshot); + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; + const snapshotOutputs = await this.snapshotService.readSnapshot(projectId, notebookId); if (!snapshotOutputs || snapshotOutputs.size === 0) { return; } - // Look up original project blocks for fallback block ID resolution - const originalProject = this.notebookManager.getAnyProjectEntry(projectId); + // Look up original project blocks for fallback block ID resolution with an exact + // (projectId, notebookId) lookup. Sibling files share a project.id, so a project-only + // lookup (getAnyProjectEntry) can return a different sibling's project whose notebooks do + // not contain this notebookId — leaving originalBlocks undefined and silently skipping the + // metadata-lost block-id recovery below. Mirrors the F1 fix in snapshotService.ts. + const originalProject = notebookId ? this.notebookManager.getOriginalProject(projectId, notebookId) : undefined; const notebookBlocksMap = new Map(); if (originalProject) { for (const nb of originalProject.project.notebooks) { @@ -369,7 +373,6 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic } const liveCells = notebook.getCells(); - const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; const originalBlocks = notebookId ? notebookBlocksMap.get(notebookId) : undefined; // Collect cells that need output updates diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index c3f8cef18f..7302a7929d 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -1,6 +1,6 @@ import { assert } 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 { Disposable, EventEmitter, FileSystemWatcher, NotebookCellKind, NotebookDocument, Uri } from 'vscode'; import type { IControllerRegistration } from '../controllers/types'; @@ -1042,9 +1042,10 @@ project: }); test('should apply snapshot outputs using original blocks when metadata is lost', async () => { - // Create a mock notebook manager that returns an original project + // Create a mock notebook manager that returns an original project via the exact + // (projectId, notebookId) lookup the snapshot path uses. const mockedManager = mock(); - when(mockedManager.getAnyProjectEntry('e132b172-b114-410e-8331-011517db664f')).thenReturn({ + when(mockedManager.getOriginalProject('e132b172-b114-410e-8331-011517db664f', 'notebook-1')).thenReturn({ version: '1.0', metadata: { createdAt: '2025-01-01T00:00:00Z' }, project: { @@ -1135,6 +1136,131 @@ project: fallbackOnDidCreate.dispose(); }); + test('should recover lost block IDs via exact (projectId, notebookId) lookup for sibling notebooks', async () => { + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + + // Two single-notebook siblings of ONE project: they share project.id but each project's + // notebooks contains only its own notebook. A returns the WRONG project (no nb-B), B the right one. + const siblingAProject = { + version: '1.0', + metadata: { createdAt: '2025-01-01T00:00:00Z' }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: 'nb-A', + name: 'Notebook A', + blocks: [{ id: 'block-A', type: 'code', sortingKey: 'a0' }] + } + ] + } + } as DeepnoteProject; + const siblingBProject = { + version: '1.0', + metadata: { createdAt: '2025-01-01T00:00:00Z' }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [ + { + id: 'nb-B', + name: 'Notebook B', + blocks: [{ id: 'block-B', type: 'code', sortingKey: 'a0' }] + } + ] + } + } as DeepnoteProject; + + const mockedManager = mock(); + // Project-only lookup returns the arbitrary sibling A (the wrong one, which lacks nb-B). + when(mockedManager.getAnyProjectEntry(projectId)).thenReturn(siblingAProject); + // Exact lookup returns sibling B — this is what the fix must use. + when(mockedManager.getOriginalProject(projectId, 'nb-B')).thenReturn(siblingBProject); + when(mockedManager.getOriginalProject(projectId, 'nb-A')).thenReturn(siblingAProject); + + const siblingDisposables: IDisposableRegistry = []; + const siblingOnDidChange = new EventEmitter(); + const siblingOnDidCreate = new EventEmitter(); + const siblingFsWatcher = mock(); + when(siblingFsWatcher.onDidChange).thenReturn(siblingOnDidChange.event); + when(siblingFsWatcher.onDidCreate).thenReturn(siblingOnDidCreate.event); + when(siblingFsWatcher.dispose()).thenReturn(); + when(mockedVSCodeNamespaces.workspace.createFileSystemWatcher(anything())).thenReturn( + instance(siblingFsWatcher) + ); + + let siblingApplyEditCount = 0; + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenCall(() => { + siblingApplyEditCount++; + return Promise.resolve(true); + }); + + const siblingWatcher = new DeepnoteFileChangeWatcher( + siblingDisposables, + instance(mockedManager), + instance(mockSnapshotService) + ); + siblingWatcher.activate(); + + const snapshotUri = Uri.file(`/workspace/snapshots/my-project_${projectId}_latest.snapshot.deepnote`); + // The open notebook is sibling B, with cell metadata block-id MISSING (metadata-lost case), + // so recovery must use originalBlocks resolved from the exact lookup. + const notebook = createMockNotebook({ + uri: Uri.file('/workspace/sibling-b.deepnote'), + metadata: { + deepnoteProjectId: projectId, + deepnoteNotebookId: 'nb-B' + }, + cells: [ + { + metadata: { type: 'code' }, // No id! + outputs: [], + kind: NotebookCellKind.Code, + document: { getText: () => 'print("hello")' } + } + ] + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + // Snapshot has an output keyed by B's block id only. + const siblingOutputs = new Map([ + [ + 'block-B', + [ + { + output_type: 'execute_result', + data: { 'text/plain': 'Sibling B Output' }, + execution_count: 1 + } as DeepnoteOutput + ] + ] + ]); + when(mockSnapshotService.readSnapshot(anything(), anything())).thenReturn(Promise.resolve(siblingOutputs)); + + siblingOnDidChange.fire(snapshotUri); + + await waitFor(() => siblingApplyEditCount > 0); + + // Recovery only succeeds if the code used getOriginalProject(projectId, 'nb-B') — the exact + // lookup. With the old getAnyProjectEntry(projectId) (sibling A, no nb-B), originalBlocks + // would be undefined, block-id recovery could not run, and no edit would be applied. + assert.isAtLeast( + siblingApplyEditCount, + 1, + 'snapshot output should be applied to sibling B via exact (projectId, notebookId) lookup' + ); + verify(mockedManager.getOriginalProject(projectId, 'nb-B')).called(); + + // Cleanup + for (const d of siblingDisposables) { + d.dispose(); + } + siblingOnDidChange.dispose(); + siblingOnDidCreate.dispose(); + }); + test('should only update cells whose outputs changed (per-cell updates)', async () => { const snapshotUri = Uri.file( '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index 29c9483c77..79644fdc8c 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts @@ -141,12 +141,28 @@ export class DeepnoteInitNotebookRunner implements IDeepnoteInitNotebookRunner, }" (${initNotebookId}) for project ${projectId} in kernel for ${getDisplayPath(notebook.uri)}` ); - const success = await this.executeInitNotebook(notebook, initNotebook); + // Tie the init run to the notebook's lifecycle: if the user closes the notebook + // mid-init, cancel so the remaining init blocks/progress stop. This is per-run state + // and must be cleaned up when the run finishes — do NOT register it in `disposables`. + const cts = new CancellationTokenSource(); + const closeListener = workspace.onDidCloseNotebookDocument((closedNotebook) => { + if (closedNotebook.uri.toString() === notebook.uri.toString()) { + logger.info(`Notebook closed while init notebook was running, cancelling for project ${projectId}`); + cts.cancel(); + } + }); + + try { + const success = await this.executeInitNotebook(notebook, initNotebook, cts.token); - if (success) { - logger.info(`Init notebook completed successfully for project ${projectId}`); - } else { - logger.warn(`Init notebook did not execute for project ${projectId} - kernel not available`); + if (success) { + logger.info(`Init notebook completed successfully for project ${projectId}`); + } else { + logger.warn(`Init notebook did not execute for project ${projectId} - kernel not available`); + } + } finally { + closeListener.dispose(); + cts.dispose(); } } catch (error) { // Log error but don't throw - we want to let the user continue anyway. diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts index 96253d93fa..ff09a6a704 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts @@ -98,6 +98,48 @@ function makeSingleNotebookFile(projectId: string, notebookId: string, codeConte } as unknown as DeepnoteFile; } +/** + * Build a single-notebook DeepnoteFile whose one notebook carries TWO code blocks (in order). + * Used to prove per-block behavior across the init loop (e.g. cancellation between blocks). + */ +function makeTwoCodeBlockFile( + projectId: string, + notebookId: string, + firstCode: string, + secondCode: string +): DeepnoteFile { + return { + version: '1.0.0', + metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, + project: { + id: projectId, + name: 'Proj', + notebooks: [ + { + id: notebookId, + name: 'Init', + blocks: [ + { + id: `${notebookId}-code-1`, + type: 'code', + sortingKey: 'a0', + blockGroup: 'g', + content: firstCode + }, + { + id: `${notebookId}-code-2`, + type: 'code', + sortingKey: 'a1', + blockGroup: 'g', + content: secondCode + } + ] + } + ] + } + } as unknown as DeepnoteFile; +} + /** The cached project entry the manager returns for `getAnyProjectEntry` (carries initNotebookId). */ function makeMainProjectEntry(projectId: string, initNotebookId: string | undefined): DeepnoteProject { return { @@ -370,6 +412,55 @@ suite('DeepnoteInitNotebookRunner', () => { assert.strictEqual(readDirectoryCount, 0, 'with no init configured there is no need to scan the directory'); }); + test('closing the notebook mid-init stops remaining init blocks (close cancels the run)', async () => { + // Regression for a lifecycle cancellation bug: runInitForKernel must pass a token tied to + // notebook close into executeInitNotebook, so closing the notebook while init is running + // stops the remaining blocks. Without that token BOTH blocks run regardless of close. + const FIRST_BLOCK_CODE = 'pip install first-init-package'; + const SECOND_BLOCK_CODE = 'pip install second-init-package'; + + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); + putFile( + SIBLING_INIT_FILE_NAME, + makeTwoCodeBlockFile(PROJECT_ID, INIT_NOTEBOOK_ID, FIRST_BLOCK_CODE, SECOND_BLOCK_CODE) + ); + + // Wire a close emitter we can fire (the runner subscribes to workspace.onDidCloseNotebookDocument). + const onDidCloseNotebookDocument = new EventEmitter(); + when(mockedVSCodeNamespaces.workspace.onDidCloseNotebookDocument).thenReturn(onDidCloseNotebookDocument.event); + + // Hold the FIRST block's execution open so we can fire close while init is mid-loop. The + // second block must never run because the per-block cancellation check trips after close. + let resolveFirstBlock!: () => void; + const firstBlockGate = new Promise<[]>((resolve) => { + resolveFirstBlock = () => resolve([]); + }); + executeHiddenSpy.callsFake((code: string) => + code === FIRST_BLOCK_CODE ? firstBlockGate : Promise.resolve([]) + ); + + const kernel = makeKernel(MAIN_FILE_NAME); + onDidStartKernel.fire(kernel); + + // Wait until the first block is in flight (its executeHidden has been called and is pending). + await waitFor(() => executeHiddenSpy.callCount >= 1); + assert.strictEqual(executeHiddenSpy.callCount, 1, 'the first init block should be executing'); + assert.strictEqual(executeHiddenSpy.firstCall.args[0], FIRST_BLOCK_CODE, 'first block runs first'); + + // Close the notebook (URI-matched) — this must cancel the run's token. + onDidCloseNotebookDocument.fire(kernel.notebook); + + // Let the first block finish; the loop then re-checks the (now cancelled) token before block 2. + resolveFirstBlock(); + await settle(); + + assert.strictEqual(executeHiddenSpy.callCount, 1, 'after close, the remaining init block(s) must NOT execute'); + assert.isFalse( + executeHiddenSpy.getCalls().some((c) => c.args[0] === SECOND_BLOCK_CODE), + 'the second init block must never run once the notebook is closed mid-init' + ); + }); + test('sibling of a DIFFERENT project is not a valid init source (project.id must match)', async () => { // A sibling exists with the right initNotebookId-shaped notebook but a different project.id. putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); From 21611dbbda91803f5535410c5777b0bc2dfe1fa2 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 11:22:34 +0000 Subject: [PATCH 13/26] refactor(deepnote): remove metadata propagation and project-level export/delete Removes two pieces of functionality; also folds in the branch's in-progress updates this work was layered on top of (they could not be isolated, as the removals are interleaved with and built on top of that WIP). Removed - project-metadata propagator: - Delete DeepnoteProjectMetadataPropagator and its types, drop the DI binding, and unwire it everywhere (activation, file-change watcher self-write hook, integration webview, explorer rename). Project-level fields are no longer fanned out across sibling files: each notebook owns its own integrations, and project-name drift is accepted for now. Drop the now-dead updateOriginalProject manager method and two stale comments. Removed - project-level explorer commands: - Delete the exportProject and deleteProject commands (constants, command-arg type, registrations, package.json command defs + sidebar menus, nls titles, and their unit tests). Per-notebook export remains via the existing exportNotebook command (first non-init notebook of the file). Also includes the branch's pending updates the above was built on: dependency bumps (incl. @deepnote/convert 4.0), the getOriginalProject -> getProjectForNotebook manager rename and getAnyProjectEntry removal, and assorted snapshot/serializer/kernel adjustments. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- .gitignore | 1 + .vscode/launch.json | 2 +- package-lock.json | 333 ++++++++-- package.json | 30 +- package.nls.json | 2 - src/commands.ts | 1 - .../deepnote/deepnoteLspClientManager.node.ts | 7 +- ...epnoteLspClientManager.node.vscode.test.ts | 2 +- .../deepnote/deepnoteActivationService.ts | 8 +- .../deepnote/deepnoteExplorerView.ts | 301 +++------ .../deepnoteExplorerView.unit.test.ts | 602 +----------------- .../deepnote/deepnoteFileChangeWatcher.ts | 28 +- .../deepnoteFileChangeWatcher.unit.test.ts | 129 +--- .../deepnoteInitNotebookRunner.node.ts | 5 +- ...epnoteInitNotebookRunner.node.unit.test.ts | 8 +- .../deepnoteKernelAutoSelector.node.ts | 8 +- .../deepnote/deepnoteNotebookManager.ts | 48 +- .../deepnoteNotebookManager.unit.test.ts | 82 +-- src/notebooks/deepnote/deepnoteSerializer.ts | 2 +- .../deepnote/deepnoteSerializer.unit.test.ts | 3 +- .../federatedAuthKernelRestartBridge.node.ts | 5 +- ...dAuthKernelRestartBridge.node.unit.test.ts | 55 +- .../integrations/integrationDetector.ts | 8 +- .../integrations/integrationManager.ts | 21 +- .../integrations/integrationWebview.ts | 51 +- .../integrationWebview.unit.test.ts | 97 +-- src/notebooks/deepnote/integrations/types.ts | 8 +- .../deepnote/snapshots/snapshotService.ts | 27 +- .../snapshots/snapshotService.unit.test.ts | 95 ++- .../deepnote/sqlCellStatusBarProvider.ts | 8 +- .../sqlCellStatusBarProvider.unit.test.ts | 64 +- src/notebooks/serviceRegistry.node.ts | 7 - src/notebooks/types.ts | 13 +- src/platform/common/constants.ts | 2 - .../deepnoteProjectMetadataPropagator.node.ts | 196 ------ ...rojectMetadataPropagator.node.unit.test.ts | 352 ---------- src/platform/deepnote/types.ts | 46 -- ...IntegrationEnvironmentVariablesProvider.ts | 9 +- ...nEnvironmentVariablesProvider.unit.test.ts | 77 ++- src/platform/notebooks/deepnote/types.ts | 15 +- src/test/unittests.ts | 2 +- 41 files changed, 716 insertions(+), 2044 deletions(-) delete mode 100644 src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts delete mode 100644 src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts delete mode 100644 src/platform/deepnote/types.ts diff --git a/.gitignore b/.gitignore index c573d86eb3..8940d9185b 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ vscode.d.ts vscode.proposed.*.d.ts xunit-test-results.xml tsconfig.tsbuildinfo +/testing diff --git a/.vscode/launch.json b/.vscode/launch.json index 8c305723c6..67602ba26d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,7 @@ "${workspaceFolder}/dist/**/*", "!${workspaceFolder}/**/node_modules**/*" ], - "preLaunchTask": "Build", + // "preLaunchTask": "Build", "skipFiles": [ "/**" ], diff --git a/package-lock.json b/package-lock.json index b26d986762..79e430c15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "license": "MIT", "dependencies": { "@c4312/evt": "^0.1.1", - "@deepnote/blocks": "^4.3.0", - "@deepnote/convert": "^3.2.0", - "@deepnote/database-integrations": "^1.4.3", - "@deepnote/runtime-core": "^0.2.0", + "@deepnote/blocks": "^4.6.0", + "@deepnote/convert": "^4.0.0", + "@deepnote/database-integrations": "^1.5.0", + "@deepnote/runtime-core": "^0.4.0", "@deepnote/sql-language-server": "^3.0.0", "@enonic/fnv-plus": "^1.3.0", "@jupyter-widgets/base": "^6.0.8", @@ -328,6 +328,85 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.139", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.139.tgz", + "integrity": "sha512-RFpxyh5j9g7ZMfKxhiwCcF7bGU872o3JvoiIqZbHOM4qkR4RCzeKJF+k+zHomcJYGeUabEbeMXTKNASzz4Toxw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mcp": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.55.tgz", + "integrity": "sha512-G2QSVjEP1Q9kclhdlfw5m/tV5YH0h+b45n2ANx5GGpCTeA1D0enp68773EWJMflGREhybsUXNmkaC+vLbLEzKg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "pkce-challenge": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.77", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.77.tgz", + "integrity": "sha512-DK0sGy/9j6KhgSrdhSKplTo3qf5frzGjLNYo9DVloH37L6DT42AxR/26UVCCoTEjR+WdsA9svm+5cT3Aye9NTg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.12.tgz", + "integrity": "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.33.tgz", + "integrity": "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.12", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1825,13 +1904,13 @@ } }, "node_modules/@deepnote/blocks": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-4.3.0.tgz", - "integrity": "sha512-pep807GUPoGeFS7kwZPkXsmgVOUtJumKPLOb5l7/SBHtiKiFAKHnHqx3MCInu9tZwXC4HC8D4gDLxVdrQpLA4A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-4.6.0.tgz", + "integrity": "sha512-Z2mQWOH5U0LJUUe23VQINJTBhQ7a+0iWcQ/Rmv6CiNXVXNuZetxwj/WjeVhR0hvtoOILKtGdJBgGHi3j84BXyw==", "license": "Apache-2.0", "dependencies": { "ts-dedent": "^2.2.0", - "yaml": "^2.8.1", + "yaml": "^2.8.3", "zod": "3.25.76" }, "engines": { @@ -1848,16 +1927,16 @@ } }, "node_modules/@deepnote/convert": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-3.2.0.tgz", - "integrity": "sha512-L7wqOuuXcMwsLwVjsSluE0z/hFl/c/kdmy7rh/d6NmtMAP5hf2VS1SR7M+mjf6/xzPcd0/eXj2PgBDW246ppuw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-4.0.0.tgz", + "integrity": "sha512-WfJNM5VJ+qf7EwxqwVzO8LhnHFqz3qtKRXhw4TciL2nedeeyJ84YwunHUsjWVk9VTs5UenG4fUUmhjrmD5wY6A==", "license": "Apache-2.0", "dependencies": { - "@deepnote/blocks": "4.3.0", + "@deepnote/blocks": "4.6.0", "chalk": "^5.6.2", "cleye": "^2.0.0", "ora": "^9.0.0", - "yaml": "^2.8.1" + "yaml": "^2.8.3" }, "bin": { "deepnote-convert": "dist/bin.js" @@ -1880,11 +1959,13 @@ } }, "node_modules/@deepnote/database-integrations": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@deepnote/database-integrations/-/database-integrations-1.4.3.tgz", - "integrity": "sha512-h12mkl4tX/0TSjF7wXq3e6YimxfcgvQzRjTr4eBg0kTbZizvhUjWEQw/9+JiQZCyNvanLaeg0LXVCRlp8LxqJQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@deepnote/database-integrations/-/database-integrations-1.5.0.tgz", + "integrity": "sha512-u2uaCui6Cyhj9gjfiELGyzpm4ME2eSpkrMmWKFaPcmsq83poj4U2WALEtzPVN3VO9IDOrJ8/IFmYGHwqPsviRA==", "license": "Apache-2.0", "dependencies": { + "@deepnote/blocks": "4.6.0", + "yaml": "^2.8.3", "zod": "3.25.76" } }, @@ -1898,16 +1979,20 @@ } }, "node_modules/@deepnote/runtime-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/runtime-core/-/runtime-core-0.2.0.tgz", - "integrity": "sha512-wIgUOSROSyFpfFd+Mx/9GA3mHdyJ7aIqs4bejS0SUr5ogC+wo1xj+ZfwfEzMQRse9M8f5SKn8qj6zjnykKRTJg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@deepnote/runtime-core/-/runtime-core-0.4.0.tgz", + "integrity": "sha512-iS5E2FUxAT83cRDt9cGvhO+CR9tuLG6+PmLT4Vm8ffQUzPSi5b0v0AuwnSOrMDPlt4SXM9DXxtB3iHhWUk/0kQ==", "license": "Apache-2.0", "dependencies": { - "@deepnote/blocks": "4.3.0", + "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/openai": "^3.0.0", + "@deepnote/blocks": "4.6.0", "@jupyterlab/nbformat": "^4.3.2", "@jupyterlab/services": "^7.3.2", + "ai": "^6.0.0", "tcp-port-used": "^1.0.2", - "ws": "^8.18.0" + "ws": "^8.20.1", + "zod": "3.25.76" } }, "node_modules/@deepnote/runtime-core/node_modules/ws": { @@ -1931,6 +2016,15 @@ } } }, + "node_modules/@deepnote/runtime-core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@deepnote/sql-language-server": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@deepnote/sql-language-server/-/sql-language-server-3.0.0.tgz", @@ -6706,6 +6800,12 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", @@ -8354,6 +8454,15 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vscode/dts": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@vscode/dts/-/dts-0.4.0.tgz", @@ -8958,6 +9067,33 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "6.0.214", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.214.tgz", + "integrity": "sha512-9MlePEXT5pXtQv4fXqmiR0RG3DZU4Dbv+kU9ktEJC2COi2RH2WvI2GiyG9MuCqgPII6f1w+5kB5fNIiArqPzaQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.139", + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -15125,6 +15261,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -20722,6 +20867,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-compare": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", @@ -24922,6 +25073,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -31736,6 +31896,53 @@ "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", "dev": true }, + "@ai-sdk/gateway": { + "version": "3.0.139", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.139.tgz", + "integrity": "sha512-RFpxyh5j9g7ZMfKxhiwCcF7bGU872o3JvoiIqZbHOM4qkR4RCzeKJF+k+zHomcJYGeUabEbeMXTKNASzz4Toxw==", + "requires": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "@vercel/oidc": "3.2.0" + } + }, + "@ai-sdk/mcp": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.55.tgz", + "integrity": "sha512-G2QSVjEP1Q9kclhdlfw5m/tV5YH0h+b45n2ANx5GGpCTeA1D0enp68773EWJMflGREhybsUXNmkaC+vLbLEzKg==", + "requires": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "pkce-challenge": "^5.0.0" + } + }, + "@ai-sdk/openai": { + "version": "3.0.77", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.77.tgz", + "integrity": "sha512-DK0sGy/9j6KhgSrdhSKplTo3qf5frzGjLNYo9DVloH37L6DT42AxR/26UVCCoTEjR+WdsA9svm+5cT3Aye9NTg==", + "requires": { + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33" + } + }, + "@ai-sdk/provider": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.12.tgz", + "integrity": "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q==", + "requires": { + "json-schema": "^0.4.0" + } + }, + "@ai-sdk/provider-utils": { + "version": "4.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.33.tgz", + "integrity": "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA==", + "requires": { + "@ai-sdk/provider": "3.0.12", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + } + }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -32896,9 +33103,9 @@ "dev": true }, "@deepnote/blocks": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-4.3.0.tgz", - "integrity": "sha512-pep807GUPoGeFS7kwZPkXsmgVOUtJumKPLOb5l7/SBHtiKiFAKHnHqx3MCInu9tZwXC4HC8D4gDLxVdrQpLA4A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@deepnote/blocks/-/blocks-4.6.0.tgz", + "integrity": "sha512-Z2mQWOH5U0LJUUe23VQINJTBhQ7a+0iWcQ/Rmv6CiNXVXNuZetxwj/WjeVhR0hvtoOILKtGdJBgGHi3j84BXyw==", "requires": { "ts-dedent": "^2.2.0", "yaml": "2.8.3", @@ -32913,11 +33120,11 @@ } }, "@deepnote/convert": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-3.2.0.tgz", - "integrity": "sha512-L7wqOuuXcMwsLwVjsSluE0z/hFl/c/kdmy7rh/d6NmtMAP5hf2VS1SR7M+mjf6/xzPcd0/eXj2PgBDW246ppuw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@deepnote/convert/-/convert-4.0.0.tgz", + "integrity": "sha512-WfJNM5VJ+qf7EwxqwVzO8LhnHFqz3qtKRXhw4TciL2nedeeyJ84YwunHUsjWVk9VTs5UenG4fUUmhjrmD5wY6A==", "requires": { - "@deepnote/blocks": "4.3.0", + "@deepnote/blocks": "4.6.0", "chalk": "^5.6.2", "cleye": "^2.0.0", "ora": "^9.0.0", @@ -32932,10 +33139,12 @@ } }, "@deepnote/database-integrations": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@deepnote/database-integrations/-/database-integrations-1.4.3.tgz", - "integrity": "sha512-h12mkl4tX/0TSjF7wXq3e6YimxfcgvQzRjTr4eBg0kTbZizvhUjWEQw/9+JiQZCyNvanLaeg0LXVCRlp8LxqJQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@deepnote/database-integrations/-/database-integrations-1.5.0.tgz", + "integrity": "sha512-u2uaCui6Cyhj9gjfiELGyzpm4ME2eSpkrMmWKFaPcmsq83poj4U2WALEtzPVN3VO9IDOrJ8/IFmYGHwqPsviRA==", "requires": { + "@deepnote/blocks": "4.6.0", + "yaml": "2.8.3", "zod": "3.25.76" }, "dependencies": { @@ -32947,15 +33156,19 @@ } }, "@deepnote/runtime-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@deepnote/runtime-core/-/runtime-core-0.2.0.tgz", - "integrity": "sha512-wIgUOSROSyFpfFd+Mx/9GA3mHdyJ7aIqs4bejS0SUr5ogC+wo1xj+ZfwfEzMQRse9M8f5SKn8qj6zjnykKRTJg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@deepnote/runtime-core/-/runtime-core-0.4.0.tgz", + "integrity": "sha512-iS5E2FUxAT83cRDt9cGvhO+CR9tuLG6+PmLT4Vm8ffQUzPSi5b0v0AuwnSOrMDPlt4SXM9DXxtB3iHhWUk/0kQ==", "requires": { - "@deepnote/blocks": "4.3.0", + "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/openai": "^3.0.0", + "@deepnote/blocks": "4.6.0", "@jupyterlab/nbformat": "^4.3.2", "@jupyterlab/services": "^7.3.2", + "ai": "^6.0.0", "tcp-port-used": "^1.0.2", - "ws": "8.21.0" + "ws": "8.21.0", + "zod": "3.25.76" }, "dependencies": { "ws": { @@ -32963,6 +33176,11 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "requires": {} + }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } } }, @@ -36474,6 +36692,11 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "@tailwindcss/node": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", @@ -37770,6 +37993,11 @@ "d3-transition": "^3.0.1" } }, + "@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==" + }, "@vscode/dts": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@vscode/dts/-/dts-0.4.0.tgz", @@ -38210,6 +38438,24 @@ "indent-string": "^4.0.0" } }, + "ai": { + "version": "6.0.214", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.214.tgz", + "integrity": "sha512-9MlePEXT5pXtQv4fXqmiR0RG3DZU4Dbv+kU9ktEJC2COi2RH2WvI2GiyG9MuCqgPII6f1w+5kB5fNIiArqPzaQ==", + "requires": { + "@ai-sdk/gateway": "3.0.139", + "@ai-sdk/provider": "3.0.12", + "@ai-sdk/provider-utils": "4.0.33", + "@opentelemetry/api": "^1.9.0" + }, + "dependencies": { + "@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" + } + } + }, "ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -42692,6 +42938,11 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true }, + "eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==" + }, "evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -46554,6 +46805,11 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "json-schema-compare": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", @@ -49514,6 +49770,11 @@ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==" }, + "pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==" + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", diff --git a/package.json b/package.json index 4dd5934f3a..e35c7314af 100644 --- a/package.json +++ b/package.json @@ -293,12 +293,6 @@ "category": "Deepnote", "icon": "$(edit)" }, - { - "command": "deepnote.deleteProject", - "title": "%deepnote.commands.deleteProject.title%", - "category": "Deepnote", - "icon": "$(trash)" - }, { "command": "deepnote.renameNotebook", "title": "%deepnote.commands.renameNotebook.title%", @@ -323,12 +317,6 @@ "category": "Deepnote", "icon": "$(add)" }, - { - "command": "deepnote.exportProject", - "title": "%deepnote.commands.exportProject.title%", - "category": "Deepnote", - "icon": "$(export)" - }, { "command": "deepnote.exportNotebook", "title": "%deepnote.commands.exportNotebook.title%", @@ -1617,16 +1605,6 @@ "when": "view == deepnoteExplorer && viewItem == projectGroup", "group": "2_manage@1" }, - { - "command": "deepnote.exportProject", - "when": "view == deepnoteExplorer && viewItem == projectGroup", - "group": "2_manage@2" - }, - { - "command": "deepnote.deleteProject", - "when": "view == deepnoteExplorer && viewItem == projectGroup", - "group": "3_delete@1" - }, { "command": "deepnote.renameNotebook", "when": "view == deepnoteExplorer && (viewItem == notebookFile || viewItem == notebook)", @@ -2688,10 +2666,10 @@ }, "dependencies": { "@c4312/evt": "^0.1.1", - "@deepnote/blocks": "^4.3.0", - "@deepnote/convert": "^3.2.0", - "@deepnote/database-integrations": "^1.4.3", - "@deepnote/runtime-core": "^0.2.0", + "@deepnote/blocks": "^4.6.0", + "@deepnote/convert": "^4.0.0", + "@deepnote/database-integrations": "^1.5.0", + "@deepnote/runtime-core": "^0.4.0", "@deepnote/sql-language-server": "^3.0.0", "@enonic/fnv-plus": "^1.3.0", "@jupyter-widgets/base": "^6.0.8", diff --git a/package.nls.json b/package.nls.json index c604b25279..f3b21dc525 100644 --- a/package.nls.json +++ b/package.nls.json @@ -277,12 +277,10 @@ "deepnote.commands.addTextBlockHeading3.title": "Add Heading 3 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.commands.exportProject.title": "Export Project...", "deepnote.commands.exportNotebook.title": "Export Notebook...", "deepnote.commands.copyNotebookDetails.title": "Copy Active Deepnote Notebook Details", "deepnote.views.explorer.name": "Explorer", diff --git a/src/commands.ts b/src/commands.ts index 8e55ebde4a..0ec0d1ad52 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -206,7 +206,6 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.ImportNotebook]: []; [DSCommands.ImportJupyterNotebook]: []; [DSCommands.RenameProject]: []; - [DSCommands.DeleteProject]: []; [DSCommands.RenameNotebook]: []; [DSCommands.DeleteNotebook]: []; [DSCommands.DuplicateNotebook]: []; diff --git a/src/kernels/deepnote/deepnoteLspClientManager.node.ts b/src/kernels/deepnote/deepnoteLspClientManager.node.ts index a092cec861..075867ab12 100644 --- a/src/kernels/deepnote/deepnoteLspClientManager.node.ts +++ b/src/kernels/deepnote/deepnoteLspClientManager.node.ts @@ -604,13 +604,14 @@ export class DeepnoteLspClientManager } const projectId = notebook.metadata?.deepnoteProjectId as string | undefined; + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; - if (!projectId) { - logger.warn('SQL LSP: No project ID in notebook metadata'); + if (!projectId || !notebookId) { + logger.warn('SQL LSP: No project/notebook ID in notebook metadata'); return []; } - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!project) { logger.warn(`SQL LSP: No project found for ID: ${projectId}`); diff --git a/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts b/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts index 64f6be1b13..fc2838bf66 100644 --- a/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts +++ b/src/kernels/deepnote/deepnoteLspClientManager.node.vscode.test.ts @@ -48,7 +48,7 @@ suite('DeepnoteLspClientManager Integration Tests', () => { // Mock notebook manager // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockNotebookManager = { - getAnyProjectEntry: () => undefined + getProjectForNotebook: () => undefined } as any; setup(() => { diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index e3e8272f9e..8ccdafd32a 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -4,7 +4,6 @@ import { commands, l10n, workspace, window, type Disposable, type NotebookDocume import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; -import { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; @@ -43,10 +42,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, @inject(IDeepnoteNotebookEnvironmentMapper) @optional() - private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper, - @inject(IDeepnoteProjectMetadataPropagator) - @optional() - private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator + private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper ) { this.integrationManager = integrationManager; } @@ -57,7 +53,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic */ public activate() { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); - this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger, this.metadataPropagator); + this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.ts b/src/notebooks/deepnote/deepnoteExplorerView.ts index 9618d4416d..5a9839fe6c 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -1,7 +1,7 @@ import { injectable, inject } from 'inversify'; import { commands, window, workspace, type TreeView, RelativePattern, Uri, l10n } from 'vscode'; import { serializeDeepnoteFile, type DeepnoteBlock, type DeepnoteFile } from '@deepnote/blocks'; -import { convertDeepnoteToJupyterNotebooks, convertIpynbFilesToDeepnoteFile } from '@deepnote/convert'; +import { convertDeepnoteToJupyterNotebooks, convertIpynbFileToDeepnoteFile } from '@deepnote/convert'; import { IExtensionContext } from '../../platform/common/types'; import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; @@ -17,7 +17,6 @@ import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { Commands } from '../../platform/common/constants'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; -import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { buildSingleNotebookFile, buildSiblingNotebookFileUri } from './deepnoteNotebookFileFactory'; import { deepnoteFileExists } from './deepnoteSiblingFileAllocator'; import { isSnapshotFile } from './snapshots/snapshotFiles'; @@ -38,8 +37,7 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(ILogger) private readonly logger: ILogger, - private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator + @inject(ILogger) private readonly logger: ILogger ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); } @@ -287,19 +285,7 @@ export class DeepnoteExplorerView { } try { - // Desktop: rename the project across every sibling .deepnote file (open or closed). - if (this.metadataPropagator) { - await this.metadataPropagator.propagateProjectMetadata(treeItem.context.projectId, (file) => { - file.project.name = newName; - }); - - this.treeDataProvider.refresh(); - await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); - - return; - } - - // Web fallback (no filesystem fan-out): rename each sibling file in the group. + // Rename each sibling .deepnote file in the project group. for (const { filePath } of group.files) { try { const fileUri = Uri.file(filePath); @@ -367,12 +353,6 @@ export class DeepnoteExplorerView { ) ); - 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) @@ -397,12 +377,6 @@ export class DeepnoteExplorerView { ) ); - 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) @@ -466,7 +440,7 @@ export class DeepnoteExplorerView { for (const fileUri of files) { // Skip snapshot sidecars (`*.snapshot.deepnote`): they are full project clones, so // their stale notebook names would otherwise pollute the uniqueness set. The tree - // provider and metadata propagator filter them the same way. + // provider filters them the same way. if (isSnapshotFile(fileUri)) { continue; } @@ -842,6 +816,66 @@ export class DeepnoteExplorerView { } } + private async checkJupyterImportTargetsAvailable(jupyterUris: readonly Uri[], folderUri: Uri): Promise { + // Each Jupyter notebook imports into its own .deepnote sibling; don't overwrite an existing + // file or let two selected notebooks with the same base name clobber each other. + const seenNames = new Set(); + + for (const jupyterUri of jupyterUris) { + const { outputFileName, outputUri } = this.deepnoteTargetForJupyterUri(jupyterUri, folderUri); + let exists = seenNames.has(outputFileName); + + if (!exists) { + try { + await workspace.fs.stat(outputUri); + exists = true; + } catch { + // No file at the target path — available. + } + } + + if (exists) { + await window.showErrorMessage( + l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) + ); + + return false; + } + + seenNames.add(outputFileName); + } + + return true; + } + + private async convertJupyterUrisToDeepnoteFiles(jupyterUris: readonly Uri[], folderUri: Uri): Promise { + // Each Jupyter notebook becomes its own single-notebook .deepnote file. + for (const jupyterUri of jupyterUris) { + const { inputPath, outputUri, projectName } = this.deepnoteTargetForJupyterUri(jupyterUri, folderUri); + + await convertIpynbFileToDeepnoteFile(inputPath, { + outputPath: outputUri.path, + projectName + }); + } + } + + private deepnoteTargetForJupyterUri( + jupyterUri: Uri, + folderUri: Uri + ): { inputPath: string; outputFileName: string; outputUri: Uri; projectName: string } { + const fileName = jupyterUri.path.split('/').pop() || 'notebook.ipynb'; + const projectName = fileName.replace(/\.ipynb$/i, ''); + const outputFileName = `${projectName}.deepnote`; + + return { + inputPath: jupyterUri.path, + outputFileName, + outputUri: Uri.joinPath(folderUri, outputFileName), + projectName + }; + } + private async importNotebook(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( @@ -894,23 +928,9 @@ export class DeepnoteExplorerView { } } - // Check for existing jupyter import output file - if (jupyterUris.length > 0) { - const firstFileName = jupyterUris[0].path.split('/').pop() || 'notebook.ipynb'; - const projectName = firstFileName.replace(/\.ipynb$/i, ''); - const outputFileName = `${projectName}.deepnote`; - const outputUri = Uri.joinPath(workspaceFolder.uri, outputFileName); - - try { - await workspace.fs.stat(outputUri); - await window.showErrorMessage( - l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) - ); - - return; - } catch { - // File doesn't exist, continue - } + // Check that each Jupyter import target is available (one .deepnote per notebook). + if (!(await this.checkJupyterImportTargetsAvailable(jupyterUris, workspaceFolder.uri))) { + return; } // Import deepnote files @@ -923,21 +943,8 @@ export class DeepnoteExplorerView { await workspace.fs.writeFile(targetUri, content); } - // Convert and import jupyter files - if (jupyterUris.length > 0) { - const inputFilePaths = jupyterUris.map((uri) => uri.path); - - // Use the first Jupyter file's name for the project - const firstFileName = jupyterUris[0].path.split('/').pop() || 'notebook.ipynb'; - const projectName = firstFileName.replace(/\.ipynb$/i, ''); - const outputFileName = `${projectName}.deepnote`; - const outputPath = Uri.joinPath(workspaceFolder.uri, outputFileName).path; - - await convertIpynbFilesToDeepnoteFile(inputFilePaths, { - outputPath: outputPath, - projectName: projectName - }); - } + // Convert jupyter files — each becomes its own single-notebook .deepnote file. + await this.convertJupyterUrisToDeepnoteFiles(jupyterUris, workspaceFolder.uri); const numberOfNotebooks = jupyterUris.length + deepnoteUris.length; @@ -986,30 +993,13 @@ export class DeepnoteExplorerView { try { const workspaceFolder = workspace.workspaceFolders[0]; - const inputFilePaths = fileUris.map((uri) => uri.path); - - // Use the first Jupyter file's name for the project - const firstFileName = fileUris[0].path.split('/').pop() || 'notebook.ipynb'; - const projectName = firstFileName.replace(/\.ipynb$/i, ''); - const outputFileName = `${projectName}.deepnote`; - const outputUri = Uri.joinPath(workspaceFolder.uri, outputFileName); - - // Check if file already exists - try { - await workspace.fs.stat(outputUri); - await window.showErrorMessage( - l10n.t('A file named "{0}" already exists in this workspace.', outputFileName) - ); + // Each Jupyter notebook becomes its own single-notebook .deepnote file. + if (!(await this.checkJupyterImportTargetsAvailable(fileUris, workspaceFolder.uri))) { return; - } catch { - // File doesn't exist, continue } - await convertIpynbFilesToDeepnoteFile(inputFilePaths, { - outputPath: outputUri.path, - projectName: projectName - }); + await this.convertJupyterUrisToDeepnoteFiles(fileUris, workspaceFolder.uri); const numberOfNotebooks = fileUris.length; @@ -1029,50 +1019,6 @@ export class DeepnoteExplorerView { } } - private async deleteProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { - return; - } - - const group = treeItem.data as ProjectGroupData; - const projectName = group.projectName; - - 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; - } - - // Delete every sibling file in the group, with per-iteration error handling so one failure - // does not stop the rest. - const failedFiles: string[] = []; - - for (const { filePath } of group.files) { - try { - await workspace.fs.delete(Uri.file(filePath), { useTrash: true }); - } catch (error) { - this.logger.error(`Failed to delete project file ${filePath}`, error); - failedFiles.push(filePath); - } - } - - this.treeDataProvider.refresh(); - - if (failedFiles.length > 0) { - await window.showErrorMessage( - l10n.t('Failed to delete {0} of {1} project files.', failedFiles.length, group.files.length) - ); - - return; - } - - await window.showInformationMessage(l10n.t('Project deleted: {0}', projectName)); - } - private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; @@ -1101,109 +1047,6 @@ export class DeepnoteExplorerView { } } - /** - * Exports every notebook of a project group (across all sibling files) to Jupyter format. - * @param treeItem The tree item representing a project group - */ - private async exportProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { - return; - } - - const group = treeItem.data as ProjectGroupData; - - try { - const format = await window.showQuickPick([{ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }], { - placeHolder: l10n.t('Select export format') - }); - - if (!format) { - return; - } - - // Collect the Jupyter notebooks to write across every sibling file (validate before - // prompting for an output folder). - const jupyterNotebooks: Array<{ filename: string; notebook: unknown }> = []; - - for (const { filePath } of group.files) { - try { - const projectData = await readDeepnoteProjectFile(Uri.file(filePath)); - - if (!projectData?.project) { - continue; - } - - jupyterNotebooks.push(...convertDeepnoteToJupyterNotebooks(projectData)); - } catch (error) { - this.logger.error(`Failed to read ${filePath} for export`, error); - } - } - - if (jupyterNotebooks.length === 0) { - 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; - } - - // 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 (single-notebook leaf file or legacy in-file notebook) to Jupyter. * @param treeItem The tree item representing a notebook diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index 24f626d483..ec2eea46a4 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -447,7 +447,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); suite('importNotebook', () => { - // `convertIpynbFilesToDeepnoteFile` does real node:fs I/O, so stub just that one + // `convertIpynbFileToDeepnoteFile` does real node:fs I/O, so stub just that one // @deepnote/convert export (all other exports stay the real implementation) while the // import flow is exercised. This mocks the side-effecting function where it matters, // instead of reimplementing the whole package. @@ -455,7 +455,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { setup(async () => { importModule = await esmock('./deepnoteExplorerView', { - '@deepnote/convert': { convertIpynbFilesToDeepnoteFile: async () => {} } + '@deepnote/convert': { convertIpynbFileToDeepnoteFile: async () => {} } }); explorerView = new importModule.DeepnoteExplorerView(mockContext, createMockLogger()); }); @@ -634,13 +634,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); suite('importJupyterNotebook', () => { - // Stub only the side-effecting `convertIpynbFilesToDeepnoteFile` (real node:fs I/O); + // Stub only the side-effecting `convertIpynbFileToDeepnoteFile` (real node:fs I/O); // all other @deepnote/convert exports remain the real implementation. let importModule: typeof import('./deepnoteExplorerView'); setup(async () => { importModule = await esmock('./deepnoteExplorerView', { - '@deepnote/convert': { convertIpynbFilesToDeepnoteFile: async () => {} } + '@deepnote/convert': { convertIpynbFileToDeepnoteFile: async () => {} } }); explorerView = new importModule.DeepnoteExplorerView(mockContext, createMockLogger()); }); @@ -1844,500 +1844,6 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); }); - 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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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(yamlStringify(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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', - blockGroup: '1', - metadata: {} - } - ], - executionMode: 'block' - } - ] - } - }; - - const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn( - Promise.resolve(Buffer.from(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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: DeepnoteFile = { - version: '1.0.0', - metadata: { - createdAt: '2024-01-01T00:00:00.000Z', - modifiedAt: '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(serializeDeepnoteFile(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.ProjectGroup, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - }, - data: { - projectId: 'project-id', - projectName: 'Test Project', - files: [{ filePath: '/test/project.deepnote', project: {} as DeepnoteFile }] - } - }; - - 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(); @@ -2833,106 +2339,6 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { }); }); - suite('deleteProject', () => { - test('deletes EVERY sibling file in the group (one delete per file)', async () => { - const projectId = 'group-project'; - const files = [ - { filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }, - { filePath: '/workspace/b.deepnote', project: singleNotebookFile(projectId, 'nb-b', 'B') }, - { filePath: '/workspace/c.deepnote', project: singleNotebookFile(projectId, 'nb-c', 'C') } - ]; - - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - const deleted: string[] = []; - const mockFS = mock(); - when(mockFS.delete(anything(), anything())).thenCall((uri: Uri) => { - deleted.push(uri.fsPath); - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectGroup, - context: { filePath: files[0].filePath, projectId }, - data: { projectId, projectName: 'Test Project', files } - }; - - await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); - - assert.strictEqual(deleted.length, 3, 'all three sibling files must be deleted'); - for (const { filePath } of files) { - assert.include(deleted, Uri.file(filePath).fsPath, `${filePath} must be deleted`); - } - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); - }); - - test('partial failure: one delete rejects, the others still delete and the failure is surfaced', async () => { - const projectId = 'group-project'; - const files = [ - { filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }, - { filePath: '/workspace/b.deepnote', project: singleNotebookFile(projectId, 'nb-b', 'B') }, - { filePath: '/workspace/c.deepnote', project: singleNotebookFile(projectId, 'nb-c', 'C') } - ]; - - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - const deleted: string[] = []; - const mockFS = mock(); - when(mockFS.delete(anything(), anything())).thenCall((uri: Uri) => { - if (uri.fsPath === Uri.file('/workspace/b.deepnote').fsPath) { - return Promise.reject(new Error('permission denied')); - } - deleted.push(uri.fsPath); - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectGroup, - context: { filePath: files[0].filePath, projectId }, - data: { projectId, projectName: 'Test Project', files } - }; - - await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); - - // The two healthy files are still deleted (one failure does not abort the loop). - assert.strictEqual(deleted.length, 2, 'the non-failing files must still be deleted'); - assert.include(deleted, Uri.file('/workspace/a.deepnote').fsPath); - assert.include(deleted, Uri.file('/workspace/c.deepnote').fsPath); - // The failure is surfaced (not silent) and no success message is shown. - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).never(); - }); - - test('returns early when the user cancels the confirmation (no deletes)', async () => { - const projectId = 'group-project'; - const files = [{ filePath: '/workspace/a.deepnote', project: singleNotebookFile(projectId, 'nb-a', 'A') }]; - - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve(undefined) - ); - - const mockFS = mock(); - when(mockFS.delete(anything(), anything())).thenReturn(Promise.resolve()); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectGroup, - context: { filePath: files[0].filePath, projectId }, - data: { projectId, projectName: 'Test Project', files } - }; - - await (explorerView as any).deleteProject(treeItem as DeepnoteTreeItem); - - verify(mockFS.delete(anything(), anything())).never(); - }); - }); - suite('deleteNotebook — single-notebook file vs legacy', () => { test('a single-notebook file deletes the FILE (does not rewrite an array)', async () => { const projectId = 'group-project'; diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index 86d6392da2..1685591377 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -17,7 +17,6 @@ import { IControllerRegistration } from '../controllers/types'; import { IExtensionSyncActivationService } from '../../platform/activation/types'; import { IDisposableRegistry } from '../../platform/common/types'; import { logger } from '../../platform/logging'; -import { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteDataConverter } from './deepnoteDataConverter'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; @@ -89,10 +88,7 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, - @inject(IControllerRegistration) @optional() private readonly controllerRegistration?: IControllerRegistration, - @inject(IDeepnoteProjectMetadataPropagator) - @optional() - private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator + @inject(IControllerRegistration) @optional() private readonly controllerRegistration?: IControllerRegistration ) { this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); } @@ -108,13 +104,6 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic if (this.snapshotService) { this.disposables.push(this.snapshotService.onFileWritten((uri) => this.markSnapshotSelfWrite(uri))); } - - if (this.metadataPropagator) { - // A propagated write to an open sibling must be treated as a self-write so it does - // not bounce through the reload-and-resave path. Reuse the same self-write registry - // and consumption path as the snapshot subscription. - this.disposables.push(this.metadataPropagator.onFileWritten((uri) => this.markSnapshotSelfWrite(uri))); - } } private clearAllTimers(): void { @@ -361,10 +350,12 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic // Look up original project blocks for fallback block ID resolution with an exact // (projectId, notebookId) lookup. Sibling files share a project.id, so a project-only - // lookup (getAnyProjectEntry) can return a different sibling's project whose notebooks do - // not contain this notebookId — leaving originalBlocks undefined and silently skipping the - // metadata-lost block-id recovery below. Mirrors the F1 fix in snapshotService.ts. - const originalProject = notebookId ? this.notebookManager.getOriginalProject(projectId, notebookId) : undefined; + // lookup can return a different sibling's project whose notebooks do not contain this + // notebookId — leaving originalBlocks undefined and silently skipping the metadata-lost + // block-id recovery below. Mirrors the exact-lookup guard in snapshotService.ts. + const originalProject = notebookId + ? this.notebookManager.getProjectForNotebook(projectId, notebookId) + : undefined; const notebookBlocksMap = new Map(); if (originalProject) { for (const nb of originalProject.project.notebooks) { @@ -593,9 +584,8 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic } /** - * Marks a URI as written by the snapshot service or the metadata propagator, so the - * resulting fs event is treated as a self-write and skipped. Shared by both subscriptions - * to keep a single, non-divergent self-write registry. + * Marks a URI as written by the snapshot service, so the resulting fs event is treated as a + * self-write and skipped. */ private markSnapshotSelfWrite(uri: Uri): void { const key = this.selfWriteKey(uri); diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index 7302a7929d..03e98c52b3 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -6,7 +6,6 @@ import { Disposable, EventEmitter, FileSystemWatcher, NotebookCellKind, Notebook import type { IControllerRegistration } from '../controllers/types'; import type { IDisposableRegistry } from '../../platform/common/types'; import type { DeepnoteOutput, DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; -import type { IDeepnoteProjectMetadataPropagator } from '../../platform/deepnote/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteFileChangeWatcher } from './deepnoteFileChangeWatcher'; @@ -1045,7 +1044,7 @@ project: // Create a mock notebook manager that returns an original project via the exact // (projectId, notebookId) lookup the snapshot path uses. const mockedManager = mock(); - when(mockedManager.getOriginalProject('e132b172-b114-410e-8331-011517db664f', 'notebook-1')).thenReturn({ + when(mockedManager.getProjectForNotebook('e132b172-b114-410e-8331-011517db664f', 'notebook-1')).thenReturn({ version: '1.0', metadata: { createdAt: '2025-01-01T00:00:00Z' }, project: { @@ -1173,11 +1172,9 @@ project: } as DeepnoteProject; const mockedManager = mock(); - // Project-only lookup returns the arbitrary sibling A (the wrong one, which lacks nb-B). - when(mockedManager.getAnyProjectEntry(projectId)).thenReturn(siblingAProject); // Exact lookup returns sibling B — this is what the fix must use. - when(mockedManager.getOriginalProject(projectId, 'nb-B')).thenReturn(siblingBProject); - when(mockedManager.getOriginalProject(projectId, 'nb-A')).thenReturn(siblingAProject); + when(mockedManager.getProjectForNotebook(projectId, 'nb-B')).thenReturn(siblingBProject); + when(mockedManager.getProjectForNotebook(projectId, 'nb-A')).thenReturn(siblingAProject); const siblingDisposables: IDisposableRegistry = []; const siblingOnDidChange = new EventEmitter(); @@ -1243,15 +1240,15 @@ project: await waitFor(() => siblingApplyEditCount > 0); - // Recovery only succeeds if the code used getOriginalProject(projectId, 'nb-B') — the exact - // lookup. With the old getAnyProjectEntry(projectId) (sibling A, no nb-B), originalBlocks - // would be undefined, block-id recovery could not run, and no edit would be applied. + // Recovery only succeeds if the code used getProjectForNotebook(projectId, 'nb-B') — the exact + // lookup. A project-only lookup could return sibling A (no nb-B), leaving originalBlocks + // undefined so block-id recovery could not run and no edit would be applied. assert.isAtLeast( siblingApplyEditCount, 1, 'snapshot output should be applied to sibling B via exact (projectId, notebookId) lookup' ); - verify(mockedManager.getOriginalProject(projectId, 'nb-B')).called(); + verify(mockedManager.getProjectForNotebook(projectId, 'nb-B')).called(); // Cleanup for (const d of siblingDisposables) { @@ -1507,116 +1504,4 @@ project: fbOnDidCreate.dispose(); }); }); - - suite('metadata-propagator self-write suppression', () => { - let propagatorDisposables: IDisposableRegistry; - let propagatorOnDidChange: EventEmitter; - let propagatorOnDidCreate: EventEmitter; - let onFileWrittenCallback: ((uri: Uri) => void) | undefined; - let propagatorReadFileCalls: number; - let propagatorApplyEditCount: number; - let propagatorSaveCount: number; - - setup(() => { - propagatorDisposables = []; - onFileWrittenCallback = undefined; - propagatorReadFileCalls = 0; - propagatorApplyEditCount = 0; - propagatorSaveCount = 0; - - propagatorOnDidChange = new EventEmitter(); - propagatorOnDidCreate = new EventEmitter(); - const fsWatcherProp = mock(); - when(fsWatcherProp.onDidChange).thenReturn(propagatorOnDidChange.event); - when(fsWatcherProp.onDidCreate).thenReturn(propagatorOnDidCreate.event); - when(fsWatcherProp.dispose()).thenReturn(); - when(mockedVSCodeNamespaces.workspace.createFileSystemWatcher(anything())).thenReturn( - instance(fsWatcherProp) - ); - - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall(() => { - propagatorReadFileCalls++; - return Promise.resolve(new TextEncoder().encode(validYaml)); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenCall(() => { - propagatorApplyEditCount++; - return Promise.resolve(true); - }); - when(mockedVSCodeNamespaces.workspace.save(anything())).thenCall(() => { - propagatorSaveCount++; - return Promise.resolve(Uri.file('/workspace/test.deepnote')); - }); - - const mockPropagator = mock(); - when(mockPropagator.onFileWritten(anything())).thenCall((cb: (uri: Uri) => void) => { - onFileWrittenCallback = cb; - return { - dispose: () => { - onFileWrittenCallback = undefined; - } - } as Disposable; - }); - - const propagatorWatcher = new DeepnoteFileChangeWatcher( - propagatorDisposables, - mockNotebookManager, - undefined, - undefined, - instance(mockPropagator) - ); - propagatorWatcher.activate(); - }); - - teardown(() => { - for (const d of propagatorDisposables) { - d.dispose(); - } - propagatorOnDidChange.dispose(); - propagatorOnDidCreate.dispose(); - }); - - test('a propagated write marks the uri as a self-write so handleFileChange skips the reload-and-resave path', async () => { - const uri = Uri.file('/workspace/sibling.deepnote'); - // Live cells differ from the YAML on disk, so WITHOUT the self-write the watcher would - // read + applyEdit + save. The propagated-write marker must short-circuit all of that. - const notebook = createMockNotebook({ uri, cellCount: 0, cells: [] }); - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - // The propagator wrote this sibling → fires its onFileWritten callback (registered on activate()). - assert.isDefined( - onFileWrittenCallback, - 'metadataPropagator.onFileWritten should be subscribed on activate' - ); - onFileWrittenCallback!(uri); - - // The resulting fs change event for that write must be consumed as a self-write. - propagatorOnDidChange.fire(uri); - - // Wait well past the debounce to prove nothing was scheduled. - await new Promise((resolve) => setTimeout(resolve, debounceWaitMs)); - - assert.strictEqual(propagatorReadFileCalls, 0, 'readFile must NOT be called for a propagated self-write'); - assert.strictEqual(propagatorApplyEditCount, 0, 'applyEdit must NOT be called for a propagated self-write'); - assert.strictEqual(propagatorSaveCount, 0, 'save must NOT be called for a propagated self-write'); - }); - - test('without a preceding propagated write, an external change to the same sibling still reloads (proves the suppression is the propagated mark, not a blanket skip)', async () => { - const uri = Uri.file('/workspace/sibling-external.deepnote'); - const notebook = createMockNotebook({ uri, cellCount: 0, cells: [] }); - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - // No onFileWrittenCallback fired → a genuine external change reloads. - propagatorOnDidChange.fire(uri); - - await waitFor(() => propagatorSaveCount > 0); - - assert.isAtLeast(propagatorApplyEditCount, 1, 'a genuine external change should applyEdit'); - assert.isAtLeast(propagatorSaveCount, 1, 'a genuine external change should save'); - }); - }); }); diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index 79644fdc8c..b6005a4423 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts @@ -119,7 +119,10 @@ export class DeepnoteInitNotebookRunner implements IDeepnoteInitNotebookRunner, return; } - const initNotebookId = this.notebookManager.getAnyProjectEntry(projectId)?.project.initNotebookId; + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; + const initNotebookId = notebookId + ? this.notebookManager.getProjectForNotebook(projectId, notebookId)?.project.initNotebookId + : undefined; if (!initNotebookId) { logger.info(`No init notebook configured for project ${projectId}, skipping init`); return; diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts index ff09a6a704..1c7f69d3d4 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts @@ -140,7 +140,7 @@ function makeTwoCodeBlockFile( } as unknown as DeepnoteFile; } -/** The cached project entry the manager returns for `getAnyProjectEntry` (carries initNotebookId). */ +/** The cached project entry the manager returns for `getProjectForNotebook` (carries initNotebookId). */ function makeMainProjectEntry(projectId: string, initNotebookId: string | undefined): DeepnoteProject { return { version: '1.0.0', @@ -198,7 +198,7 @@ suite('DeepnoteInitNotebookRunner', () => { const notebook = { uri, notebookType: opts?.notebookType ?? 'deepnote', - metadata: { deepnoteProjectId: opts?.projectId ?? PROJECT_ID } + metadata: { deepnoteProjectId: opts?.projectId ?? PROJECT_ID, deepnoteNotebookId: MAIN_NOTEBOOK_ID } } as unknown as NotebookDocument; return { notebook } as unknown as IKernel; @@ -226,7 +226,7 @@ suite('DeepnoteInitNotebookRunner', () => { } as never); // Default cached project: has an init notebook configured. - when(mockNotebookManager.getAnyProjectEntry(PROJECT_ID)).thenReturn( + when(mockNotebookManager.getProjectForNotebook(PROJECT_ID, MAIN_NOTEBOOK_ID)).thenReturn( makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) ); @@ -399,7 +399,7 @@ suite('DeepnoteInitNotebookRunner', () => { test('no init configured: undefined initNotebookId runs nothing and never scans the directory', async () => { // The cached project has NO initNotebookId. - when(mockNotebookManager.getAnyProjectEntry(PROJECT_ID)).thenReturn( + when(mockNotebookManager.getProjectForNotebook(PROJECT_ID, MAIN_NOTEBOOK_ID)).thenReturn( makeMainProjectEntry(PROJECT_ID, undefined) ); putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, undefined) as unknown as DeepnoteFile); diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index f58d2ccef9..9fc1faf02d 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -575,9 +575,11 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Prepare init notebook execution const projectId = notebook.metadata?.deepnoteProjectId; - const project = projectId - ? (this.notebookManager.getAnyProjectEntry(projectId) as DeepnoteFile | undefined) - : undefined; + const notebookId = notebook.metadata?.deepnoteNotebookId; + const project = + projectId && notebookId + ? (this.notebookManager.getProjectForNotebook(projectId, notebookId) as DeepnoteFile | undefined) + : undefined; if (project) { // Only create requirements.txt if requirements have changed from what's on disk diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 9e30652e10..be48a18763 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -13,29 +13,6 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { // that share a single project.id do not clobber each other's cached project data. private readonly originalProjects = new Map>(); - /** - * Returns any cached project entry for the given project id. - * Intended for project-level read-only callers that have only a `projectId` - * (no specific notebook). Because sibling files deliberately share a `project.id`, - * this may return the cached project of any one sibling — it must never be used - * on a save path (use {@link getOriginalProject} there). - * @param projectId Project identifier - * @returns A cached project for the project, or undefined if none is cached - */ - getAnyProjectEntry(projectId: string): DeepnoteProject | undefined { - const notebookEntries = this.originalProjects.get(projectId); - - if (!notebookEntries) { - return undefined; - } - - for (const project of notebookEntries.values()) { - return project; - } - - return undefined; - } - /** * Retrieves the cached project data for an exact (projectId, notebookId) pair. * This performs an exact match only and never falls back to another sibling's @@ -44,7 +21,7 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { * @param notebookId Notebook identifier within the project * @returns The cached project data for that notebook, or undefined if not found */ - getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined { + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined { return this.originalProjects.get(projectId)?.get(notebookId); } @@ -70,31 +47,10 @@ export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { notebookEntries.set(notebookId, clonedProject); } - /** - * Updates the cached project data for an exact (projectId, notebookId) pair. - * Unlike {@link storeOriginalProject}, this does not change the project's current - * notebook bookkeeping — it only refreshes the cached project for that notebook. - * @param projectId Project identifier - * @param notebookId Notebook identifier within the project - * @param project Updated project data to store - */ - updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void { - const clonedProject = structuredClone(project); - - let notebookEntries = this.originalProjects.get(projectId); - - if (!notebookEntries) { - notebookEntries = new Map(); - this.originalProjects.set(projectId, notebookEntries); - } - - notebookEntries.set(notebookId, clonedProject); - } - /** * Updates the integrations list in the cached project data (cache-only). * Iterates every cached notebook entry under the project and updates each entry's - * integrations. The on-disk fan-out across sibling files is handled separately. + * integrations. * * @param projectId - Project identifier * @param integrations - Array of integration metadata to store in the project diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index efad7923dc..7f618034b2 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -25,9 +25,9 @@ suite('DeepnoteNotebookManager', () => { manager = new DeepnoteNotebookManager(); }); - suite('getOriginalProject', () => { + suite('getProjectForNotebook', () => { test('should return undefined for unknown project', () => { - const result = manager.getOriginalProject('unknown-project', 'notebook-456'); + const result = manager.getProjectForNotebook('unknown-project', 'notebook-456'); assert.strictEqual(result, undefined); }); @@ -35,7 +35,7 @@ suite('DeepnoteNotebookManager', () => { test('should return original project after storing', () => { manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const result = manager.getOriginalProject('project-123', 'notebook-456'); + const result = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(result, mockProject); }); @@ -45,7 +45,7 @@ suite('DeepnoteNotebookManager', () => { test('should store the project for the (projectId, notebookId) pair', () => { manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const storedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(storedProject, mockProject); }); @@ -62,7 +62,7 @@ suite('DeepnoteNotebookManager', () => { manager.storeOriginalProject('project-123', 'notebook-456', mockProject); manager.storeOriginalProject('project-123', 'notebook-456', updatedProject); - const storedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const storedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(storedProject, updatedProject); }); @@ -81,7 +81,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, integrations); }); @@ -105,7 +105,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations); }); @@ -124,7 +124,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, []); }); @@ -135,7 +135,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, false); - const project = manager.getOriginalProject('unknown-project', 'notebook-456'); + const project = manager.getProjectForNotebook('unknown-project', 'notebook-456'); assert.strictEqual(project, undefined); }); @@ -148,7 +148,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123', 'notebook-456'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.strictEqual(updatedProject?.project.id, mockProject.project.id); assert.strictEqual(updatedProject?.project.name, mockProject.project.name); assert.strictEqual(updatedProject?.version, mockProject.version); @@ -170,8 +170,8 @@ suite('DeepnoteNotebookManager', () => { manager.storeOriginalProject('project-1', 'notebook-1', projectOne); manager.storeOriginalProject('project-2', 'notebook-2', projectTwo); - assert.deepStrictEqual(manager.getOriginalProject('project-1', 'notebook-1'), projectOne); - assert.deepStrictEqual(manager.getOriginalProject('project-2', 'notebook-2'), projectTwo); + assert.deepStrictEqual(manager.getProjectForNotebook('project-1', 'notebook-1'), projectOne); + assert.deepStrictEqual(manager.getProjectForNotebook('project-2', 'notebook-2'), projectTwo); }); }); @@ -208,67 +208,21 @@ suite('DeepnoteNotebookManager', () => { manager.storeOriginalProject(projectId, nbA, projectA); manager.storeOriginalProject(projectId, nbB, projectB); - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA), projectA); - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB), projectB); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA), projectA); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbB), projectB); }); - test('getOriginalProject is exact: returns undefined for an uncached notebook even though a sibling IS cached (NO fallback)', () => { + test('getProjectForNotebook is exact: returns undefined for an uncached notebook even though a sibling IS cached (NO fallback)', () => { manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); // A different notebook of the SAME project is cached, but the requested one is not. // The exact lookup must NOT fall back to the sibling — this is the key anti-regression // (a save path relies on it to never write against the wrong sibling's project). - const result = manager.getOriginalProject(projectId, 'not-cached'); + const result = manager.getProjectForNotebook(projectId, 'not-cached'); assert.strictEqual(result, undefined); }); - test('getAnyProjectEntry returns one of the project entries (project-level)', () => { - const projectA = siblingProject(nbA, 'Sibling A'); - const projectB = siblingProject(nbB, 'Sibling B'); - - manager.storeOriginalProject(projectId, nbA, projectA); - manager.storeOriginalProject(projectId, nbB, projectB); - - const result = manager.getAnyProjectEntry(projectId); - - assert.notStrictEqual(result, undefined); - // It must be one of the project's own cached entries. - const isOneOfTheSiblings = - JSON.stringify(result) === JSON.stringify(projectA) || - JSON.stringify(result) === JSON.stringify(projectB); - assert.strictEqual( - isOneOfTheSiblings, - true, - 'getAnyProjectEntry should return one of the cached sibling projects' - ); - }); - - test('getAnyProjectEntry returns undefined for an unknown project', () => { - manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); - - assert.strictEqual(manager.getAnyProjectEntry('unknown-project'), undefined); - }); - - test('updateOriginalProject refreshes the exact entry without affecting the sibling', () => { - const projectA = siblingProject(nbA, 'Sibling A'); - const projectB = siblingProject(nbB, 'Sibling B'); - - manager.storeOriginalProject(projectId, nbA, projectA); - manager.storeOriginalProject(projectId, nbB, projectB); - - const renamedA: DeepnoteProject = { - ...projectA, - project: { ...projectA.project, name: 'Sibling A Renamed' } - }; - - manager.updateOriginalProject(projectId, nbA, renamedA); - - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA), renamedA); - // Sibling B must be untouched by the update to A. - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB), projectB); - }); - test('updateProjectIntegrations updates every cached notebook entry under the project', () => { manager.storeOriginalProject(projectId, nbA, siblingProject(nbA, 'Sibling A')); manager.storeOriginalProject(projectId, nbB, siblingProject(nbB, 'Sibling B')); @@ -282,8 +236,8 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(updated, true); // BOTH siblings of the project must see the new integrations. - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbA)?.project.integrations, integrations); - assert.deepStrictEqual(manager.getOriginalProject(projectId, nbB)?.project.integrations, integrations); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA)?.project.integrations, integrations); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbB)?.project.integrations, integrations); }); }); }); diff --git a/src/notebooks/deepnote/deepnoteSerializer.ts b/src/notebooks/deepnote/deepnoteSerializer.ts index e7473633cb..e7f6bc6188 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -218,7 +218,7 @@ export class DeepnoteNotebookSerializer implements NotebookSerializer { // Fetch the cached project with an exact (projectId, notebookId) lookup. Sibling files // share a project.id, so a project-only lookup could return a different sibling's project. - const storedProject = this.notebookManager.getOriginalProject(projectId, notebookId); + const storedProject = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!storedProject) { throw new Error('Original Deepnote project not found. Cannot save changes.'); diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index 633e7bff11..e200bc07e0 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -362,8 +362,7 @@ project: }); test('should handle manager state operations', () => { - assert.isFunction(manager.getAnyProjectEntry, 'has getAnyProjectEntry method'); - assert.isFunction(manager.getOriginalProject, 'has getOriginalProject method'); + assert.isFunction(manager.getProjectForNotebook, 'has getProjectForNotebook method'); assert.isFunction(manager.storeOriginalProject, 'has storeOriginalProject method'); }); }); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts index 40a554800c..0c3c048305 100644 --- a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts @@ -58,11 +58,12 @@ export class FederatedAuthKernelRestartBridge implements IExtensionSyncActivatio } const projectId = notebook.metadata?.deepnoteProjectId as string | undefined; - if (!projectId) { + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; + if (!projectId || !notebookId) { continue; } - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!project) { continue; } diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts index fde8cf060a..b2ca50288f 100644 --- a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts @@ -18,11 +18,18 @@ suite('FederatedAuthKernelRestartBridge', () => { let disposables: IDisposable[]; let onDidChangeTokens: EventEmitter; - function createMockNotebook(notebookType: string, uri: Uri, projectId?: string): NotebookDocument { + function createMockNotebook( + notebookType: string, + uri: Uri, + projectId?: string, + notebookId?: string + ): NotebookDocument { const notebook = mock(); when(notebook.notebookType).thenReturn(notebookType); when(notebook.uri).thenReturn(uri); - when(notebook.metadata).thenReturn(projectId ? { deepnoteProjectId: projectId } : {}); + when(notebook.metadata).thenReturn( + projectId ? { deepnoteProjectId: projectId, deepnoteNotebookId: notebookId } : {} + ); return instance(notebook); } @@ -62,8 +69,8 @@ suite('FederatedAuthKernelRestartBridge', () => { }); test('restarts only the affected notebook when one of many references the integration', async () => { - const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); - const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a', 'notebook-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b', 'notebook-b'); const kernelA = mkKernel(); const kernelB = mkKernel(); @@ -71,8 +78,12 @@ suite('FederatedAuthKernelRestartBridge', () => { when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); // Only project A references 'bq-shared'. - when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['other-bq'])); + when(notebookManager.getProjectForNotebook('project-a', 'notebook-a')).thenReturn( + createMockProject('project-a', ['bq-shared']) + ); + when(notebookManager.getProjectForNotebook('project-b', 'notebook-b')).thenReturn( + createMockProject('project-b', ['other-bq']) + ); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); @@ -82,16 +93,20 @@ suite('FederatedAuthKernelRestartBridge', () => { }); test('restarts both kernels when two notebooks reference the same integration', async () => { - const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); - const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a', 'notebook-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b', 'notebook-b'); const kernelA = mkKernel(); const kernelB = mkKernel(); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); - when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + when(notebookManager.getProjectForNotebook('project-a', 'notebook-a')).thenReturn( + createMockProject('project-a', ['bq-shared']) + ); + when(notebookManager.getProjectForNotebook('project-b', 'notebook-b')).thenReturn( + createMockProject('project-b', ['bq-shared']) + ); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); @@ -104,13 +119,13 @@ suite('FederatedAuthKernelRestartBridge', () => { [ [ 'non-Deepnote notebooks', - () => createMockNotebook('jupyter-notebook', Uri.file('/a.ipynb'), 'project-a'), + () => createMockNotebook('jupyter-notebook', Uri.file('/a.ipynb'), 'project-a', 'notebook-a'), () => mkKernel(), () => createMockProject('project-a', ['bq-1']) ], [ 'notebooks whose kernel has not started', - () => createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'), + () => createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a', 'notebook-a'), () => mkKernel({ startedAtLeastOnce: false }), () => createMockProject('project-a', ['bq-1']) ], @@ -130,7 +145,7 @@ suite('FederatedAuthKernelRestartBridge', () => { when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); when(kernelProvider.get(notebook)).thenReturn(instance(kernel)); if (project) { - when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-a', 'notebook-a')).thenReturn(project); } onDidChangeTokens.fire('bq-1'); @@ -138,22 +153,26 @@ suite('FederatedAuthKernelRestartBridge', () => { verify(kernel.restart()).never(); if (!project) { - verify(notebookManager.getAnyProjectEntry(anyString())).never(); + verify(notebookManager.getProjectForNotebook(anyString(), anyString())).never(); } }); }); test('continues restarting other kernels when one fails', async () => { - const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); - const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a', 'notebook-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b', 'notebook-b'); const kernelA = mkKernel({ restartRejects: new Error('boom') }); const kernelB = mkKernel(); when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); - when(notebookManager.getAnyProjectEntry('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getAnyProjectEntry('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + when(notebookManager.getProjectForNotebook('project-a', 'notebook-a')).thenReturn( + createMockProject('project-a', ['bq-shared']) + ); + when(notebookManager.getProjectForNotebook('project-b', 'notebook-b')).thenReturn( + createMockProject('project-b', ['bq-shared']) + ); onDidChangeTokens.fire('bq-shared'); await settleAsyncHandlers(); diff --git a/src/notebooks/deepnote/integrations/integrationDetector.ts b/src/notebooks/deepnote/integrations/integrationDetector.ts index 40c5668bd1..5715ead44c 100644 --- a/src/notebooks/deepnote/integrations/integrationDetector.ts +++ b/src/notebooks/deepnote/integrations/integrationDetector.ts @@ -24,9 +24,9 @@ export class IntegrationDetector implements IIntegrationDetector { * Detect all integrations used in the given project. * Uses the project's integrations field as the source of truth. */ - async detectIntegrations(projectId: string): Promise> { + async detectIntegrations(projectId: string, notebookId: string): Promise> { // Get the project - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!project) { logger.warn( `IntegrationDetector: No project found for ID: ${projectId}. The project may not have been loaded yet.` @@ -74,8 +74,8 @@ export class IntegrationDetector implements IIntegrationDetector { /** * Check if a project has any unconfigured integrations */ - async hasUnconfiguredIntegrations(projectId: string): Promise { - const integrations = await this.detectIntegrations(projectId); + async hasUnconfiguredIntegrations(projectId: string, notebookId: string): Promise { + const integrations = await this.detectIntegrations(projectId, notebookId); for (const integration of integrations.values()) { if (integration.status === IntegrationStatus.Disconnected) { diff --git a/src/notebooks/deepnote/integrations/integrationManager.ts b/src/notebooks/deepnote/integrations/integrationManager.ts index ff6e5b0e73..15829749c9 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -95,16 +95,17 @@ export class IntegrationManager implements IIntegrationManager { return; } - // Get the project ID from the notebook metadata + // Get the project and notebook IDs from the notebook metadata const projectId = activeNotebook.metadata?.deepnoteProjectId; - if (!projectId) { + const notebookId = activeNotebook.metadata?.deepnoteNotebookId; + if (!projectId || !notebookId) { await commands.executeCommand('setContext', this.hasIntegrationsContext, false); await commands.executeCommand('setContext', this.hasUnconfiguredIntegrationsContext, false); return; } // Detect integrations in the project - const integrations = await this.integrationDetector.detectIntegrations(projectId); + const integrations = await this.integrationDetector.detectIntegrations(projectId, notebookId); const hasIntegrations = integrations.size > 0; const hasUnconfigured = Array.from(integrations.values()).some( (integration) => integration.status === IntegrationStatus.Disconnected @@ -127,7 +128,8 @@ export class IntegrationManager implements IIntegrationManager { } const projectId = activeNotebook.metadata?.deepnoteProjectId; - if (!projectId) { + const notebookId = activeNotebook.metadata?.deepnoteNotebookId; + if (!projectId || !notebookId) { void window.showErrorMessage(l10n.t('Cannot determine project ID')); return; } @@ -136,7 +138,7 @@ export class IntegrationManager implements IIntegrationManager { logger.trace(`IntegrationManager: Notebook metadata:`, activeNotebook.metadata); // First try to detect integrations from the stored project - let integrations = await this.integrationDetector.detectIntegrations(projectId); + let integrations = await this.integrationDetector.detectIntegrations(projectId, notebookId); logger.debug(`IntegrationManager: Found ${integrations.size} integrations`); // If a specific integration was requested (e.g., from status bar click), @@ -146,7 +148,7 @@ export class IntegrationManager implements IIntegrationManager { const config = await this.integrationStorage.getIntegrationConfig(selectedIntegrationId); // Try to get integration metadata from the project - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); const projectIntegration = project?.project.integrations?.find((i) => i.id === selectedIntegrationId); let integrationName: string | undefined; @@ -174,6 +176,11 @@ export class IntegrationManager implements IIntegrationManager { } // Show the webview with optional selected integration - await this.webviewProvider.show(projectId, integrations, selectedIntegrationId); + await this.webviewProvider.show( + projectId, + integrations, + selectedIntegrationId, + activeNotebook.metadata?.deepnoteProjectName + ); } } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 277a2fdde8..6eee134162 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -8,7 +8,6 @@ import { IDisposableRegistry, IExtensionContext } from '../../../platform/common import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; -import { IDeepnoteProjectMetadataPropagator } from '../../../platform/deepnote/types'; import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; import { IFederatedAuthTokenStorage, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; import { @@ -31,6 +30,8 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private projectId: string | undefined; + private projectName: string | undefined; + /** Generation counter for `updateWebview()` ("latest call wins"; stale in-flight updates bail). */ private updateGeneration = 0; @@ -41,10 +42,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, @inject(IFederatedAuthTokenStorage) @optional() - private readonly tokenStorage?: IFederatedAuthTokenStorage, - @inject(IDeepnoteProjectMetadataPropagator) - @optional() - private readonly metadataPropagator?: IDeepnoteProjectMetadataPropagator + private readonly tokenStorage?: IFederatedAuthTokenStorage ) { // Refresh on token-storage change so the auth pill flips without panel reload. Pushed into the extension-lifetime registry to survive panel close/reopen. if (this.tokenStorage) { @@ -63,14 +61,17 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { * @param projectId The Deepnote project ID * @param integrations Map of integration IDs to their status * @param selectedIntegrationId Optional integration ID to select/configure immediately + * @param projectName Optional project display name (sourced from the active notebook's metadata) */ public async show( projectId: string, integrations: Map, - selectedIntegrationId?: string + selectedIntegrationId?: string, + projectName?: string ): Promise { // Update the stored integrations and project ID with the latest data this.projectId = projectId; + this.projectName = projectName; this.integrations = integrations; const column = window.activeTextEditor ? window.activeTextEditor.viewColumn : ViewColumn.One; @@ -472,16 +473,9 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { logger.debug(`IntegrationWebviewProvider: Sending ${integrationsData.length} integrations to webview`); - // Get the project name from the notebook manager - let projectName: string | undefined; - if (this.projectId) { - const project = this.notebookManager.getAnyProjectEntry(this.projectId); - projectName = project?.project.name; - } - await this.currentPanel.webview.postMessage({ integrations: integrationsData, - projectName, + projectName: this.projectName, type: 'update' }); } @@ -768,34 +762,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { `IntegrationWebviewProvider: Updating project ${this.projectId} with ${projectIntegrations.length} integrations` ); - // Desktop: fan out the integrations change to every sibling .deepnote file on disk - // (open or closed). The propagator's cache refresh subsumes the manager cache update, - // so this is the single place that updates both disk and the in-memory copies. - if (this.metadataPropagator) { - const { updated, failures } = await this.metadataPropagator.propagateProjectMetadata( - this.projectId, - (file) => { - file.project.integrations = projectIntegrations; - } - ); - - // Preserve the existing "project not found" contract: surface it only when no file - // matched the project (failures are surfaced by the propagator itself). - if (updated.length === 0 && failures.length === 0) { - logger.error( - `IntegrationWebviewProvider: Failed to update integrations for project ${this.projectId} - project not found` - ); - void window.showErrorMessage( - l10n.t( - 'Failed to update integrations: project not found. Please reopen the notebook and try again.' - ) - ); - } - - return; - } - - // Web fallback (no filesystem fan-out): update only the cached project entries. + // Update the cached project entries; each notebook persists its own integrations on save. const success = this.notebookManager.updateProjectIntegrations(this.projectId, projectIntegrations); if (!success) { diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index a999b3dfa1..85f613014d 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -6,7 +6,6 @@ import { anyString, anything, instance, mock, reset, verify, when } from 'ts-moc import { IExtensionContext, IDisposable } from '../../../platform/common/types'; import { Commands } from '../../../platform/common/constants'; import { IDeepnoteNotebookManager } from '../../types'; -import { IDeepnoteProjectMetadataPropagator, ProjectMetadataPropagationResult } from '../../../platform/deepnote/types'; import { IntegrationWebviewProvider } from './integrationWebview'; import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage, IIntegrationStorage } from './types'; import { computeMetadataFingerprint } from './federatedAuth/federatedAuthTokenStorage.node'; @@ -151,7 +150,6 @@ suite('IntegrationWebviewProvider', () => { function buildProvider( opts: { tokenStorage?: IFederatedAuthTokenStorage; - metadataPropagator?: IDeepnoteProjectMetadataPropagator; } = {} ): IntegrationWebviewProvider { return new IntegrationWebviewProvider( @@ -159,8 +157,7 @@ suite('IntegrationWebviewProvider', () => { instance(integrationStorage), instance(notebookManager), extensionSubscriptions, - opts.tokenStorage, - opts.metadataPropagator + opts.tokenStorage ); } @@ -439,51 +436,29 @@ suite('IntegrationWebviewProvider', () => { assert.isEmpty(updateMessages, 'no `update` postMessage should be issued after the panel disposes mid-update'); }); - suite('updateProjectIntegrationsList: propagator vs cache-only fan-out', () => { - function makePropagator(result: ProjectMetadataPropagationResult): { - propagator: IDeepnoteProjectMetadataPropagator; - calls: Array<{ projectId: string; mutator: (file: import('@deepnote/blocks').DeepnoteFile) => void }>; - } { - const calls: Array<{ - projectId: string; - mutator: (file: import('@deepnote/blocks').DeepnoteFile) => void; - }> = []; - const propagator: IDeepnoteProjectMetadataPropagator = { - onFileWritten: () => ({ dispose: () => undefined }), - propagateProjectMetadata: async (projectId, mutator) => { - calls.push({ projectId, mutator }); - - return result; - } - }; - - return { propagator, calls }; - } - + suite('updateProjectIntegrationsList: cache-only update', () => { async function callUpdateProjectIntegrationsList(provider: IntegrationWebviewProvider): Promise { - // `show()` sets `projectId` + the integrations map; then drive the private fan-out method. + // `show()` sets `projectId` + the integrations map; then drive the private update method. await show(provider, singleIntegrationMap('pg-1', buildPostgresIntegration({ id: 'pg-1' }))); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (provider as any).updateProjectIntegrationsList(); } - test('when the propagator is present it drives propagateProjectMetadata (NOT the cache-only updateProjectIntegrations)', async () => { - const { propagator, calls } = makePropagator({ updated: [Uri.file('/x/a.deepnote')], failures: [] }); + test('updates the cached project integrations via notebookManager.updateProjectIntegrations', async () => { const updateProjectIntegrationsSpy = sinon.spy((_projectId: string, _integrations: unknown[]) => true); when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenCall( updateProjectIntegrationsSpy ); - const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + const provider = buildProvider({ tokenStorage }); await callUpdateProjectIntegrationsList(provider); - assert.strictEqual(calls.length, 1, 'propagateProjectMetadata should be called once'); - assert.strictEqual(calls[0].projectId, PROJECT_ID); - sinon.assert.notCalled(updateProjectIntegrationsSpy); + sinon.assert.calledOnce(updateProjectIntegrationsSpy); + sinon.assert.calledWith(updateProjectIntegrationsSpy, PROJECT_ID); }); - test('"project not found" error shows only when updated.length === 0 AND failures.length === 0', async () => { + test('shows a "project not found" error when no cached entry was updated', async () => { const errors: string[] = []; when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { errors.push(msg); @@ -491,63 +466,17 @@ suite('IntegrationWebviewProvider', () => { return Promise.resolve(undefined); }); - // updated.length === 0 && failures.length === 0 → group empty/unresolvable → error. - const { propagator } = makePropagator({ updated: [], failures: [] }); - const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); + // updateProjectIntegrations returns false → no cached entry for the project → error. + when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenReturn(false); + + const provider = buildProvider({ tokenStorage }); await callUpdateProjectIntegrationsList(provider); assert.strictEqual( errors.length, 1, - 'project-not-found error should show when nothing was updated and nothing failed' + 'project-not-found error should show when no cached entry was updated' ); }); - - test('"project not found" error does NOT show when at least one file was updated', async () => { - const errors: string[] = []; - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { - errors.push(msg); - - return Promise.resolve(undefined); - }); - - const { propagator } = makePropagator({ updated: [Uri.file('/x/a.deepnote')], failures: [] }); - const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); - await callUpdateProjectIntegrationsList(provider); - - assert.deepStrictEqual(errors, [], 'no project-not-found error when a file was updated'); - }); - - test('"project not found" error does NOT show when there were per-file failures (propagator already surfaced them)', async () => { - const errors: string[] = []; - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenCall((msg: string) => { - errors.push(msg); - - return Promise.resolve(undefined); - }); - - // updated empty but failures present → propagator already warned; no project-not-found error. - const { propagator } = makePropagator({ - updated: [], - failures: [{ uri: Uri.file('/x/bad.deepnote'), error: new Error('boom') }] - }); - const provider = buildProvider({ tokenStorage, metadataPropagator: propagator }); - await callUpdateProjectIntegrationsList(provider); - - assert.deepStrictEqual(errors, [], 'no project-not-found error when failures were collected'); - }); - - test('when the propagator is absent (web) it falls back to notebookManager.updateProjectIntegrations', async () => { - const updateProjectIntegrationsSpy = sinon.spy((_projectId: string, _integrations: unknown[]) => true); - when(notebookManager.updateProjectIntegrations(anyString(), anything())).thenCall( - updateProjectIntegrationsSpy - ); - - const provider = buildProvider({ tokenStorage }); // no metadataPropagator - await callUpdateProjectIntegrationsList(provider); - - sinon.assert.calledOnce(updateProjectIntegrationsSpy); - sinon.assert.calledWith(updateProjectIntegrationsSpy, PROJECT_ID); - }); }); }); diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index 88ae1c9bca..81dae07717 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -11,12 +11,12 @@ export interface IIntegrationDetector { /** * Detect all integrations used in the given project */ - detectIntegrations(projectId: string): Promise>; + detectIntegrations(projectId: string, notebookId: string): Promise>; /** * Check if a project has any unconfigured integrations */ - hasUnconfiguredIntegrations(projectId: string): Promise; + hasUnconfiguredIntegrations(projectId: string, notebookId: string): Promise; } export const IIntegrationWebviewProvider = Symbol('IIntegrationWebviewProvider'); @@ -26,11 +26,13 @@ export interface IIntegrationWebviewProvider { * @param projectId The Deepnote project ID * @param integrations Map of integration IDs to their status * @param selectedIntegrationId Optional integration ID to select/configure immediately + * @param projectName Optional project display name (sourced from the active notebook's metadata) */ show( projectId: string, integrations: Map, - selectedIntegrationId?: string + selectedIntegrationId?: string, + projectName?: string ): Promise; } diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 4a64be0373..b3308bfda4 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -683,14 +683,6 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } } - private findProjectUriFromId(projectId: string): Uri | undefined { - const notebookDoc = workspace.notebookDocuments.find( - (doc) => doc.notebookType === 'deepnote' && doc.metadata?.deepnoteProjectId === projectId - ); - - return notebookDoc?.uri; - } - private getComparableProjectContent(data: DeepnoteFile): object { return { version: data.version, @@ -927,10 +919,10 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } // Fetch the cached project with an exact (projectId, notebookId) lookup. Sibling files share a - // project.id, so a project-only lookup (getAnyProjectEntry) can return a different sibling's - // project whose notebooks do not contain this notebookId — which would silently skip the - // snapshot write for every sibling but the first one cached. - const originalProject = this.notebookManager?.getOriginalProject(projectId, notebookId); + // project.id, so a project-only lookup can return a different sibling's project whose notebooks + // do not contain this notebookId — which would silently skip the snapshot write for every + // sibling but the first one cached. + const originalProject = this.notebookManager?.getProjectForNotebook(projectId, notebookId); if (!originalProject) { logger.warn(`[Snapshot] No original project found for ${projectId}/${notebookId}`); @@ -938,13 +930,10 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } - const projectUri = this.findProjectUriFromId(projectId); - - if (!projectUri) { - logger.warn(`[Snapshot] Could not find project URI for ${projectId}`); - - return; - } + // Use the exact saved notebook's own URI (resolved above) so the snapshot lands in this + // file's sibling `snapshots/` dir. A projectId-only lookup could pick a different open + // sibling sharing this project.id and write the snapshot next to the wrong file. + const projectUri = notebook.uri; const deepnoteNotebook = originalProject.project.notebooks?.find((nb) => nb.id === notebookId); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index 2998a7de07..0adef1793d 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -1488,8 +1488,7 @@ project: }; const mockNotebookManager = { - getAnyProjectEntry: sinon.stub().returns(originalProject), - getOriginalProject: sinon.stub().returns(originalProject) + getProjectForNotebook: sinon.stub().returns(originalProject) }; // Create a new service with the mock notebook manager @@ -1535,16 +1534,92 @@ project: 'updateLatestSnapshot should NOT be called when all code cells are executed' ); - // F1 regression: the save path must resolve the project via the EXACT - // (projectId, notebookId) lookup, not the project-only getAnyProjectEntry (which can - // return a different open sibling and silently skip the snapshot write). + // Regression: the save path must resolve the project via the EXACT + // (projectId, notebookId) lookup — a project-only lookup could return a different + // open sibling and silently skip the snapshot write. assert.isTrue( - mockNotebookManager.getOriginalProject.calledWith(projectId, notebookId), - 'performSnapshotSave must fetch via getOriginalProject(projectId, notebookId)' + mockNotebookManager.getProjectForNotebook.calledWith(projectId, notebookId), + 'performSnapshotSave must fetch via getProjectForNotebook(projectId, notebookId)' ); - assert.isFalse( - mockNotebookManager.getAnyProjectEntry.called, - 'performSnapshotSave must NOT use the project-only getAnyProjectEntry' + }); + + test('writes the snapshot next to the saved notebook, not a sibling that shares the project id', async () => { + const mockConfig = mock(); + when(mockConfig.get('snapshots.enabled', true)).thenReturn(true); + when(mockedVSCodeNamespaces.workspace.getConfiguration('deepnote')).thenReturn(instance(mockConfig)); + + const sharedProjectId = 'shared-project-id'; + // Two single-notebook siblings share a project.id but live in DIFFERENT folders. + const siblingAUri = 'file:///workspace/foo/a.deepnote'; + const targetBUri = 'file:///workspace/bar/b.deepnote'; + + const makeCell = (id: string) => ({ + kind: NotebookCellKind.Code, + document: { getText: () => 'print(1)', languageId: 'python' }, + metadata: { id }, + outputs: [], + executionSummary: { success: true } + }); + const siblingA = { + uri: Uri.parse(siblingAUri), + notebookType: 'deepnote', + metadata: { deepnoteProjectId: sharedProjectId, deepnoteNotebookId: 'notebook-a' }, + getCells: () => [makeCell('cell-a')] + }; + const notebookB = { + uri: Uri.parse(targetBUri), + notebookType: 'deepnote', + metadata: { deepnoteProjectId: sharedProjectId, deepnoteNotebookId: 'notebook-b' }, + getCells: () => [makeCell('cell-b')] + }; + + // Sibling A is enumerated FIRST — a projectId-only lookup would pick A's folder. + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([siblingA, notebookB] as any); + + const originalProject: DeepnoteFile = { + metadata: { createdAt: '2025-01-01T00:00:00Z' }, + version: '1.0.0', + project: { + id: sharedProjectId, + name: 'Test Project', + notebooks: [{ id: 'notebook-b', name: 'Notebook B', blocks: [] }] + } + }; + const mockNotebookManager = { + getProjectForNotebook: sinon.stub().returns(originalProject) + }; + + const testService = new SnapshotService( + instance(mockEnvironmentCapture), + mockDisposables, + mockNotebookManager as any + ); + const testServiceAny = testService as any; + + const startTime = Date.now(); + testServiceAny.recordCellExecutionStart(targetBUri, 'cell-b', startTime); + testServiceAny.recordCellExecutionEnd(targetBUri, 'cell-b', startTime + 100, true); + + const mockFs = mock(); + when(mockFs.stat(anything())).thenResolve({ type: FileType.Directory } as any); + when(mockFs.readFile(anything())).thenReject(new Error('ENOENT')); + when(mockFs.writeFile(anything(), anything())).thenResolve(); + when(mockFs.copy(anything(), anything(), anything())).thenResolve(); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + const buildSnapshotPathSpy = sinon.spy(testServiceAny, 'buildSnapshotPath'); + + await testServiceAny.performSnapshotSave(targetBUri); + + // The snapshot dir is derived from projectUri's parent. With the fix, projectUri is + // notebook B's OWN uri (folder /bar), not sibling A's (/foo). The pre-fix projectId-only + // lookup would have passed A's uri here (A is enumerated first). + assert.isTrue(buildSnapshotPathSpy.called, 'buildSnapshotPath should be called'); + const projectUriArg = buildSnapshotPathSpy.firstCall.args[0] as Uri; + assert.strictEqual( + projectUriArg.toString(), + Uri.parse(targetBUri).toString(), + 'snapshot must be built from the saved notebook own uri, not a sibling sharing the project id' ); }); }); diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index 26db219e77..05cd8db748 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -229,7 +229,8 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid displayName = config.name; } else { // Integration is not configured, try to get the name from the project's integration list - const project = this.notebookManager.getAnyProjectEntry(projectId); + const notebookId = cell.notebook.metadata?.deepnoteNotebookId; + const project = notebookId ? this.notebookManager.getProjectForNotebook(projectId, notebookId) : undefined; const projectIntegration = project?.project.integrations?.find((i) => i.id === integrationId); const baseName = projectIntegration?.name || l10n.t('Unknown integration'); displayName = l10n.t('{0} (configure)', baseName); @@ -325,13 +326,14 @@ export class SqlCellStatusBarProvider implements NotebookCellStatusBarItemProvid // Get the project ID from the notebook metadata const projectId = cell.notebook.metadata?.deepnoteProjectId; - if (!projectId) { + const notebookId = cell.notebook.metadata?.deepnoteNotebookId; + if (!projectId || !notebookId) { void window.showErrorMessage(l10n.t('Cannot determine project ID')); return; } // Get the project to access its integrations list - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!project) { void window.showErrorMessage(l10n.t('Project not found')); return; diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts index 76c9933e28..f729453582 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.unit.test.ts @@ -165,11 +165,11 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: integrationId }, - notebookMetadata: { deepnoteProjectId: 'project-1' } + notebookMetadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' } }); when(integrationStorage.getProjectIntegrationConfig(anything(), anything())).thenResolve(undefined); - when(notebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(notebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -196,11 +196,11 @@ suite('SqlCellStatusBarProvider', () => { const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: integrationId }, - notebookMetadata: { deepnoteProjectId: 'project-1' } + notebookMetadata: { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' } }); when(integrationStorage.getProjectIntegrationConfig(anything(), anything())).thenResolve(undefined); - when(notebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(notebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -422,7 +422,7 @@ suite('SqlCellStatusBarProvider', () => { } ); - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ notebook: { @@ -430,7 +430,7 @@ suite('SqlCellStatusBarProvider', () => { }, selection: { start: 0 } } as any); - when(activateNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(activateNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } } as any); when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); @@ -832,7 +832,7 @@ suite('SqlCellStatusBarProvider', () => { }); test('updates cell metadata with selected integration', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'old-integration' }, @@ -840,7 +840,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -865,14 +865,14 @@ suite('SqlCellStatusBarProvider', () => { }); test('does not update if user cancels quick pick', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'old-integration' }, notebookMetadata }); - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -889,7 +889,7 @@ suite('SqlCellStatusBarProvider', () => { }); test('shows error message if workspace edit fails', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'old-integration' }, @@ -897,7 +897,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -914,7 +914,7 @@ suite('SqlCellStatusBarProvider', () => { }); test('fires onDidChangeCellStatusBarItems after successful update', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'old-integration' }, @@ -922,7 +922,7 @@ suite('SqlCellStatusBarProvider', () => { }); const newIntegrationId = 'new-integration'; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -945,14 +945,14 @@ suite('SqlCellStatusBarProvider', () => { }); test('executes manage integrations command when configure option is selected', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'current-integration' }, notebookMetadata }); - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -974,11 +974,11 @@ suite('SqlCellStatusBarProvider', () => { }); test('includes DuckDB integration in quick pick items', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } @@ -997,11 +997,11 @@ suite('SqlCellStatusBarProvider', () => { }); test('shows BigQuery type label for Google BigQuery integrations', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -1026,11 +1026,11 @@ suite('SqlCellStatusBarProvider', () => { }); test('shows raw type for unknown integration types', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -1056,7 +1056,7 @@ suite('SqlCellStatusBarProvider', () => { test('marks current integration as selected in quick pick', async () => { const currentIntegrationId = 'current-integration'; - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: currentIntegrationId }, @@ -1064,7 +1064,7 @@ suite('SqlCellStatusBarProvider', () => { }); let quickPickItems: any[] = []; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -1098,10 +1098,10 @@ suite('SqlCellStatusBarProvider', () => { }); test('shows error message when project is not found', async () => { - const notebookMetadata = { deepnoteProjectId: 'missing-project' }; + const notebookMetadata = { deepnoteProjectId: 'missing-project', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); - when(commandNotebookManager.getAnyProjectEntry('missing-project')).thenReturn(undefined); + when(commandNotebookManager.getProjectForNotebook('missing-project', 'notebook-1')).thenReturn(undefined); await switchIntegrationHandler(cell); @@ -1110,11 +1110,11 @@ suite('SqlCellStatusBarProvider', () => { }); test('skips DATAFRAME_SQL_INTEGRATION_ID from project integrations list', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', notebookMetadata }); let quickPickItems: any[] = []; - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -1156,14 +1156,14 @@ suite('SqlCellStatusBarProvider', () => { test('does not update when selected integration is same as current', async () => { const currentIntegrationId = 'current-integration'; - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: currentIntegrationId }, notebookMetadata }); - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [ { @@ -1186,14 +1186,14 @@ suite('SqlCellStatusBarProvider', () => { }); test('does not update when selected item has no id property', async () => { - const notebookMetadata = { deepnoteProjectId: 'project-1' }; + const notebookMetadata = { deepnoteProjectId: 'project-1', deepnoteNotebookId: 'notebook-1' }; const cell = createMockCell({ languageId: 'sql', metadata: { sql_integration_id: 'current-integration' }, notebookMetadata }); - when(commandNotebookManager.getAnyProjectEntry('project-1')).thenReturn({ + when(commandNotebookManager.getProjectForNotebook('project-1', 'notebook-1')).thenReturn({ project: { integrations: [] } diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 200901c0b5..66f85e37fe 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -64,8 +64,6 @@ import { IPlatformNotebookEditorProvider, IPlatformDeepnoteNotebookManager } from '../platform/notebooks/deepnote/types'; -import { IDeepnoteProjectMetadataPropagator } from '../platform/deepnote/types'; -import { DeepnoteProjectMetadataPropagator } from '../platform/deepnote/deepnoteProjectMetadataPropagator.node'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { DirtyInputBlockStatusBarProvider } from './deepnote/dirtyInputBlockStatusBarProvider'; import { StaleOutputStatusBarProvider } from './deepnote/staleOutputStatusBarProvider'; @@ -186,11 +184,6 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IDeepnoteNotebookManager, DeepnoteNotebookManager); // Bind the platform-layer interface to the same implementation serviceManager.addBinding(IDeepnoteNotebookManager, IPlatformDeepnoteNotebookManager); - // Project-metadata propagation across sibling .deepnote files (desktop-only filesystem fan-out) - serviceManager.addSingleton( - IDeepnoteProjectMetadataPropagator, - DeepnoteProjectMetadataPropagator - ); serviceManager.addSingleton(IIntegrationStorage, IntegrationStorage); serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); diff --git a/src/notebooks/types.ts b/src/notebooks/types.ts index 18a097beeb..13b6adeef3 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -37,23 +37,12 @@ export interface ProjectIntegration { export const IDeepnoteNotebookManager = Symbol('IDeepnoteNotebookManager'); export interface IDeepnoteNotebookManager { - /** - * Returns any cached project entry for the project id (project-level read-only callers). - * Because sibling files share a `project.id`, this may return any one sibling's cached - * project — never use it on a save path. - */ - getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; /** * Returns the cached project for an exact (projectId, notebookId) pair, or undefined. * Exact match only — never falls back to another sibling. The save path uses this. */ - getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined; + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined; storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; - /** - * Updates the cached project for an exact (projectId, notebookId) pair, without changing - * the project's current-notebook bookkeeping. - */ - updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; /** * Updates the integrations list in the cached project data (cache-only). diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index fb9b5ef10d..39f3381a0c 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -251,12 +251,10 @@ export namespace Commands { 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 ExportProject = 'deepnote.exportProject'; export const ExportNotebook = 'deepnote.exportNotebook'; export const OpenInDeepnote = 'deepnote.openInDeepnote'; export const ExportAsPythonScript = 'deepnote.exportAsPythonScript'; diff --git a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts deleted file mode 100644 index 4455ac11b4..0000000000 --- a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { inject, injectable } from 'inversify'; -import { Disposable, RelativePattern, Uri, window, workspace } from 'vscode'; -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; - -import { IDisposableRegistry } from '../common/types'; -import { logger } from '../logging'; -import { IPlatformDeepnoteNotebookManager } from '../notebooks/deepnote/types'; -import { IDeepnoteProjectMetadataPropagator, ProjectMetadataPropagationResult } from './types'; - -/** - * Suffix that identifies snapshot `.deepnote` files. Snapshots are notebook-output sidecars, - * not project source files, so they are excluded from the propagation group. - * - * Re-implemented locally (rather than imported from the notebooks-layer - * `snapshots/snapshotFiles.ts`) to avoid this platform-layer module reaching across into the - * notebooks layer for a one-line `endsWith` check. - */ -const SNAPSHOT_FILE_SUFFIX = '.snapshot.deepnote'; - -/** - * Upper bound on `.deepnote` files inspected per workspace folder. Bounds the on-disk scan - * so a pathological workspace cannot stall the propagation pass. - */ -const MAX_DEEPNOTE_FILES_PER_FOLDER = 5_000; - -/** - * Desktop-only implementation of {@link IDeepnoteProjectMetadataPropagator}. - * - * Walks the workspace via `workspace.findFiles`/`workspace.fs` and rewrites every sibling - * `.deepnote` file of a project so that a project-level edit (integrations, name, …) reaches - * closed siblings, not just the open editor. For open siblings it additionally refreshes the - * manager cache so an open editor does not save a stale project. - */ -@injectable() -export class DeepnoteProjectMetadataPropagator implements IDeepnoteProjectMetadataPropagator { - private readonly fileWrittenCallbacks: ((uri: Uri) => void)[] = []; - - constructor( - @inject(IDisposableRegistry) disposables: IDisposableRegistry, - @inject(IPlatformDeepnoteNotebookManager) - private readonly notebookManager: IPlatformDeepnoteNotebookManager - ) { - disposables.push({ dispose: () => (this.fileWrittenCallbacks.length = 0) }); - } - - public onFileWritten(callback: (uri: Uri) => void): Disposable { - this.fileWrittenCallbacks.push(callback); - - return { - dispose: () => { - const idx = this.fileWrittenCallbacks.indexOf(callback); - - if (idx >= 0) { - this.fileWrittenCallbacks.splice(idx, 1); - } - } - }; - } - - public async propagateProjectMetadata( - projectId: string, - mutator: (file: DeepnoteFile) => void - ): Promise { - const updated: Uri[] = []; - const failures: Array<{ uri: Uri; error: unknown }> = []; - - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - - const candidates = await this.enumerateProjectFiles(projectId, decoder); - - for (const { uri, file, originalBytes } of candidates) { - try { - mutator(file); - - // No-op skip: serialize the post-mutator file BEFORE bumping modifiedAt and - // compare to the original on-disk bytes. If the mutator changed nothing, skip - // entirely (no write, no modifiedAt bump) so a "save" that changes nothing - // cannot start a churn loop. - const serialized = serializeDeepnoteFile(file); - - if (serialized === originalBytes) { - continue; - } - - if (!file.metadata) { - file.metadata = { createdAt: new Date().toISOString() }; - } - file.metadata.modifiedAt = new Date().toISOString(); - - const content = encoder.encode(serializeDeepnoteFile(file)); - - // Fire self-write callbacks BEFORE the write so the file watcher marks this a - // self-write and skips the reload-and-resave for an open sibling. - this.fireFileWritten(uri); - - await workspace.fs.writeFile(uri, content); - - updated.push(uri); - - this.refreshManagerCache(projectId, file); - } catch (error) { - logger.error(`[MetadataPropagator] Failed to update project file: ${uri.path}`, error); - failures.push({ uri, error }); - } - } - - if (failures.length > 0) { - const total = updated.length + failures.length; - - await window.showWarningMessage( - `Updated ${updated.length} of ${total} project files; ${failures.length} could not be updated.` - ); - } - - return { updated, failures }; - } - - /** - * Enumerates every non-snapshot `.deepnote` file across the workspace whose - * `project.id === projectId`. Membership is "matches project.id on disk" — open documents - * and the manager cache are never consulted here. - */ - private async enumerateProjectFiles( - projectId: string, - decoder: TextDecoder - ): Promise> { - const matches: Array<{ uri: Uri; file: DeepnoteFile; originalBytes: string }> = []; - - const workspaceFolders = workspace.workspaceFolders; - - if (!workspaceFolders || workspaceFolders.length === 0) { - return matches; - } - - for (const folder of workspaceFolders) { - let files: Uri[]; - - try { - files = await workspace.findFiles( - new RelativePattern(folder, '**/*.deepnote'), - undefined, - MAX_DEEPNOTE_FILES_PER_FOLDER - ); - } catch (error) { - logger.warn(`[MetadataPropagator] Failed to enumerate .deepnote files in ${folder.uri.path}`, error); - - continue; - } - - for (const uri of files) { - if (uri.path.endsWith(SNAPSHOT_FILE_SUFFIX)) { - continue; - } - - try { - const bytes = await workspace.fs.readFile(uri); - const originalBytes = decoder.decode(bytes); - const file = deserializeDeepnoteFile(originalBytes); - - if (file.project.id === projectId) { - matches.push({ uri, file, originalBytes }); - } - } catch (error) { - logger.warn(`[MetadataPropagator] Failed to read/parse candidate file: ${uri.path}`, error); - } - } - } - - return matches; - } - - private fireFileWritten(uri: Uri): void { - for (const callback of this.fileWrittenCallbacks) { - try { - callback(uri); - } catch (error) { - logger.warn('[MetadataPropagator] File written callback failed', error); - } - } - } - - /** - * Refreshes the manager cache for an updated file that is also open, so an open editor - * does not save a stale project. This is the only place open-state is consulted, and it is - * for cache coherence — not to decide which files to write. - */ - private refreshManagerCache(projectId: string, file: DeepnoteFile): void { - // Single-notebook files: refresh the one notebook entry if it is cached. - for (const notebook of file.project.notebooks) { - if (this.notebookManager.getOriginalProject(projectId, notebook.id)) { - this.notebookManager.updateOriginalProject(projectId, notebook.id, file); - } - } - } -} diff --git a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts b/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts deleted file mode 100644 index 2f176267eb..0000000000 --- a/src/platform/deepnote/deepnoteProjectMetadataPropagator.node.unit.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { assert } from 'chai'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { Uri, WorkspaceFolder } from 'vscode'; - -import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; - -import { DeepnoteProjectMetadataPropagator } from './deepnoteProjectMetadataPropagator.node'; -import { IDisposableRegistry } from '../common/types'; -import { IPlatformDeepnoteNotebookManager } from '../notebooks/deepnote/types'; -import type { DeepnoteProject } from './deepnoteTypes'; -import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; - -suite('MetadataPropagator', () => { - let propagator: DeepnoteProjectMetadataPropagator; - let disposables: IDisposableRegistry; - let mockManager: IPlatformDeepnoteNotebookManager; - - const PROJECT_ID = 'project-1'; - const OTHER_PROJECT_ID = 'project-2'; - - setup(() => { - resetVSCodeMocks(); - disposables = []; - mockManager = mock(); - // Default: nothing cached, so the cache refresh is a no-op unless a test says otherwise. - when(mockManager.getOriginalProject(anything(), anything())).thenReturn(undefined); - - propagator = new DeepnoteProjectMetadataPropagator(disposables, instance(mockManager)); - }); - - teardown(() => { - for (const d of disposables) { - d.dispose(); - } - }); - - /** Builds a single-notebook DeepnoteFile for a given project + notebook id. */ - function buildFile(projectId: string, notebookId: string, notebookName = 'Notebook'): DeepnoteFile { - return { - metadata: { createdAt: '2025-01-01T00:00:00Z', modifiedAt: '2025-01-01T00:00:00Z' }, - version: '1.0.0', - project: { - id: projectId, - name: 'My Project', - integrations: [], - notebooks: [ - { - id: notebookId, - name: notebookName, - blocks: [ - { - id: `${notebookId}-block-1`, - type: 'code', - blockGroup: '1', - sortingKey: 'a0', - metadata: {}, - content: 'print(1)', - outputs: [] - } - ] - } - ] - } - } as DeepnoteFile; - } - - /** - * Canonical on-disk bytes for a file: what `serializeDeepnoteFile` produces. The propagator - * reads these verbatim and compares them against its own re-serialization for the no-op skip, - * so tests must feed the canonical form (not hand-written YAML). - */ - function canonicalBytes(file: DeepnoteFile): Uint8Array { - return new TextEncoder().encode(serializeDeepnoteFile(file)); - } - - /** - * Wires the workspace mock so `findFiles` returns `entries.map(e => e.uri)` and `fs.readFile` - * returns the canonical bytes for each uri. Returns a `writes` array capturing every - * `fs.writeFile(uri, content)` call so tests can assert on-disk results, plus an `order` log - * interleaving `onFileWritten` and `writeFile` events keyed by uri path. - */ - function setupWorkspace( - entries: Array<{ uri: Uri; file: DeepnoteFile }>, - opts: { rejectWriteForPath?: string } = {} - ): { writes: Array<{ uri: Uri; content: Uint8Array }>; order: string[] } { - const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve( - entries.map((e) => e.uri) as any - ); - - const byPath = new Map(entries.map((e) => [e.uri.path, e.file])); - const writes: Array<{ uri: Uri; content: Uint8Array }> = []; - const order: string[] = []; - - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - const file = byPath.get(uri.path); - if (!file) { - return Promise.reject(new Error(`No file for ${uri.path}`)); - } - - return Promise.resolve(canonicalBytes(file)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { - order.push(`write:${uri.path}`); - - if (opts.rejectWriteForPath && uri.path === opts.rejectWriteForPath) { - return Promise.reject(new Error('Write failed')); - } - writes.push({ uri, content }); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - return { writes, order }; - } - - function parseWritten(writes: Array<{ uri: Uri; content: Uint8Array }>, uri: Uri): DeepnoteFile { - const entry = writes.find((w) => w.uri.path === uri.path); - assert.isDefined(entry, `expected a write for ${uri.path}`); - - return deserializeDeepnoteFile(new TextDecoder().decode(entry!.content)); - } - - test('without on-disk fan-out, a closed sibling keeps stale integrations: both siblings are rewritten and the CLOSED one reflects the change', async () => { - const openUri = Uri.file('/workspace/open.deepnote'); - const closedUri = Uri.file('/workspace/closed.deepnote'); - const { writes } = setupWorkspace([ - { uri: openUri, file: buildFile(PROJECT_ID, 'nb-open') }, - { uri: closedUri, file: buildFile(PROJECT_ID, 'nb-closed') } - ]); - - const newIntegrations = [{ id: 'pg-1', name: 'Postgres', type: 'postgres' }]; - const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.integrations = newIntegrations as DeepnoteFile['project']['integrations']; - }); - - // Both uris written. - assert.strictEqual(writes.length, 2, 'both siblings should be written to disk'); - assert.deepStrictEqual(result.updated.map((u) => u.path).sort(), [closedUri.path, openUri.path].sort()); - assert.deepStrictEqual(result.failures, []); - - // Load-bearing: the CLOSED sibling's on-disk bytes reflect the new integrations. - const closedWritten = parseWritten(writes, closedUri); - assert.deepStrictEqual(closedWritten.project.integrations, newIntegrations); - const openWritten = parseWritten(writes, openUri); - assert.deepStrictEqual(openWritten.project.integrations, newIntegrations); - }); - - test('a sibling with a non-matching project.id is never written (skips out-of-group files)', async () => { - const matchUri = Uri.file('/workspace/match.deepnote'); - const otherUri = Uri.file('/workspace/other.deepnote'); - const { writes } = setupWorkspace([ - { uri: matchUri, file: buildFile(PROJECT_ID, 'nb-match') }, - { uri: otherUri, file: buildFile(OTHER_PROJECT_ID, 'nb-other') } - ]); - - const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed'; - }); - - assert.strictEqual(writes.length, 1, 'only the matching project file should be written'); - assert.strictEqual(writes[0].uri.path, matchUri.path); - assert.deepStrictEqual( - result.updated.map((u) => u.path), - [matchUri.path] - ); - // The non-matching file is absent from writes entirely. - assert.isUndefined(writes.find((w) => w.uri.path === otherUri.path)); - }); - - test("a name/integrations mutator leaves each file's single project.notebooks[0] untouched", async () => { - const uriA = Uri.file('/workspace/a.deepnote'); - const uriB = Uri.file('/workspace/b.deepnote'); - const fileA = buildFile(PROJECT_ID, 'nb-a', 'Notebook A'); - const fileB = buildFile(PROJECT_ID, 'nb-b', 'Notebook B'); - const { writes } = setupWorkspace([ - { uri: uriA, file: fileA }, - { uri: uriB, file: fileB } - ]); - - await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed Project'; - f.project.integrations = [ - { id: 'i', name: 'PG', type: 'postgres' } - ] as DeepnoteFile['project']['integrations']; - }); - - const writtenA = parseWritten(writes, uriA); - const writtenB = parseWritten(writes, uriB); - - // Project-level fields changed. - assert.strictEqual(writtenA.project.name, 'Renamed Project'); - // The single original notebook (id + name) is preserved verbatim per file. - assert.strictEqual(writtenA.project.notebooks.length, 1); - assert.deepStrictEqual( - { id: writtenA.project.notebooks[0].id, name: writtenA.project.notebooks[0].name }, - { id: 'nb-a', name: 'Notebook A' } - ); - assert.strictEqual(writtenB.project.notebooks.length, 1); - assert.deepStrictEqual( - { id: writtenB.project.notebooks[0].id, name: writtenB.project.notebooks[0].name }, - { id: 'nb-b', name: 'Notebook B' } - ); - }); - - test('a mutator that sets a field to its current value is a no-op: no writeFile, no modifiedAt bump, no updated entry', async () => { - const uri = Uri.file('/workspace/noop.deepnote'); - const file = buildFile(PROJECT_ID, 'nb-1'); - const { writes } = setupWorkspace([{ uri, file }]); - - const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - // Set name to its CURRENT value → serialized bytes are unchanged. - f.project.name = 'My Project'; - }); - - assert.strictEqual(writes.length, 0, 'writeFile must NOT be called for a no-op mutation'); - assert.deepStrictEqual(result.updated, [], 'no file should be reported as updated'); - assert.deepStrictEqual(result.failures, []); - }); - - test('a writeFile rejection for one uri is isolated: the other file is still written, the failure is collected (not thrown), and a summarized warning fires', async () => { - const goodUri = Uri.file('/workspace/good.deepnote'); - const badUri = Uri.file('/workspace/bad.deepnote'); - const { writes } = setupWorkspace( - [ - { uri: goodUri, file: buildFile(PROJECT_ID, 'nb-good') }, - { uri: badUri, file: buildFile(PROJECT_ID, 'nb-bad') } - ], - { rejectWriteForPath: badUri.path } - ); - - let warned = false; - when(mockedVSCodeNamespaces.window.showWarningMessage(anything())).thenCall(() => { - warned = true; - - return Promise.resolve(undefined); - }); - - const result = await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed'; - }); - - // The good file is still written despite the bad file failing. - assert.strictEqual(writes.length, 1, 'the non-failing file should still be written'); - assert.strictEqual(writes[0].uri.path, goodUri.path); - assert.deepStrictEqual( - result.updated.map((u) => u.path), - [goodUri.path] - ); - // The failure is collected, not thrown. - assert.strictEqual(result.failures.length, 1); - assert.strictEqual(result.failures[0].uri.path, badUri.path); - // A single summarized warning is shown. - assert.isTrue(warned, 'a summarized showWarningMessage should fire on partial failure'); - }); - - test('onFileWritten fires synchronously BEFORE the write for that uri (self-write marked before fs.writeFile)', async () => { - const uri = Uri.file('/workspace/ordered.deepnote'); - const { order } = setupWorkspace([{ uri, file: buildFile(PROJECT_ID, 'nb-1') }]); - - const callbackOrder: string[] = []; - const disposable = propagator.onFileWritten((u) => { - callbackOrder.push(`callback:${u.path}`); - order.push(`callback:${u.path}`); - }); - - await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed'; - }); - disposable.dispose(); - - // The callback received the uri. - assert.deepStrictEqual(callbackOrder, [`callback:${uri.path}`]); - // Ordering: the callback for this uri runs BEFORE its write. - assert.deepStrictEqual(order, [`callback:${uri.path}`, `write:${uri.path}`]); - }); - - test('cache refresh runs only for cached entries: updateOriginalProject is called for the open sibling and NOT for the closed sibling', async () => { - const openUri = Uri.file('/workspace/open.deepnote'); - const closedUri = Uri.file('/workspace/closed.deepnote'); - setupWorkspace([ - { uri: openUri, file: buildFile(PROJECT_ID, 'nb-open') }, - { uri: closedUri, file: buildFile(PROJECT_ID, 'nb-closed') } - ]); - - // The open sibling is cached; the closed one is not. - when(mockManager.getOriginalProject(PROJECT_ID, 'nb-open')).thenReturn({} as DeepnoteProject); - when(mockManager.getOriginalProject(PROJECT_ID, 'nb-closed')).thenReturn(undefined); - - const refreshed: Array<{ projectId: string; notebookId: string }> = []; - when(mockManager.updateOriginalProject(anything(), anything(), anything())).thenCall( - (projectId: string, notebookId: string) => { - refreshed.push({ projectId, notebookId }); - } - ); - - await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed'; - }); - - // Exactly one cache refresh: for the open (cached) sibling only. - assert.deepStrictEqual(refreshed, [{ projectId: PROJECT_ID, notebookId: 'nb-open' }]); - }); - - test('a *.snapshot.deepnote returned by findFiles is never read or written (snapshot sidecars excluded)', async () => { - const sourceUri = Uri.file('/workspace/source.deepnote'); - const snapshotUri = Uri.file('/workspace/snapshots/my-project_project-1_latest.snapshot.deepnote'); - - const workspaceFolder: WorkspaceFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - when(mockedVSCodeNamespaces.workspace.findFiles(anything(), anything(), anything())).thenResolve([ - sourceUri, - snapshotUri - ] as any); - - const sourceFile = buildFile(PROJECT_ID, 'nb-1'); - const readPaths: string[] = []; - const writes: Uri[] = []; - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - readPaths.push(uri.path); - - return Promise.resolve(canonicalBytes(sourceFile)); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { - writes.push(uri); - - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - await propagator.propagateProjectMetadata(PROJECT_ID, (f) => { - f.project.name = 'Renamed'; - }); - - // The snapshot file is never read nor written. - assert.notInclude(readPaths, snapshotUri.path, 'snapshot file must not be read'); - assert.isUndefined( - writes.find((u) => u.path === snapshotUri.path), - 'snapshot file must not be written' - ); - // The real source file is processed. - assert.include(readPaths, sourceUri.path); - assert.deepStrictEqual( - writes.map((u) => u.path), - [sourceUri.path] - ); - }); -}); diff --git a/src/platform/deepnote/types.ts b/src/platform/deepnote/types.ts deleted file mode 100644 index 8c5d9a30b3..0000000000 --- a/src/platform/deepnote/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Disposable, Uri } from 'vscode'; -import type { DeepnoteFile } from '@deepnote/blocks'; - -/** - * Result of a project-metadata propagation pass. - * `updated` holds the URIs whose on-disk content was rewritten; `failures` holds the - * per-file errors that did not abort the rest of the pass. - */ -export interface ProjectMetadataPropagationResult { - updated: Uri[]; - failures: Array<{ uri: Uri; error: unknown }>; -} - -export const IDeepnoteProjectMetadataPropagator = Symbol('IDeepnoteProjectMetadataPropagator'); - -/** - * Propagates project-level metadata changes across every sibling `.deepnote` file of a - * project (open or closed) by enumerating the project group on disk and rewriting each file. - * - * Because each sibling file carries its own copy of the project-level fields - * (`project.integrations`, `project.settings`, `project.name`, …), any edit to such a field - * must be written into all sibling files of the project, or unopened siblings keep stale - * values. Membership is determined purely by `project.id` on disk — never by which editors - * happen to be open or which documents the manager has cached. - */ -export interface IDeepnoteProjectMetadataPropagator { - /** - * Registers a callback that is invoked synchronously before each file the propagator writes. - * Used by the file-change watcher for deterministic self-write detection. - * @returns A disposable that removes the callback. - */ - onFileWritten(callback: (uri: Uri) => void): Disposable; - - /** - * Enumerates every `.deepnote` file in the workspace whose `project.id === projectId`, - * applies `mutator` to each file's project-level fields, and writes it back to disk. - * Files whose serialized bytes are unchanged by the mutator are skipped (no write, no - * `modifiedAt` bump). Returns the URIs that were rewritten and any per-file failures. - * @param projectId The project whose sibling files should be updated - * @param mutator Mutates the parsed file in place; must touch only project-level fields - */ - propagateProjectMetadata( - projectId: string, - mutator: (file: DeepnoteFile) => void - ): Promise; -} diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index 8171c93f32..7a612e927e 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts @@ -88,15 +88,18 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati // Get the project ID from the notebook metadata const projectId = notebook.metadata?.deepnoteProjectId as string | undefined; - if (!projectId) { - logger.trace(`SqlIntegrationEnvironmentVariablesProvider: No project ID found in notebook metadata`); + const notebookId = notebook.metadata?.deepnoteNotebookId as string | undefined; + if (!projectId || !notebookId) { + logger.trace( + `SqlIntegrationEnvironmentVariablesProvider: No project/notebook ID found in notebook metadata` + ); return {}; } logger.trace(`SqlIntegrationEnvironmentVariablesProvider: Project ID: ${projectId}`); // Get the project from the notebook manager - const project = this.notebookManager.getAnyProjectEntry(projectId); + const project = this.notebookManager.getProjectForNotebook(projectId, notebookId); if (!project) { logger.trace(`SqlIntegrationEnvironmentVariablesProvider: No project found for ID: ${projectId}`); return {}; diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts index 8fb191882e..f8cb8b72bb 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts @@ -100,9 +100,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { test('Returns empty object when project is not found in notebook manager', async () => { const resource = Uri.file('/test/notebook.deepnote'); const notebook = mock(); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(undefined); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(undefined); const result = await provider.getEnvironmentVariables(resource); @@ -114,9 +117,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { const notebook = mock(); const project = createMockProject('project-123', []); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -146,9 +152,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'postgres-1', name: 'My Postgres DB', type: 'pgsql' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); const result = await provider.getEnvironmentVariables(resource); @@ -178,9 +187,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'missing-integration', name: 'Missing', type: 'pgsql' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('missing-integration')).thenResolve(undefined); @@ -195,9 +207,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { const notebook = mock(); const project = createMockProject('project-123', []); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -235,9 +250,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'bigquery-1', name: 'BigQuery', type: 'big-query' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('postgres-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('bigquery-1')).thenResolve(bigqueryConfig); @@ -268,9 +286,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'my-postgres', name: 'Production DB', type: 'pgsql' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-postgres')).thenResolve(postgresConfig); const result = await provider.getEnvironmentVariables(resource); @@ -320,9 +341,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'my-bigquery', name: 'Analytics BQ', type: 'big-query' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-bigquery')).thenResolve(bigqueryConfig); const result = await provider.getEnvironmentVariables(resource); @@ -346,9 +370,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { const notebook = mock(); const project = createMockProject('project-123', []); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); const result = await provider.getEnvironmentVariables(resource); @@ -379,9 +406,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'my-snowflake', name: 'Snowflake DB', type: 'snowflake' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('my-snowflake')).thenResolve(snowflakeConfig); const result = await provider.getEnvironmentVariables(resource); @@ -440,9 +470,12 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { { id: 'bq-oauth', name: 'OAuth BQ', type: 'big-query' } ]); - when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebook.metadata).thenReturn({ + deepnoteProjectId: 'project-123', + deepnoteNotebookId: 'notebook-123' + }); when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); - when(notebookManager.getAnyProjectEntry('project-123')).thenReturn(project); + when(notebookManager.getProjectForNotebook('project-123', 'notebook-123')).thenReturn(project); when(integrationStorage.getIntegrationConfig('pg-1')).thenResolve(postgresConfig); when(integrationStorage.getIntegrationConfig('bq-oauth')).thenResolve(federatedConfig); diff --git a/src/platform/notebooks/deepnote/types.ts b/src/platform/notebooks/deepnote/types.ts index 7ab818dffd..84c6522155 100644 --- a/src/platform/notebooks/deepnote/types.ts +++ b/src/platform/notebooks/deepnote/types.ts @@ -115,23 +115,10 @@ export interface IPlatformNotebookEditorProvider { */ export const IPlatformDeepnoteNotebookManager = Symbol('IPlatformDeepnoteNotebookManager'); export interface IPlatformDeepnoteNotebookManager { - /** - * Returns any cached project entry for the project id, for project-level read-only callers - * that have only a `projectId` (no specific notebook). Sibling files share a `project.id`, - * so this may return any one sibling's cached project. - */ - getAnyProjectEntry(projectId: string): DeepnoteProject | undefined; - /** * Returns the cached project for an exact (projectId, notebookId) pair, or undefined if * that precise entry is not cached. Performs an exact match only and never falls back to * another sibling's project. */ - getOriginalProject(projectId: string, notebookId: string): DeepnoteProject | undefined; - - /** - * Refreshes the cached project for an exact (projectId, notebookId) pair so the in-memory - * copy matches disk. Used by the project-metadata propagator to keep open siblings in sync. - */ - updateOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void; + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined; } diff --git a/src/test/unittests.ts b/src/test/unittests.ts index a624856322..7b0ea1112f 100644 --- a/src/test/unittests.ts +++ b/src/test/unittests.ts @@ -42,7 +42,7 @@ Module._load = function (request: string, _parent: NodeModule) { } if (request === '@deepnote/convert') { return { - convertIpynbFilesToDeepnoteFile: async () => { + convertIpynbFileToDeepnoteFile: async () => { // Mock implementation - does nothing in tests } }; From 663d31d598749831c4b81958512dd55ca22fc326 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 14:04:58 +0000 Subject: [PATCH 14/26] Revert unnecessary renamings --- .../deepnote/deepnoteServerStarter.node.ts | 108 +++++++++--------- src/kernels/deepnote/types.ts | 12 +- 2 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 26637f8ada..ee4ef108a1 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -103,14 +103,14 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv: boolean, additionalPackages: string[], environmentId: string, - notebookUri: Uri, + deepnoteFileUri: Uri, token?: CancellationToken ): Promise { - const notebookKey = notebookUri.toString(); + const fileKey = deepnoteFileUri.toString(); - let pendingOp = this.pendingOperations.get(notebookKey); + let pendingOp = this.pendingOperations.get(fileKey); if (pendingOp) { - logger.info(`Waiting for pending operation on ${notebookKey} to complete...`); + logger.info(`Waiting for pending operation on ${fileKey} to complete...`); try { await pendingOp.promise; } catch { @@ -118,28 +118,28 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } } - let existingContext = this.projectContexts.get(notebookKey); + let existingContext = this.projectContexts.get(fileKey); if (existingContext != null) { const { environmentId: existingEnvironmentId, serverInfo: existingServerInfo } = existingContext; if (existingEnvironmentId === environmentId) { if (existingServerInfo != null && (await this.isServerRunning(existingServerInfo))) { logger.info( - `Deepnote server already running at ${existingServerInfo.url} for ${notebookKey} (environmentId ${environmentId})` + `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (environmentId ${environmentId})` ); return existingServerInfo; } - pendingOp = this.pendingOperations.get(notebookKey); + pendingOp = this.pendingOperations.get(fileKey); if (pendingOp && pendingOp.type === 'start') { return await pendingOp.promise; } } else { logger.info( - `Stopping existing server for ${notebookKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` + `Stopping existing server for ${fileKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` ); - await this.stopServerForEnvironment(existingContext, notebookUri, token); + await this.stopServerForEnvironment(existingContext, deepnoteFileUri, token); existingContext.environmentId = environmentId; } } else { @@ -148,7 +148,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension serverInfo: null }; - this.projectContexts.set(notebookKey, newContext); + this.projectContexts.set(fileKey, newContext); existingContext = newContext; } @@ -161,11 +161,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv, additionalPackages, environmentId, - notebookUri, + deepnoteFileUri, token ) }; - this.pendingOperations.set(notebookKey, operation); + this.pendingOperations.set(fileKey, operation); try { const result = await operation.promise; @@ -173,8 +173,8 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension existingContext.serverInfo = result; return result; } finally { - if (this.pendingOperations.get(notebookKey) === operation) { - this.pendingOperations.delete(notebookKey); + if (this.pendingOperations.get(fileKey) === operation) { + this.pendingOperations.delete(fileKey); } } } @@ -183,20 +183,20 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Stop the deepnote-toolkit server for a notebook. * Safe no-op when the notebook has no running server. */ - public async stopServer(notebookUri: Uri, token?: CancellationToken): Promise { + public async stopServer(deepnoteFileUri: Uri, token?: CancellationToken): Promise { Cancellation.throwIfCanceled(token); - const notebookKey = notebookUri.toString(); - const projectContext = this.projectContexts.get(notebookKey) ?? null; + const fileKey = deepnoteFileUri.toString(); + const projectContext = this.projectContexts.get(fileKey) ?? null; if (projectContext == null) { - logger.warn(`No project context found for ${notebookKey}, skipping stop server...`); + logger.warn(`No project context found for ${fileKey}, skipping stop server...`); return; } - const pendingOp = this.pendingOperations.get(notebookKey); + const pendingOp = this.pendingOperations.get(fileKey); if (pendingOp) { - logger.info(`Waiting for pending operation on ${notebookKey} before stopping...`); + logger.info(`Waiting for pending operation on ${fileKey} before stopping...`); try { await pendingOp.promise; } catch { @@ -208,15 +208,15 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const operation = { type: 'stop' as const, - promise: this.stopServerForEnvironment(projectContext, notebookUri, token) + promise: this.stopServerForEnvironment(projectContext, deepnoteFileUri, token) }; - this.pendingOperations.set(notebookKey, operation); + this.pendingOperations.set(fileKey, operation); try { await operation.promise; } finally { - if (this.pendingOperations.get(notebookKey) === operation) { - this.pendingOperations.delete(notebookKey); + if (this.pendingOperations.get(fileKey) === operation) { + this.pendingOperations.delete(fileKey); } } } @@ -237,10 +237,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension managedVenv: boolean, additionalPackages: string[], environmentId: string, - notebookUri: Uri, + deepnoteFileUri: Uri, token?: CancellationToken ): Promise { - const notebookKey = notebookUri.toString(); + const fileKey = deepnoteFileUri.toString(); Cancellation.throwIfCanceled(token); @@ -260,25 +260,25 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension Cancellation.throwIfCanceled(token); - logger.info(`Starting deepnote-toolkit server for ${notebookKey} (environmentId ${environmentId})`); + logger.info(`Starting deepnote-toolkit server for ${fileKey} (environmentId ${environmentId})`); this.outputChannel.appendLine(l10n.t('Starting Deepnote server...')); - const extraEnv = await this.gatherSqlIntegrationEnvVars(notebookUri, environmentId, token); + const extraEnv = await this.gatherSqlIntegrationEnvVars(deepnoteFileUri, environmentId, token); // Initialize output tracking for error reporting - this.serverOutputByFile.set(notebookKey, { stdout: '', stderr: '' }); + this.serverOutputByFile.set(fileKey, { stdout: '', stderr: '' }); let serverInfo: DeepnoteServerInfo | undefined; try { serverInfo = await startServer({ pythonEnv: venvPath.fsPath, - workingDirectory: path.dirname(notebookUri.fsPath), + workingDirectory: path.dirname(deepnoteFileUri.fsPath), startupTimeoutMs: SERVER_STARTUP_TIMEOUT_MS, env: extraEnv }); } catch (error) { - const capturedOutput = this.serverOutputByFile.get(notebookKey); - this.serverOutputByFile.delete(notebookKey); + const capturedOutput = this.serverOutputByFile.get(fileKey); + this.serverOutputByFile.delete(fileKey); throw new DeepnoteServerStartupError( interpreter.uri.fsPath, @@ -293,17 +293,17 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension projectContext.serverInfo = serverInfo; // Set up output channel logging from the server process - this.monitorServerOutput(notebookKey, serverInfo); + this.monitorServerOutput(fileKey, serverInfo); // Write lock file for orphan-cleanup tracking const serverPid = serverInfo.process.pid; if (serverPid) { await this.writeLockFile(serverPid); } else { - logger.warn(`Could not get PID for server process for ${notebookKey}`); + logger.warn(`Could not get PID for server process for ${fileKey}`); } - logger.info(`Deepnote server started successfully at ${serverInfo.url} for ${notebookKey}`); + logger.info(`Deepnote server started successfully at ${serverInfo.url} for ${fileKey}`); this.outputChannel.appendLine(l10n.t('✓ Deepnote server running at {0}', serverInfo.url)); return serverInfo; @@ -314,10 +314,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension */ private async stopServerForEnvironment( projectContext: ProjectContext, - notebookUri: Uri, + deepnoteFileUri: Uri, token?: CancellationToken ): Promise { - const notebookKey = notebookUri.toString(); + const fileKey = deepnoteFileUri.toString(); Cancellation.throwIfCanceled(token); @@ -327,9 +327,9 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const serverPid = serverInfo.process.pid; try { - logger.info(`Stopping Deepnote server for ${notebookKey}...`); + logger.info(`Stopping Deepnote server for ${fileKey}...`); await stopServer(serverInfo); - this.outputChannel.appendLine(l10n.t('Deepnote server stopped for {0}', notebookKey)); + this.outputChannel.appendLine(l10n.t('Deepnote server stopped for {0}', fileKey)); } catch (ex) { logger.error('Error stopping Deepnote server', ex); } finally { @@ -343,12 +343,12 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension Cancellation.throwIfCanceled(token); - this.serverOutputByFile.delete(notebookKey); + this.serverOutputByFile.delete(fileKey); - const disposables = this.disposablesByFile.get(notebookKey); + const disposables = this.disposablesByFile.get(fileKey); if (disposables) { disposables.forEach((d) => d.dispose()); - this.disposablesByFile.delete(notebookKey); + this.disposablesByFile.delete(fileKey); } } @@ -368,7 +368,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Gather SQL integration environment variables for the deepnote-toolkit server. */ private async gatherSqlIntegrationEnvVars( - notebookUri: Uri, + deepnoteFileUri: Uri, environmentId: string, token?: CancellationToken ): Promise> { @@ -379,13 +379,13 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension return extraEnv; } - const notebookKey = notebookUri.toString(); + const fileKey = deepnoteFileUri.toString(); logger.debug( - `DeepnoteServerStarter: Injecting SQL integration env vars for ${notebookKey} with environmentId ${environmentId}` + `DeepnoteServerStarter: Injecting SQL integration env vars for ${fileKey} with environmentId ${environmentId}` ); try { - const sqlEnvVars = await this.sqlIntegrationEnvVars.getEnvironmentVariables(notebookUri, token); + const sqlEnvVars = await this.sqlIntegrationEnvVars.getEnvironmentVariables(deepnoteFileUri, token); if (sqlEnvVars && Object.keys(sqlEnvVars).length > 0) { logger.debug(`DeepnoteServerStarter: Injecting ${Object.keys(sqlEnvVars).length} SQL env vars`); Object.assign(extraEnv, sqlEnvVars); @@ -402,19 +402,19 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension /** * Stream stdout/stderr from the server process to the VSCode output channel. */ - private monitorServerOutput(notebookKey: string, serverInfo: DeepnoteServerInfo): void { + private monitorServerOutput(fileKey: string, serverInfo: DeepnoteServerInfo): void { const proc = serverInfo.process; const disposables: IDisposable[] = []; - this.disposablesByFile.set(notebookKey, disposables); + this.disposablesByFile.set(fileKey, disposables); if (proc.stdout) { const stdout = proc.stdout; const onData = (data: Buffer) => { const text = data.toString(); - logger.trace(`Deepnote server (${notebookKey}): ${text}`); + logger.trace(`Deepnote server (${fileKey}): ${text}`); this.outputChannel.appendLine(text); - const outputTracking = this.serverOutputByFile.get(notebookKey); + const outputTracking = this.serverOutputByFile.get(fileKey); if (outputTracking) { outputTracking.stdout = (outputTracking.stdout + text).slice(-MAX_OUTPUT_TRACKING_LENGTH); } @@ -431,10 +431,10 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const stderr = proc.stderr; const onData = (data: Buffer) => { const text = data.toString(); - logger.warn(`Deepnote server stderr (${notebookKey}): ${text}`); + logger.warn(`Deepnote server stderr (${fileKey}): ${text}`); this.outputChannel.appendLine(text); - const outputTracking = this.serverOutputByFile.get(notebookKey); + const outputTracking = this.serverOutputByFile.get(fileKey); if (outputTracking) { outputTracking.stderr = (outputTracking.stderr + text).slice(-MAX_OUTPUT_TRACKING_LENGTH); } @@ -487,11 +487,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension await this.deleteLockFile(pid); } - for (const [notebookKey, disposables] of this.disposablesByFile.entries()) { + for (const [fileKey, disposables] of this.disposablesByFile.entries()) { try { disposables.forEach((d) => d.dispose()); } catch (ex) { - logger.error(`Error disposing resources for ${notebookKey}`, ex); + logger.error(`Error disposing resources for ${fileKey}`, ex); } } diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index f1a8c89121..f84cc2f6f3 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -157,8 +157,7 @@ export interface IDeepnoteServerStarter { * @param venvPath The path to the venv * @param managedVenv Whether the venv is managed by this extension (created by us) * @param environmentId The environment ID (for server management) - * @param notebookUri The URI of the notebook (used both as the server key and to derive - * the working directory and SQL integration environment) + * @param deepnoteFileUri The URI of the .deepnote file * @param token Cancellation token to cancel the operation * @returns Connection information (URL, port, etc.) */ @@ -168,17 +167,16 @@ export interface IDeepnoteServerStarter { managedVenv: boolean, additionalPackages: string[], environmentId: string, - notebookUri: vscode.Uri, + deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken ): Promise; /** - * Stops the deepnote-toolkit server for a notebook. - * Safe no-op when the notebook has no running server. - * @param notebookUri The URI of the notebook + * Stops the deepnote-toolkit server for a kernel environment. + * @param environmentId The environment ID * @param token Cancellation token to cancel the operation */ - stopServer(notebookUri: vscode.Uri, token?: vscode.CancellationToken): Promise; + stopServer(deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken): Promise; /** * Disposes all server processes and resources. From 1643ef5a4085385336ba81dd5411b17faf435b2e Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 14:18:36 +0000 Subject: [PATCH 15/26] fix(deepnote): trim indented bullet content before stripping markdown @deepnote/blocks@4.6.0+ renders `text-cell-bullet` blocks with `indent_level >= 1` using leading spaces (two per level) before the bullet marker. stripMarkdown's bullet regex only matches at column 0, so the leading indentation must be trimmed first for the plain-text cell value to round-trip correctly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- src/notebooks/deepnote/converters/textBlockConverter.ts | 6 ++++++ .../deepnote/converters/textBlockConverter.unit.test.ts | 9 ++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/notebooks/deepnote/converters/textBlockConverter.ts b/src/notebooks/deepnote/converters/textBlockConverter.ts index 54182d2399..1d214308a6 100644 --- a/src/notebooks/deepnote/converters/textBlockConverter.ts +++ b/src/notebooks/deepnote/converters/textBlockConverter.ts @@ -34,6 +34,12 @@ export class TextBlockConverter implements BlockConverter { // Update block content with cell value first block.content = cell.value || ''; + // stripMarkdown's bullet regex only matches at column 0; indented bullets + // (indent_level >= 1) render with leading spaces that must be trimmed first. + if (block.type === 'text-cell-bullet') { + block.content = block.content.trim(); + } + // Then strip the markdown formatting to get plain text const textValue = unescapeMarkdown(stripMarkdown(block)); diff --git a/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts b/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts index 1e378f9a77..84ea566fbe 100644 --- a/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts +++ b/src/notebooks/deepnote/converters/textBlockConverter.unit.test.ts @@ -575,9 +575,9 @@ suite('TextBlockConverter', () => { assertRoundTrip({ type: 'text-cell-todo', content: 'a_b * c', metadata: { checked: false } }); }); - test('text-cell-bullet round-trips with metadata.indent_level: 1 (renders flat on 4.3.0)', () => { - // indent_level travels through metadata, not content. On 4.3.0 the bullet - // renders flat (no leading indentation), and the content must still round-trip. + test('text-cell-bullet round-trips with metadata.indent_level: 1', () => { + // indent_level travels through metadata, not content. @deepnote/blocks@4.6.0+ + // renders two leading spaces per indent level before the bullet marker. const cell = converter.convertToCell({ blockGroup: 'group-123', content: 'a_b * c', @@ -587,8 +587,7 @@ suite('TextBlockConverter', () => { type: 'text-cell-bullet' }); - // Documents the flat (un-indented) rendering on 4.3.0. - assert.strictEqual(cell.value, '- a\\_b \\* c'); + assert.strictEqual(cell.value, ' - a\\_b \\* c'); assertRoundTrip({ type: 'text-cell-bullet', content: 'a_b * c', metadata: { indent_level: 1 } }); }); From be6426338e92c123e26644bc110a2466605f5b7c Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 14:18:43 +0000 Subject: [PATCH 16/26] refactor(deepnote): import convert helpers directly, drop snapshotFiles re-exports snapshotFiles.ts re-exported six snapshot-filename helpers from @deepnote/convert. Remove the re-export block and import the helpers directly from @deepnote/convert at each use site (snapshotService.ts and the snapshotFiles unit test). snapshotFiles.ts now keeps only its local helpers (SNAPSHOT_FILE_SUFFIX, isSnapshotFile, extractProjectIdFromSnapshotUri) plus the single internal use of parseSnapshotFilename. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- .../deepnote/snapshots/snapshotFiles.ts | 24 +------------------ .../snapshots/snapshotFiles.unit.test.ts | 10 ++------ .../deepnote/snapshots/snapshotService.ts | 15 ++++++------ 3 files changed, 11 insertions(+), 38 deletions(-) diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.ts index 381a038390..1323b1071a 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.ts @@ -1,31 +1,9 @@ -import { - decodeNotebookIdFromFilename, - encodeNotebookIdForFilename, - generateSnapshotFilename, - parseSnapshotFilename, - resolveSnapshotNotebookId, - slugifyProjectName -} from '@deepnote/convert'; +import { parseSnapshotFilename } from '@deepnote/convert'; import { Uri } from 'vscode'; /** File suffix for snapshot files */ export const SNAPSHOT_FILE_SUFFIX = '.snapshot.deepnote'; -/** - * Re-export the snapshot filename helpers from `@deepnote/convert` so the snapshot service - * and tests share a single, CLI-compatible implementation of the filename grammar (which - * percent-encodes the notebook id and NFD-normalizes accents). The local hand-written - * regex/slugify previously diverged from the CLI and is gone. - */ -export { - decodeNotebookIdFromFilename, - encodeNotebookIdForFilename, - generateSnapshotFilename, - parseSnapshotFilename, - resolveSnapshotNotebookId, - slugifyProjectName -}; - /** * Checks if a URI represents a snapshot file */ diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts index fedae3936e..715ab3d19b 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts @@ -1,14 +1,8 @@ +import { generateSnapshotFilename, parseSnapshotFilename, slugifyProjectName } from '@deepnote/convert'; import { assert } from 'chai'; import { Uri } from 'vscode'; -import { - extractProjectIdFromSnapshotUri, - generateSnapshotFilename, - isSnapshotFile, - parseSnapshotFilename, - slugifyProjectName, - SNAPSHOT_FILE_SUFFIX -} from './snapshotFiles'; +import { extractProjectIdFromSnapshotUri, isSnapshotFile, SNAPSHOT_FILE_SUFFIX } from './snapshotFiles'; suite('snapshotFiles', () => { suite('SNAPSHOT_FILE_SUFFIX', () => { diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index b3308bfda4..893f28d377 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -8,7 +8,14 @@ import { type Execution, type ExecutionError } from '@deepnote/blocks'; -import { countBlocksWithOutputs, hasOutputs } from '@deepnote/convert'; +import { + countBlocksWithOutputs, + generateSnapshotFilename, + hasOutputs, + parseSnapshotFilename, + resolveSnapshotNotebookId, + slugifyProjectName +} from '@deepnote/convert'; import fastDeepEqual from 'fast-deep-equal'; import { inject, injectable, optional } from 'inversify'; import { @@ -28,12 +35,6 @@ import { IExtensionSyncActivationService } from '../../../platform/activation/ty import { IDisposableRegistry } from '../../../platform/common/types'; import type { DeepnoteOutput } from '../../../platform/deepnote/deepnoteTypes'; import { InvalidProjectNameError } from '../../../platform/errors/invalidProjectNameError'; -import { - generateSnapshotFilename, - parseSnapshotFilename, - resolveSnapshotNotebookId, - slugifyProjectName -} from './snapshotFiles'; import { logger } from '../../../platform/logging'; import { notebookCellExecutions, From 589845d3271bbfccb9b7fcfcd3b7e3686cbe7f0d Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 18:42:31 +0000 Subject: [PATCH 17/26] refactor(deepnote): update buildSnapshotPath to use object destructuring Refactor the buildSnapshotPath method to accept an object as an argument, improving readability and maintainability. Update all relevant calls to this method throughout the snapshotService and its unit tests to match the new signature. This change enhances the clarity of parameter usage and reduces the risk of errors when passing arguments. --- .../deepnote/snapshots/snapshotService.ts | 35 ++++++++++++------- .../snapshots/snapshotService.unit.test.ts | 26 ++++++++------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/notebooks/deepnote/snapshots/snapshotService.ts b/src/notebooks/deepnote/snapshots/snapshotService.ts index 893f28d377..b45cff481c 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -276,7 +276,13 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync const { latestPath, content } = prepared; const timestamp = generateTimestamp(); - const timestampedPath = this.buildSnapshotPath(projectUri, projectId, projectName, timestamp, notebookId); + const timestampedPath = this.buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: timestamp, + notebookId + }); // Write to timestamped file first (safe - doesn't touch existing files) try { @@ -579,19 +585,22 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return this.recentlyWrittenUris.has(uri.toString()); } - private buildSnapshotPath( - projectUri: Uri, - projectId: string, - projectName: string, - variant: 'latest' | string, - notebookId?: string - ): Uri { + private buildSnapshotPath({ + notebookId, + projectId, + projectName, + projectUri, + variant + }: { + notebookId?: string; + projectId: string; + projectName: string; + projectUri: Uri; + variant: 'latest' | string; + }): Uri { const parentDir = Uri.joinPath(projectUri, '..'); - // convert's slugifyProjectName returns '' (rather than throwing) for names with no - // slug-safe characters; preserve the existing "skip snapshots for invalid names" contract - // by validating here so prepareSnapshotData can catch InvalidProjectNameError. - if (typeof projectName !== 'string' || !projectName.trim()) { + if (projectName.trim().length <= 0) { throw new InvalidProjectNameError(); } @@ -1073,7 +1082,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync let latestPath: Uri; try { - latestPath = this.buildSnapshotPath(projectUri, projectId, projectName, 'latest', notebookId); + latestPath = this.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest', notebookId }); } catch (error) { if (error instanceof InvalidProjectNameError) { logger.warn('[Snapshot] Skipping snapshots due to invalid project name', error); diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index 0adef1793d..16b3170682 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -64,7 +64,7 @@ suite('SnapshotService', () => { const projectId = 'e132b172-b114-410e-8331-011517db664f'; const projectName = 'My Project'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }); assert.include(result.fsPath, 'snapshots'); assert.include(result.fsPath, 'my-project'); @@ -79,7 +79,7 @@ suite('SnapshotService', () => { const projectName = 'My Project'; const timestamp = '2025-12-11T10-31-48'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, timestamp); + const result = serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: timestamp }); assert.include(result.fsPath, 'snapshots'); assert.include(result.fsPath, 'my-project'); @@ -93,7 +93,7 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'Customer Churn ML Playbook!'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }); assert.include(result.fsPath, 'customer-churn-ml-playbook'); assert.notInclude(result.fsPath, '!'); @@ -105,7 +105,7 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'Test@#$%Project'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }); // convert's slugifyProjectName collapses a run of special characters to a single hyphen. assert.include(result.fsPath, 'test-project'); @@ -117,7 +117,13 @@ suite('SnapshotService', () => { const projectName = 'My Project'; const notebookId = 'notebook-1'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest', notebookId); + const result = serviceAny.buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: 'latest', + notebookId + }); assert.include(result.fsPath, `${projectId}_notebook-1_latest.snapshot.deepnote`); }); @@ -127,7 +133,7 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'My Project Name'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }); assert.include(result.fsPath, 'my-project-name'); assert.notInclude(result.fsPath, '--'); @@ -139,7 +145,7 @@ suite('SnapshotService', () => { const projectName = ''; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); @@ -150,7 +156,7 @@ suite('SnapshotService', () => { const projectName = '@#$%^&*()'; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); @@ -161,7 +167,7 @@ suite('SnapshotService', () => { const projectName = ' '; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => serviceAny.buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); @@ -1615,7 +1621,7 @@ project: // notebook B's OWN uri (folder /bar), not sibling A's (/foo). The pre-fix projectId-only // lookup would have passed A's uri here (A is enumerated first). assert.isTrue(buildSnapshotPathSpy.called, 'buildSnapshotPath should be called'); - const projectUriArg = buildSnapshotPathSpy.firstCall.args[0] as Uri; + const projectUriArg = buildSnapshotPathSpy.firstCall.args[0].projectUri as Uri; assert.strictEqual( projectUriArg.toString(), Uri.parse(targetBUri).toString(), From 3a910c1d439a7d2c3f5b1d8b7d0d6c5b57285007 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 30 Jun 2026 19:51:21 +0000 Subject: [PATCH 18/26] test(deepnote): remove redundant subagent-written tests Trim the single-notebook test suites by removing duplicate and tautological tests and collapsing/merging several others, shrinking the PR's test additions by ~640 lines with no loss of real coverage. Cuts target only tests this branch added: - exact-(projectId, notebookId)-lookup restatements duplicated across the watcher, serializer, snapshot, and manager suites - wrapper tests already covered by the delegate's own tests (addNotebookToProject, sibling-file allocation, project-id resolution) - tautologies over trivial template/getter functions (serverUtils) - framework-registration smoke tests (status bar) Merges keep the one meaningful assertion and drop the duplicate scaffolding (e.g. legacy-delete no-op folded into the existing delete test; two init builders parametrized into one). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- .../deepnoteServerStarter.unit.test.ts | 25 +--- .../deepnoteEnvironmentManager.unit.test.ts | 18 --- .../deepnoteEnvironmentsView.unit.test.ts | 13 -- .../deepnoteExplorerView.unit.test.ts | 114 +--------------- .../deepnoteFileChangeWatcher.unit.test.ts | 125 +----------------- ...epnoteInitNotebookRunner.node.unit.test.ts | 117 +++------------- ...deepnoteMultiNotebookSplitter.unit.test.ts | 52 -------- .../deepnoteNotebookFileFactory.unit.test.ts | 42 +----- ...deepnoteNotebookInfoStatusBar.unit.test.ts | 21 --- .../deepnoteNotebookManager.unit.test.ts | 27 ---- .../deepnote/deepnoteSerializer.unit.test.ts | 51 ------- .../deepnoteTreeDataProvider.unit.test.ts | 18 --- .../snapshots/snapshotFiles.unit.test.ts | 32 ----- .../snapshots/snapshotService.unit.test.ts | 32 ----- .../deepnoteProjectFileReader.unit.test.ts | 13 -- .../deepnoteProjectIdResolver.unit.test.ts | 9 -- .../deepnoteServerUtils.node.unit.test.ts | 29 ---- 17 files changed, 31 insertions(+), 707 deletions(-) diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index 0391769b7a..bf2be7836c 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -154,15 +154,17 @@ suite('DeepnoteServerStarter', () => { const infoB = await start(uriB); // runtime-core startServer must be invoked once per notebook — NOT reused across siblings. - assert.strictEqual( - runtimeCore.__getStartServerCalls().length, - 2, - 'each distinct notebook URI must spawn its own server process' - ); + const calls = runtimeCore.__getStartServerCalls(); + assert.strictEqual(calls.length, 2, 'each distinct notebook URI must spawn its own server process'); // The two servers are distinct (distinct map entries / distinct ServerInfo). assert.notStrictEqual(infoA.url, infoB.url, 'sibling notebooks must not share one server'); + // Each server uses dirname(its own notebookUri.fsPath) as working directory. + // Both files share the same parent dir, so both servers use that dir as cwd. + assert.strictEqual(calls[0].workingDirectory, '/workspace/project'); + assert.strictEqual(calls[1].workingDirectory, '/workspace/project'); + // Two distinct projectContexts keyed by notebook.uri.toString(). // eslint-disable-next-line @typescript-eslint/no-explicit-any const contexts = (serverStarter as any).projectContexts as Map; @@ -171,19 +173,6 @@ suite('DeepnoteServerStarter', () => { assert.isTrue(contexts.has(uriB.toString()), 'context keyed by notebook B URI'); }); - test("each notebook's server uses dirname(its own notebookUri.fsPath) as working directory (catches wrong-cwd capture)", async () => { - const runtimeCore = await getRuntimeCoreMock(); - - await start(uriA); - await start(uriB); - - const calls = runtimeCore.__getStartServerCalls(); - assert.strictEqual(calls.length, 2); - // Both files share the same parent dir, so both servers use that dir as cwd. - assert.strictEqual(calls[0].workingDirectory, '/workspace/project'); - assert.strictEqual(calls[1].workingDirectory, '/workspace/project'); - }); - test('REUSES the running server when the SAME notebook URI re-requests the same environment (catches redundant respawn)', async () => { const runtimeCore = await getRuntimeCoreMock(); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts index ee4453ee4b..381e9c9d9a 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts @@ -281,24 +281,6 @@ suite('DeepnoteEnvironmentManager', () => { // Verify directory no longer exists assert.isFalse(fs.existsSync(venvDirPath), 'Directory should not exist after deletion'); }); - - test('deletion does NOT reference a server-stop map — the dead environmentServers map is gone (stopping is the view’s job)', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - // The manager has no server-starter collaborator and no per-environment server map: - // deletion is purely "delete the env (and managed venv)". Stopping servers is the - // view's responsibility (DeepnoteEnvironmentsView.deleteEnvironmentCommand). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - assert.isUndefined((manager as any).environmentServers, 'the dead environmentServers map must not exist'); - - // Deletion succeeds with no server-stopping collaborator wired in. - await manager.deleteEnvironment(config.id); - - assert.isUndefined(manager.getEnvironment(config.id)); - }); }); suite('updateLastUsed', () => { diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts index 096d35819b..b112bf9daf 100644 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts @@ -835,19 +835,6 @@ suite('DeepnoteEnvironmentsView', () => { when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); }; - test('stops BOTH notebooks (open + closed-but-running) via getNotebooksUsingEnvironment (catches "never stops closed servers")', async () => { - const callLog: string[] = []; - wireOrderedDeletion(callLog); - - await view.deleteEnvironmentCommand(envId); - - // Servers are actually STOPPED (stopServer invoked) for both URIs — not merely controllers disposed. - verify(mockServerStarter.stopServer(openNotebookUri, anything())).once(); - verify(mockServerStarter.stopServer(closedNotebookUri, anything())).once(); - assert.include(callLog, `stop:${openNotebookUri.toString()}`); - assert.include(callLog, `stop:${closedNotebookUri.toString()}`); - }); - test('every stopServer precedes every removeEnvironmentForNotebook, and both precede deleteEnvironment (catches inverted order)', async () => { const callLog: string[] = []; wireOrderedDeletion(callLog); diff --git a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts index ec2eea46a4..fe7375b050 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -1260,6 +1260,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Verify success message was shown verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + verify(mockFS.delete(anything(), anything())).never(); }); test('should return early if tree item is project-scoped (not notebook-scoped)', async () => { @@ -2261,68 +2262,6 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { } suite('addNotebookToProject', () => { - test('writes a NEW sibling .deepnote file and does NOT append to any existing file project.notebooks', async () => { - const projectId = 'group-project'; - const sourceFilePath = '/workspace/proj-a.deepnote'; - const sourceFile = singleNotebookFile(projectId, 'nb-a', 'Notebook A'); - const newNotebookId = 'new-nb-id'; - - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ - { uri: Uri.file('/workspace') } as any - ]); - // collectNotebookNamesForProject enumerates .deepnote files in the workspace. - when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn( - Promise.resolve([Uri.file(sourceFilePath)]) - ); - - const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn( - Promise.resolve(Buffer.from(serializeDeepnoteFile(sourceFile))) - ); - // stat rejects so the allocator treats every candidate sibling name as free. - when(mockFS.stat(anything())).thenReturn(Promise.reject(new Error('not found'))); - - const writes: Array<{ uri: Uri; content: Uint8Array }> = []; - when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { - writes.push({ uri, content }); - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('Notebook B')); - when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve({ notebookType: 'deepnote' } as any) - ); - when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) - ); - - uuidStubs.push(createUuidMock([newNotebookId, 'bg', 'blk'])); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectGroup, - context: { filePath: sourceFilePath, projectId }, - data: { - projectId, - projectName: 'Test Project', - files: [{ filePath: sourceFilePath, project: sourceFile }] - } - }; - - await (explorerView as any).addNotebookToProject(treeItem as DeepnoteTreeItem); - - // Exactly one file was written, and it is a NEW path (not the source file). - assert.strictEqual(writes.length, 1, 'addNotebookToProject must write exactly one new sibling file'); - assert.notStrictEqual(writes[0].uri.path, sourceFilePath, 'the new file must not overwrite the source'); - - // The written file is single-notebook and holds only the new notebook (no append). - const written = deserializeDeepnoteFile(Buffer.from(writes[0].content).toString('utf8')); - assert.strictEqual(written.project.id, projectId, 'sibling carries the same project.id'); - assert.strictEqual(written.project.notebooks.length, 1, 'sibling is single-notebook (no append)'); - assert.strictEqual(written.project.notebooks[0].id, newNotebookId); - assert.strictEqual(written.project.notebooks[0].name, 'Notebook B'); - }); - test('returns early for a non-group tree item (does not write)', async () => { const mockFS = mock(); when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); @@ -2377,57 +2316,6 @@ suite('DeepnoteExplorerView - Sibling-file command semantics', () => { // It must NOT rewrite the file's notebooks array on a single-notebook delete. verify(mockFS.writeFile(anything(), anything())).never(); }); - - test('a legacy multi-notebook file removes the notebook from the array and writes the file back (no file delete)', async () => { - const projectId = 'group-project'; - const filePath = '/workspace/legacy.deepnote'; - const keepId = 'keep-nb'; - const dropId = 'drop-nb'; - const legacy: DeepnoteFile = { - version: '1.0.0', - metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, - project: { - id: projectId, - name: 'Test Project', - notebooks: [ - { id: keepId, name: 'Keep', blocks: [], executionMode: 'block' }, - { id: dropId, name: 'Drop', blocks: [], executionMode: 'block' } - ] - } - }; - - const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(serializeDeepnoteFile(legacy)))); - - let writtenContent: Uint8Array | undefined; - when(mockFS.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { - writtenContent = content; - return Promise.resolve(); - }); - when(mockFS.delete(anything(), anything())).thenReturn(Promise.resolve()); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Legacy Notebook child selected by notebookId. - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { filePath, projectId, notebookId: dropId }, - data: { id: dropId, name: 'Drop', blocks: [], executionMode: 'block' } as DeepnoteNotebook - }; - - await explorerView.deleteNotebook(treeItem as DeepnoteTreeItem); - - // The file is rewritten (array op) and NOT deleted. - assert.isDefined(writtenContent, 'the legacy file must be rewritten'); - verify(mockFS.delete(anything(), anything())).never(); - - const updated = deserializeDeepnoteFile(Buffer.from(writtenContent!).toString('utf8')); - assert.strictEqual(updated.project.notebooks.length, 1, 'the dropped notebook is removed from the array'); - assert.strictEqual(updated.project.notebooks[0].id, keepId, 'the kept notebook survives'); - }); }); suite('collectNotebookNamesForProject', () => { diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts index 03e98c52b3..88b4fee5bf 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anything, instance, mock, when } from 'ts-mockito'; import { Disposable, EventEmitter, FileSystemWatcher, NotebookCellKind, NotebookDocument, Uri } from 'vscode'; import type { IControllerRegistration } from '../controllers/types'; @@ -1135,129 +1135,6 @@ project: fallbackOnDidCreate.dispose(); }); - test('should recover lost block IDs via exact (projectId, notebookId) lookup for sibling notebooks', async () => { - const projectId = 'e132b172-b114-410e-8331-011517db664f'; - - // Two single-notebook siblings of ONE project: they share project.id but each project's - // notebooks contains only its own notebook. A returns the WRONG project (no nb-B), B the right one. - const siblingAProject = { - version: '1.0', - metadata: { createdAt: '2025-01-01T00:00:00Z' }, - project: { - id: projectId, - name: 'Test Project', - notebooks: [ - { - id: 'nb-A', - name: 'Notebook A', - blocks: [{ id: 'block-A', type: 'code', sortingKey: 'a0' }] - } - ] - } - } as DeepnoteProject; - const siblingBProject = { - version: '1.0', - metadata: { createdAt: '2025-01-01T00:00:00Z' }, - project: { - id: projectId, - name: 'Test Project', - notebooks: [ - { - id: 'nb-B', - name: 'Notebook B', - blocks: [{ id: 'block-B', type: 'code', sortingKey: 'a0' }] - } - ] - } - } as DeepnoteProject; - - const mockedManager = mock(); - // Exact lookup returns sibling B — this is what the fix must use. - when(mockedManager.getProjectForNotebook(projectId, 'nb-B')).thenReturn(siblingBProject); - when(mockedManager.getProjectForNotebook(projectId, 'nb-A')).thenReturn(siblingAProject); - - const siblingDisposables: IDisposableRegistry = []; - const siblingOnDidChange = new EventEmitter(); - const siblingOnDidCreate = new EventEmitter(); - const siblingFsWatcher = mock(); - when(siblingFsWatcher.onDidChange).thenReturn(siblingOnDidChange.event); - when(siblingFsWatcher.onDidCreate).thenReturn(siblingOnDidCreate.event); - when(siblingFsWatcher.dispose()).thenReturn(); - when(mockedVSCodeNamespaces.workspace.createFileSystemWatcher(anything())).thenReturn( - instance(siblingFsWatcher) - ); - - let siblingApplyEditCount = 0; - when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenCall(() => { - siblingApplyEditCount++; - return Promise.resolve(true); - }); - - const siblingWatcher = new DeepnoteFileChangeWatcher( - siblingDisposables, - instance(mockedManager), - instance(mockSnapshotService) - ); - siblingWatcher.activate(); - - const snapshotUri = Uri.file(`/workspace/snapshots/my-project_${projectId}_latest.snapshot.deepnote`); - // The open notebook is sibling B, with cell metadata block-id MISSING (metadata-lost case), - // so recovery must use originalBlocks resolved from the exact lookup. - const notebook = createMockNotebook({ - uri: Uri.file('/workspace/sibling-b.deepnote'), - metadata: { - deepnoteProjectId: projectId, - deepnoteNotebookId: 'nb-B' - }, - cells: [ - { - metadata: { type: 'code' }, // No id! - outputs: [], - kind: NotebookCellKind.Code, - document: { getText: () => 'print("hello")' } - } - ] - }); - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - // Snapshot has an output keyed by B's block id only. - const siblingOutputs = new Map([ - [ - 'block-B', - [ - { - output_type: 'execute_result', - data: { 'text/plain': 'Sibling B Output' }, - execution_count: 1 - } as DeepnoteOutput - ] - ] - ]); - when(mockSnapshotService.readSnapshot(anything(), anything())).thenReturn(Promise.resolve(siblingOutputs)); - - siblingOnDidChange.fire(snapshotUri); - - await waitFor(() => siblingApplyEditCount > 0); - - // Recovery only succeeds if the code used getProjectForNotebook(projectId, 'nb-B') — the exact - // lookup. A project-only lookup could return sibling A (no nb-B), leaving originalBlocks - // undefined so block-id recovery could not run and no edit would be applied. - assert.isAtLeast( - siblingApplyEditCount, - 1, - 'snapshot output should be applied to sibling B via exact (projectId, notebookId) lookup' - ); - verify(mockedManager.getProjectForNotebook(projectId, 'nb-B')).called(); - - // Cleanup - for (const d of siblingDisposables) { - d.dispose(); - } - siblingOnDidChange.dispose(); - siblingOnDidCreate.dispose(); - }); - test('should only update cells whose outputs changed (per-cell updates)', async () => { const snapshotUri = Uri.file( '/workspace/snapshots/my-project_e132b172-b114-410e-8331-011517db664f_latest.snapshot.deepnote' diff --git a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts index 1c7f69d3d4..a8e7bb6657 100644 --- a/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts @@ -61,11 +61,19 @@ function basename(uri: Uri): string { } /** - * Build a single-notebook DeepnoteFile whose one notebook carries the given code block. - * Adds a trailing markdown block so the runner's `type === 'code'` filter is exercised - * (a 1-code + 1-markdown init must produce exactly ONE executeHidden call per run). + * Build a single-notebook DeepnoteFile whose one notebook carries the given code blocks (in order). + * Appends a trailing markdown block so the runner's `type === 'code'` filter is exercised + * (only the code blocks must produce executeHidden calls — the markdown must be skipped). */ -function makeSingleNotebookFile(projectId: string, notebookId: string, codeContent: string): DeepnoteFile { +function makeNotebookFile(projectId: string, notebookId: string, codeContents: string[]): DeepnoteFile { + const codeBlocks = codeContents.map((content, index) => ({ + id: `${notebookId}-code-${index}`, + type: 'code', + sortingKey: `a${index}`, + blockGroup: 'g', + content + })); + return { version: '1.0.0', metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, @@ -77,17 +85,11 @@ function makeSingleNotebookFile(projectId: string, notebookId: string, codeConte id: notebookId, name: 'Init', blocks: [ - { - id: `${notebookId}-code`, - type: 'code', - sortingKey: 'a0', - blockGroup: 'g', - content: codeContent - }, + ...codeBlocks, { id: `${notebookId}-md`, type: 'markdown', - sortingKey: 'a1', + sortingKey: `a${codeContents.length}`, blockGroup: 'g', content: '# notes' } @@ -98,48 +100,6 @@ function makeSingleNotebookFile(projectId: string, notebookId: string, codeConte } as unknown as DeepnoteFile; } -/** - * Build a single-notebook DeepnoteFile whose one notebook carries TWO code blocks (in order). - * Used to prove per-block behavior across the init loop (e.g. cancellation between blocks). - */ -function makeTwoCodeBlockFile( - projectId: string, - notebookId: string, - firstCode: string, - secondCode: string -): DeepnoteFile { - return { - version: '1.0.0', - metadata: { createdAt: '2020-01-01T00:00:00Z', modifiedAt: '2021-01-01T00:00:00Z' }, - project: { - id: projectId, - name: 'Proj', - notebooks: [ - { - id: notebookId, - name: 'Init', - blocks: [ - { - id: `${notebookId}-code-1`, - type: 'code', - sortingKey: 'a0', - blockGroup: 'g', - content: firstCode - }, - { - id: `${notebookId}-code-2`, - type: 'code', - sortingKey: 'a1', - blockGroup: 'g', - content: secondCode - } - ] - } - ] - } - } as unknown as DeepnoteFile; -} - /** The cached project entry the manager returns for `getProjectForNotebook` (carries initNotebookId). */ function makeMainProjectEntry(projectId: string, initNotebookId: string | undefined): DeepnoteProject { return { @@ -264,7 +224,7 @@ suite('DeepnoteInitNotebookRunner', () => { // Main file's cached project references INIT_NOTEBOOK_ID but does NOT contain it; the // init lives in a sibling .deepnote in the same directory. putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, [SIBLING_INIT_CODE])); const kernel = makeKernel(MAIN_FILE_NAME); onDidStartKernel.fire(kernel); @@ -307,31 +267,9 @@ suite('DeepnoteInitNotebookRunner', () => { assert.strictEqual(executeHiddenSpy.callCount, 0, 'still nothing to run while the sibling is absent'); }); - test('per-kernel A/B: two different kernels each run init when their onDidStartKernel fires', async () => { - // Two siblings of ONE project, each opened in its own kernel. Both kernels' starts run init. - putFile('a.deepnote', makeSingleNotebookFile(PROJECT_ID, MAIN_NOTEBOOK_ID, 'print("a main")')); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); - - const kernelA = makeKernel('a.deepnote'); - const kernelB = makeKernel('b.deepnote'); - - onDidStartKernel.fire(kernelA); - onDidStartKernel.fire(kernelB); - - // Each kernel runs the single init code block once → two calls total. - await waitFor(() => executeHiddenSpy.callCount >= 2); - - assert.strictEqual(executeHiddenSpy.callCount, 2, 'both kernels A and B must each run init exactly once'); - assert.deepStrictEqual( - executeHiddenSpy.getCalls().map((c) => c.args[0]), - [SIBLING_INIT_CODE, SIBLING_INIT_CODE], - 'both runs execute the sibling init block' - ); - }); - test('same kernel start fires twice → init runs only once (WeakSet gate prevents doubling)', async () => { putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, [SIBLING_INIT_CODE])); const kernel = makeKernel(MAIN_FILE_NAME); @@ -356,7 +294,7 @@ suite('DeepnoteInitNotebookRunner', () => { // This is the key fix: an in-place restart fires onDidRestartKernel (NOT onDidStartKernel) // and loses all in-kernel state, so init MUST re-run before the next user cell. putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, [SIBLING_INIT_CODE])); const kernel = makeKernel(MAIN_FILE_NAME); @@ -387,7 +325,7 @@ suite('DeepnoteInitNotebookRunner', () => { test('non-deepnote kernel is ignored: onDidStartKernel for a non-deepnote notebook does nothing', async () => { putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, [SIBLING_INIT_CODE])); const kernel = makeKernel(MAIN_FILE_NAME, { notebookType: 'jupyter-notebook' }); onDidStartKernel.fire(kernel); @@ -397,21 +335,6 @@ suite('DeepnoteInitNotebookRunner', () => { assert.strictEqual(readDirectoryCount, 0, 'a non-deepnote kernel must not even scan for siblings'); }); - test('no init configured: undefined initNotebookId runs nothing and never scans the directory', async () => { - // The cached project has NO initNotebookId. - when(mockNotebookManager.getProjectForNotebook(PROJECT_ID, MAIN_NOTEBOOK_ID)).thenReturn( - makeMainProjectEntry(PROJECT_ID, undefined) - ); - putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, undefined) as unknown as DeepnoteFile); - - const kernel = makeKernel(MAIN_FILE_NAME); - onDidStartKernel.fire(kernel); - await settle(); - - assert.strictEqual(executeHiddenSpy.callCount, 0, 'no init must run when initNotebookId is undefined'); - assert.strictEqual(readDirectoryCount, 0, 'with no init configured there is no need to scan the directory'); - }); - test('closing the notebook mid-init stops remaining init blocks (close cancels the run)', async () => { // Regression for a lifecycle cancellation bug: runInitForKernel must pass a token tied to // notebook close into executeInitNotebook, so closing the notebook while init is running @@ -422,7 +345,7 @@ suite('DeepnoteInitNotebookRunner', () => { putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); putFile( SIBLING_INIT_FILE_NAME, - makeTwoCodeBlockFile(PROJECT_ID, INIT_NOTEBOOK_ID, FIRST_BLOCK_CODE, SECOND_BLOCK_CODE) + makeNotebookFile(PROJECT_ID, INIT_NOTEBOOK_ID, [FIRST_BLOCK_CODE, SECOND_BLOCK_CODE]) ); // Wire a close emitter we can fire (the runner subscribes to workspace.onDidCloseNotebookDocument). @@ -464,7 +387,7 @@ suite('DeepnoteInitNotebookRunner', () => { test('sibling of a DIFFERENT project is not a valid init source (project.id must match)', async () => { // A sibling exists with the right initNotebookId-shaped notebook but a different project.id. putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID) as unknown as DeepnoteFile); - putFile(SIBLING_INIT_FILE_NAME, makeSingleNotebookFile(OTHER_PROJECT_ID, INIT_NOTEBOOK_ID, SIBLING_INIT_CODE)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(OTHER_PROJECT_ID, INIT_NOTEBOOK_ID, [SIBLING_INIT_CODE])); const kernel = makeKernel(MAIN_FILE_NAME); onDidStartKernel.fire(kernel); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts index 31c48f4aeb..d38717acae 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -473,22 +473,6 @@ suite('DeepnoteMultiNotebookSplitter', () => { assert.notInclude(writeTargets, 'multi-alpha.deepnote', 'must NOT overwrite the pre-existing file'); }); - test('two notebooks whose slugs collide within one batch get distinct names via the shared reserved set', async () => { - // Both notebooks slugify to the same `dup` slug. - const file = makeFile([makeNotebook('n1', 'Dup', 'a'), makeNotebook('n2', 'Dup', 'b')]); - stubReadFile(file); - acceptSplit(); - - onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); - - // The mocked splitByNotebooks already de-dupes its own outputFilename in-batch - // (multi-dup.deepnote, multi-dup-2.deepnote); the shared `reserved` set in the - // splitter guarantees the writes land on two DISTINCT names regardless. - assert.strictEqual(writeTargets.length, 2, 'two writes for two notebooks'); - assert.strictEqual(new Set(writeTargets).size, 2, 'the two siblings must get distinct names'); - }); - test('the ORIGINAL file URI is never a write target (regression: never rewrite the open document in place)', async () => { const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); stubReadFile(file); @@ -554,40 +538,4 @@ suite('DeepnoteMultiNotebookSplitter', () => { ); }); }); - - suite('snapshots untouched', () => { - test('no write or delete target is a path under snapshots/ (regression: split must not touch snapshots)', async () => { - const file = makeFile([ - makeNotebook('n1', 'Alpha', 'a'), - makeNotebook('n2', 'Beta', 'b'), - makeNotebook('n3', 'Gamma', 'c') - ]); - const writeUris: Uri[] = []; - const deleteUris: Uri[] = []; - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall(() => - Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) - ); - when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { - writeUris.push(uri); - writeTargets.push(basename(uri)); - return Promise.resolve(); - }); - when(mockFs.stat(anything())).thenReject(new Error('not found')); - when(mockFs.delete(anything(), anything())).thenCall((uri: Uri) => { - deleteUris.push(uri); - deleteTargets.push(basename(uri)); - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - acceptSplit(); - - onDidOpen.fire(notebookDoc(Uri.file('/ws/project/multi.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); - - for (const uri of [...writeUris, ...deleteUris]) { - assert.notInclude(uri.path, '/snapshots/', `must not target a snapshots/ path: ${uri.path}`); - } - }); - }); }); diff --git a/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts index 1cebe7e880..f9ca618150 100644 --- a/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts @@ -48,9 +48,8 @@ suite('DeepnoteNotebookFileFactory', () => { suite('getFileStem', () => { test('returns basename up to the FIRST dot (regression: a.b.deepnote must collapse to a)', () => { - assert.strictEqual(getFileStem(Uri.file('/x/a.b.deepnote')), 'a'); assert.strictEqual(getFileStem(Uri.file('/x/report.deepnote')), 'report'); - assert.strictEqual(getFileStem(Uri.file('/x/report.backup.deepnote')), 'report'); + assert.strictEqual(getFileStem(Uri.file('/x/a.b.deepnote')), 'a'); }); }); @@ -68,19 +67,6 @@ suite('DeepnoteNotebookFileFactory', () => { newNotebook, 'the one notebook must be the provided one' ); - }); - - test('preserves project id/name/integrations/settings + top-level version (regression: project-level metadata must survive)', () => { - const source = makeSource(); - - const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); - - assert.strictEqual(built.project.id, 'project-1'); - assert.strictEqual(built.project.name, 'My Project'); - assert.deepStrictEqual(built.project.integrations, [ - { id: 'int-1', name: 'My Postgres', type: 'postgres' } - ]); - assert.deepStrictEqual(built.project.settings, { requirements: ['pandas'] }); assert.strictEqual(built.version, '1.0.0', 'top-level version must be preserved'); }); @@ -106,21 +92,6 @@ suite('DeepnoteNotebookFileFactory', () => { assert.isString(built.metadata.modifiedAt, 'a modifiedAt must be stamped when absent'); }); - test('does NOT stamp a metadata.snapshotHash onto the built file (regression: snapshotHash is snapshot-only and must not be synthesized)', () => { - // The source carries no snapshotHash; the factory must not invent one (it is a - // snapshot-only field). Any pre-existing in-memory hash is harmless because - // serializeDeepnoteFile strips it — see the round-trip test below. - const source = makeSource(); - - const built = buildSingleNotebookFile(source, makeNotebook('nb-2', 'Second')); - - assert.notProperty( - built.metadata, - 'snapshotHash', - 'buildSingleNotebookFile must not stamp a snapshotHash on the built file' - ); - }); - test('built file has no metadata.snapshotHash after a serialize -> deserialize round-trip (schema-stripped)', () => { const source = makeSource(); @@ -148,20 +119,11 @@ suite('DeepnoteNotebookFileFactory', () => { assert.deepStrictEqual(uri, Uri.file('/workspace/project/report-my-notebook.deepnote')); }); - test('bumps -2 / -3 via the shared allocator on collision (regression: must not clobber an existing sibling)', async () => { + test('bumps -2 via the shared allocator on collision (regression: must not clobber an existing sibling)', async () => { const existsFirst = (uri: Uri) => Promise.resolve((uri.path.split('/').pop() ?? '') === 'report-my-notebook.deepnote'); const uri2 = await buildSiblingNotebookFileUri(original, 'My Notebook', existsFirst); assert.deepStrictEqual(uri2, Uri.file('/workspace/project/report-my-notebook-2.deepnote')); - - const existsTwo = (uri: Uri) => { - const name = uri.path.split('/').pop() ?? ''; - return Promise.resolve( - name === 'report-my-notebook.deepnote' || name === 'report-my-notebook-2.deepnote' - ); - }; - const uri3 = await buildSiblingNotebookFileUri(original, 'My Notebook', existsTwo); - assert.deepStrictEqual(uri3, Uri.file('/workspace/project/report-my-notebook-3.deepnote')); }); test('falls back to {stem}-notebook.deepnote for an empty/blank notebook name (regression: blank slug must not yield {stem}-.deepnote)', async () => { diff --git a/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts index e91ffa0ddb..52ab7fdea6 100644 --- a/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts @@ -88,10 +88,6 @@ suite('DeepnoteNotebookInfoStatusBar', () => { resetVSCodeMocks(); }); - test('constructor registers the status bar in the disposable registry', () => { - assert.include(disposableRegistry as unknown as unknown[], statusBar, 'status bar must register itself'); - }); - test('shows "$(notebook) " for an active deepnote notebook (name from metadata.deepnoteNotebookName)', () => { when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ notebook: makeNotebook({ metadata: { deepnoteNotebookName: 'My Analysis' } }) @@ -114,14 +110,6 @@ suite('DeepnoteNotebookInfoStatusBar', () => { assert.isFalse(fakeItem.visible, 'a non-deepnote editor must not show the Deepnote status bar'); }); - test('HIDES the status bar when there is no active notebook editor', () => { - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); - - statusBar.activate(); - - assert.isFalse(fakeItem.visible, 'no active editor must hide the status bar'); - }); - test('updates on active-editor change (hidden → shown when a deepnote notebook becomes active)', () => { when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); statusBar.activate(); @@ -214,15 +202,6 @@ suite('DeepnoteNotebookInfoStatusBar', () => { assert.strictEqual(clipboardText, '', 'nothing should be copied when there is no active deepnote notebook'); }); - test('registers the CopyNotebookDetails command on activate', () => { - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); - - statusBar.activate(); - - const [commandId] = capture(mockedVSCodeNamespaces.commands.registerCommand).first() as any; - assert.strictEqual(commandId, Commands.CopyNotebookDetails); - }); - test('dispose() disposes the status bar item and clears its subscriptions', () => { when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(undefined); statusBar.activate(); diff --git a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts index 7f618034b2..ad84663467 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -31,14 +31,6 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, undefined); }); - - test('should return original project after storing', () => { - manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - - const result = manager.getProjectForNotebook('project-123', 'notebook-456'); - - assert.deepStrictEqual(result, mockProject); - }); }); suite('storeOriginalProject', () => { @@ -156,25 +148,6 @@ suite('DeepnoteNotebookManager', () => { }); }); - suite('integration scenarios', () => { - test('should store and retrieve projects for multiple project ids independently', () => { - const projectOne: DeepnoteProject = { - ...mockProject, - project: { ...mockProject.project, id: 'project-1' } - }; - const projectTwo: DeepnoteProject = { - ...mockProject, - project: { ...mockProject.project, id: 'project-2' } - }; - - manager.storeOriginalProject('project-1', 'notebook-1', projectOne); - manager.storeOriginalProject('project-2', 'notebook-2', projectTwo); - - assert.deepStrictEqual(manager.getProjectForNotebook('project-1', 'notebook-1'), projectOne); - assert.deepStrictEqual(manager.getProjectForNotebook('project-2', 'notebook-2'), projectTwo); - }); - }); - // Two sibling .deepnote files of ONE project share project.id but each holds a // different single notebook. These tests pin the load-bearing new semantics: // nested (projectId, notebookId) storage with an exact, no-fallback lookup. diff --git a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts index e200bc07e0..0829313c92 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.unit.test.ts @@ -284,33 +284,6 @@ project: assert.notStrictEqual(parsed.project.notebooks[0].id, nbA); }); - test('catches wrong-sibling save: saving notebookId=A writes sibling A (not B) from the same project cache', async () => { - manager.storeOriginalProject(sharedProjectId, nbA, siblingFile(nbA, 'block-a', 'print("A")')); - manager.storeOriginalProject(sharedProjectId, nbB, siblingFile(nbB, 'block-b', 'print("B")')); - - const notebookData = { - cells: [ - { - kind: 2, - value: 'print("A")', - languageId: 'python', - metadata: { id: 'block-a' } - } - ], - metadata: { - deepnoteProjectId: sharedProjectId, - deepnoteNotebookId: nbA - } - }; - - const result = await serializer.serializeNotebook(notebookData as any, {} as any); - const parsed = deserializeDeepnoteFile(new TextDecoder().decode(result)); - - assert.strictEqual(parsed.project.notebooks.length, 1); - assert.strictEqual(parsed.project.notebooks[0].id, nbA); - assert.strictEqual(parsed.project.notebooks[0].blocks[0].id, 'block-a'); - }); - test('catches save-against-wrong-sibling-on-cache-miss: when only sibling A is cached, saving notebookId=B throws the clear error instead of saving against A', async () => { // Only sibling A is cached; the document is sibling B. An exact (projectId, notebookId) // lookup must miss and throw — it must NOT fall back to A (which shares project.id). @@ -1074,20 +1047,6 @@ project: assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main One'); }); - test('catches URI-driven selection: deserialize receives only bytes, so a ?notebook= query cannot change the selection', async () => { - // deserializeNotebook's contract is (content, token) — no URI is ever passed. Even if a - // caller's document URI carries ?notebook=init-notebook, the serializer has no access to - // it and must still render the first non-init notebook (main). This pins that the dropped - // selection-by-query machinery has no path back in. - const bytes = projectToYaml(initMainFile()); - - const result = await serializer.deserializeNotebook(bytes, {} as any); - - // Selection is byte-derived (first non-init), independent of any URL query. - assert.strictEqual(result.metadata?.deepnoteNotebookId, 'main-notebook'); - assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main'); - }); - test('catches init composition at deserialize: an [init, main] file renders ONLY main blocks (init setup blocks are not merged)', async () => { const content = projectToYaml(initMainFile()); const result = await serializer.deserializeNotebook(content, {} as any); @@ -1538,16 +1497,6 @@ project: }); } - test('catches over-eager diff: identical single-notebook input (all notebook-level fields equal) reports no change', () => { - const originalProject = singleNotebookFile({}); - const newProject = structuredClone(originalProject); - - const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); - - assert.isFalse(result); - }); - test('catches missed block-id diff: a block id change (same content/type) is detected', () => { const originalProject = singleNotebookFile({}); const newProject = singleNotebookFile({}); diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts index 0d9212c075..a0605f1dff 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts @@ -739,24 +739,6 @@ suite('DeepnoteTreeDataProvider', () => { assert.isTrue(cachedProjects.has(filePathB), 'the sibling sharing project.id must remain cached'); assert.isTrue(cachedProjects.has(filePathOther), 'the other project must remain cached'); }); - - test('refreshProject fires a FULL-tree change (undefined), never a scoped fire(item)', () => { - provider.refreshProject(filePathA); - - assert.strictEqual(fireArgs.length, 1, 'refreshProject must fire exactly once'); - assert.isUndefined(fireArgs[0], 'refreshProject must fire undefined (full-tree), not a tree item'); - }); - - test('every refresh path (refresh/refreshProject/refreshNotebook) fires undefined only — no scoped fire(item)', () => { - provider.refresh(); - provider.refreshProject(filePathB); - provider.refreshNotebook(projectId); - - assert.strictEqual(fireArgs.length, 3, 'each refresh call fires exactly once'); - for (const arg of fireArgs) { - assert.isUndefined(arg, 'no refresh path may use a scoped fire(item); all fires must be undefined'); - } - }); }); suite('getNonInitNotebooks excludes the init notebook', () => { diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts index 715ab3d19b..d3e30558a2 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts @@ -52,26 +52,12 @@ suite('snapshotFiles', () => { assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); }); - test('should extract project ID from legacy timestamped snapshot URI', () => { - const uri = Uri.file(`/path/to/snapshots/my-project_${projectId}_2025-01-15T10-31-48.snapshot.deepnote`); - - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); - }); - test('should extract project ID from notebook-scoped latest snapshot URI', () => { const uri = Uri.file(`/path/to/snapshots/my-project_${projectId}_notebook-1_latest.snapshot.deepnote`); assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); }); - test('should extract project ID from notebook-scoped timestamped snapshot URI', () => { - const uri = Uri.file( - `/path/to/snapshots/my-project_${projectId}_notebook-1_2025-01-15T10-31-48.snapshot.deepnote` - ); - - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); - }); - test('should return undefined for non-snapshot files', () => { const uri = Uri.file('/path/to/my-project.deepnote'); @@ -115,24 +101,6 @@ suite('snapshotFiles', () => { }); }); - test('round-trips the latest variant for the notebook-scoped form (catches losing the "latest" pointer marker)', () => { - const filename = generateSnapshotFilename({ - slug: 'my-project', - projectId, - notebookId, - timestamp: 'latest' - }); - - const parsed = parseSnapshotFilename(filename); - - assert.deepStrictEqual(parsed, { - slug: 'my-project', - projectId, - notebookId, - timestamp: 'latest' - }); - }); - test('percent-encodes a notebook id with non-filename-safe characters and decodes it back unchanged (catches a path-unsafe filename or a lossy decode)', () => { const trickyNotebookId = 'naïve/notebook id'; const filename = generateSnapshotFilename({ diff --git a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts index 16b3170682..095364a8a7 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -931,30 +931,6 @@ project: const first = result?.get('block-1')?.[0] as { text?: string } | undefined; assert.strictEqual(first?.text, 'from-timestamped'); }); - - test('skips a corrupt/unparseable candidate and uses the next valid one (catches aborting the lookup on one bad file)', async () => { - // `latest` sorts first; make it unparseable so the walk must continue to the timestamped one. - const latestUri = Uri.file(`/workspace/snapshots/test-project_${projectId}_latest.snapshot.deepnote`); - const timestampedUri = Uri.file( - `/workspace/snapshots/test-project_${projectId}_2025-01-02T10-00-00.snapshot.deepnote` - ); - - stubFiles([ - { uri: latestUri, yaml: 'this: is: not: valid: deepnote: [[[' }, - { - uri: timestampedUri, - yaml: snapshotYaml( - 'print(1)', - `\n - output_type: stream\n name: stdout\n text: 'from-valid'` - ) - } - ]); - - const result = await service.readSnapshot(projectId); - - const first = result?.get('block-1')?.[0] as { text?: string } | undefined; - assert.strictEqual(first?.text, 'from-valid'); - }); }); suite('deferred snapshot save timing', () => { @@ -1539,14 +1515,6 @@ project: updateLatestSnapshotSpy.called, 'updateLatestSnapshot should NOT be called when all code cells are executed' ); - - // Regression: the save path must resolve the project via the EXACT - // (projectId, notebookId) lookup — a project-only lookup could return a different - // open sibling and silently skip the snapshot write. - assert.isTrue( - mockNotebookManager.getProjectForNotebook.calledWith(projectId, notebookId), - 'performSnapshotSave must fetch via getProjectForNotebook(projectId, notebookId)' - ); }); test('writes the snapshot next to the saved notebook, not a sibling that shares the project id', async () => { diff --git a/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts b/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts index 586d79f364..d2de7cef01 100644 --- a/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts +++ b/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts @@ -83,17 +83,4 @@ suite('DeepnoteProjectFileReader', () => { assert.isTrue(threw, 'readDeepnoteProjectFile should surface (not swallow) a malformed buffer'); }); - - test('rejects on a non-YAML / garbage buffer', async () => { - stubReadFile('not: valid: yaml: ['); - - let threw = false; - try { - await readDeepnoteProjectFile(Uri.file('/workspace/garbage.deepnote')); - } catch { - threw = true; - } - - assert.isTrue(threw, 'readDeepnoteProjectFile should surface a non-parseable buffer'); - }); }); diff --git a/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts b/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts index d692ce944a..8598362289 100644 --- a/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts +++ b/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts @@ -124,14 +124,5 @@ suite('DeepnoteProjectIdResolver', () => { assert.strictEqual((readUri as Uri).fragment, ''); assert.strictEqual((readUri as Uri).path, '/workspace/project.deepnote'); }); - - test('returns undefined (does not throw) when the fallback file read fails', async () => { - stubReadFile(new Error('ENOENT')); - const notebook = createNotebook(Uri.file('/workspace/missing.deepnote'), {}); - - const result = await resolveProjectIdForNotebook(notebook); - - assert.strictEqual(result, undefined); - }); }); }); diff --git a/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts b/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts index f4bc2825f6..7fafed8d13 100644 --- a/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts +++ b/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts @@ -14,15 +14,6 @@ import { createDeepnoteServerConfigHandle } from './deepnoteServerUtils.node'; * per-notebook uniqueness/byte-stability the contract relies on. */ suite('DeepnoteServerUtils - createDeepnoteServerConfigHandle', () => { - test('returns deepnote-config-server-${environmentId}-${notebookUri.toString()} (catches handle-format drift)', () => { - const uri = Uri.file('/workspace/project/notebook.deepnote'); - - assert.strictEqual( - createDeepnoteServerConfigHandle('env-123', uri), - `deepnote-config-server-env-123-${uri.toString()}` - ); - }); - test('two different notebook URIs produce DIFFERENT handles (catches sibling collision)', () => { const uriA = Uri.file('/workspace/project/notebook-a.deepnote'); const uriB = Uri.file('/workspace/project/notebook-b.deepnote'); @@ -33,24 +24,4 @@ suite('DeepnoteServerUtils - createDeepnoteServerConfigHandle', () => { 'sibling notebooks sharing one environment must still get distinct server handles' ); }); - - test('same env + same URI yields a BYTE-IDENTICAL handle (producer/consumer match invariant)', () => { - const uri = Uri.file('/workspace/project/notebook.deepnote'); - - // Build two Uri instances for the same path to mimic producer vs. consumer building it - // independently from `notebook.uri`. - const produced = createDeepnoteServerConfigHandle('env-9', uri); - const compared = createDeepnoteServerConfigHandle('env-9', Uri.file('/workspace/project/notebook.deepnote')); - - assert.strictEqual(produced, compared, 'the produced and compared handle must be byte-for-byte identical'); - }); - - test('different environmentId for the same notebook produces different handles', () => { - const uri = Uri.file('/workspace/project/notebook.deepnote'); - - assert.notStrictEqual( - createDeepnoteServerConfigHandle('env-1', uri), - createDeepnoteServerConfigHandle('env-2', uri) - ); - }); }); From ee38cf6ec5bb3f1b929d2a814153e8dd36dd0316 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 1 Jul 2026 14:58:11 +0000 Subject: [PATCH 19/26] feat(deepnote): retire the split-away original as a .legacy backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When splitting a legacy multi-notebook .deepnote file into single-notebook siblings, rename the original to `.deepnote.legacy` instead of moving it to the OS trash. The `.legacy` suffix takes it out of the extension's view (it no longer matches `*.deepnote`) while keeping it on disk next to the split results, so the user can restore it by removing the suffix. Unlike `workspace.fs.delete({ useTrash: true })`, this is deterministic and does not depend on an OS trash backend (which can be absent on headless Linux). Collisions bump the name to `.legacy-2`, `.legacy-3`, … and the rename still happens only after every child is durably written (write-before-retire). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- .../deepnote/deepnoteMultiNotebookSplitter.ts | 36 ++++- ...deepnoteMultiNotebookSplitter.unit.test.ts | 126 ++++++++++++------ 2 files changed, 116 insertions(+), 46 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts index b2a72d4533..71a43e1758 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -11,10 +11,16 @@ import { getFileStem } from './deepnoteNotebookFileFactory'; const SPLIT_ACTION = l10n.t('Split into separate files'); +/** Suffix appended to a split-away original so it no longer matches `*.deepnote` yet stays on disk. */ +const LEGACY_SUFFIX = '.legacy'; + +/** Upper bound on `.legacy` / `.legacy-N` suffix attempts when the base name is already taken. */ +const MAX_LEGACY_ALLOCATION_ATTEMPTS = 10_000; + /** * Detects legacy multi-notebook `.deepnote` files when they are opened and, on explicit - * user action, splits them into one new single-notebook sibling file per notebook before - * deleting the original. There is NO automatic rewrite on open. + * user action, splits them into one new single-notebook sibling file per notebook, then retires + * the original by renaming it to `.deepnote.legacy`. There is NO automatic rewrite on open. * * The environment mapper is optional: it is undefined on the web target, where environment * migration is a desktop-only no-op. @@ -132,7 +138,7 @@ export class DeepnoteMultiNotebookSplitter { const deepnoteFile = await readDeepnoteProjectFile(fileUri); const parentDir = Uri.joinPath(fileUri, '..'); - // 2. Write the children: N new files, then (only after) delete the original. + // 2. Write the children: N new files, then (only after) retire the original. const entries = splitByNotebooks(deepnoteFile, getFileStem(fileUri)); const reserved = new Set(); const newUris: Uri[] = []; @@ -156,9 +162,11 @@ export class DeepnoteMultiNotebookSplitter { } } - // 4. Only after all children are durably written: close the tab + delete the original. + // 4. Only after all children are durably written: close the tab + retire the original by + // renaming it to `.deepnote.legacy`. await this.closeNotebookTab(fileUri); - await workspace.fs.delete(fileUri, { useTrash: true }); + const legacyUri = await this.allocateLegacyUri(fileUri); + await workspace.fs.rename(fileUri, legacyUri, { overwrite: false }); if (this.envMapper) { await this.envMapper.removeEnvironmentForNotebook(fileUri); @@ -180,6 +188,24 @@ export class DeepnoteMultiNotebookSplitter { } } + /** + * Resolves a collision-free `.legacy` (then `.legacy-2`, `.legacy-3`, …) URI next to + * the original file. + * @param fileUri The original `.deepnote` file being retired + */ + private async allocateLegacyUri(fileUri: Uri): Promise { + for (let attempt = 1; attempt <= MAX_LEGACY_ALLOCATION_ATTEMPTS; attempt++) { + const suffix = attempt === 1 ? LEGACY_SUFFIX : `${LEGACY_SUFFIX}-${attempt}`; + const candidateUri = fileUri.with({ path: `${fileUri.path}${suffix}` }); + + if (!(await this.exists(candidateUri))) { + return candidateUri; + } + } + + throw new Error(`Unable to allocate a free "${LEGACY_SUFFIX}" filename for "${fileUri.toString()}".`); + } + private async closeNotebookTab(fileUri: Uri): Promise { for (const group of window.tabGroups.all) { for (const tab of group.tabs) { diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts index d38717acae..f3b2e7dee9 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -24,7 +24,7 @@ async function waitFor(condition: () => boolean, timeoutMs = waitTimeoutMs): Pro } } -/** A short settle delay used to PROVE that nothing further happened (no write/delete/prompt). */ +/** A short settle delay used to PROVE that nothing further happened (no write/rename/prompt). */ function settle(): Promise { return new Promise((resolve) => setTimeout(resolve, 80)); } @@ -35,9 +35,12 @@ function basename(uri: Uri): string { /** * Tests for the on-demand multi-notebook splitter (§2). These exercise the splitter's - * ORCHESTRATION (prompt gating, write/delete ORDER, env migration, dirty gate, abort-on-failure) + * ORCHESTRATION (prompt gating, write/rename ORDER, env migration, dirty gate, abort-on-failure) * plus the REAL local `allocateSiblingUri`, against the MOCKED `@deepnote/convert` `splitByNotebooks`. * + * The original file is retired by RENAMING it to `.deepnote.legacy` (not deleted): the suffix + * takes it out of the extension's view while keeping it on disk to restore. + * * NOTE: `instanceof TabInputNotebook` is always false against the test class-proxy, so the * tab-close path is NOT unit-exercisable and is intentionally not asserted (see harness notes). */ @@ -47,10 +50,11 @@ suite('DeepnoteMultiNotebookSplitter', () => { let refreshTreeCount: number; let envMapper: IDeepnoteNotebookEnvironmentMapper; - // Ordered log of side-effecting fs operations, so we can assert write-before-delete ORDER. - let callLog: Array<{ op: 'write' | 'delete'; name: string }>; + // Ordered log of side-effecting fs operations, so we can assert write-before-rename ORDER. + let callLog: Array<{ op: 'write' | 'rename'; name: string }>; let writeTargets: string[]; - let deleteTargets: string[]; + // Each retire of the original, captured as { from: , to: } basenames. + let renameOps: Array<{ from: string; to: string }>; let warnCount: number; // Names that the injected `exists` probe reports as already present on disk. let existingOnDisk: Set; @@ -104,10 +108,11 @@ suite('DeepnoteMultiNotebookSplitter', () => { existingOnDisk.add(name); return Promise.resolve(); }); - when(mockFs.delete(anything(), anything())).thenCall((uri: Uri) => { - const name = basename(uri); - callLog.push({ op: 'delete', name }); - deleteTargets.push(name); + when(mockFs.rename(anything(), anything(), anything())).thenCall((source: Uri, target: Uri) => { + const from = basename(source); + const to = basename(target); + callLog.push({ op: 'rename', name: from }); + renameOps.push({ from, to }); return Promise.resolve(); }); when(mockFs.stat(anything())).thenCall((uri: Uri) => { @@ -140,7 +145,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { resetVSCodeMocks(); callLog = []; writeTargets = []; - deleteTargets = []; + renameOps = []; warnCount = 0; refreshTreeCount = 0; existingOnDisk = new Set(); @@ -195,7 +200,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { } suite('prompt gating', () => { - test('a 3-notebook file prompts and writes/deletes NOTHING until the action is taken (regression: no silent rewrite on open)', async () => { + test('a 3-notebook file prompts and writes/renames NOTHING until the action is taken (regression: no silent rewrite on open)', async () => { const file = makeFile([ makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b'), @@ -211,7 +216,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { assert.strictEqual(warnCount, 1, 'should prompt exactly once'); assert.strictEqual(writeTargets.length, 0, 'no writeFile until the split action is taken'); - assert.strictEqual(deleteTargets.length, 0, 'no delete until the split action is taken'); + assert.strictEqual(renameOps.length, 0, 'no rename until the split action is taken'); }); test('a single-notebook file does NOT prompt (regression: a valid file must not be flagged)', async () => { @@ -253,7 +258,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { }); suite('split action', () => { - test('writes N new files then deletes the original — delete happens AFTER the last write (ORDER, load-bearing)', async () => { + test('writes N new files then retires the original — the rename happens AFTER the last write (ORDER, load-bearing)', async () => { const file = makeFile([ makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b'), @@ -265,11 +270,11 @@ suite('DeepnoteMultiNotebookSplitter', () => { const originalUri = Uri.file('/ws/multi.deepnote'); onDidOpen.fire(notebookDoc(originalUri)); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); - // N = 3 writes, exactly one delete. + // N = 3 writes, exactly one rename. assert.strictEqual(writeTargets.length, 3, 'should write one new file per notebook (N=3)'); - assert.strictEqual(deleteTargets.length, 1, 'should delete the original exactly once'); + assert.strictEqual(renameOps.length, 1, 'should retire the original exactly once'); // The convert mock names files {stem}-{slug}.deepnote. assert.deepStrictEqual(writeTargets, [ @@ -278,41 +283,80 @@ suite('DeepnoteMultiNotebookSplitter', () => { 'multi-gamma.deepnote' ]); - // ORDER: every write must come before the single delete in the call log. - const deleteIndex = callLog.findIndex((c) => c.op === 'delete'); + // ORDER: every write must come before the single rename in the call log. + const renameIndex = callLog.findIndex((c) => c.op === 'rename'); const lastWriteIndex = callLog.map((c) => c.op).lastIndexOf('write'); - assert.isAbove(deleteIndex, lastWriteIndex, 'delete must happen AFTER the last write'); + assert.isAbove(renameIndex, lastWriteIndex, 'the rename must happen AFTER the last write'); assert.strictEqual( callLog.filter((c) => c.op === 'write').length, 3, - 'all three writes must precede the delete' + 'all three writes must precede the rename' ); - // The deleted file is the original. - assert.strictEqual(deleteTargets[0], 'multi.deepnote', 'the deleted file must be the original'); + // The retired file is the original, renamed to its `.legacy` sibling. + assert.strictEqual(renameOps[0].from, 'multi.deepnote', 'the retired file must be the original'); + assert.strictEqual(renameOps[0].to, 'multi.deepnote.legacy', 'the original must be renamed to .legacy'); }); - test('deletes the original with { useTrash: true } (regression: must go to trash, not hard-delete)', async () => { + test('retires the original by renaming it to .deepnote.legacy, never deleting it (regression: keep a restorable backup)', async () => { const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); const mockFs = mock(); - let deleteOptions: { useTrash?: boolean } | undefined; + let renameTarget: string | undefined; + let renameOptions: { overwrite?: boolean } | undefined; + let deleteCalled = false; when(mockFs.readFile(anything())).thenCall(() => Promise.resolve(new TextEncoder().encode(serializeDeepnoteFile(file))) ); when(mockFs.writeFile(anything(), anything())).thenResolve(); when(mockFs.stat(anything())).thenReject(new Error('not found')); - when(mockFs.delete(anything(), anything())).thenCall((_uri: Uri, opts: { useTrash?: boolean }) => { - deleteOptions = opts; - deleteTargets.push('deleted'); + when(mockFs.rename(anything(), anything(), anything())).thenCall( + (source: Uri, target: Uri, opts: { overwrite?: boolean }) => { + renameTarget = basename(target); + renameOptions = opts; + renameOps.push({ from: basename(source), to: basename(target) }); + return Promise.resolve(); + } + ); + when(mockFs.delete(anything(), anything())).thenCall(() => { + deleteCalled = true; return Promise.resolve(); }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); acceptSplit(); onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); + + assert.strictEqual( + renameTarget, + 'multi.deepnote.legacy', + 'the original must be renamed to .deepnote.legacy' + ); + assert.deepStrictEqual( + renameOptions, + { overwrite: false }, + 'the rename must not overwrite an existing backup' + ); + assert.isFalse(deleteCalled, 'the original must be renamed, never deleted'); + }); + + test('bumps the legacy name to .legacy-2 when .deepnote.legacy already exists (regression: never clobber a prior backup)', async () => { + const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); + stubReadFile(file); + acceptSplit(); + + // A previous split already left a backup on disk. + existingOnDisk.add('multi.deepnote.legacy'); + + onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); + await waitFor(() => renameOps.length >= 1); - assert.deepStrictEqual(deleteOptions, { useTrash: true }, 'delete must request the trash'); + assert.strictEqual(renameOps[0].from, 'multi.deepnote', 'the original is the rename source'); + assert.strictEqual( + renameOps[0].to, + 'multi.deepnote.legacy-2', + 'a taken .legacy name must be bumped to .legacy-2' + ); }); test('copies the original env mapping onto each new file and removes the original mapping (regression: split-time env migration)', async () => { @@ -376,8 +420,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { }); }); - suite('abort-before-delete on write failure (load-bearing safety)', () => { - test('if a child writeFile rejects, delete is NEVER called and an error is surfaced (original left intact)', async () => { + suite('abort-before-retire on write failure (load-bearing safety)', () => { + test('if a child writeFile rejects, the original is NEVER renamed and an error is surfaced (original left intact)', async () => { const file = makeFile([ makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b'), @@ -400,8 +444,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { await waitFor(() => errorShown); await settle(); - assert.strictEqual(deleteTargets.length, 0, 'delete must NEVER be called when a child write fails'); - // The first write succeeded before the failure; the original is still present (never deleted). + assert.strictEqual(renameOps.length, 0, 'the original must NEVER be renamed when a child write fails'); + // The first write succeeded before the failure; the original is still present (never retired). assert.isTrue(errorShown, 'an error must be surfaced on write failure'); assert.deepStrictEqual(writeTargets, ['multi-alpha.deepnote'], 'only writes before the failure occurred'); }); @@ -419,7 +463,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { onDidOpen.fire(doc); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); assert.isTrue( (doc as unknown as { _saved: boolean })._saved, @@ -428,7 +472,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { assert.strictEqual(writeTargets.length, 2, 'split proceeds after a successful save'); }); - test('if save() returns false (declined), the split ABORTS — no writeFile, no delete (regression: must not lose unsaved edits)', async () => { + test('if save() returns false (declined), the split ABORTS — no writeFile, no rename (regression: must not lose unsaved edits)', async () => { const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); stubReadFile(file); acceptSplit(); @@ -449,7 +493,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { await settle(); assert.strictEqual(writeTargets.length, 0, 'declined save must abort before any write'); - assert.strictEqual(deleteTargets.length, 0, 'declined save must abort before any delete'); + assert.strictEqual(renameOps.length, 0, 'declined save must abort before any rename'); }); }); @@ -463,7 +507,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { existingOnDisk.add('multi-alpha.deepnote'); onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); assert.deepStrictEqual( writeTargets, @@ -479,7 +523,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { acceptSplit(); onDidOpen.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); assert.notInclude(writeTargets, 'multi.deepnote', 'the original file must never be written'); }); @@ -508,15 +552,15 @@ suite('DeepnoteMultiNotebookSplitter', () => { return Promise.resolve(); }); when(mockFs.stat(anything())).thenReject(new Error('not found')); - when(mockFs.delete(anything(), anything())).thenCall(() => { - deleteTargets.push('deleted'); + when(mockFs.rename(anything(), anything(), anything())).thenCall((source: Uri, target: Uri) => { + renameOps.push({ from: basename(source), to: basename(target) }); return Promise.resolve(); }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); acceptSplit(); onDidOpen.fire(notebookDoc(Uri.file('/ws/legacy.deepnote'))); - await waitFor(() => deleteTargets.length >= 1); + await waitFor(() => renameOps.length >= 1); // The mock splitByNotebooks emits the init notebook FIRST. assert.strictEqual(writtenFiles.length, 2, 'two files written for [init, main]'); From 8c341e095b64d6d664505f31af43c53496c4d4f3 Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 1 Jul 2026 14:58:22 +0000 Subject: [PATCH 20/26] test(e2e): add multi-notebook split test with per-spec screenshots Add an ExTester end-to-end test that drives the real VS Code UI through the on-open split of a legacy multi-notebook .deepnote file: it asserts the split prompt, the one-file-per-notebook result, the retained `.legacy` backup, that each sibling opens without re-prompting, and that content plus the project integration fan out into every split file. Add a `createScreenshotter(this)` helper that captures step screenshots into a per-spec directory derived from the running test file (`test/e2e/screenshots//`), plus the `sales-analytics.deepnote` fixture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P2CLu8UmD8ceGNyv8u96pQ --- test/e2e/fixtures/sales-analytics.deepnote | 72 ++++++ test/e2e/helpers/index.ts | 1 + test/e2e/helpers/screenshots.ts | 109 +++++++++ test/e2e/suite/splitMultiNotebook.e2e.test.ts | 213 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 test/e2e/fixtures/sales-analytics.deepnote create mode 100644 test/e2e/helpers/screenshots.ts create mode 100644 test/e2e/suite/splitMultiNotebook.e2e.test.ts diff --git a/test/e2e/fixtures/sales-analytics.deepnote b/test/e2e/fixtures/sales-analytics.deepnote new file mode 100644 index 0000000000..a7b8959ab4 --- /dev/null +++ b/test/e2e/fixtures/sales-analytics.deepnote @@ -0,0 +1,72 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa + integrations: + - id: int-bq-sales + name: Sales BigQuery + type: big-query + name: Sales Analytics + notebooks: + - blocks: + - id: blk-0001 + blockGroup: grp-0001 + sortingKey: "000000" + type: text-cell-h1 + content: Sales Analytics + metadata: {} + - id: blk-0002 + blockGroup: grp-0002 + sortingKey: "000001" + type: text-cell-p + content: This project has **three** notebooks in a single legacy file. + metadata: {} + - id: blk-0003 + blockGroup: grp-0003 + sortingKey: "000002" + type: code + content: print("Overview notebook") + metadata: {} + executionMode: block + id: a-nb-overview + name: Overview + - blocks: + - id: blk-0004 + blockGroup: grp-0004 + sortingKey: "000000" + type: code + content: >- + revenue = 1000 + + print(f"revenue={revenue}") + metadata: {} + - id: blk-0005 + blockGroup: grp-0005 + sortingKey: "000001" + type: sql + content: SELECT region, SUM(amount) AS total FROM sales GROUP BY region + metadata: + deepnote_variable_name: df_revenue + deepnote_return_variable_type: dataframe + sql_integration_id: int-bq-sales + executionMode: block + id: a-nb-revenue + name: Revenue + - blocks: + - id: blk-0006 + blockGroup: grp-0006 + sortingKey: "000000" + type: code + content: >- + print("Forecast notebook") + + for i in range(3): + print("forecast", i) + metadata: {} + executionMode: block + id: a-nb-forecast + name: Forecast +version: 1.0.0 diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index c33f734de7..ada3e9f036 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -5,4 +5,5 @@ export * from './fixtures'; export * from './notebook'; export * from './notifications'; export * from './quickInput'; +export * from './screenshots'; export * from './workspace'; diff --git a/test/e2e/helpers/screenshots.ts b/test/e2e/helpers/screenshots.ts new file mode 100644 index 0000000000..025705d038 --- /dev/null +++ b/test/e2e/helpers/screenshots.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { VSBrowser } from 'vscode-extension-tester'; + +// Root for the intentional, step-by-step screenshots these suites capture. ExTester's own +// `VSBrowser.takeScreenshot` writes flat into `TEST_RESOURCES/screenshots//` and +// cannot create per-test sub-paths, so we build on the same underlying `driver.takeScreenshot()` +// primitive but organise output into one directory per test spec under `test/e2e/screenshots/` +// (resolved from cwd, matching how `fixtures.ts` locates `test/e2e/fixtures`). +const SCREENSHOT_ROOT = path.resolve(process.cwd(), 'test', 'e2e', 'screenshots'); + +/** Strips the compiled-spec suffix (`.e2e.test.js`/`.ts`, then a bare `.js`/`.ts`) from a basename. */ +function stripSpecExtension(basename: string): string { + return basename.replace(/\.e2e\.test\.(js|ts)$/i, '').replace(/\.(js|ts)$/i, ''); +} + +/** Resolves the running spec's file path from the Mocha context — a test/hook runnable, or its suite. */ +function resolveSpecFile(context: Mocha.Context): string | undefined { + // `context.runnable()` returns the active test OR hook, so this also resolves the file when a + // screenshot is captured from a `before`/`after` hook (where `context.test` may be unset). + const runnable = context.runnable(); + + if (!runnable) { + return undefined; + } + + if (runnable.file) { + return runnable.file; + } + + return runnable.parent?.file; +} + +/** + * Derives a stable per-spec slug from the running Mocha context — the spec file's basename with the + * `.e2e.test.(js|ts)` suffix stripped (e.g. `splitMultiNotebook.e2e.test.js` -> `splitMultiNotebook`). + * Throws when the spec file cannot be resolved (e.g. called from an arrow-function test, where `this` + * is not bound to the Mocha context). + */ +function specSlug(context: Mocha.Context): string { + const file = resolveSpecFile(context); + + if (!file) { + throw new Error( + 'Cannot derive a screenshot directory: no spec file on the Mocha context. Call ' + + 'captureScreenshot/createScreenshotter from a `function () {}` test or hook (not an arrow function).' + ); + } + + const slug = stripSpecExtension(path.basename(file)); + + if (!slug) { + throw new Error(`Cannot derive a screenshot directory from spec file "${file}".`); + } + + return slug; +} + +/** Makes a label safe to embed in a filename. */ +function slugifyLabel(label: string): string { + return ( + label + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'shot' + ); +} + +/** + * Captures a full-window screenshot into `test/e2e/screenshots//.png` and returns the path. + * The directory is derived from the running spec so different suites never collide. + * + * Prefer {@link createScreenshotter} in a test body — it binds the context once and auto-numbers. + * + * @param context The Mocha context (`this` inside a `function () {}` test/hook) + * @param name The file name (without extension), e.g. `01-split-prompt` + */ +export async function captureScreenshot(context: Mocha.Context, name: string): Promise { + const dir = path.join(SCREENSHOT_ROOT, specSlug(context)); + fs.mkdirSync(dir, { recursive: true }); + + const file = path.join(dir, `${slugifyLabel(name)}.png`); + const image = await VSBrowser.instance.driver.takeScreenshot(); + fs.writeFileSync(file, image, 'base64'); + console.log(`[e2e] screenshot -> ${file}`); + + return file; +} + +/** + * Returns a screenshot function bound to the current test. Each call writes the next + * `NN-