diff --git a/.gitignore b/.gitignore index adbc881280..7174f36b60 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ vscode.d.ts vscode.proposed.*.d.ts xunit-test-results.xml tsconfig.tsbuildinfo +/testing # ExTester (vscode-extension-tester) E2E artifacts test-resources diff --git a/build/mocha-esm-loader.js b/build/mocha-esm-loader.js index 7a45afa78b..0cce4d46e6 100644 --- a/build/mocha-esm-loader.js +++ b/build/mocha-esm-loader.js @@ -13,6 +13,7 @@ const projectRoot = path.join(__dirname, '..'); const projectOutUrl = pathToFileURL(path.join(projectRoot, 'out')).href + '/'; const vscodeMockPath = pathToFileURL(path.join(projectRoot, 'out/test/vscode-mock.js')).href; const telemetryMockPath = pathToFileURL(path.join(projectRoot, 'out/test/mocks/vsc/telemetryReporter.js')).href; +const runtimeCoreMockPath = pathToFileURL(path.join(projectRoot, 'out/test/mocks/deepnoteRuntimeCore.js')).href; // Mock source for @vscode/python-extension - used in multiple places const PYTHON_EXTENSION_MOCK = ` @@ -104,11 +105,12 @@ 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') { + // Intercept @deepnote/runtime-core - the real startServer/stopServer spawn/kill Python + // processes. Resolves to the compiled typed mock (src/test/mocks/deepnoteRuntimeCore.ts) + // so the code under test and tests importing its __ helpers share one module instance. + if (specifier === '@deepnote/runtime-core') { return { - url: 'vscode-mock:///deepnote-convert', + url: runtimeCoreMockPath, shortCircuit: true }; } @@ -328,6 +330,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'); @@ -352,45 +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: ` - export const convertIpynbFilesToDeepnoteFile = async () => { - // Mock implementation - does nothing in tests - }; - - export const convertDeepnoteToJupyterNotebooks = (deepnoteFile) => { - // Mock implementation that converts Deepnote notebooks to Jupyter format - const notebooks = deepnoteFile?.project?.notebooks || []; - return notebooks.map(nb => ({ - filename: nb.name.replace(/[<>:"/\\\\|?*]/g, '_').replace(/\\s+/g, '-') + '.ipynb', - notebook: { - cells: (nb.blocks || []).map(block => ({ - cell_type: block.type === 'markdown' ? 'markdown' : 'code', - source: block.content || '', - metadata: { - deepnote_cell_type: block.type, - cell_id: block.id - }, - outputs: block.outputs || [] - })), - metadata: { - deepnote_notebook_id: nb.id, - deepnote_notebook_name: nb.name, - deepnote_execution_mode: nb.executionMode - }, - nbformat: 4, - nbformat_minor: 5 - } - })); - }; - `, - shortCircuit: true - }; - } - // Handle @vscode/python-extension mock - needed because it requires VS Code runtime if (moduleName === 'python-extension') { return { diff --git a/package-lock.json b/package-lock.json index e520ea76d7..98e574a8fd 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/blocks": "^4.6.0", + "@deepnote/convert": "^4.0.0", "@deepnote/database-integrations": "^1.5.0", - "@deepnote/runtime-core": "^0.2.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", @@ -332,6 +332,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", @@ -2010,13 +2089,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": { @@ -2033,16 +2112,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" @@ -2075,21 +2154,6 @@ "zod": "3.25.76" } }, - "node_modules/@deepnote/database-integrations/node_modules/@deepnote/blocks": { - "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.3", - "zod": "3.25.76" - }, - "engines": { - "node": ">=22.14.0", - "pnpm": ">=10.17.1" - } - }, "node_modules/@deepnote/database-integrations/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -2100,16 +2164,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": { @@ -2133,6 +2201,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", @@ -7332,6 +7409,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", @@ -9141,6 +9224,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", @@ -10146,6 +10238,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", @@ -16918,6 +17037,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", @@ -22717,6 +22845,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", @@ -27326,6 +27460,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", @@ -35298,6 +35441,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", @@ -36583,9 +36773,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", @@ -36600,11 +36790,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", @@ -36628,16 +36818,6 @@ "zod": "3.25.76" }, "dependencies": { - "@deepnote/blocks": { - "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", - "zod": "3.25.76" - } - }, "zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -36646,15 +36826,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": { @@ -36662,6 +36846,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==" } } }, @@ -40466,6 +40655,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", @@ -41889,6 +42083,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", @@ -42581,6 +42780,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", @@ -47455,6 +47672,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", @@ -51440,6 +51662,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", @@ -54677,6 +54904,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 9ae5460e09..8dc22af21d 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%", @@ -324,16 +318,16 @@ "icon": "$(add)" }, { - "command": "deepnote.exportProject", - "title": "%deepnote.commands.exportProject.title%", + "command": "deepnote.exportNotebook", + "title": "%deepnote.commands.exportNotebook.title%", "category": "Deepnote", "icon": "$(export)" }, { - "command": "deepnote.exportNotebook", - "title": "%deepnote.commands.exportNotebook.title%", + "command": "deepnote.copyNotebookDetails", + "title": "%deepnote.commands.copyNotebookDetails.title%", "category": "Deepnote", - "icon": "$(export)" + "icon": "$(copy)" }, { "command": "dataScience.ClearCache", @@ -1603,42 +1597,32 @@ }, { "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", - "group": "2_manage@2" - }, - { - "command": "deepnote.deleteProject", - "when": "view == deepnoteExplorer && viewItem == projectFile", - "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" } ] @@ -2689,10 +2673,10 @@ }, "dependencies": { "@c4312/evt": "^0.1.1", - "@deepnote/blocks": "^4.3.0", - "@deepnote/convert": "^3.2.0", + "@deepnote/blocks": "^4.6.0", + "@deepnote/convert": "^4.0.0", "@deepnote/database-integrations": "^1.5.0", - "@deepnote/runtime-core": "^0.2.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 79fdb1ed8e..f3b21dc525 100644 --- a/package.nls.json +++ b/package.nls.json @@ -277,13 +277,12 @@ "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", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", "deepnote.views.environments.name": "Environments", 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 8389da4bad..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.getOriginalProject(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 31a6c85845..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 = { - getOriginalProject: () => undefined + getProjectForNotebook: () => undefined } as any; setup(() => { diff --git a/src/kernels/deepnote/deepnoteServerHandleRegistry.node.ts b/src/kernels/deepnote/deepnoteServerHandleRegistry.node.ts new file mode 100644 index 0000000000..c97c5d8fa3 --- /dev/null +++ b/src/kernels/deepnote/deepnoteServerHandleRegistry.node.ts @@ -0,0 +1,19 @@ +import { injectable } from 'inversify'; +import { IServerHandleRegistry } from './types'; + +/** + * Tracks the Deepnote server handle registered for each notebook. + * One server handle per notebook, keyed by notebook.uri.toString(). + */ +@injectable() +export class ServerHandleRegistry implements IServerHandleRegistry { + private readonly handles = new Map(); + + public get(notebookKey: string): string | undefined { + return this.handles.get(notebookKey); + } + + public set(notebookKey: string, handle: string): void { + this.handles.set(notebookKey, handle); + } +} diff --git a/src/kernels/deepnote/deepnoteServerHandleRegistry.unit.test.ts b/src/kernels/deepnote/deepnoteServerHandleRegistry.unit.test.ts new file mode 100644 index 0000000000..70837a6b3c --- /dev/null +++ b/src/kernels/deepnote/deepnoteServerHandleRegistry.unit.test.ts @@ -0,0 +1,36 @@ +import { assert } from 'chai'; + +import { ServerHandleRegistry } from './deepnoteServerHandleRegistry.node'; + +suite('ServerHandleRegistry', () => { + let registry: ServerHandleRegistry; + + setup(() => { + registry = new ServerHandleRegistry(); + }); + + test('get returns undefined for a key that was never set', () => { + assert.strictEqual(registry.get('file:///unknown.deepnote'), undefined); + }); + + test('set then get returns the stored handle', () => { + registry.set('file:///nb.deepnote', 'handle-1'); + + assert.strictEqual(registry.get('file:///nb.deepnote'), 'handle-1'); + }); + + test('set overwrites the handle previously stored for a key', () => { + registry.set('file:///nb.deepnote', 'handle-1'); + registry.set('file:///nb.deepnote', 'handle-2'); + + assert.strictEqual(registry.get('file:///nb.deepnote'), 'handle-2'); + }); + + test('handles for different keys do not collide', () => { + registry.set('file:///a.deepnote', 'handle-a'); + registry.set('file:///b.deepnote', 'handle-b'); + + assert.strictEqual(registry.get('file:///a.deepnote'), 'handle-a'); + assert.strictEqual(registry.get('file:///b.deepnote'), 'handle-b'); + }); +}); diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index f90e3a8ec1..685a46c227 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -1,13 +1,51 @@ import { assert } from 'chai'; import * as fakeTimers from '@sinonjs/fake-timers'; +import * as sinon from 'sinon'; import { anything, instance, mock, when } from 'ts-mockito'; +import { CancellationError, CancellationToken, Uri } from 'vscode'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; import { DeepnoteServerStarter } from './deepnoteServerStarter.node'; import { IProcessServiceFactory } from '../../platform/common/process/types.node'; -import { IAsyncDisposableRegistry, IOutputChannel } from '../../platform/common/types'; -import { IDeepnoteToolkitInstaller } from './types'; +import { IAsyncDisposableRegistry, IDisposable, IOutputChannel } from '../../platform/common/types'; +import { DeepnoteServerInfo, IDeepnoteToolkitInstaller } from './types'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { + __getStartServerCalls, + __getStopServerCalls, + __resetRuntimeCoreMock +} from '../../test/mocks/deepnoteRuntimeCore'; + +type PendingOperation = + | { type: 'start'; promise: Promise } + | { type: 'stop'; promise: Promise }; + +interface ProjectContext { + environmentId: string; + serverInfo: DeepnoteServerInfo | null; +} + +/** + * Structural mirror of DeepnoteServerStarter's private surface (deepnoteServerStarter.node.ts). + * `internals` is the single typed seam for reaching private state in these tests. + */ +interface DeepnoteServerStarterInternals { + readonly disposablesByFile: Map; + readonly pendingOperations: Map; + readonly projectContexts: Map; + readonly serverOutputByFile: Map; + gatherSqlIntegrationEnvVars( + deepnoteFileUri: Uri, + environmentId: string, + token?: CancellationToken + ): Promise>; + isServerRunning(serverInfo: DeepnoteServerInfo): Promise; +} + +function internals(starter: DeepnoteServerStarter): DeepnoteServerStarterInternals { + return starter as unknown as DeepnoteServerStarterInternals; +} /** * Unit tests for DeepnoteServerStarter. @@ -25,11 +63,6 @@ suite('DeepnoteServerStarter', () => { let mockAsyncRegistry: IAsyncDisposableRegistry; let mockSqlIntegrationEnvVars: ISqlIntegrationEnvVarsProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getPrivateMethod = (obj: any, methodName: string) => { - return obj[methodName].bind(obj); - }; - setup(() => { mockProcessServiceFactory = mock(); mockToolkitInstaller = mock(); @@ -52,6 +85,7 @@ suite('DeepnoteServerStarter', () => { }); teardown(async () => { + sinon.restore(); if (serverStarter) { await serverStarter.dispose(); } @@ -68,9 +102,10 @@ suite('DeepnoteServerStarter', () => { instance(mockAsyncRegistry) ); - const gatherEnvVars = getPrivateMethod(starterWithoutSql, 'gatherSqlIntegrationEnvVars'); - const { Uri } = await import('vscode'); - const result = await gatherEnvVars(Uri.file('/test/file.deepnote'), 'env1'); + const result = await internals(starterWithoutSql).gatherSqlIntegrationEnvVars( + Uri.file('/test/file.deepnote'), + 'env1' + ); assert.deepStrictEqual(result, {}); @@ -78,8 +113,6 @@ suite('DeepnoteServerStarter', () => { }); test('should return empty object when provider rejects with cancellation error', async () => { - const { CancellationError, Uri } = await import('vscode'); - const cancelledProvider = mock(); when(cancelledProvider.getEnvironmentVariables(anything(), anything())).thenReject(new CancellationError()); @@ -92,8 +125,10 @@ suite('DeepnoteServerStarter', () => { instance(cancelledProvider) ); - const gatherEnvVars = getPrivateMethod(starterWithCancelledSql, 'gatherSqlIntegrationEnvVars'); - const result = await gatherEnvVars(Uri.file('/test/file.deepnote'), 'env1'); + const result = await internals(starterWithCancelledSql).gatherSqlIntegrationEnvVars( + Uri.file('/test/file.deepnote'), + 'env1' + ); assert.deepStrictEqual(result, {}); @@ -101,6 +136,128 @@ suite('DeepnoteServerStarter', () => { }); }); + suite('per-notebook keying (startServer/stopServer)', () => { + const interpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.file('/usr/bin/python3') + }; + 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(() => { + __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 infoA = await start(uriA); + const infoB = await start(uriB); + + // runtime-core startServer must be invoked once per notebook — NOT reused across siblings. + const calls = __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.fsPath. + const contexts = internals(serverStarter).projectContexts; + assert.strictEqual(contexts.size, 2, 'one project context per notebook URI'); + assert.isTrue(contexts.has(uriA.fsPath), 'context keyed by notebook A URI'); + assert.isTrue(contexts.has(uriB.fsPath), 'context keyed by notebook B URI'); + }); + + test('REUSES the running server when the SAME notebook URI re-requests the same environment (catches redundant respawn)', async () => { + // Stub the running-server health probe to report "running" so the reuse branch is taken. + sinon.stub(internals(serverStarter), 'isServerRunning').resolves(true); + + const first = await start(uriA); + assert.strictEqual(__getStartServerCalls().length, 1); + + const second = await start(uriA); + + assert.strictEqual( + __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 () => { + 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(__getStopServerCalls().length, 1, 'only notebook A server stopped'); + + const contexts = internals(serverStarter).projectContexts; + // A's context still exists but its server is cleared; B's server is untouched. + assert.strictEqual(contexts.get(uriA.fsPath)?.serverInfo, null, "A's server info cleared"); + assert.isNotNull(contexts.get(uriB.fsPath)?.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 () => { + // Never started anything for this URI. + await serverStarter.stopServer(Uri.file('/workspace/project/never-started.deepnote')); + + assert.strictEqual( + __getStopServerCalls().length, + 0, + 'stopping a notebook with no server must not invoke runtime-core stopServer' + ); + }); + + test('forwards the SQL integration provider env vars into the started server', async () => { + // The injected provider yields env vars for this notebook; they must reach runtime-core's start. + when(mockSqlIntegrationEnvVars.getEnvironmentVariables(anything(), anything())).thenResolve({ FOO: 'bar' }); + + await start(uriA); + + const calls = __getStartServerCalls(); + assert.strictEqual(calls.length, 1, 'the server must be started once'); + assert.strictEqual(calls[0].env?.FOO, 'bar', 'SQL integration env vars must be forwarded to startServer'); + }); + + test('does NOT leak one notebook SQL env vars into a sibling whose provider yields none', async () => { + // The provider is keyed by notebook URI: it yields env vars for A but nothing for its sibling B. + when(mockSqlIntegrationEnvVars.getEnvironmentVariables(uriA, anything())).thenResolve({ FOO: 'bar' }); + when(mockSqlIntegrationEnvVars.getEnvironmentVariables(uriB, anything())).thenResolve({}); + + await start(uriA); + await start(uriB); + + const calls = __getStartServerCalls(); + assert.strictEqual(calls.length, 2, 'each sibling notebook spawns its own server'); + + // env is gathered per start() call, so A's vars must not carry over into B's server. + assert.strictEqual(calls[0].env?.FOO, 'bar', "notebook A's server receives A's SQL env vars"); + assert.notProperty(calls[1].env ?? {}, 'FOO', "notebook B's server must NOT inherit A's SQL env vars"); + }); + }); + suite('dispose', () => { let clock: fakeTimers.InstalledClock; @@ -115,8 +272,7 @@ suite('DeepnoteServerStarter', () => { test('should clear all internal state', async () => { await serverStarter.dispose(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const starter = serverStarter as any; + const starter = internals(serverStarter); assert.strictEqual(starter.disposablesByFile.size, 0); assert.strictEqual(starter.pendingOperations.size, 0); assert.strictEqual(starter.projectContexts.size, 0); @@ -124,8 +280,7 @@ suite('DeepnoteServerStarter', () => { }); test('should wait for in-flight pending operations before completing', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const starter = serverStarter as any; + const starter = internals(serverStarter); let resolveDeferred!: () => void; const deferred = new Promise((resolve) => { diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts index 4a156d750d..a89ab3f44e 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,12 +201,6 @@ 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); - } - Cancellation.throwIfCanceled(token); // Only delete the virtual environment directory if it was created by us (managed venv) diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts index 4a0fe619ca..381e9c9d9a 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) diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts index 4d6c3ec7fa..0b5ab50948 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,27 @@ 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 reaches closed-but-running ones too). + // 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); + for (const uri of uris) { + await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(uri); + } + await this.environmentManager.deleteEnvironment(environmentId, token); logger.info(`Deleted environment: ${environmentId}`); } @@ -370,11 +384,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 +483,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..e2a9cf0edc 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,134 @@ 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') + }; + + 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 mockOpenNotebookDoc = mock(); + when(mockOpenNotebookDoc.uri).thenReturn(openNotebookUri); + when(mockOpenNotebookDoc.notebookType).thenReturn('deepnote'); + when(mockOpenNotebookDoc.isClosed).thenReturn(false); + const openNotebookDoc = instance(mockOpenNotebookDoc); + 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('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..71f69ee1ff 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -206,6 +206,23 @@ export interface IDeepnoteServerProvider { unregisterServer(handle: string): void; } +export const IServerHandleRegistry = Symbol('IServerHandleRegistry'); +export interface IServerHandleRegistry { + /** + * Get the server handle tracked for a notebook. + * @param notebookKey The notebook key (notebook.uri.toString()) + * @returns The server handle, or undefined if none is tracked + */ + get(notebookKey: string): string | undefined; + + /** + * Track the server handle for a notebook. + * @param notebookKey The notebook key (notebook.uri.toString()) + * @param handle The server provider handle + */ + set(notebookKey: string, handle: string): void; +} + export const IDeepnoteKernelAutoSelector = Symbol('IDeepnoteKernelAutoSelector'); export interface IDeepnoteKernelAutoSelector { /** 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 } }); }); diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index 96c35288cd..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; } @@ -45,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.notebookManager, this.logger); + this.explorerView = new DeepnoteExplorerView(this.extensionContext, this.logger); this.editProtection = new DeepnoteInputBlockEditProtection(this.logger); this.snapshotsEnabled = this.isSnapshotsEnabled(); @@ -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 33599da2f0..17bdb9431c 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.ts @@ -1,21 +1,34 @@ 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 { convertDeepnoteToJupyterNotebooks, convertIpynbFileToDeepnoteFile } 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 { + type DeepnoteTreeItem, + DeepnoteTreeItemType, + type DeepnoteTreeItemContext, + type ProjectGroupData, + getNonInitNotebooks, + isSingleNotebookFile, + resolveLeafNotebook +} from './deepnoteTreeItem'; import { uuidUtils } from '../../platform/common/uuid'; +import { getFilePath } from '../../platform/common/platform/fs-paths'; import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { Commands } from '../../platform/common/constants'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; +import { buildSingleNotebookFile, buildSiblingNotebookFileUri } from './deepnoteNotebookFileFactory'; +import { deepnoteFileExists } from './deepnoteSiblingFileAllocator'; +import { isSnapshotFile } from './snapshots/snapshotFiles'; /** - * Manages the Deepnote explorer tree view and related commands + * Manages the Deepnote explorer tree view and its commands. Sibling `.deepnote` files are grouped + * by `project.id`; project-scoped commands span the group, notebook-scoped ones a single leaf/child. */ + @injectable() export class DeepnoteExplorerView { private readonly treeDataProvider: DeepnoteTreeDataProvider; @@ -24,8 +37,7 @@ export class DeepnoteExplorerView { constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, - @inject(IDeepnoteNotebookManager) private readonly manager: IDeepnoteNotebookManager, - @inject(ILogger) logger: ILogger + @inject(ILogger) private readonly logger: ILogger ) { this.treeDataProvider = new DeepnoteTreeDataProvider(logger); } @@ -42,80 +54,119 @@ export class DeepnoteExplorerView { this.registerCommands(); } + /** Refreshes the full tree; exposed so outside callers (e.g. the splitter) needn't reach into the provider. */ + public refresh(): void { + this.treeDataProvider.refresh(); + } + + /** + * 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) + */ + public async collectNotebookNamesForProject(projectId: string, excludeName?: string): Promise> { + const names = new Set(); + + for (const workspaceFolder of workspace.workspaceFolders || []) { + let files: Uri[]; + + 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) { + // Skip snapshot sidecars: they are full project clones whose stale notebook names + // would otherwise pollute the uniqueness set. + if (isSnapshotFile(fileUri)) { + continue; + } + + 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; + } + /** - * 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 + * Creates a new sibling `.deepnote` file with a single new notebook, then opens it. + * Never appends to `project.notebooks`. + * @param sourceUri 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 createAndAddNotebookToProject(fileUri: Uri): Promise<{ id: string; name: string } | null> { - // Read the Deepnote project file - const projectData = await readDeepnoteProjectFile(fileUri); + 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, newNotebook.id); + 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); @@ -125,16 +176,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'; @@ -143,20 +187,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; } @@ -166,23 +197,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.targetsSingleNotebookFile(treeItem, projectData)) { + await this.deleteNotebookFile(fileUri); + this.treeDataProvider.refresh(); + await window.showInformationMessage(l10n.t('Notebook deleted: {0}', notebookName)); - await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + 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.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'; @@ -190,79 +245,63 @@ export class DeepnoteExplorerView { } } + /** + * Deletes a `.deepnote` file, honouring `files.enableTrash` like VS Code's Explorer. The OS trash + * isn't reliably available everywhere (headless CI, containers), so those environments set it false + * for a dependency-free permanent delete. + */ + private async deleteNotebookFile(fileUri: Uri): Promise { + const useTrash = workspace.getConfiguration('files').get('enableTrash', true); + await workspace.fs.delete(fileUri, { useTrash }); + } + 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.targetsSingleNotebookFile(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 behavior). + projectData.project.notebooks.push(newNotebook); + + await this.writeProjectFile(fileUri, projectData); - await this.treeDataProvider.refreshNotebook(treeItem.context.projectId); + 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 @@ -276,12 +315,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'), @@ -290,6 +329,7 @@ export class DeepnoteExplorerView { if (!value || value.trim().length === 0) { return l10n.t('Project name cannot be empty'); } + return null; } }); @@ -299,27 +339,35 @@ export class DeepnoteExplorerView { } try { - const fileUri = Uri.file(treeItem.context.filePath); - const projectData = await readDeepnoteProjectFile(fileUri); + let failedCount = 0; - if (!projectData?.project) { - await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - return; - } + for (const { filePath } of group.files) { + try { + const fileUri = Uri.file(filePath); + const projectData = await readDeepnoteProjectFile(fileUri); + + 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) { + failedCount++; + 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)); + this.treeDataProvider.refresh(); - await this.treeDataProvider.refreshProject(treeItem.context.filePath); - await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); + if (failedCount > 0) { + await window.showWarningMessage( + l10n.t('Project renamed to "{0}", but {1} file(s) could not be updated.', newName, failedCount) + ); + } else { + await window.showInformationMessage(l10n.t('Project renamed to: {0}', newName)); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await window.showErrorMessage(l10n.t('Failed to rename project: {0}', errorMessage)); @@ -368,12 +416,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) @@ -398,12 +440,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) @@ -412,16 +448,42 @@ 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 generateSuggestedNotebookName(projectData: DeepnoteFile): string { - const notebookCount = projectData.project.notebooks?.length || 0; - const existingNames = new Set(projectData.project.notebooks?.map((nb: DeepnoteNotebook) => nb.name) || []); + private isNotebookScoped(treeItem: DeepnoteTreeItem): boolean { + return treeItem.type === DeepnoteTreeItemType.ProjectFile || treeItem.type === DeepnoteTreeItemType.Notebook; + } + + /** + * Resolve the notebook a notebook-scoped command targets: `context.notebookId` for a legacy + * in-file child, otherwise the leaf file's 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 resolveLeafNotebook(projectData); + } + + /** + * Whether the tree item targets a single-notebook leaf file, as opposed to a legacy + * multi-notebook file's in-file child. + */ + private targetsSingleNotebookFile(treeItem: DeepnoteTreeItem, projectData: DeepnoteFile): boolean { + if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + return false; + } - let nextNumber = notebookCount + 1; + return isSingleNotebookFile(projectData); + } + + /** Generates a unique suggested notebook name given the names already in use. */ + private generateSuggestedNotebookName(existingNames: Set): string { + let nextNumber = existingNames.size + 1; let suggestedName = `Notebook ${nextNumber}`; + while (existingNames.has(suggestedName)) { nextNumber++; suggestedName = `Notebook ${nextNumber}`; @@ -430,6 +492,42 @@ export class DeepnoteExplorerView { return suggestedName; } + /** + * 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 @@ -447,9 +545,11 @@ 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; } }); @@ -483,34 +583,31 @@ export class DeepnoteExplorerView { } /** - * Saves the project data to file and opens the specified notebook - * @param fileUri The URI of the project file - * @param projectData The project data to save - * @param notebookId The notebook ID to open + * Serializes a project file and writes it back to disk, stamping `modifiedAt`. */ - private async saveProjectAndOpenNotebook( - fileUri: Uri, - projectData: DeepnoteFile, - notebookId: string - ): 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 new notebook - this.manager.selectNotebookForProject(projectData.project.id, 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, preview: false @@ -522,29 +619,10 @@ 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.')); - - return; - } - 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 @@ -574,8 +652,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; } @@ -585,6 +665,7 @@ export class DeepnoteExplorerView { if (!projectId || !notebookId) { await window.showWarningMessage('Cannot reveal notebook: missing metadata.'); + return; } @@ -604,7 +685,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' @@ -653,6 +734,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 @@ -701,10 +783,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, @@ -719,24 +798,24 @@ export class DeepnoteExplorerView { private async newNotebook(): Promise { const activeEditor = window.activeNotebookEditor; + if (!activeEditor || activeEditor.notebook.notebookType !== 'deepnote') { await window.showErrorMessage(l10n.t('No active Deepnote file opened. Please open a Deepnote file first.')); + return; } const document = activeEditor.notebook; - - // Get the file URI (strip query params if present) - let fileUri = document.uri; - if (fileUri.query) { - fileUri = fileUri.with({ query: '' }); - } + 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) { @@ -745,6 +824,82 @@ export class DeepnoteExplorerView { } } + private async checkJupyterImportTargetsAvailable(jupyterUris: readonly Uri[], folderUri: Uri): Promise { + // Guard against overwriting an existing file or two same-basename selections clobbering 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 { + const failedNames: string[] = []; + + for (const jupyterUri of jupyterUris) { + const { inputPath, outputFileName, outputUri, projectName } = this.deepnoteTargetForJupyterUri( + jupyterUri, + folderUri + ); + + try { + await convertIpynbFileToDeepnoteFile(inputPath, { + outputPath: getFilePath(outputUri), + projectName + }); + } catch (error) { + failedNames.push(outputFileName); + this.logger.error(`Failed to convert Jupyter notebook ${inputPath}`, error); + } + } + + if (failedNames.length > 0) { + await window.showWarningMessage( + l10n.t('Failed to import {0} notebook(s): {1}', failedNames.length, failedNames.join(', ')) + ); + } + + return failedNames.length; + } + + 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: getFilePath(jupyterUri), + outputFileName, + outputUri: Uri.joinPath(folderUri, outputFileName), + projectName + }; + } + private async importNotebook(): Promise { if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { const selection = await window.showInformationMessage( @@ -790,28 +945,15 @@ 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 } } - // 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 - } + if (!(await this.checkJupyterImportTargetsAvailable(jupyterUris, workspaceFolder.uri))) { + return; } // Import deepnote files @@ -824,27 +966,13 @@ 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; + const failedCount = await this.convertJupyterUrisToDeepnoteFiles(jupyterUris, workspaceFolder.uri); - await convertIpynbFilesToDeepnoteFile(inputFilePaths, { - outputPath: outputPath, - projectName: projectName - }); - } - - const numberOfNotebooks = jupyterUris.length + deepnoteUris.length; + const numberOfNotebooks = jupyterUris.length + deepnoteUris.length - failedCount; if (numberOfNotebooks > 1) { await window.showInformationMessage(l10n.t('{0} notebooks imported successfully.', numberOfNotebooks)); - } else { + } else if (numberOfNotebooks === 1) { await window.showInformationMessage(l10n.t('Notebook imported successfully.')); } @@ -887,37 +1015,20 @@ 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) - ); + if (!(await this.checkJupyterImportTargetsAvailable(fileUris, workspaceFolder.uri))) { return; - } catch { - // File doesn't exist, continue } - await convertIpynbFilesToDeepnoteFile(inputFilePaths, { - outputPath: outputUri.path, - projectName: projectName - }); + const failedCount = await this.convertJupyterUrisToDeepnoteFiles(fileUris, workspaceFolder.uri); - const numberOfNotebooks = fileUris.length; + const numberOfNotebooks = fileUris.length - failedCount; if (numberOfNotebooks > 1) { await window.showInformationMessage( l10n.t('{0} Jupyter notebooks imported successfully.', numberOfNotebooks) ); - } else { + } else if (numberOfNotebooks === 1) { await window.showInformationMessage(l10n.t('Jupyter notebook imported successfully.')); } @@ -929,47 +1040,26 @@ export class DeepnoteExplorerView { } } - private async deleteProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { + private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { + if (treeItem.type !== DeepnoteTreeItemType.ProjectGroup) { return; } - const project = treeItem.data as DeepnoteFile; - const projectName = project.project.name; - - const confirmation = await window.showWarningMessage( - l10n.t('Are you sure you want to delete project "{0}"?', projectName), - { modal: true }, - l10n.t('Delete') - ); - - if (confirmation !== l10n.t('Delete')) { - return; - } + const group = treeItem.data as ProjectGroupData; + const sourceFile = group.files[0]; - 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)); - } - } + if (!sourceFile) { + await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - private async addNotebookToProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { return; } try { - const fileUri = Uri.file(treeItem.context.filePath); - - // Use shared helper to create and add notebook - const result = await this.createAndAddNotebookToProject(fileUri); + 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) { @@ -978,98 +1068,9 @@ export class DeepnoteExplorerView { } } - /** - * Exports all notebooks from a Deepnote project to Jupyter format - * @param treeItem The tree item representing a project - */ - private async exportProject(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.ProjectFile) { - return; - } - - try { - const format = await window.showQuickPick([{ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }], { - placeHolder: l10n.t('Select export format') - }); - - if (!format) { - return; - } - - const fileUri = Uri.file(treeItem.context.filePath); - const projectData = await readDeepnoteProjectFile(fileUri); - - if (!projectData?.project) { - await window.showErrorMessage(l10n.t('Invalid Deepnote file format')); - - return; - } - - const outputFolder = await window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: l10n.t('Export Here'), - title: l10n.t('Select Export Location') - }); - - if (!outputFolder?.length) { - return; - } - - const jupyterNotebooks = convertDeepnoteToJupyterNotebooks(projectData); - - // Check for existing files before writing - const existingFiles: string[] = []; - for (const { filename } of jupyterNotebooks) { - const outputPath = Uri.joinPath(outputFolder[0], filename); - try { - await workspace.fs.stat(outputPath); - existingFiles.push(filename); - } catch { - // File doesn't exist, safe to write - } - } - - if (existingFiles.length > 0) { - const fileList = existingFiles.join(', '); - const overwrite = l10n.t('Overwrite'); - const result = await window.showWarningMessage( - l10n.t('The following files already exist: {0}. Do you want to overwrite them?', fileList), - { modal: true }, - overwrite - ); - - if (result !== overwrite) { - return; - } - } - - for (const { filename, notebook } of jupyterNotebooks) { - const outputPath = Uri.joinPath(outputFolder[0], filename); - - await workspace.fs.writeFile(outputPath, new TextEncoder().encode(JSON.stringify(notebook, null, 2))); - } - - const count = jupyterNotebooks.length; - const message = - count === 1 - ? l10n.t('Exported 1 notebook successfully') - : l10n.t('Exported {0} notebooks successfully', count); - - await window.showInformationMessage(message); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - await window.showErrorMessage(l10n.t('Failed to export: {0}', errorMessage)); - } - } - - /** - * Exports a single notebook from a Deepnote project to Jupyter format - * @param treeItem The tree item representing a notebook - */ + /** Exports a single notebook (single-notebook leaf or legacy in-file notebook) to Jupyter. */ private async exportNotebook(treeItem: DeepnoteTreeItem): Promise { - if (treeItem.type !== DeepnoteTreeItemType.Notebook) { + if (!this.isNotebookScoped(treeItem)) { return; } @@ -1103,7 +1104,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')); @@ -1123,6 +1124,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 5e5e2de826..f90127331b 100644 --- a/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteExplorerView.unit.test.ts @@ -1,12 +1,23 @@ 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'; +import { + FileType, + Uri, + workspace, + type FileStat, + type InputBoxOptions, + type NotebookDocument, + type NotebookEditor, + type WorkspaceConfiguration, + type WorkspaceFolder +} from 'vscode'; import { stringify as yamlStringify } from 'yaml'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; -import { DeepnoteNotebookManager } from './deepnoteNotebookManager'; +import { DeepnoteTreeDataProvider } from './deepnoteTreeDataProvider'; import { DeepnoteTreeItem, DeepnoteTreeItemType, type DeepnoteTreeItemContext } from './deepnoteTreeItem'; import type { IExtensionContext } from '../../platform/common/types'; import type { DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; @@ -39,20 +50,77 @@ function createUuidMock(uuids: string[]): sinon.SinonStub { return stub; } +/** + * Structural mirror of DeepnoteExplorerView's private surface (deepnoteExplorerView.ts). + * `internals` is the single typed seam for reaching private state/methods in these tests. + */ +interface DeepnoteExplorerViewInternals { + readonly extensionContext: IExtensionContext; + readonly treeDataProvider: DeepnoteTreeDataProvider; + addNotebookToProject(treeItem: DeepnoteTreeItem): Promise; + exportNotebook(treeItem: DeepnoteTreeItem): Promise; + importJupyterNotebook(): Promise; + importNotebook(): Promise; + newProject(): Promise; + openFile(treeItem: DeepnoteTreeItem): Promise; + openNotebook(context: DeepnoteTreeItemContext): Promise; + refreshExplorer(): void; + revealActiveNotebook(): Promise; +} + +function internals(view: DeepnoteExplorerView): DeepnoteExplorerViewInternals { + return view as unknown as DeepnoteExplorerViewInternals; +} + +function makeExtensionContext(): IExtensionContext { + return { subscriptions: [] } as unknown as IExtensionContext; +} + +function makeWorkspaceFolder(uri: Uri): WorkspaceFolder { + return { uri, name: uri.path.split('/').pop() ?? '', index: 0 }; +} + +function makeNotebookDocument(): NotebookDocument { + return { + uri: Uri.parse('file:///test/notebook.deepnote'), + notebookType: 'deepnote', + version: 1, + isDirty: false, + isUntitled: false, + isClosed: false, + cellCount: 0, + cellAt: () => { + throw new Error('Not implemented'); + }, + getCells: () => [], + save: async () => true + } as unknown as NotebookDocument; +} + +// A plain object, deliberately not a ts-mockito instance: mock proxies expose a callable `then`, +// so awaiting one (via Promise.resolve) treats it as a thenable that never settles. +function stubNotebookEditor(): NotebookEditor { + return { notebook: makeNotebookDocument() } as unknown as NotebookEditor; +} + +function stubShowQuickPick(item: { label: string; value: string }): void { + when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( + Promise.resolve(item) as never + ); +} + +const fileStat: FileStat = { type: FileType.File, ctime: 0, mtime: 0, size: 0 }; + suite('DeepnoteExplorerView', () => { let explorerView: DeepnoteExplorerView; let mockExtensionContext: IExtensionContext; - let manager: DeepnoteNotebookManager; let mockLogger: ILogger; setup(() => { - mockExtensionContext = { - subscriptions: [] - } as any; + mockExtensionContext = makeExtensionContext(); - manager = new DeepnoteNotebookManager(); mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockExtensionContext, manager, mockLogger); + explorerView = new DeepnoteExplorerView(mockExtensionContext, mockLogger); }); suite('constructor', () => { @@ -62,8 +130,8 @@ suite('DeepnoteExplorerView', () => { test('should initialize with proper dependencies', () => { // Verify that internal components are accessible - assert.isDefined((explorerView as any).extensionContext); - assert.strictEqual((explorerView as any).extensionContext, mockExtensionContext); + assert.isDefined(internals(explorerView).extensionContext); + assert.strictEqual(internals(explorerView).extensionContext, mockExtensionContext); }); }); @@ -93,7 +161,7 @@ suite('DeepnoteExplorerView', () => { // This should not throw an error - method should handle gracefully try { - await (explorerView as any).openNotebook(contextWithoutId); + await internals(explorerView).openNotebook(contextWithoutId); assert.isTrue(true, 'openNotebook handled undefined notebookId gracefully'); } catch (error) { // Expected in test environment @@ -103,7 +171,7 @@ suite('DeepnoteExplorerView', () => { test('should handle valid context', async () => { try { - await (explorerView as any).openNotebook(mockContext); + await internals(explorerView).openNotebook(mockContext); assert.isTrue(true, 'openNotebook handled valid context'); } catch (error) { // Expected in test environment without VS Code APIs @@ -116,7 +184,7 @@ suite('DeepnoteExplorerView', () => { // The actual URI creation is tested through integration, but we can verify // that the method exists and processes the context correctly try { - await (explorerView as any).openNotebook(mockContext); + await internals(explorerView).openNotebook(mockContext); assert.isTrue(true, 'openNotebook uses base file URI approach'); } catch (error) { // Expected in test environment - the method should exist and attempt to process @@ -127,13 +195,13 @@ suite('DeepnoteExplorerView', () => { suite('openFile', () => { test('should handle non-project file items', async () => { - const mockTreeItem = { - type: 'notebook', // Not ProjectFile - context: { filePath: '/test/path' } - } as any; + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.Notebook, // Not ProjectFile + context: { filePath: '/test/path', projectId: 'test-project-id' } + }; try { - await (explorerView as any).openFile(mockTreeItem); + await internals(explorerView).openFile(mockTreeItem as DeepnoteTreeItem); assert.isTrue(true, 'openFile handled non-project file gracefully'); } catch (error) { // Expected in test environment @@ -142,13 +210,13 @@ suite('DeepnoteExplorerView', () => { }); test('should handle project file items', async () => { - const mockTreeItem = { - type: 'ProjectFile', - context: { filePath: '/test/path/project.deepnote' } - } as any; + const mockTreeItem: Partial = { + type: DeepnoteTreeItemType.ProjectFile, + context: { filePath: '/test/path/project.deepnote', projectId: 'test-project-id' } + }; try { - await (explorerView as any).openFile(mockTreeItem); + await internals(explorerView).openFile(mockTreeItem as DeepnoteTreeItem); assert.isTrue(true, 'openFile handled project file'); } catch (error) { // Expected in test environment @@ -160,7 +228,7 @@ suite('DeepnoteExplorerView', () => { suite('revealActiveNotebook', () => { test('should handle missing active notebook editor', async () => { try { - await (explorerView as any).revealActiveNotebook(); + await internals(explorerView).revealActiveNotebook(); assert.isTrue(true, 'revealActiveNotebook handled missing editor gracefully'); } catch (error) { // Expected in test environment @@ -172,7 +240,7 @@ suite('DeepnoteExplorerView', () => { suite('refreshExplorer', () => { test('should call refresh method', () => { try { - (explorerView as any).refreshExplorer(); + internals(explorerView).refreshExplorer(); assert.isTrue(true, 'refreshExplorer method exists and can be called'); } catch (error) { // Expected in test environment @@ -183,20 +251,18 @@ suite('DeepnoteExplorerView', () => { suite('integration scenarios', () => { test('should handle multiple explorer view instances', () => { - const context1 = { subscriptions: [] } as any; - const context2 = { subscriptions: [] } as any; + const context1 = makeExtensionContext(); + const context2 = makeExtensionContext(); - 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); - assert.strictEqual((view2 as any).extensionContext, context2); - assert.notStrictEqual((view1 as any).extensionContext, (view2 as any).extensionContext); + assert.strictEqual(internals(view1).extensionContext, context1); + assert.strictEqual(internals(view2).extensionContext, context2); + assert.notStrictEqual(internals(view1).extensionContext, internals(view2).extensionContext); // Verify views are independent instances assert.notStrictEqual(view1, view2); @@ -204,14 +270,13 @@ suite('DeepnoteExplorerView', () => { test('should maintain component references', () => { // Verify that internal components exist - assert.isDefined((explorerView as any).extensionContext); + assert.isDefined(internals(explorerView).extensionContext); // After construction, some components should be initialized - const hasTreeDataProvider = (explorerView as any).treeDataProvider !== undefined; - const hasSerializer = (explorerView as any).serializer !== undefined; + const hasTreeDataProvider = internals(explorerView).treeDataProvider !== undefined; // At least one component should be defined after construction - assert.isTrue(hasTreeDataProvider || hasSerializer, 'Components are being initialized'); + assert.isTrue(hasTreeDataProvider, 'Components are being initialized'); }); }); }); @@ -219,7 +284,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[] = []; @@ -228,13 +292,10 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { resetVSCodeMocks(); uuidStubs = []; - mockContext = { - subscriptions: [] - } as unknown as IExtensionContext; + mockContext = makeExtensionContext(); - mockManager = new DeepnoteNotebookManager(); const mockLogger = createMockLogger(); - explorerView = new DeepnoteExplorerView(mockContext, mockManager, mockLogger); + explorerView = new DeepnoteExplorerView(mockContext, mockLogger); }); teardown(() => { @@ -248,14 +309,14 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { test('should create a new project with valid input', async () => { const projectName = 'My Test Project'; const sanitizedFileName = 'my-test-project.deepnote'; - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const projectId = 'test-project-id'; const notebookId = 'test-notebook-id'; const blockId = 'test-block-id'; const blockGroupId = 'test-blockgroup-id'; // Mock workspace - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); // Mock user input when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(projectName)); @@ -271,12 +332,11 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); // Mock notebook opening - const mockNotebook = { notebookType: 'deepnote' }; when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve(mockNotebook as any) + Promise.resolve(makeNotebookDocument()) ); when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) + Promise.resolve(stubNotebookEditor()) ); // Execute command - capture writeFile call @@ -288,7 +348,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(); }); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); // Verify file was written expect(capturedUri).to.exist; @@ -297,7 +357,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Verify YAML content const yamlContent = Buffer.from(capturedContent!).toString('utf8'); - const projectData = deserializeDeepnoteFile(yamlContent) as any; + const projectData = deserializeDeepnoteFile(yamlContent); expect(projectData.version).to.equal('1.0.0'); expect(projectData.metadata.createdAt).to.exist; @@ -313,9 +373,9 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { test('should sanitize project name for filename', async () => { const projectName = 'My Project!@# 123'; const expectedFileName = 'my-project----123.deepnote'; // Each special char becomes a dash - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(projectName)); const uuidStub = createUuidMock(['test-id', 'test-id', 'test-id', 'test-id']); @@ -331,13 +391,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve({} as any) + Promise.resolve(makeNotebookDocument()) ); when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) + Promise.resolve(stubNotebookEditor()) ); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(capturedUri).to.exist; expect(capturedUri!.path).to.include(expectedFileName); @@ -361,23 +421,23 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(); }); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(showInfoCalled).to.be.true; expect(executeCommandCalled).to.be.true; }); test('should validate empty project name', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - let validationFunction: any; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + let validationFunction: InputBoxOptions['validateInput']; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: InputBoxOptions) => { validationFunction = options?.validateInput; return Promise.resolve(undefined); }); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(validationFunction).to.exist; const result = validationFunction!(''); @@ -386,13 +446,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { test('should show error if file already exists', async () => { const projectName = 'Existing Project'; - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(projectName)); const mockFS = mock(); - when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); // File exists + when(mockFS.stat(anything())).thenReturn(Promise.resolve(fileStat)); // File exists when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); let errorShown = false; @@ -401,16 +461,16 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(errorShown).to.be.true; }); test('should handle file write errors', async () => { const projectName = 'Test Project'; - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(projectName)); const uuidStub = createUuidMock(['test-id', 'test-id', 'test-id', 'test-id']); @@ -427,15 +487,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(errorMessage).to.exist; expect(errorMessage).to.include('Permission denied'); }); test('should return early if user cancels input', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); const mockFS = mock(); @@ -446,19 +506,34 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - await (explorerView as any).newProject(); + await internals(explorerView).newProject(); expect(writeFileCalled).to.be.false; }); }); suite('importNotebook', () => { + // Stub only `convertIpynbFileToDeepnoteFile` (real node:fs I/O); all other @deepnote/convert + // exports stay real, so the import flow runs without reimplementing the package. + let importModule: typeof import('./deepnoteExplorerView'); + + setup(async () => { + importModule = await esmock('./deepnoteExplorerView', { + '@deepnote/convert': { convertIpynbFileToDeepnoteFile: async () => {} } + }); + explorerView = new importModule.DeepnoteExplorerView(mockContext, createMockLogger()); + }); + + teardown(() => { + esmock.purge(importModule); + }); + test('should import deepnote files', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/test.deepnote'); const fileContent = Buffer.from('test content'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -475,17 +550,17 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { Promise.resolve(undefined) ); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); expect(capturedUri).to.exist; expect(capturedUri!.path).to.include('test.deepnote'); }); test('should import and convert jupyter files', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/my-notebook.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -498,18 +573,18 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); // Verify success message was shown (indicating convert was called successfully) expect(infoMessageShown).to.be.true; }); test('should import multiple files', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const deepnoteUri = Uri.file('/external/test1.deepnote'); const jupyterUri = Uri.file('/external/test2.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( Promise.resolve([deepnoteUri, jupyterUri]) ); @@ -526,21 +601,21 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); expect(capturedMessage).to.exist; expect(capturedMessage).to.include('2'); }); test('should show error if file already exists', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/existing.deepnote'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); - when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); // File exists + when(mockFS.stat(anything())).thenReturn(Promise.resolve(fileStat)); // File exists let writeFileCalled = false; when(mockFS.writeFile(anything(), anything())).thenCall(() => { @@ -555,17 +630,17 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); expect(errorShown).to.be.true; expect(writeFileCalled).to.be.false; }); test('should handle import errors', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/test.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -575,13 +650,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Test is simplified - the mock convert function succeeds by default // To properly test error handling, we would need to modify the mock in vscode-mock.ts // For now, we'll just verify the method completes without throwing - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); }); test('should return early if user cancels dialog', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); const mockFS = mock(); @@ -592,7 +667,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); expect(writeFileCalled).to.be.false; }); @@ -615,7 +690,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(); }); - await (explorerView as any).importNotebook(); + await internals(explorerView).importNotebook(); expect(showInfoCalled).to.be.true; expect(executeCommandCalled).to.be.true; @@ -623,11 +698,26 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); suite('importJupyterNotebook', () => { + // 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': { convertIpynbFileToDeepnoteFile: 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 workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/my-analysis.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -640,17 +730,17 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); // Verify success message was shown (indicating convert was called successfully) expect(infoMessageShown).to.be.true; }); test('should import multiple jupyter notebooks', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUris = [Uri.file('/external/notebook1.ipynb'), Uri.file('/external/notebook2.ipynb')]; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(sourceUris)); const mockFS = mock(); @@ -663,21 +753,115 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); expect(capturedMessage).to.exist; expect(capturedMessage).to.include('2'); }); + test('reports only the succeeded count when some conversions fail', async () => { + const failingModule = await esmock('./deepnoteExplorerView', { + '@deepnote/convert': { + convertIpynbFileToDeepnoteFile: async (inputPath: string) => { + if (inputPath.includes('fail')) { + throw new Error('conversion failed'); + } + } + } + }); + const partialExplorer = new failingModule.DeepnoteExplorerView(mockContext, createMockLogger()); + + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); + const sourceUris = [ + Uri.file('/external/ok.ipynb'), + Uri.file('/external/fail1.ipynb'), + Uri.file('/external/fail2.ipynb') + ]; + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(sourceUris)); + + const mockFS = mock(); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + let infoMessage: string | undefined; + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenCall((msg: string) => { + infoMessage = msg; + return Promise.resolve(undefined); + }); + + let warningMessage: string | undefined; + when(mockedVSCodeNamespaces.window.showWarningMessage(anything())).thenCall((msg: string) => { + warningMessage = msg; + return Promise.resolve(undefined); + }); + + try { + await internals(partialExplorer).importJupyterNotebook(); + + // One of three succeeded: singular success message, never the full selection count. + expect(infoMessage).to.exist; + expect(infoMessage).to.equal('Jupyter notebook imported successfully.'); + expect(infoMessage).to.not.include('3'); + expect(warningMessage).to.exist; + expect(warningMessage).to.include('2'); + } finally { + esmock.purge(failingModule); + } + }); + + test('shows no success message when every conversion fails', async () => { + const failingModule = await esmock('./deepnoteExplorerView', { + '@deepnote/convert': { + convertIpynbFileToDeepnoteFile: async () => { + throw new Error('conversion failed'); + } + } + }); + const failedExplorer = new failingModule.DeepnoteExplorerView(mockContext, createMockLogger()); + + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); + const sourceUris = [Uri.file('/external/fail1.ipynb'), Uri.file('/external/fail2.ipynb')]; + + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(sourceUris)); + + const mockFS = mock(); + when(mockFS.stat(anything())).thenReject(new Error('File not found')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + + let infoMessageShown = false; + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenCall(() => { + infoMessageShown = true; + return Promise.resolve(undefined); + }); + + let warningShown = false; + when(mockedVSCodeNamespaces.window.showWarningMessage(anything())).thenCall(() => { + warningShown = true; + return Promise.resolve(undefined); + }); + + try { + await internals(failedExplorer).importJupyterNotebook(); + + expect(infoMessageShown).to.be.false; + expect(warningShown).to.be.true; + } finally { + esmock.purge(failingModule); + } + }); + test('should show error if output file already exists', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/existing.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); - when(mockFS.stat(anything())).thenReturn(Promise.resolve({} as any)); // File exists + when(mockFS.stat(anything())).thenReturn(Promise.resolve(fileStat)); // File exists when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); let errorShown = false; @@ -686,16 +870,16 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); expect(errorShown).to.be.true; }); test('should handle conversion errors', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/test.ipynb'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -705,13 +889,13 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { // Test is simplified - the mock convert function succeeds by default // To properly test error handling, we would need to modify the mock in vscode-mock.ts // For now, we'll just verify the method completes without throwing - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); }); test('should return early if user cancels dialog', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); let infoMessageShown = false; @@ -720,7 +904,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); // Verify no success message was shown (indicating convert was not called) expect(infoMessageShown).to.be.false; @@ -744,17 +928,17 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); expect(showInfoCalled).to.be.true; expect(executeCommandCalled).to.be.true; }); test('should remove .ipynb extension case-insensitively', async () => { - const workspaceFolder = { uri: Uri.file('/workspace') }; + const workspaceFolder = makeWorkspaceFolder(Uri.file('/workspace')); const sourceUri = Uri.file('/external/notebook.IPYNB'); - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder as any]); + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve([sourceUri])); const mockFS = mock(); @@ -767,15 +951,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { return Promise.resolve(undefined); }); - await (explorerView as any).importJupyterNotebook(); + await internals(explorerView).importJupyterNotebook(); // Verify success message was shown (indicating convert was called successfully) expect(infoMessageShown).to.be.true; }); }); - 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'; @@ -784,7 +968,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: { @@ -810,9 +994,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(); }); @@ -821,39 +1009,40 @@ 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); // Mock notebook opening - const mockNotebook = { notebookType: 'deepnote' }; when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve(mockNotebook as any) + Promise.resolve(makeNotebookDocument()) ); when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) + Promise.resolve(stubNotebookEditor()) ); // 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; + const updatedProjectData = deserializeDeepnoteFile(updatedYamlContent); - 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 () => { @@ -886,7 +1075,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; @@ -897,7 +1086,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: { @@ -907,10 +1096,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' }] } }; @@ -920,10 +1106,11 @@ 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; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: any) => { + let capturedInputBoxOptions: InputBoxOptions | undefined; + when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options: InputBoxOptions) => { capturedInputBoxOptions = options; return Promise.resolve('Test Notebook'); }); @@ -932,18 +1119,18 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { uuidStubs.push(uuidStub); when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve({} as any) + Promise.resolve(makeNotebookDocument()) ); when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) + Promise.resolve(stubNotebookEditor()) ); - // 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; - expect(capturedInputBoxOptions.value).to.equal('Notebook 3'); + expect(capturedInputBoxOptions!.value).to.equal('Notebook 3'); }); }); @@ -1045,9 +1232,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' @@ -1230,11 +1417,12 @@ 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 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' @@ -1260,8 +1448,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)); @@ -1289,9 +1493,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(); }); }); @@ -1356,12 +1560,11 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { uuidStubs.push(uuidStub); // Mock notebook opening - const mockNotebook = { notebookType: 'deepnote' }; when(mockedVSCodeNamespaces.workspace.openNotebookDocument(anything())).thenReturn( - Promise.resolve(mockNotebook as any) + Promise.resolve(makeNotebookDocument()) ); when(mockedVSCodeNamespaces.window.showNotebookDocument(anything(), anything())).thenReturn( - Promise.resolve(undefined as any) + Promise.resolve(stubNotebookEditor()) ); when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( Promise.resolve(undefined) @@ -1422,9 +1625,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' @@ -1612,8 +1815,8 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 'custom' in duplicateBlock.metadata ) { assert.notStrictEqual( - (originalBlock.metadata as any).custom, - (duplicateBlock.metadata as any).custom, + (originalBlock.metadata as Record).custom, + (duplicateBlock.metadata as Record).custom, 'Nested metadata objects should be different instances' ); } @@ -1678,14 +1881,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, cacheKey: fileUri.toString(), project: existingProjectData }] + } }; // Execute the method @@ -1712,7 +1919,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: { @@ -1764,14 +1971,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, cacheKey: fileUri.toString(), project: existingProjectData }] + } }; // Execute the method @@ -1790,7 +2001,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { }); }); - suite('exportProject', () => { + suite('exportNotebook', () => { test('should return early if user cancels format selection', async () => { resetVSCodeMocks(); @@ -1803,14 +2014,15 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { ); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); // Verify no file operations occurred verify(mockFS.writeFile(anything(), anything())).never(); @@ -1840,20 +2052,19 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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 - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); // Verify no file was written verify(mockFS.writeFile(anything(), anything())).never(); @@ -1872,28 +2083,28 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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 - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); // Verify error message was shown verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); }); - test('should export all notebooks when triggered from project', async () => { + test('should export single notebook matching the notebookId', async () => { resetVSCodeMocks(); + const targetNotebookId = 'nb-2'; const projectData: DeepnoteFile = { version: '1.0.0', metadata: { @@ -1905,7 +2116,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { name: 'Test Project', notebooks: [ { id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }, - { id: 'nb-2', name: 'Notebook 2', blocks: [], executionMode: 'block' } + { id: targetNotebookId, name: 'Notebook 2', blocks: [], executionMode: 'block' } ] } }; @@ -1917,9 +2128,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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 - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( Promise.resolve([Uri.file('/output/folder')]) ); @@ -1928,27 +2137,34 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { ); let writeCount = 0; - when(mockFS.writeFile(anything(), anything())).thenCall(() => { + const writtenFiles: { uri: Uri; content: Uint8Array }[] = []; + when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { writeCount++; + writtenFiles.push({ uri, content }); return Promise.resolve(); }); + // Notebook tree item with specific notebookId const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: targetNotebookId } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); - // Verify both notebooks were exported - assert.strictEqual(writeCount, 2); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + // Verify only one notebook was exported + assert.strictEqual(writeCount, 1); + + // Verify the exported notebook has correct metadata + const exportedContent = JSON.parse(Buffer.from(writtenFiles[0].content).toString('utf8')); + assert.strictEqual(exportedContent.metadata.deepnote_notebook_id, targetNotebookId); }); - test('should write correct Jupyter notebook JSON format', async () => { + test('should show error if notebook not found', async () => { resetVSCodeMocks(); const projectData: DeepnoteFile = { @@ -1960,23 +2176,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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' - } - ] + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] } }; @@ -1984,46 +2184,33 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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 - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); 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(); - }); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + // Notebook tree item with non-existent notebookId const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'non-existent-nb' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); - // 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'); + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); }); - test('should use correct output path with Uri.joinPath', async () => { + test('should handle export errors gracefully', async () => { resetVSCodeMocks(); const projectData: DeepnoteFile = { @@ -2035,7 +2222,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { project: { id: 'project-id', name: 'Test Project', - notebooks: [{ id: 'nb-1', name: 'My Notebook', blocks: [], executionMode: 'block' }] + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] } }; @@ -2046,39 +2233,31 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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 - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( Promise.resolve([Uri.file('/output/folder')]) ); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( - Promise.resolve(undefined) - ); + when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); - let capturedUri: Uri | undefined; - when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri) => { - capturedUri = uri; - return Promise.resolve(); - }); + // Simulate write error + when(mockFS.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); - // Verify the output path is correctly constructed - assert.isDefined(capturedUri); - assert.isTrue(capturedUri!.fsPath.startsWith('/output/folder')); - assert.isTrue(capturedUri!.fsPath.endsWith('.ipynb')); + // Verify error message was shown + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); }); - test('should handle export errors gracefully', async () => { + test('should prompt for overwrite when file already exists and cancel if declined', async () => { resetVSCodeMocks(); const projectData: DeepnoteFile = { @@ -2098,35 +2277,37 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { when(mockFS.readFile(anything())).thenReturn( Promise.resolve(Buffer.from(serializeDeepnoteFile(projectData))) ); - when(mockFS.stat(anything())).thenReject(new Error('File not found')); + // File exists - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve(fileStat)); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); 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')); + // User cancels overwrite + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve(undefined) + ); const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); - // Verify error message was shown - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + // Verify warning message was shown about file existing + verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); + // Verify no file was written + verify(mockFS.writeFile(anything(), anything())).never(); }); - test('should prompt for overwrite when files already exist and cancel if declined', async () => { + test('should overwrite file when user confirms', async () => { resetVSCodeMocks(); const projectData: DeepnoteFile = { @@ -2138,10 +2319,7 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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' } - ] + notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] } }; @@ -2149,471 +2327,294 @@ suite('DeepnoteExplorerView - Empty State Commands', () => { 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)); + // File exists - stat returns successfully + when(mockFS.stat(anything())).thenReturn(Promise.resolve(fileStat)); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any - ); + stubShowQuickPick({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }); when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( Promise.resolve([Uri.file('/output/folder')]) ); - // User cancels overwrite + // User confirms overwrite when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Overwrite') + ); + when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( Promise.resolve(undefined) ); + let writeCount = 0; + when(mockFS.writeFile(anything(), anything())).thenCall(() => { + writeCount++; + return Promise.resolve(); + }); + const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, + type: DeepnoteTreeItemType.Notebook, context: { filePath: '/test/project.deepnote', - projectId: 'project-id' + projectId: 'project-id', + notebookId: 'nb-1' } }; - await (explorerView as any).exportProject(treeItem); + await internals(explorerView).exportNotebook(treeItem as DeepnoteTreeItem); - // 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(); + // Verify file was written after user confirmed overwrite + assert.strictEqual(writeCount, 1); }); + }); +}); - 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(); - }); +// Project-group operations span every sibling file: new/duplicate notebooks become NEW sibling +// files (never appended), single-notebook deletes remove the FILE, name uniqueness spans the group. +suite('DeepnoteExplorerView - Sibling-file command semantics', () => { + let explorerView: DeepnoteExplorerView; + let mockContext: IExtensionContext; + let sandbox: sinon.SinonSandbox; + let uuidStubs: sinon.SinonStub[] = []; - const treeItem: Partial = { - type: DeepnoteTreeItemType.ProjectFile, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id' - } - }; + setup(() => { + sandbox = sinon.createSandbox(); + resetVSCodeMocks(); + uuidStubs = []; - await (explorerView as any).exportProject(treeItem); + mockContext = makeExtensionContext(); + explorerView = new DeepnoteExplorerView(mockContext, createMockLogger()); + }); - // Verify file was written after user confirmed overwrite - assert.strictEqual(writeCount, 1); - }); + teardown(() => { + sandbox.restore(); + uuidStubs.forEach((stub) => stub.restore()); + uuidStubs = []; + resetVSCodeMocks(); }); - suite('exportNotebook', () => { - test('should return early if user cancels format selection', async () => { - 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('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)); - // User cancels format selection - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve(undefined) - ); - const treeItem: Partial = { type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } + context: { filePath: '/workspace/x.deepnote', projectId: 'p' } }; - await (explorerView as any).exportNotebook(treeItem); + await internals(explorerView).addNotebookToProject(treeItem as DeepnoteTreeItem); - // 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(); + suite('deleteNotebook — single-notebook file vs legacy', () => { + function stubFilesEnableTrash(enabled: boolean): void { + const filesConfig = mock(); + when(filesConfig.get('enableTrash', anything())).thenReturn(enabled); + when(mockedVSCodeNamespaces.workspace.getConfiguration('files')).thenReturn(instance(filesConfig)); + } - 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' }] - } - }; + test('moves a single-notebook file to the OS trash when files.enableTrash is enabled', 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))) ); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - // User selects format but cancels folder selection - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any - ); - when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn(Promise.resolve(undefined)); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } - }; - - await (explorerView as any).exportNotebook(treeItem); - - // Verify no file was written - verify(mockFS.writeFile(anything(), anything())).never(); - }); - - test('should show error for invalid Deepnote file format', async () => { - resetVSCodeMocks(); - - // Invalid project data (no project property) - const invalidData = { - version: '1.0.0', - metadata: { createdAt: '2024-01-01T00:00:00.000Z' } - }; - const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(yamlStringify(invalidData)))); + let deletedUri: Uri | undefined; + let deletedWithTrash: boolean | undefined; + when(mockFS.delete(anything(), anything())).thenCall((uri: Uri, options?: { useTrash?: boolean }) => { + deletedUri = uri; + deletedWithTrash = options?.useTrash; + return Promise.resolve(); + }); + when(mockFS.writeFile(anything(), anything())).thenReturn(Promise.resolve()); 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.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') ); - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); + stubFilesEnableTrash(true); + // ProjectFile node with no notebookId => single-notebook leaf => delete the file. const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } + type: DeepnoteTreeItemType.ProjectFile, + context: { filePath, projectId }, + data: projectData }; - await (explorerView as any).exportNotebook(treeItem); + await explorerView.deleteNotebook(treeItem as DeepnoteTreeItem); - // Verify error message was shown - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + 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'); + assert.strictEqual(deletedWithTrash, true, 'delete must use the OS trash when files.enableTrash is on'); + // It must NOT rewrite the file's notebooks array on a single-notebook delete. + verify(mockFS.writeFile(anything(), anything())).never(); }); - test('should export single notebook matching the notebookId', async () => { - resetVSCodeMocks(); - - const targetNotebookId = 'nb-2'; - 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: targetNotebookId, name: 'Notebook 2', blocks: [], executionMode: 'block' } - ] - } - }; + test('deletes a single-notebook file permanently when files.enableTrash is disabled', 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))) ); - when(mockFS.stat(anything())).thenReject(new Error('File not found')); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any - ); - when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( - Promise.resolve([Uri.file('/output/folder')]) - ); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenReturn( - Promise.resolve(undefined) - ); - - let writeCount = 0; - const writtenFiles: { uri: Uri; content: Uint8Array }[] = []; - when(mockFS.writeFile(anything(), anything())).thenCall((uri: Uri, content: Uint8Array) => { - writeCount++; - writtenFiles.push({ uri, content }); + let deletedUri: Uri | undefined; + let deletedWithTrash: boolean | undefined; + when(mockFS.delete(anything(), anything())).thenCall((uri: Uri, options?: { useTrash?: boolean }) => { + deletedUri = uri; + deletedWithTrash = options?.useTrash; return Promise.resolve(); }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); + when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( + Promise.resolve('Delete') + ); + // No trash-capable filesystem (the E2E suite and any such environment set this off). + stubFilesEnableTrash(false); - // Notebook tree item with specific notebookId const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: targetNotebookId - } + type: DeepnoteTreeItemType.ProjectFile, + context: { filePath, projectId }, + data: projectData }; - await (explorerView as any).exportNotebook(treeItem); + await explorerView.deleteNotebook(treeItem as DeepnoteTreeItem); - // Verify only one notebook was exported - assert.strictEqual(writeCount, 1); - - // Verify the exported notebook has correct metadata - const exportedContent = JSON.parse(Buffer.from(writtenFiles[0].content).toString('utf8')); - assert.strictEqual(exportedContent.metadata.deepnote_notebook_id, targetNotebookId); + assert.strictEqual(deletedUri!.fsPath, fileUri.fsPath, 'the target file must be deleted'); + assert.strictEqual(deletedWithTrash, false, 'delete must be permanent when files.enableTrash is off'); + // The user's confirmed delete still succeeds: a success toast, NOT a "Failed to delete" error. + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); }); + }); - test('should show error if notebook not found', async () => { - resetVSCodeMocks(); + 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 projectData: DeepnoteFile = { + 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' - }, + metadata: { createdAt: '2024-01-01T00:00:00.000Z', modifiedAt: '2024-01-01T00:00:00.000Z' }, project: { - id: 'project-id', + id: projectId, name: 'Test Project', - notebooks: [{ id: 'nb-1', name: 'Notebook 1', blocks: [], executionMode: 'block' }] + 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'); - const mockFS = mock(); - when(mockFS.readFile(anything())).thenReturn( - Promise.resolve(Buffer.from(serializeDeepnoteFile(projectData))) - ); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFS)); - - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenReturn( - Promise.resolve({ label: 'Jupyter Notebook (.ipynb)', value: 'jupyter' }) as any - ); - when(mockedVSCodeNamespaces.window.showOpenDialog(anything())).thenReturn( - Promise.resolve([Uri.file('/output/folder')]) + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + makeWorkspaceFolder(Uri.file('/workspace')) + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn( + Promise.resolve([Uri.file(pathA), Uri.file(pathB), Uri.file(pathOther)]) ); - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenReturn(Promise.resolve(undefined)); - // Notebook tree item with non-existent notebookId - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'non-existent-nb' - } + // 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 }; - - await (explorerView as any).exportNotebook(treeItem); - - // Verify error message was shown - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - // Verify no file was written - verify(mockFS.writeFile(anything(), anything())).never(); - }); - - test('should handle export errors gracefully', async () => { - resetVSCodeMocks(); - - const projectData: 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.readFile(anything())).thenCall((uri: Uri) => + Promise.resolve(Buffer.from(serializeDeepnoteFile(byPath[uri.fsPath]))) ); - 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)); + const names: Set = await explorerView.collectNotebookNamesForProject(projectId); - // Simulate write error - when(mockFS.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); - - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } - }; - - await (explorerView as any).exportNotebook(treeItem); - - // Verify error message was shown - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); + // 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('should prompt for overwrite when file already exists and cancel if declined', async () => { - resetVSCodeMocks(); + 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'); - 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' }] - } - }; + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + makeWorkspaceFolder(Uri.file('/workspace')) + ]); + when(mockedVSCodeNamespaces.workspace.findFiles(anything())).thenReturn(Promise.resolve([Uri.file(pathA)])); 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(mockFS.readFile(anything())).thenReturn(Promise.resolve(Buffer.from(serializeDeepnoteFile(fileA)))); 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 names: Set = await explorerView.collectNotebookNamesForProject(projectId, 'Alpha'); - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } - }; - - await (explorerView as any).exportNotebook(treeItem); - - // Verify warning message was shown about file existing - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); - // Verify no file was written - verify(mockFS.writeFile(anything(), anything())).never(); + assert.isFalse(names.has('Alpha'), 'the excluded current name must not be in the set'); + assert.strictEqual(names.size, 0); }); - test('should overwrite file when user confirms', async () => { - resetVSCodeMocks(); + 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'); - 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' }] - } - }; + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([ + makeWorkspaceFolder(Uri.file('/workspace')) + ]); + 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())).thenReturn( - Promise.resolve(Buffer.from(serializeDeepnoteFile(projectData))) + when(mockFS.readFile(anything())).thenCall((uri: Uri) => + Promise.resolve(Buffer.from(serializeDeepnoteFile(byPath[uri.fsPath]))) ); - // 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 names: Set = await explorerView.collectNotebookNamesForProject(projectId); - const treeItem: Partial = { - type: DeepnoteTreeItemType.Notebook, - context: { - filePath: '/test/project.deepnote', - projectId: 'project-id', - notebookId: 'nb-1' - } - }; - - await (explorerView as any).exportNotebook(treeItem); - - // Verify file was written after user confirmed overwrite - assert.strictEqual(writeCount, 1); + 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/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts index 090c84d815..fb1b66f12c 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -102,25 +102,7 @@ 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))); } } @@ -360,13 +342,17 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic return; } - const snapshotOutputs = await this.snapshotService.readSnapshot(projectId); + 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.getOriginalProject(projectId); + // Exact (projectId, notebookId) lookup: sibling files share a project.id, so a project-only + // lookup could return a different sibling and silently skip the block-id recovery below. + const originalProject = notebookId + ? this.notebookManager.getProjectForNotebook(projectId, notebookId) + : undefined; const notebookBlocksMap = new Map(); if (originalProject) { for (const nb of originalProject.project.notebooks) { @@ -375,7 +361,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 @@ -595,6 +580,28 @@ export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationServic ); } + /** + * 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); + 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 0ab2d21fed..88b4fee5bf 100644 --- a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -113,7 +113,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 @@ -136,7 +136,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 @@ -335,7 +335,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 @@ -407,7 +407,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 @@ -425,7 +425,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 @@ -489,13 +489,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 @@ -505,7 +505,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 @@ -638,7 +638,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); }); @@ -686,7 +686,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: [ @@ -715,7 +717,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([]); @@ -732,7 +736,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: [] }] @@ -755,7 +761,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); @@ -766,9 +774,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: [ @@ -793,7 +803,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: [ @@ -817,7 +829,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: [ @@ -863,7 +877,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'), @@ -888,7 +904,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, @@ -932,7 +950,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: [ @@ -960,7 +980,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: [ @@ -984,7 +1006,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'), @@ -1017,13 +1041,14 @@ 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.getOriginalProject('project-1')).thenReturn({ + when(mockedManager.getProjectForNotebook('e132b172-b114-410e-8331-011517db664f', 'notebook-1')).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: [ { @@ -1059,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! @@ -1089,7 +1119,7 @@ project: ] ] ]); - when(mockSnapshotService.readSnapshot(anything())).thenReturn(Promise.resolve(newOutputs)); + when(mockSnapshotService.readSnapshot(anything(), anything())).thenReturn(Promise.resolve(newOutputs)); fallbackOnDidChange.fire(snapshotUri); @@ -1106,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 = { @@ -1154,7 +1186,7 @@ project: ] ] ]); - when(mockSnapshotService.readSnapshot(anything())).thenCall(() => { + when(mockSnapshotService.readSnapshot(anything(), anything())).thenCall(() => { readSnapshotCallCount++; return Promise.resolve(multiOutputs); }); @@ -1226,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: [ @@ -1259,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: [ @@ -1315,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/deepnoteInitNotebookRunner.node.ts b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.ts index a201cc93ea..9a7befbe4e 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,163 @@ 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. + * Runs a project's init notebook (from its sibling `.deepnote` file, referenced by + * `project.initNotebookId`) in the kernel on start, and again on restart. Tracked per kernel — not + * per project/URI — so a same-environment restart re-initializes correctly. */ @injectable() -export class DeepnoteInitNotebookRunner { +export class DeepnoteInitNotebookRunner implements IDeepnoteInitNotebookRunner, IExtensionSyncActivationService { + // Kernels that have already run init in their current lifetime; entries are collected on dispose. + private readonly initRunByKernel = new WeakSet(); + // In-flight init run per kernel, so a restart can cancel a still-running start-triggered run. + private readonly inFlightInitByKernel = new WeakMap(); + 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 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) || this.inFlightInitByKernel.has(kernel)) { + return; + } + + await this.runInitForKernel(kernel); + + // Mark even when no init was found — only affects THIS kernel; a new kernel re-scans. + this.initRunByKernel.add(kernel); + } + + private async onDidRestartKernel(kernel: IKernel): Promise { + // A restart loses all in-kernel state, so re-run init unconditionally. + this.inFlightInitByKernel.get(kernel)?.cancel(); + 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; - } + private async runInitForKernel(kernel: IKernel): Promise { + const notebook = kernel.notebook; - // 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; - } - - 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.getOriginalProject(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 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}`); - // 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) { 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} in kernel for ${getDisplayPath(notebook.uri)}` + ); - logger.info(`Running init notebook "${initNotebook.name}" (${initNotebookId}) for project ${projectId}`); + // Cancel if the notebook is closed mid-init. Per-run state — do NOT register in `disposables`. + const cts = new CancellationTokenSource(); + this.inFlightInitByKernel.set(kernel, cts); + 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(); + } + }); - // Execute the init notebook with progress - const success = await this.executeInitNotebook(notebook, initNotebook, token); + try { + const success = await this.executeInitNotebook(notebook, initNotebook, cts.token); - 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`); + 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(); + // Only clear if a superseding restart hasn't already replaced our CTS. + if (this.inFlightInitByKernel.get(kernel) === cts) { + this.inFlightInitByKernel.delete(kernel); + } + cts.dispose(); } } 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); + } + } + + /** + * Scans sibling `.deepnote` files (skipping snapshots) and returns the single notebook of the + * first valid init source (same `project.id`, one notebook matching `initNotebookId`), or + * undefined if none. + */ + 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) { + // One 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 +294,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 +320,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 +346,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 +386,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 +401,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..8e0896d384 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteInitNotebookRunner.node.unit.test.ts @@ -0,0 +1,408 @@ +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, INotebookKernelExecution } from '../../kernels/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import type { DeepnoteNotebook, 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; + +// A kernel is WeakSet-marked only after runInitForKernel returns, past the runner's ~1000ms +// display delay (INIT_COMPLETE_DISPLAY_DELAY_MS); a second start for the same kernel must wait past it. +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 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 makeNotebookFile(projectId: string, notebookId: string, codeContents: string[]): DeepnoteFile { + const codeBlocks = codeContents.map((content, index): DeepnoteNotebook['blocks'][number] => ({ + id: `${notebookId}-code-${index}`, + type: 'code', + sortingKey: `a${index}`, + blockGroup: 'g', + content, + metadata: {} + })); + + 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: [ + ...codeBlocks, + { + id: `${notebookId}-md`, + type: 'markdown', + sortingKey: `a${codeContents.length}`, + blockGroup: 'g', + content: '# notes', + metadata: {} + } + ] + } + ] + } + }; +} + +/** The cached project entry the manager returns for `getProjectForNotebook` (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, + metadata: {} + } + ] + } + ] + } + }; +} + +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, deepnoteNotebookId: MAIN_NOTEBOOK_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(instance(mock())); + + executeHiddenSpy = sinon.stub().callsFake(() => Promise.resolve([])); + when(mockKernelProvider.getKernelExecution(anything())).thenReturn({ + executeHidden: executeHiddenSpy + } as unknown as INotebookKernelExecution); + + // Default cached project: has an init notebook configured. + when(mockNotebookManager.getProjectForNotebook(PROJECT_ID, MAIN_NOTEBOOK_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)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(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)); + + 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('same kernel start fires twice → init runs only once (WeakSet gate prevents doubling)', async () => { + putFile(MAIN_FILE_NAME, makeMainProjectEntry(PROJECT_ID, INIT_NOTEBOOK_ID)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(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 () => { + // 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)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(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)); + 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); + 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('closing the notebook mid-init stops remaining init blocks (close cancels the run)', async () => { + // runInitForKernel must pass a close-tied token into executeInitNotebook, so closing the + // notebook mid-init stops remaining blocks. Without it, 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)); + putFile( + SIBLING_INIT_FILE_NAME, + 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). + 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)); + putFile(SIBLING_INIT_FILE_NAME, makeNotebookFile(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 bd0a3797af..1d044ca4b5 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, @@ -33,7 +32,8 @@ import { IDeepnoteNotebookEnvironmentMapper, IDeepnoteServerProvider, IDeepnoteServerStarter, - IDeepnoteToolkitInstaller + IDeepnoteToolkitInstaller, + IServerHandleRegistry } from '../../kernels/deepnote/types'; import { createJupyterConnectionInfo } from '../../kernels/jupyter/jupyterUtils'; import { JupyterLabHelper } from '../../kernels/jupyter/session/jupyterLabHelper'; @@ -42,7 +42,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 +56,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'; @@ -77,13 +76,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, 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 - 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 +88,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, @@ -105,7 +96,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, @inject(IDeepnoteNotebookEnvironmentMapper) private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller + @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller, + @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry ) {} public activate() { @@ -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}`); @@ -279,24 +267,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, if (event.notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { return; } - - // const baseFileUri = event.notebook.uri.with({ query: '', fragment: '' }); - // const notebookKey = baseFileUri.fsPath; - - // // If the Deepnote controller for this notebook was deselected, try to reselect it - // // Since controllers are now protected from disposal, this should rarely happen - // if (!event.selected) { - // const ourController = this.notebookControllers.get(notebookKey); - // if (ourController && ourController.id === event.controller.id) { - // logger.warn( - // `Deepnote controller was unexpectedly deselected for ${getDisplayPath( - // event.notebook.uri - // )}. Reselecting...` - // ); - // // Reselect the controller - // ourController.controller.updateNotebookAffinity(event.notebook, NotebookControllerAffinity.Preferred); - // } - // } } private onDidCloseNotebook(notebook: NotebookDocument) { @@ -320,64 +290,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 @@ -389,9 +301,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)}`); @@ -410,12 +320,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // The controller will stay alive - it will just get updated via updateConnection() this.notebookConnectionMetadata.delete(notebookKey); - // Clear old server handle - new environment will register a new handle - const oldServerHandle = this.projectServerHandles.get(projectKey); - if (oldServerHandle) { - logger.info(`Clearing old server handle from tracking: ${oldServerHandle}`); - this.projectServerHandles.delete(projectKey); - } + // Capture the old handle but don't unregister it yet: a failed or cancelled switch would + // strand the still-selected controller on a dead handle. + const oldServerHandle = this.serverHandleRegistry.get(notebookKey); // Stop existing LSP clients so new ones can be created with fresh environment // Without this, the SQL LSP client's command handlers remain registered and @@ -425,24 +332,25 @@ 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); + + // Setup succeeded. If it registered a new server handle (full setup path), drop the old one. + // The verified-controller early return reuses the existing handle, so nothing to clear then. + const newServerHandle = this.serverHandleRegistry.get(notebookKey); + + if (oldServerHandle && oldServerHandle !== newServerHandle) { + logger.info(`Clearing old server handle from tracking: ${oldServerHandle}`); + this.serverProvider.unregisterServer(oldServerHandle); + } logger.info(`Controller successfully switched to new environment`); } @@ -452,14 +360,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 +375,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 +389,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 +447,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, configuration.managedVenv, configuration.packages ?? [], configuration.id, - baseFileUri, + notebook.uri, progressToken ); @@ -568,12 +462,11 @@ 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) this.serverProvider.registerServer(serverProviderHandle.handle, serverInfo); - this.projectServerHandles.set(projectKey, serverProviderHandle.handle); + this.serverHandleRegistry.set(notebookKey, serverProviderHandle.handle); const lspInterpreterUri = this.getVenvInterpreterUri(configuration.venvPath); @@ -603,7 +496,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.requestCreator, this.requestAgentCreator, this.configService, - baseFileUri + notebook.uri ); const sessionManager = JupyterLabHelper.create(connectionInfo.settings); @@ -641,7 +534,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, kernelSpec, baseUrl: serverInfo.url, id: controllerId, - projectFilePath: baseFileUri.toString(), + projectFilePath: notebook.uri.toString(), serverProviderHandle, serverInfo, environmentName: configuration.name, @@ -670,9 +563,11 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Prepare init notebook execution const projectId = notebook.metadata?.deepnoteProjectId; - const project = projectId - ? (this.notebookManager.getOriginalProject(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 @@ -687,11 +582,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 @@ -803,15 +693,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); @@ -819,9 +707,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); @@ -837,14 +725,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)}`); @@ -859,7 +740,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, )}, triggering setup` ); - return this.setupKernelForEnvironment(notebook, environment, baseFileUri, notebookKey, projectKey, token); + return this.setupKernelForEnvironment(notebook, environment, notebookKey, token); } /** @@ -867,9 +748,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, */ private async pickAndSetupEnvironment( notebook: NotebookDocument, - baseFileUri: Uri, notebookKey: string, - projectKey: string, token: CancellationToken ): Promise { Cancellation.throwIfCanceled(token); @@ -885,16 +764,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)}`); @@ -909,9 +781,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private async setupKernelForEnvironment( notebook: NotebookDocument, environment: DeepnoteEnvironment, - baseFileUri: Uri, notebookKey: string, - projectKey: string, token: CancellationToken ): Promise { try { @@ -925,9 +795,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..1f1ea429d4 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -3,6 +3,7 @@ import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DeepnoteKernelAutoSelector } from './deepnoteKernelAutoSelector.node'; import { createMockChildProcess } from '../../kernels/deepnote/deepnoteTestHelpers.node'; +import { ServerHandleRegistry } from '../../kernels/deepnote/deepnoteServerHandleRegistry.node'; import { IDeepnoteEnvironmentManager, IDeepnoteLspClientManager, @@ -16,7 +17,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'; @@ -28,6 +28,7 @@ import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock suite('DeepnoteKernelAutoSelector - rebuildController', () => { let selector: DeepnoteKernelAutoSelector; + let registry: ServerHandleRegistry; let mockDisposableRegistry: IDisposableRegistry; let mockControllerRegistration: IControllerRegistration; let mockPythonExtensionChecker: IPythonExtensionChecker; @@ -35,7 +36,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 +65,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockLspClientManager = mock(); mockRequestCreator = mock(); mockConfigService = mock(); - mockInitNotebookRunner = mock(); mockNotebookManager = mock(); mockKernelProvider = mock(); mockRequirementsHelper = mock(); @@ -121,6 +120,8 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockLoadingController ); + registry = new ServerHandleRegistry(); + // Create selector instance selector = new DeepnoteKernelAutoSelector( instance(mockDisposableRegistry), @@ -131,7 +132,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockRequestCreator), undefined, // requestAgentCreator is optional instance(mockConfigService), - instance(mockInitNotebookRunner), instance(mockNotebookManager), instance(mockKernelProvider), instance(mockRequirementsHelper), @@ -139,7 +139,8 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockServerStarter), instance(mockNotebookEnvironmentMapper), instance(mockOutputChannel), - instance(mockToolkitInstaller) + instance(mockToolkitInstaller), + registry ); }); @@ -288,11 +289,63 @@ 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' ); }); + + test('should keep the old server handle registered when the environment switch fails', async () => { + // Arrange - old handle already tracked, no new handle registered because setup fails + const notebookKey = mockNotebook.uri.toString(); + const oldServerHandle = 'old-server-handle'; + registry.set(notebookKey, oldServerHandle); + + const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); + when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); + when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + + sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').rejects(new Error('startServer failed')); + + // Act + await assert.isRejected( + selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)) + ); + + // Assert - the old handle must remain registered so the selected controller keeps resolving + verify(mockServerProvider.unregisterServer(oldServerHandle)).never(); + }); + + test('should unregister the old server handle after switching to a different environment', async () => { + // Arrange - old handle tracked; successful setup registers a different new handle + const notebookKey = mockNotebook.uri.toString(); + const oldServerHandle = 'old-server-handle'; + const newServerHandle = 'new-server-handle'; + registry.set(notebookKey, oldServerHandle); + + const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); + when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); + when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + + // Real setup registers a new handle for the notebook - emulate that side effect + sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').callsFake(async () => { + registry.set(notebookKey, newServerHandle); + }); + + // Act + await selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)); + + // Assert - only the stale old handle is unregistered; the new one stays + verify(mockServerProvider.unregisterServer(oldServerHandle)).once(); + verify(mockServerProvider.unregisterServer(newServerHandle)).never(); + + // Assert - the registry ends up tracking the new handle, not the stale one + assert.strictEqual( + registry.get(notebookKey), + newServerHandle, + 'registry should track the new handle after a successful switch' + ); + }); }); suite('pickEnvironment', () => { @@ -322,26 +375,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) @@ -410,9 +443,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 +478,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(); @@ -587,21 +616,8 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // - Validates server cleanup during rebuild // - Ensures old server is unregistered from provider // - // THE ACTUAL IMPLEMENTATION at deepnoteKernelAutoSelector.node.ts:269-291: - // - // // Clear cached state - // this.notebookControllers.delete(notebookKey); - // this.notebookConnectionMetadata.delete(notebookKey); - // - // // Unregister old server - // const oldServerHandle = this.notebookServerHandles.get(notebookKey); - // if (oldServerHandle) { - // this.serverProvider.unregisterServer(oldServerHandle); - // this.notebookServerHandles.delete(notebookKey); - // } - // - // These operations happen BEFORE calling ensureKernelSelected() to create the new controller, - // ensuring clean state for the environment switch. + // rebuildController drops the old server handle only AFTER setup registers a + // replacement, so a failed/cancelled switch never strands the controller on a dead handle. assert.ok(true, 'UT-2 is validated by existing tests and implementation (INV-9)'); }); 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/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts new file mode 100644 index 0000000000..ca61a7c6ed --- /dev/null +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -0,0 +1,227 @@ +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'); + +/** 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; + +/** + * On opening a legacy multi-notebook `.deepnote` file, prompts to split it into one single-notebook + * sibling file per notebook and retire the original as `.deepnote.legacy`. No automatic rewrite. + * The environment mapper is undefined on the web target, where env 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-time 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 { + // 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, '..'); + + // Write all children before retiring the original (see step below). + const entries = splitByNotebooks(deepnoteFile, getFileStem(fileUri)); + const reserved = new Set(); + const newUris: Uri[] = []; + const encoder = new TextEncoder(); + + try { + 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); + } + } catch (writeError) { + // Delete partial siblings: orphans left on disk would bump a retry's name allocation to a duplicate. + for (const uri of newUris) { + await workspace.fs + .delete(uri, { useTrash: false }) + .then(undefined, (cleanupError) => + this.logger.error(`Failed to clean up partial split file: ${uri.toString()}`, cleanupError) + ); + } + + throw writeError; + } + + // 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); + } + } + } + + // Only now that all children are durably written: close the tab and retire the original. + await this.closeNotebookTab(fileUri); + const legacyUri = await this.allocateLegacyUri(fileUri); + await workspace.fs.rename(fileUri, legacyUri, { overwrite: false }); + + 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; a re-run re-derives the rest via the allocator. + 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) + ); + } + } + + /** Resolves a collision-free `.legacy` (then `.legacy-2`, …) URI next to the original. */ + 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) { + 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..d0ac24bfe7 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -0,0 +1,578 @@ +import { deserializeDeepnoteFile, serializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { assert } from 'chai'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter, FileType, NotebookDocument, TabGroups, 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/rename/prompt). */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 80)); +} + +function basename(uri: Uri): string { + return uri.path.split('/').pop() ?? ''; +} + +/** + * Splitter orchestration (prompt gating, write/rename order, env migration, dirty gate, abort) with + * the real `allocateSiblingUri` and a mocked `splitByNotebooks`; the original is retired by rename to `.legacy`. + */ +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-rename ORDER. + let callLog: Array<{ op: 'write' | 'rename'; name: string }>; + let writeTargets: string[]; + // Each retire of the original, captured as { from: , to: } filenames. + 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; + // 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, metadata: {} }] + }; + } + + 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 + } + }; + } + + /** 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.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) => { + if (existingOnDisk.has(basename(uri))) { + return Promise.resolve({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); + } + 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 & { readonly _saved: boolean } { + 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 & { readonly _saved: boolean }; + } + + setup(() => { + resetVSCodeMocks(); + callLog = []; + writeTargets = []; + renameOps = []; + 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 unknown as TabGroups); + + // 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/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'), + 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(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 () => { + 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 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'), + makeNotebook('n3', 'Gamma', 'c') + ]); + stubReadFile(file); + acceptSplit(); + + const originalUri = Uri.file('/ws/multi.deepnote'); + onDidOpen.fire(notebookDoc(originalUri)); + + await waitFor(() => renameOps.length >= 1); + + // N = 3 writes, exactly one rename. + assert.strictEqual(writeTargets.length, 3, 'should write one new file per notebook (N=3)'); + assert.strictEqual(renameOps.length, 1, 'should retire 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 rename in the call log. + const renameIndex = callLog.findIndex((c) => c.op === 'rename'); + const lastWriteIndex = callLog.map((c) => c.op).lastIndexOf('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 rename' + ); + + // 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('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 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.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(() => 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.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 () => { + 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-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'), + 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(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'); + }); + }); + + 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(() => renameOps.length >= 1); + + assert.isTrue(doc._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 rename (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(renameOps.length, 0, 'declined save must abort before any rename'); + }); + }); + + 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(() => renameOps.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('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(() => renameOps.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.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(() => renameOps.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' + ); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts b/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts new file mode 100644 index 0000000000..ada8d14746 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookFileFactory.ts @@ -0,0 +1,61 @@ +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`. + */ +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. + * `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. + */ +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`). + */ +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..5f680ad41f --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookFileFactory.unit.test.ts @@ -0,0 +1,137 @@ +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'; + +/** + * 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 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)', + metadata: {} + } + ] + }; + } + + 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')] + } + }; + } + + suite('getFileStem', () => { + test('returns basename up to the FIRST dot (regression: a.b.deepnote must collapse to a)', () => { + assert.strictEqual(getFileStem(Uri.file('/x/report.deepnote')), 'report'); + assert.strictEqual(getFileStem(Uri.file('/x/a.b.deepnote')), 'a'); + }); + }); + + 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' + ); + 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('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 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')); + }); + + 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/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..a78f6b647d --- /dev/null +++ b/src/notebooks/deepnote/deepnoteNotebookInfoStatusBar.unit.test.ts @@ -0,0 +1,238 @@ +import { assert, expect } from 'chai'; +import { anything, capture, verify, when } from 'ts-mockito'; +import { + EventEmitter, + NotebookDocument, + NotebookDocumentChangeEvent, + NotebookEditor, + StatusBarItem, + 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'; + +/** + * 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; +}): NotebookDocument { + return { + notebookType: options.notebookType ?? 'deepnote', + metadata: options.metadata ?? {}, + uri: options.uri ?? Uri.file('/workspace/proj.deepnote') + } as unknown as NotebookDocument; +} + +/** + * Wrap a notebook in the minimal NotebookEditor the status bar reads (`editor.notebook`). + */ +function editorFor(notebook: NotebookDocument): NotebookEditor { + return { notebook } as unknown as NotebookEditor; +} + +/** + * Build a document-change event for a notebook; the status bar only inspects `event.notebook`. + */ +function docChangeFor(notebook: NotebookDocument): NotebookDocumentChangeEvent { + return { notebook, metadata: undefined, contentChanges: [], cellChanges: [] }; +} + +suite('DeepnoteNotebookInfoStatusBar', () => { + let statusBar: DeepnoteNotebookInfoStatusBar; + let fakeItem: FakeStatusBarItem; + let disposableRegistry: IDisposableRegistry; + let activeEditorEmitter: EventEmitter; + let docChangeEmitter: EventEmitter; + + setup(() => { + resetVSCodeMocks(); + + fakeItem = createFakeStatusBarItem(); + disposableRegistry = []; + + // 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 unknown as StatusBarItem + ); + 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('shows "$(notebook) " for an active deepnote notebook (name from metadata.deepnoteNotebookName)', () => { + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn( + editorFor(makeNotebook({ metadata: { deepnoteNotebookName: 'My Analysis' } })) + ); + + 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( + editorFor(makeNotebook({ notebookType: 'jupyter-notebook', metadata: { deepnoteNotebookName: 'X' } })) + ); + + statusBar.activate(); + + assert.isFalse(fakeItem.visible, 'a non-deepnote editor must not show the Deepnote 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 = editorFor(makeNotebook({ metadata: { deepnoteNotebookName: 'Switched In' } })); + when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(editor); + 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(editorFor(notebook)); + 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(docChangeFor(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(editorFor(activeNotebook)); + 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(docChangeFor(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 = editorFor( + 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); + + statusBar.activate(); + + // Invoke the command handler registered against Commands.CopyNotebookDetails. + const [, handler] = capture(mockedVSCodeNamespaces.commands.registerCommand).first(); + 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(); + 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('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/deepnoteNotebookManager.ts b/src/notebooks/deepnote/deepnoteNotebookManager.ts index 069f3a570c..7bb71e332a 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.ts @@ -9,121 +9,51 @@ import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; */ @injectable() export class DeepnoteNotebookManager implements IDeepnoteNotebookManager { - private readonly currentNotebookId = new Map(); - private readonly originalProjects = new Map(); - private readonly projectsWithInitNotebookRun = new Set(); - private readonly selectedNotebookByProject = 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>(); /** - * Gets the currently selected notebook ID for a project. - * @param projectId Project identifier - * @returns Current notebook ID or undefined if not set + * Retrieves the cached project data for an exact (projectId, notebookId) pair; never falls + * back to another sibling's project, returning undefined when that entry is not cached. */ - getCurrentNotebookId(projectId: string): string | undefined { - return this.currentNotebookId.get(projectId); + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined { + return this.originalProjects.get(projectId)?.get(notebookId); } - /** - * Retrieves the original project data for a given project ID. - * @param projectId Project identifier - * @returns Original project data or undefined if not found - */ - getOriginalProject(projectId: string): DeepnoteProject | undefined { - return this.originalProjects.get(projectId); - } - - /** - * 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); - } - - /** - * 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 and sets the initial current notebook. - * This is used during deserialization to cache project data and track the active notebook. - * @param projectId Project identifier - * @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) + /** Stores the original project data for an exact (projectId, notebookId) pair. */ + storeOriginalProject(projectId: string, notebookId: string, project: DeepnoteProject): void { + // structuredClone to prevent mutations affecting stored state and handle circular refs. const clonedProject = structuredClone(project); - this.originalProjects.set(projectId, clonedProject); - this.currentNotebookId.set(projectId, notebookId); - } + let notebookEntries = this.originalProjects.get(projectId); - /** - * 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); + if (!notebookEntries) { + notebookEntries = new Map(); + this.originalProjects.set(projectId, notebookEntries); + } + + notebookEntries.set(notebookId, clonedProject); } /** - * Updates the integrations list in the project data. - * This modifies the stored project to reflect changes in configured integrations. - * - * @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 + * Updates the integrations list across every cached notebook entry under the project (cache-only). + * @returns `true` if at least one cached entry was 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..2192cf2099 100644 --- a/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteNotebookManager.unit.test.ts @@ -25,113 +25,21 @@ suite('DeepnoteNotebookManager', () => { manager = new DeepnoteNotebookManager(); }); - suite('getCurrentNotebookId', () => { + suite('getProjectForNotebook', () => { test('should return undefined for unknown project', () => { - const result = manager.getCurrentNotebookId('unknown-project'); + const result = manager.getProjectForNotebook('unknown-project', 'notebook-456'); assert.strictEqual(result, undefined); }); - - test('should return notebook ID after storing project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); - - const result = manager.getCurrentNotebookId('project-123'); - - assert.strictEqual(result, 'notebook-456'); - }); - - test('should return updated notebook ID', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); - 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'); - - assert.strictEqual(result, undefined); - }); - - test('should return original project after storing', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); - - const result = manager.getOriginalProject('project-123'); - - assert.deepStrictEqual(result, mockProject); - }); - }); - - 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', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); + test('should store the project for the (projectId, notebookId) pair', () => { + manager.storeOriginalProject('project-123', 'notebook-456', mockProject); - const storedProject = manager.getOriginalProject('project-123'); - const currentNotebookId = manager.getCurrentNotebookId('project-123'); + const storedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(storedProject, mockProject); - assert.strictEqual(currentNotebookId, 'notebook-456'); }); test('should overwrite existing project data', () => { @@ -143,50 +51,18 @@ 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 currentNotebookId = manager.getCurrentNotebookId('project-123'); + const storedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(storedProject, updatedProject); - assert.strictEqual(currentNotebookId, 'notebook-789'); - }); - }); - - suite('updateCurrentNotebookId', () => { - test('should update notebook ID for existing project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-456'); - 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'); }); }); 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 +73,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, integrations); }); @@ -210,7 +86,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 +97,7 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + const updatedProject = manager.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, newIntegrations); }); @@ -234,13 +110,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.getProjectForNotebook('project-123', 'notebook-456'); assert.deepStrictEqual(updatedProject?.project.integrations, []); }); @@ -251,12 +127,12 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, false); - const project = manager.getOriginalProject('unknown-project'); + const project = manager.getProjectForNotebook('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,72 +140,75 @@ suite('DeepnoteNotebookManager', () => { assert.strictEqual(result, true); - const updatedProject = manager.getOriginalProject('project-123'); + 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); 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', mockProject, 'notebook-456'); - manager.updateCurrentNotebookId('project-123', undefined as any); + // Two sibling .deepnote files of one project share project.id but hold different single notebooks. + // These pin the 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'; - const integrations: ProjectIntegration[] = [ - { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, - { id: 'int-2', name: 'BigQuery', type: 'big-query' } - ]; + // 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: [] + } + ] + } + }; + } - const result = manager.updateProjectIntegrations('project-123', integrations); + test('stores two siblings of the same project without clobbering each other', () => { + const projectA = siblingProject(nbA, 'Sibling A'); + const projectB = siblingProject(nbB, 'Sibling B'); - assert.strictEqual(result, true); + manager.storeOriginalProject(projectId, nbA, projectA); + manager.storeOriginalProject(projectId, nbB, projectB); - const updatedProject = manager.getOriginalProject('project-123'); - 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); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbA), projectA); + assert.deepStrictEqual(manager.getProjectForNotebook(projectId, nbB), projectB); }); - }); - suite('integration scenarios', () => { - test('should handle complete workflow for multiple projects', () => { - manager.storeOriginalProject('project-1', mockProject, 'notebook-1'); - manager.selectNotebookForProject('project-1', 'notebook-1'); + 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')); - manager.storeOriginalProject('project-2', mockProject, 'notebook-2'); - manager.selectNotebookForProject('project-2', 'notebook-2'); + // The exact lookup must NOT fall back to a cached sibling of the same project — a save + // path relies on this to never write against the wrong sibling's project. + const result = manager.getProjectForNotebook(projectId, 'not-cached'); - 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'); + assert.strictEqual(result, undefined); }); - test('should handle notebook switching within same project', () => { - manager.storeOriginalProject('project-123', mockProject, 'notebook-1'); - 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('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')); - test('should maintain separation between current and selected notebook IDs', () => { - // Store original project sets current notebook - manager.storeOriginalProject('project-123', mockProject, 'notebook-original'); + const integrations: ProjectIntegration[] = [ + { id: 'int-1', name: 'PostgreSQL', type: 'pgsql' }, + { id: 'int-2', name: 'BigQuery', type: 'big-query' } + ]; - // Selecting a different notebook for the project - manager.selectNotebookForProject('project-123', 'notebook-selected'); + const updated = manager.updateProjectIntegrations(projectId, integrations); - // Both should be maintained independently - assert.strictEqual(manager.getCurrentNotebookId('project-123'), 'notebook-original'); - assert.strictEqual(manager.getTheSelectedNotebookForAProject('project-123'), 'notebook-selected'); + assert.strictEqual(updated, true); + // BOTH siblings of the project must see the new 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/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..5d029bcf12 100644 --- a/src/notebooks/deepnote/deepnoteSerializer.ts +++ b/src/notebooks/deepnote/deepnoteSerializer.ts @@ -1,7 +1,8 @@ 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 { 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 +63,6 @@ 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. * @param content Raw file content as bytes * @param token Cancellation token (unused) * @returns Promise resolving to notebook data @@ -90,22 +90,20 @@ 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 it. + 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]; @@ -125,7 +123,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`); @@ -157,7 +155,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 { @@ -181,39 +179,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,35 +203,27 @@ 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'); + 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 - const storedProject = this.notebookManager.getOriginalProject(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.getProjectForNotebook(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) { @@ -325,15 +282,13 @@ 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 - ); + // snapshotHash is transient: stripped on serialize and recomputed here each save. + (originalProject.metadata as { snapshotHash?: string }).snapshotHash = computeSnapshotHash(originalProject); // 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(); @@ -347,7 +302,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'); @@ -458,118 +413,63 @@ 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. - * Compares notebook content (block sources, types, and IDs) while ignoring - * outputs, execution metadata, and timestamps. + * Detects whether actual content has changed for the edited notebook: notebook-level fields + * and block content (sources, types, IDs), 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); + private detectContentChanges(newProject: DeepnoteFile, originalProject: DeepnoteFile, notebookId: string): boolean { + // Match the edited notebook by id, not a fixed [0] slot: in a legacy [init, main] file the + // edited notebook isn't at index 0, so comparing [0] would miss edits and 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; + } - if (!newNotebook) { - return true; // Notebook removed - } + if ( + newNotebook.id !== originalNotebook.id || + newNotebook.name !== originalNotebook.name || + newNotebook.executionMode !== originalNotebook.executionMode || + newNotebook.isModule !== originalNotebook.isModule || + newNotebook.workingDirectory !== originalNotebook.workingDirectory + ) { + return true; } - for (const newNotebook of newProject.project.notebooks) { - const originalNotebook = originalProject.project.notebooks.find((nb) => nb.id === newNotebook.id); + const newBlocks = newNotebook.blocks ?? []; + const originalBlocks = originalNotebook.blocks ?? []; - if (!originalNotebook) { - return true; // New notebook added - } + if (newBlocks.length !== originalBlocks.length) { + return true; + } + + for (let i = 0; i < newBlocks.length; i++) { + const newBlock = newBlocks[i]; + const originalBlock = originalBlocks[i]; if ( - newNotebook.name !== originalNotebook.name || - newNotebook.executionMode !== originalNotebook.executionMode || - newNotebook.isModule !== originalNotebook.isModule || - newNotebook.workingDirectory !== originalNotebook.workingDirectory + newBlock.content !== originalBlock.content || + newBlock.type !== originalBlock.type || + newBlock.id !== originalBlock.id ) { return true; } - - const newBlocks = newNotebook.blocks ?? []; - const originalBlocks = originalNotebook.blocks ?? []; - - if (newBlocks.length !== originalBlocks.length) { - 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 one in the file is the init notebook. */ 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 c3332f974d..0829313c92 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/ ); }); @@ -179,7 +187,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: [ @@ -205,163 +213,102 @@ 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'); - - // Should fall back to stored selection since active editor is for different project - assert.strictEqual(result, 'stored-notebook'); - }); - - test('should ignore active editor when notebook type is not deepnote', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); - - // Mock active editor with non-deepnote notebook type - const mockActiveNotebook = { - notebookType: 'jupyter-notebook', - metadata: { - deepnoteProjectId: 'project-123', - deepnoteNotebookId: 'active-editor-notebook' - } - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); - - const result = serializer.findCurrentNotebookId('project-123'); - - // Should fall back to stored selection since active editor is not a deepnote notebook - assert.strictEqual(result, 'stored-notebook'); - }); - test('should ignore active editor when notebook ID is missing', () => { - manager.selectNotebookForProject('project-123', 'stored-notebook'); + 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: {} + } + }; + } - // Mock active editor without notebook ID in metadata - const mockActiveNotebook = { - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: 'project-123' - // Missing deepnoteNotebookId - } - }; + 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")')); - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn({ - notebook: mockActiveNotebook - } as any); + // 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 + } + }; - const result = serializer.findCurrentNotebookId('project-123'); + const result = await serializer.serializeNotebook(notebookData as any, {} as any); + const parsed = deserializeDeepnoteFile(new TextDecoder().decode(result)); + + // 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); + }); + + 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 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 +335,9 @@ project: }); test('should handle manager state operations', () => { - assert.isFunction(manager.getCurrentNotebookId, 'has getCurrentNotebookId 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.getProjectForNotebook, 'has getProjectForNotebook method'); assert.isFunction(manager.storeOriginalProject, 'has storeOriginalProject method'); }); - - test('should have findCurrentNotebookId method', () => { - assert.isFunction(serializer.findCurrentNotebookId, 'has findCurrentNotebookId method'); - }); }); suite('data structure handling', () => { @@ -472,7 +409,7 @@ project: } }; - manager.storeOriginalProject('project-circular', projectWithCircularRef, 'notebook-1'); + manager.storeOriginalProject('project-circular', 'notebook-1', projectWithCircularRef); const notebookData = { cells: [ @@ -540,7 +477,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 +563,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 +630,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 = { @@ -856,7 +793,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 +801,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 +835,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 +844,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 +862,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 +878,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,73 +917,206 @@ 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' } - ] + ], + executionMode: 'block', + isModule: false + }, + { + id: 'main2', + name: 'Main Two', + blocks: [ + { + id: 'm2-block', + content: 'print("two")', + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + type: 'code' + } + ], + executionMode: 'block', + isModule: false } - ] + ], + settings: {} } }; - const originalProject: DeepnoteFile = { + const content = projectToYaml(file); + const result = await serializer.deserializeNotebook(content, {} as any); + + assert.strictEqual(result.metadata?.deepnoteNotebookId, 'main1'); + assert.strictEqual(result.metadata?.deepnoteNotebookName, 'Main One'); + }); + + 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', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: 'project-standalone-init', + name: 'Standalone Init', + initNotebookId: 'init-notebook', + notebooks: [ + { + id: 'init-notebook', + name: 'Init', + blocks: [ + { + id: 'init-only-block', + content: 'print("init")', + sortingKey: 'a0', + blockGroup: '1', + metadata: {}, + type: 'code' + } + ], + executionMode: 'block', + isModule: false + } + ], + settings: {} + } + }; + + 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: { @@ -1088,12 +1142,13 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const projectCopy = structuredClone(project); + const result = serializerAny.detectContentChanges(project, projectCopy, 'nb-1'); - assert.isTrue(result); + assert.isFalse(result); }); - test('should detect changes when block type differs', () => { + test('should detect changes when block content differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1107,11 +1162,11 @@ project: blocks: [ { id: 'b1', - type: 'markdown', + type: 'code', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: '# Hello' + content: 'print(2)' } ] } @@ -1136,7 +1191,7 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: '# Hello' + content: 'print(1)' } ] } @@ -1145,12 +1200,12 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); - test('should detect changes when block count differs', () => { + test('should detect changes when block type differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1164,19 +1219,11 @@ project: blocks: [ { id: 'b1', - type: 'code', + type: 'markdown', sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' - }, - { - id: 'b2', - type: 'code', - sortingKey: 'a1', - blockGroup: '1', - metadata: {}, - content: 'print(2)' + content: '# Hello' } ] } @@ -1201,7 +1248,7 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + content: '# Hello' } ] } @@ -1210,12 +1257,12 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); - test('should detect new notebook added', () => { + test('should detect changes when block count differs', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1234,13 +1281,16 @@ project: blockGroup: '1', metadata: {}, content: 'print(1)' + }, + { + id: 'b2', + type: 'code', + sortingKey: 'a1', + blockGroup: '1', + metadata: {}, + content: 'print(2)' } ] - }, - { - id: 'nb-2', - name: 'New Notebook', - blocks: [] } ] } @@ -1272,12 +1322,12 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isTrue(result); }); - test('should detect notebook removed', () => { + test('should ignore output changes', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1295,7 +1345,8 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + content: 'print(1)', + outputs: [{ output_type: 'stream', text: '1\n' }] } ] } @@ -1323,23 +1374,18 @@ project: content: 'print(1)' } ] - }, - { - id: 'nb-2', - name: 'Second Notebook', - blocks: [] } ] } }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); - assert.isTrue(result); + assert.isFalse(result); }); - test('should ignore output changes', () => { + test('should ignore execution metadata changes', () => { const newProject: DeepnoteFile = { version: '1.0.0', metadata: { createdAt: '2023-01-01T00:00:00Z' }, @@ -1358,7 +1404,9 @@ project: blockGroup: '1', metadata: {}, content: 'print(1)', - outputs: [{ output_type: 'stream', text: '1\n' }] + executionCount: 5, + executionStartedAt: '2025-01-01T00:00:00Z', + executionFinishedAt: '2025-01-01T00:00:01Z' } ] } @@ -1392,22 +1440,27 @@ project: }; const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); + const result = serializerAny.detectContentChanges(newProject, originalProject, 'nb-1'); assert.isFalse(result); }); - test('should ignore execution metadata changes', () => { - const newProject: DeepnoteFile = { + // 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-1', + id: 'project-nb-fields', name: 'Test', notebooks: [ { id: 'nb-1', name: 'Notebook', + executionMode: 'block', + isModule: false, + workingDirectory: '/work', blocks: [ { id: 'b1', @@ -1415,27 +1468,61 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)', - executionCount: 5, - executionStartedAt: '2025-01-01T00:00:00Z', - executionFinishedAt: '2025-01-01T00:00:01Z' + content: 'print(1)' } - ] + ], + ...overrides } ] } }; + } - const originalProject: DeepnoteFile = { + 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, 'nb-1'); + + assert.isTrue(result, `change to notebook-level field '${field}' should be detected`); + }); + } + + 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, '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: 'nb-1', - name: 'Notebook', + id: 'main-1', + name: 'Main', blocks: [ { id: 'b1', @@ -1443,18 +1530,20 @@ project: sortingKey: 'a0', blockGroup: '1', metadata: {}, - content: 'print(1)' + content: mainContent } ] } ] } - }; + }); const serializerAny = serializer as any; - const result = serializerAny.detectContentChanges(newProject, originalProject); - assert.isFalse(result); + // 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')); }); }); @@ -1491,7 +1580,7 @@ project: } }; - manager.storeOriginalProject('project-snapshot-hash', projectData, 'notebook-1'); + manager.storeOriginalProject('project-snapshot-hash', 'notebook-1', projectData); const notebookData = { cells: [ @@ -1566,13 +1655,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 +1756,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 +1805,7 @@ project: } }; - manager.storeOriginalProject('project-content-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-content-change', 'notebook-1', projectData1); const notebookData1 = { cells: [ @@ -1794,7 +1883,7 @@ project: } }; - manager.storeOriginalProject('project-version-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-version-change', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1818,7 +1907,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 +1949,7 @@ project: } }; - manager.storeOriginalProject('project-integrations-change', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-integrations-change', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1885,7 +1974,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 +2016,7 @@ project: } }; - manager.storeOriginalProject('project-env-hash', projectData1, 'notebook-1'); + manager.storeOriginalProject('project-env-hash', 'notebook-1', projectData1); const notebookData = { cells: [ @@ -1952,7 +2041,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/deepnoteSiblingFileAllocator.ts b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts new file mode 100644 index 0000000000..a2faba70d0 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.ts @@ -0,0 +1,59 @@ +import { Uri, workspace } from 'vscode'; + +// Upper bound on `-N` suffix attempts; mirrors the 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 is treated as "does not exist". + */ +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 full `.deepnote` basename. On a clash a numeric suffix + * goes before the extension (`a.backup.deepnote` → `a.backup-2.deepnote`); `reserved` blocks batch reuse. + */ +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.` + ); +} + +// Names without the extension get 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..813ababde2 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteSiblingFileAllocator.unit.test.ts @@ -0,0 +1,85 @@ +import { assert } from 'chai'; +import { Uri } from 'vscode'; + +import { allocateSiblingUri, MAX_SIBLING_ALLOCATION_ATTEMPTS } from './deepnoteSiblingFileAllocator'; + +/** + * Tests the shared, collision-safe sibling-file allocator (used by the splitter and file factory) + * 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 filenames (within parentDir) as present. */ + function existsFor(existingFilenames: string[]): (uri: Uri) => Promise { + const present = new Set(existingFilenames); + + 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'); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts index be9e471e27..4ebf25ff64 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.ts @@ -13,7 +13,15 @@ import { l10n } from 'vscode'; -import { DeepnoteTreeItem, DeepnoteTreeItemType, DeepnoteTreeItemContext } from './deepnoteTreeItem'; +import { + DeepnoteTreeItem, + DeepnoteTreeItemType, + DeepnoteTreeItemContext, + ProjectGroupData, + getNonInitNotebooks, + isSingleNotebookFile, + resolveLeafNotebook +} from './deepnoteTreeItem'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; import { readDeepnoteProjectFile } from './deepnoteProjectUtils'; import { ILogger } from '../../platform/logging/types'; @@ -25,12 +33,13 @@ 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. + * Explorer tree: root → `ProjectGroup` (per `project.id`) → `ProjectFile` → `Notebook` (legacy only). + * Groups re-derive from the URI-keyed `cachedProjects` per read, so refreshes fire a full-tree change. */ export class DeepnoteTreeDataProvider implements TreeDataProvider { private _onDidChangeTreeData: EventEmitter = new EventEmitter< @@ -39,8 +48,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 +68,38 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { - // Get the cached tree item BEFORE clearing caches - const cacheKey = `project:${filePath}`; - const cachedTreeItem = this.treeItemCache.get(cacheKey); - - // Clear the project data cache to force reload - this.cachedProjects.delete(filePath); - - if (cachedTreeItem) { - // Reload the project data and update the cached tree item - try { - const fileUri = Uri.file(filePath); - const project = await this.loadDeepnoteProject(fileUri); - if (project) { - // Update the tree item's data - cachedTreeItem.data = project; - - // Update visual fields (label, description, tooltip) to reflect changes - cachedTreeItem.updateVisualFields(); - } - } catch (error) { - this.logger?.error(`Failed to reload project ${filePath}`, error); - } - - // Fire change event with the existing cached tree item - this._onDidChangeTreeData.fire(cachedTreeItem); - } else { - // If not found in cache, do a full refresh - this._onDidChangeTreeData.fire(); - } + public refreshProject(cacheKey: string): void { + this.cachedProjects.delete(cacheKey); + this.fileItemCache.delete(cacheKey); + 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. Evicts ALL matching `cachedProjects` entries (never + * breaking on the first match) so the whole group stays consistent. */ - 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 [cacheKey, project] of this.cachedProjects) { + if (project.project.id === projectId) { + this.cachedProjects.delete(cacheKey); + this.fileItemCache.delete(cacheKey); } } - 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 +107,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 +132,69 @@ 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; + } + + if (fileItem.collapsibleState === TreeItemCollapsibleState.None) { + const leafNotebook = resolveLeafNotebook(fileItem.data as DeepnoteProject); + + if (leafNotebook?.id === notebookId) { + return fileItem; + } + + continue; + } + + const notebooks = await this.getNotebooksForProjectFile(fileItem); + const match = notebooks.find((notebookItem) => notebookItem.context.notebookId === notebookId); + + if (match) { + return match; + } + } + + return group; + } + + return undefined; + } + + /** + * Return an element's parent from the item caches; `TreeView.reveal` requires `getParent`. + */ + public getParent(element: DeepnoteTreeItem): DeepnoteTreeItem | undefined { + if (element.type === DeepnoteTreeItemType.Notebook) { + return this.fileItemCache.get(Uri.file(element.context.filePath).toString()); + } + + if (element.type === DeepnoteTreeItemType.ProjectFile) { + return this.groupItemCache.get(element.context.projectId); + } + + return undefined; } private createLoadingTreeItem(): DeepnoteTreeItem { @@ -184,85 +206,131 @@ 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; single-file groups are expanded. */ + 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; - } + groupItems.sort(compareTreeItemsByLabel); - 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; - } + return groupItems; + } - deepnoteFiles.push(treeItem); - } catch (error) { - this.logger?.error(`Failed to load Deepnote project from ${file.path}`, error); - } + /** Group file→project entries by `project.id`; files sorted by path for stable ordering. */ + private buildProjectGroups(projectsByPath: Map): ProjectGroupData[] { + const groupsById = new Map(); + + for (const [cacheKey, project] of projectsByPath) { + const filePath = Uri.parse(cacheKey).path; + 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, cacheKey, project }); } - // Sort projects alphabetically by name (case-insensitive) - deepnoteFiles.sort(compareTreeItemsByLabel); + const groups = Array.from(groupsById.values()); + + for (const group of groups) { + group.files.sort((a, b) => a.filePath.localeCompare(b.filePath)); + } - return deepnoteFiles; + groups.sort((a, b) => a.projectName.toLowerCase().localeCompare(b.projectName.toLowerCase())); + + return groups; } - private async getNotebooksForProject(projectItem: DeepnoteTreeItem): Promise { + /** Children of a `ProjectGroup`: one `ProjectFile` per sibling file. */ + private async getFilesForGroup(groupItem: DeepnoteTreeItem): Promise { + const group = groupItem.data as ProjectGroupData; + const fileItems: DeepnoteTreeItem[] = []; + + for (const { filePath, cacheKey, project } of group.files) { + const isLeaf = isSingleNotebookFile(project); + const collapsibleState = isLeaf ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed; + + const context: DeepnoteTreeItemContext = { + filePath, + projectId: project.project.id + }; + + let fileItem = this.fileItemCache.get(cacheKey); + + if (fileItem) { + fileItem.data = project; + fileItem.collapsibleState = collapsibleState; + fileItem.updateVisualFields(); + } else { + fileItem = new DeepnoteTreeItem(DeepnoteTreeItemType.ProjectFile, context, project, collapsibleState); + this.fileItemCache.set(cacheKey, fileItem); + } + + fileItems.push(fileItem); + } + + fileItems.sort(compareTreeItemsByLabel); + + return fileItems; + } + + /** 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 +350,30 @@ 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 cacheKey = fileUri.toString(); + + const cached = this.cachedProjects.get(cacheKey); - const cached = this.cachedProjects.get(filePath); if (cached) { return cached; } @@ -294,11 +382,12 @@ export class DeepnoteTreeDataProvider implements TreeDataProvider { if (isSnapshotFile(uri)) { return; } - // New file created, do full refresh - this._onDidChangeTreeData.fire(); + + this.cachedProjects.delete(uri.toString()); + this.fileItemCache.delete(uri.toString()); + this._onDidChangeTreeData.fire(undefined); }); this.fileWatcher.onDidDelete((uri) => { if (isSnapshotFile(uri)) { return; } - // File deleted, clear both caches and do full refresh - this.cachedProjects.delete(uri.path); - this.treeItemCache.delete(`project:${uri.path}`); - this._onDidChangeTreeData.fire(); - }); - } - - /** - * 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; + this.cachedProjects.delete(uri.toString()); + this.fileItemCache.delete(uri.toString()); + this._onDidChangeTreeData.fire(undefined); + }); } private updateContextKey(): void { diff --git a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts index 4944422590..19c8d0dec8 100644 --- a/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteTreeDataProvider.unit.test.ts @@ -1,9 +1,56 @@ import { assert } from 'chai'; -import { l10n } from 'vscode'; +import { l10n, ThemeIcon } from 'vscode'; import { DeepnoteTreeDataProvider, compareTreeItemsByLabel } from './deepnoteTreeDataProvider'; -import { DeepnoteTreeItem, DeepnoteTreeItemType } from './deepnoteTreeItem'; -import type { DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; +import { + applyVisualFields, + DeepnoteTreeItem, + DeepnoteTreeItemType, + getNonInitNotebooks, + ProjectGroupData +} from './deepnoteTreeItem'; +import type { DeepnoteNotebook, DeepnoteProject } from '../../platform/deepnote/deepnoteTypes'; + +/** + * Structural mirror of DeepnoteTreeDataProvider's private surface (deepnoteTreeDataProvider.ts). + * `internals` is the single typed seam these tests use to reach private caches and helpers. + */ +interface DeepnoteTreeDataProviderInternals { + readonly cachedProjects: Map; + getProjectGroups(): Promise; +} + +function internals(provider: DeepnoteTreeDataProvider): DeepnoteTreeDataProviderInternals { + return provider as unknown as DeepnoteTreeDataProviderInternals; +} + +/** + * 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; @@ -323,8 +370,8 @@ suite('DeepnoteTreeDataProvider', () => { test('should support selective refresh of a specific project', async () => { // Verify that refreshProject method exists and doesn't throw assert.doesNotThrow(() => { - if (typeof (provider as any).refreshProject === 'function') { - void (provider as any).refreshProject('/workspace/project.deepnote'); + if (typeof provider.refreshProject === 'function') { + void provider.refreshProject('/workspace/project.deepnote'); } }); }); @@ -332,19 +379,15 @@ suite('DeepnoteTreeDataProvider', () => { test('should support selective refresh of notebooks for a project', async () => { // Verify that refreshNotebook method exists and doesn't throw assert.doesNotThrow(() => { - if (typeof (provider as any).refreshNotebook === 'function') { - void (provider as any).refreshNotebook('project-123'); + if (typeof provider.refreshNotebook === 'function') { + void provider.refreshNotebook('project-123'); } }); }); - test('should update visual fields when project data changes', async () => { - // Access private caches - const treeItemCache = (provider as any).treeItemCache as Map; - - // Create initial project with 1 notebook + test('applyVisualFields reflects the renamed project and new notebook count on a project-file item', () => { + // A legacy multi-notebook project (2 notebooks) renders as a projectFile node. const filePath = '/workspace/test-project.deepnote'; - const cacheKey = `project:${filePath}`; const initialProject: DeepnoteProject = { metadata: { createdAt: '2023-01-01T00:00:00Z', @@ -360,6 +403,13 @@ suite('DeepnoteTreeDataProvider', () => { blocks: [], executionMode: 'block', isModule: false + }, + { + id: 'notebook-2', + name: 'Notebook 2', + blocks: [], + executionMode: 'block', + isModule: false } ], settings: {} @@ -367,32 +417,31 @@ suite('DeepnoteTreeDataProvider', () => { version: '1.0.0' }; - const mockTreeItem = new DeepnoteTreeItem( - DeepnoteTreeItemType.ProjectFile, - { - filePath: filePath, - projectId: 'project-123' - }, - initialProject, - 1 - ); - treeItemCache.set(cacheKey, mockTreeItem); + // applyVisualFields only reads type/data/context and writes the visual fields, so a minimal + // shape suffices. DeepnoteTreeItem is deliberately not constructed: the mocha ESM loader drops + // new.target, so subclass prototype methods (updateVisualFields) are unavailable on instances. + const item = { + type: DeepnoteTreeItemType.ProjectFile, + context: { filePath, projectId: 'project-123' }, + data: initialProject + } as unknown as DeepnoteTreeItem; + + applyVisualFields(item); - // Verify initial state - assert.strictEqual(mockTreeItem.label, 'Original Name'); - assert.strictEqual(mockTreeItem.description, '1 notebook'); + assert.strictEqual(item.label, 'Original Name'); + assert.strictEqual(item.description, '2 notebooks'); - // Update the project data (simulating rename and adding notebooks) + // Rename the project and add a third notebook, then re-derive the visual fields. 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 @@ -401,65 +450,108 @@ suite('DeepnoteTreeDataProvider', () => { } }; - mockTreeItem.data = updatedProject; - // Call updateVisualFields if it exists (it may not work properly in test environment due to proxy limitations) - 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; - mockTreeItem.description = `${notebookCount} notebook${notebookCount !== 1 ? 's' : ''}`; - } + item.data = updatedProject; + applyVisualFields(item); - // Verify visual fields were updated - assert.strictEqual(mockTreeItem.label, 'Renamed Project', 'Label should reflect new project name'); - assert.strictEqual( - mockTreeItem.description, - '2 notebooks', - 'Description should reflect new notebook count' - ); - assert.include( - mockTreeItem.tooltip as string, - 'Renamed Project', - 'Tooltip should include new project name' - ); + assert.strictEqual(item.label, 'Renamed Project', 'Label should reflect new project name'); + assert.strictEqual(item.description, '3 notebooks', 'Description should reflect new notebook count'); + assert.include(item.tooltip as string, 'Renamed Project', 'Tooltip should include new project name'); }); + }); - test('should clear both caches when file is deleted', () => { - // Access private caches - const cachedProjects = (provider as any).cachedProjects as Map; - const treeItemCache = (provider as any).treeItemCache as Map; + // Direct per-type coverage of applyVisualFields (the ProjectFile legacy-collapsible branch is + // covered by the re-derivation test above). DeepnoteTreeItem is deliberately NOT constructed: the + // mocha ESM loader drops new.target, so subclass methods are unavailable on instances built here; + // applyVisualFields only reads type/context/data and writes the visual fields, so a minimal shape + // suffices. + suite('applyVisualFields per DeepnoteTreeItemType branch', () => { + // Reads a ThemeIcon's id without a cast; a non-ThemeIcon iconPath (string/Uri) yields undefined. + function iconId(item: DeepnoteTreeItem): string | undefined { + return item.iconPath instanceof ThemeIcon ? item.iconPath.id : undefined; + } - // Add entries to both caches - const filePath = '/workspace/test-project.deepnote'; - const cacheKey = `project:${filePath}`; + function makeItem( + type: DeepnoteTreeItemType, + context: { filePath: string; projectId: string; notebookId?: string }, + data: DeepnoteProject | DeepnoteNotebook | ProjectGroupData | null + ): DeepnoteTreeItem { + return { type, context, data } as unknown as DeepnoteTreeItem; + } + + test('Loading → spinner icon and a non-clickable loading label', () => { + const item = makeItem(DeepnoteTreeItemType.Loading, { filePath: '', projectId: '' }, null); - cachedProjects.set(filePath, mockProject); - const mockTreeItem = new DeepnoteTreeItem( + applyVisualFields(item); + + assert.strictEqual(item.contextValue, 'loading'); + assert.strictEqual(item.label, 'Loading…'); + assert.strictEqual(item.tooltip, 'Loading…'); + assert.strictEqual(item.description, ''); + assert.strictEqual(iconId(item), 'loading~spin'); + }); + + test('ProjectGroup → folder icon, pluralized file count, and no open command', () => { + const group: ProjectGroupData = { + projectId: 'group-1', + projectName: 'My Group', + files: [ + { filePath: '/a.deepnote', cacheKey: 'a', project: makeSingleNotebookProject('group-1', 'nb-a') }, + { filePath: '/b.deepnote', cacheKey: 'b', project: makeSingleNotebookProject('group-1', 'nb-b') } + ] + }; + const item = makeItem(DeepnoteTreeItemType.ProjectGroup, { filePath: '', projectId: 'group-1' }, group); + + applyVisualFields(item); + + assert.strictEqual(item.contextValue, 'projectGroup'); + assert.strictEqual(item.label, 'My Group'); + assert.strictEqual(item.tooltip, 'Deepnote Project: My Group'); + assert.strictEqual(item.description, '2 files'); + assert.strictEqual(iconId(item), 'folder'); + assert.isUndefined(item.command, 'a project group is a container, not directly openable'); + }); + + test('ProjectFile single-notebook leaf → notebook icon and an open command carrying the leaf notebook id', () => { + const project = makeSingleNotebookProject('proj-1', 'nb-1', 'Leaf Project'); + const item = makeItem( DeepnoteTreeItemType.ProjectFile, - { - filePath: filePath, - projectId: 'project-123' - }, - mockProject, - 1 + { filePath: '/leaf.deepnote', projectId: 'proj-1' }, + project ); - treeItemCache.set(cacheKey, mockTreeItem); - // Verify both caches have the entry - assert.isTrue(cachedProjects.has(filePath), 'cachedProjects should have entry before deletion'); - assert.isTrue(treeItemCache.has(cacheKey), 'treeItemCache should have entry before deletion'); + applyVisualFields(item); - // 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); + assert.strictEqual(item.contextValue, 'notebookFile'); + assert.strictEqual(item.label, 'Notebook nb-1'); + assert.strictEqual(item.description, '0 cells'); + assert.strictEqual(iconId(item), 'notebook'); + assert.strictEqual(item.command?.command, 'deepnote.openNotebook'); + assert.deepStrictEqual(item.command?.arguments, [ + { filePath: '/leaf.deepnote', projectId: 'proj-1', notebookId: 'nb-1' } + ]); + }); - // 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'); + test('Notebook → file-code icon and an open command reusing the item context by reference', () => { + const notebook: DeepnoteNotebook = { + id: 'nb-42', + name: 'Child Notebook', + blocks: [{ blockGroup: 'g', id: 'b1', content: '', sortingKey: 'a0', metadata: {}, type: 'code' }], + executionMode: 'block', + isModule: false + }; + const context = { filePath: '/legacy.deepnote', projectId: 'proj-9', notebookId: 'nb-42' }; + const item = makeItem(DeepnoteTreeItemType.Notebook, context, notebook); + + applyVisualFields(item); + + assert.strictEqual(item.contextValue, 'notebook'); + assert.strictEqual(item.label, 'Child Notebook'); + assert.strictEqual(item.description, '1 cell'); + assert.include(item.tooltip as string, 'Execution Mode: block'); + assert.strictEqual(iconId(item), 'file-code'); + assert.strictEqual(item.command?.command, 'deepnote.openNotebook'); + // The Notebook branch passes item.context straight through (contrast the leaf's fresh object). + assert.strictEqual(item.command?.arguments?.[0], context); }); }); @@ -648,4 +740,203 @@ suite('DeepnoteTreeDataProvider', () => { assert.strictEqual(notebookItems[2].label, 'zebra notebook', 'Third should be zebra notebook'); }); }); + + // Sibling files share one project.id, so refresh must rebuild the whole grouped subtree + // rather than patch a single cached item. + 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 = internals(provider).cachedProjects; + 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'); + }); + }); + + 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'); + }); + }); + + suite('getChildren groups siblings and distinguishes leaf vs legacy files', () => { + const projectId = 'group-project'; + + // Invoke getProjectGroups()/getChildren(groupItem) directly rather than the root getChildren(), + // which short-circuits to [] when workspace.workspaceFolders is unset; the seeded cache drives grouping. + function seed(entries: Array<[string, DeepnoteProject]>): void { + const cachedProjects = internals(provider).cachedProjects; + for (const [filePath, project] of entries) { + cachedProjects.set(filePath, project); + } + } + + async function getGroupItems(): Promise { + return internals(provider).getProjectGroups(); + } + + 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/init-main.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 0763f0e1bb..c66f5dcc5a 100644 --- a/src/notebooks/deepnote/deepnoteTreeItem.ts +++ b/src/notebooks/deepnote/deepnoteTreeItem.ts @@ -1,10 +1,12 @@ -import { TreeItem, TreeItemCollapsibleState, Uri, ThemeIcon } from 'vscode'; +import { TreeItem, TreeItemCollapsibleState, ThemeIcon } from 'vscode'; import type { DeepnoteProject, DeepnoteNotebook } from '../../platform/deepnote/deepnoteTypes'; /** - * Represents different types of items in the Deepnote tree view + * Tree item types: `ProjectGroup` (one per `project.id`) → `ProjectFile` (one `.deepnote` file, + * a leaf when it has a single non-init notebook) → `Notebook` (legacy multi-notebook child). */ export enum DeepnoteTreeItemType { + ProjectGroup = 'projectGroup', ProjectFile = 'projectFile', Notebook = 'notebook', Loading = 'loading' @@ -20,107 +22,155 @@ 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; + // `filePath` is the native path (for `Uri.file`); `cacheKey` is the opaque `Uri.toString()` cache key. + readonly files: Array<{ filePath: string; cacheKey: 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' : ''}`; - } - } +/** + * Notebooks of a project excluding the init notebook (`project.initNotebookId`). + */ +export function getNonInitNotebooks(project: DeepnoteProject): DeepnoteNotebook[] { + const notebooks = project.project.notebooks ?? []; + const initNotebookId = project.project.initNotebookId; + + return notebooks.filter((notebook) => notebook.id !== initNotebookId); +} + +/** + * True when a file renders as a single-notebook leaf: one non-init notebook, or an init-only file + * whose sole notebook is the init notebook. + */ +export function isSingleNotebookFile(project: DeepnoteProject): boolean { + const nonInit = getNonInitNotebooks(project); + + if (nonInit.length === 1) { + return true; + } + + return nonInit.length === 0 && (project.project.notebooks?.length ?? 0) === 1; +} + +/** + * The single notebook to render for a leaf file: first non-init notebook, falling back to the + * first notebook when the only notebook IS the init notebook. + */ +export function resolveLeafNotebook(project: DeepnoteProject): DeepnoteNotebook | undefined { + const nonInit = getNonInitNotebooks(project); + + if (nonInit.length > 0) { + return nonInit[0]; + } + + return project.project.notebooks?.[0]; +} + +/** + * Sets the item's visual fields from its type/data. A free function, not a method: calling a + * subclass method from a `TreeItem` constructor is unsafe in transpiled ES-module output. + */ +export 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; + + 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); + + if (isSingleNotebookFile(project)) { + const notebook = resolveLeafNotebook(project); + const blockCount = notebook?.blocks?.length ?? 0; - 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 = { + 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}`; + 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' : ''}`; - } + /** + * Re-applies the visual fields; call after mutating `data` to reflect changes in the tree. + */ + public updateVisualFields(): void { + applyVisualFields(this); } } diff --git a/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts b/src/notebooks/deepnote/deepnoteTreeItem.unit.test.ts index bbadeceee4..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); @@ -258,13 +266,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/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts index de858ad723..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.getOriginalProject(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 dbd76c0f9a..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.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('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.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('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.getOriginalProject('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.getOriginalProject(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.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); - when(notebookManager.getOriginalProject('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 fe64c64a7e..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.getOriginalProject(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 1b495e19a6..3483ba446d 100644 --- a/src/notebooks/deepnote/integrations/integrationManager.ts +++ b/src/notebooks/deepnote/integrations/integrationManager.ts @@ -95,16 +95,16 @@ export class IntegrationManager implements IIntegrationManager { return; } - // Get the project ID 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,8 +127,9 @@ export class IntegrationManager implements IIntegrationManager { } const projectId = activeNotebook.metadata?.deepnoteProjectId; - if (!projectId) { - void window.showErrorMessage(l10n.t('Cannot determine project ID')); + const notebookId = activeNotebook.metadata?.deepnoteNotebookId; + if (!projectId || !notebookId) { + void window.showErrorMessage(l10n.t('Cannot determine project or notebook ID')); return; } @@ -136,7 +137,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 +147,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.getProjectForNotebook(projectId, notebookId); const projectIntegration = project?.project.integrations?.find((i) => i.id === selectedIntegrationId); let integrationName: string | undefined; @@ -174,6 +175,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 5e4f7c23be..f722a05c65 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -30,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; @@ -59,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; @@ -477,16 +482,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.getOriginalProject(this.projectId); - projectName = project?.project.name; - } - await this.currentPanel.webview.postMessage({ integrations: integrationsData, - projectName, + projectName: this.projectName, type: 'update' }); } diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts index 474d06eae0..0c7435c5a6 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -21,6 +21,18 @@ import { buildServiceAccountIntegration } from './federatedAuth/federatedAuthTestHelpers'; +/** + * Structural mirror of IntegrationWebviewProvider's private surface (integrationWebview.ts). + * `internals` is the single typed seam this test uses to drive the private update method. + */ +interface IntegrationWebviewProviderInternals { + updateProjectIntegrationsList(): Promise; +} + +function internals(provider: IntegrationWebviewProvider): IntegrationWebviewProviderInternals { + return provider as unknown as IntegrationWebviewProviderInternals; +} + interface CapturedMessage { type: string; integrations?: Array<{ id: string; tokenStatus?: string }>; @@ -147,7 +159,11 @@ suite('IntegrationWebviewProvider', () => { onDidChangeTokens.dispose(); }); - function buildProvider(opts: { tokenStorage?: IFederatedAuthTokenStorage } = {}): IntegrationWebviewProvider { + function buildProvider( + opts: { + tokenStorage?: IFederatedAuthTokenStorage; + } = {} + ): IntegrationWebviewProvider { return new IntegrationWebviewProvider( instance(extensionContext), instance(integrationStorage), @@ -431,4 +447,47 @@ 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: cache-only update', () => { + async function callUpdateProjectIntegrationsList(provider: IntegrationWebviewProvider): Promise { + // `show()` sets `projectId` + the integrations map; then drive the private update method. + await show(provider, singleIntegrationMap('pg-1', buildPostgresIntegration({ id: 'pg-1' }))); + + await internals(provider).updateProjectIntegrationsList(); + } + + 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 }); + await callUpdateProjectIntegrationsList(provider); + + sinon.assert.calledOnce(updateProjectIntegrationsSpy); + sinon.assert.calledWith(updateProjectIntegrationsSpy, PROJECT_ID); + }); + + 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); + + return Promise.resolve(undefined); + }); + + // 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 no cached entry was updated' + ); + }); + }); }); 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/executionMetadataTracker.ts b/src/notebooks/deepnote/snapshots/executionMetadataTracker.ts new file mode 100644 index 0000000000..f04856befc --- /dev/null +++ b/src/notebooks/deepnote/snapshots/executionMetadataTracker.ts @@ -0,0 +1,212 @@ +import { injectable } from 'inversify'; + +import type { Execution, ExecutionError } from '@deepnote/blocks'; + +import { logger } from '../../../platform/logging'; + +/** + * Block-level execution metadata. + */ +export interface BlockExecutionMetadata { + /** SHA-256 hash of block source code (prefixed with "sha256:") */ + contentHash: string; + + /** ISO 8601 timestamp when block execution started */ + executionStartedAt?: string; + + /** ISO 8601 timestamp when block execution completed */ + executionFinishedAt?: string; +} + +/** + * Per-notebook cell-execution state. Holds only the execution-timing/counter concern; + * environment capture lives separately on the service. + */ +interface TrackedExecution { + /** Number of blocks executed so far */ + blocksExecuted: number; + + /** Number of blocks that failed */ + blocksFailed: number; + + /** Number of blocks that succeeded */ + blocksSucceeded: number; + + /** Per-cell execution metadata, keyed by cell ID */ + cellMetadata: Map; + + /** Top-level error if any */ + error?: { name?: string; message?: string; traceback?: string[] }; + + /** ISO 8601 timestamp when last cell finished executing */ + finishedAt?: string; + + /** ISO 8601 timestamp when first cell started executing */ + startedAt: string; + + /** Total duration in milliseconds */ + totalDurationMs: number; +} + +/** + * Owns per-notebook cell-execution metadata: timing, counters, per-cell state, and the + * derived execution summary. Kept separate from environment capture so the two concerns + * do not share one mutable struct. + */ +@injectable() +export class ExecutionMetadataTracker { + private readonly executionStates = new Map(); + + clear(notebookUri: string): void { + this.executionStates.delete(notebookUri); + } + + /** + * Returns the tracked state for a notebook, creating a fresh session (counters zeroed, + * `startedAt` seeded from `startTime`) if none exists. Existing state is preserved so a + * capture-time seed is not overwritten by a later first-cell start. + */ + ensureExecutionState(notebookUri: string, startTime: number): void { + if (this.executionStates.has(notebookUri)) { + return; + } + + this.executionStates.set(notebookUri, { + blocksFailed: 0, + blocksExecuted: 0, + blocksSucceeded: 0, + cellMetadata: new Map(), + startedAt: new Date(startTime).toISOString(), + totalDurationMs: 0 + }); + + logger.trace(`[Snapshot] Created new execution state for ${notebookUri}`); + } + + getBlockExecutionMetadata(notebookUri: string, cellId: string): BlockExecutionMetadata | undefined { + const state = this.executionStates.get(notebookUri); + + if (!state) { + return; + } + + return state.cellMetadata.get(cellId); + } + + /** + * Number of blocks executed for a notebook, or `undefined` when the notebook is untracked. + * The undefined/number distinction lets callers avoid conflating "no session" with "zero blocks". + */ + getExecutedBlockCount(notebookUri: string): number | undefined { + return this.executionStates.get(notebookUri)?.blocksExecuted; + } + + getExecutionMetadata(notebookUri: string): Execution | undefined { + const state = this.executionStates.get(notebookUri); + + if (!state) { + return; + } + + // Don't return execution metadata if no cells have been executed + if (state.blocksExecuted === 0) { + return; + } + + const execution: Execution = { + finishedAt: state.finishedAt || state.startedAt, + startedAt: state.startedAt, + summary: { + blocksExecuted: state.blocksExecuted, + blocksFailed: state.blocksFailed, + blocksSucceeded: state.blocksSucceeded, + totalDurationMs: state.totalDurationMs + }, + triggeredBy: 'user' + }; + + if (state.error) { + execution.error = state.error; + } + + return execution; + } + + /** + * Whether any tracked cell started execution but has not finished yet. + */ + hasPendingCellStateChanges(notebookUri: string): boolean { + const state = this.executionStates.get(notebookUri); + + if (!state) { + return false; + } + + for (const metadata of state.cellMetadata.values()) { + if (metadata.executionStartedAt && !metadata.executionFinishedAt) { + return true; + } + } + + return false; + } + + recordCellExecutionEnd( + notebookUri: string, + cellId: string, + endTime: number, + success: boolean, + error?: ExecutionError + ): void { + const state = this.executionStates.get(notebookUri); + + if (!state) { + logger.warn(`[Snapshot] No execution state found for notebook ${notebookUri}`); + + return; + } + + const isoTimestamp = new Date(endTime).toISOString(); + const cellMetadata = state.cellMetadata.get(cellId); + + if (cellMetadata) { + cellMetadata.executionFinishedAt = isoTimestamp; + } + + state.blocksExecuted++; + + if (success) { + state.blocksSucceeded++; + } else { + state.blocksFailed++; + + if (error) { + state.error = error; + } + } + + state.finishedAt = isoTimestamp; + + const startMs = new Date(state.startedAt).getTime(); + + state.totalDurationMs = endTime - startMs; + + logger.trace(`[Snapshot] Cell ${cellId} execution ended at ${isoTimestamp} (success: ${success})`); + } + + recordCellExecutionStart(notebookUri: string, cellId: string, startTime: number): void { + this.ensureExecutionState(notebookUri, startTime); + + const state = this.executionStates.get(notebookUri)!; + const isoTimestamp = new Date(startTime).toISOString(); + const cellMetadata = state.cellMetadata.get(cellId) || { contentHash: '' }; + + cellMetadata.executionStartedAt = isoTimestamp; + + delete cellMetadata.executionFinishedAt; + + state.cellMetadata.set(cellId, cellMetadata); + + logger.trace(`[Snapshot] Cell ${cellId} execution started at ${isoTimestamp}`); + } +} diff --git a/src/notebooks/deepnote/snapshots/executionMetadataTracker.unit.test.ts b/src/notebooks/deepnote/snapshots/executionMetadataTracker.unit.test.ts new file mode 100644 index 0000000000..1d0ed65812 --- /dev/null +++ b/src/notebooks/deepnote/snapshots/executionMetadataTracker.unit.test.ts @@ -0,0 +1,227 @@ +import { assert } from 'chai'; + +import { ExecutionMetadataTracker } from './executionMetadataTracker'; + +suite('ExecutionMetadataTracker', () => { + let tracker: ExecutionMetadataTracker; + const notebookUri = 'file:///path/to/notebook.deepnote'; + const cellId = 'cell-123'; + + setup(() => { + tracker = new ExecutionMetadataTracker(); + }); + + suite('recordCellExecutionStart', () => { + test('records the cell start time and leaves it unfinished', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + + const metadata = tracker.getBlockExecutionMetadata(notebookUri, cellId); + assert.isDefined(metadata); + assert.strictEqual(metadata!.executionStartedAt, new Date(startTime).toISOString()); + assert.isUndefined(metadata!.executionFinishedAt); + }); + + test('does not surface execution metadata until a cell completes', () => { + tracker.recordCellExecutionStart(notebookUri, cellId, Date.now()); + + // No cell has completed, so blocksExecuted is 0 and there is no summary to return. + assert.isUndefined(tracker.getExecutionMetadata(notebookUri)); + }); + + test('re-starting a completed cell clears its finished timestamp', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime + 200); + + const metadata = tracker.getBlockExecutionMetadata(notebookUri, cellId); + assert.isDefined(metadata); + assert.isUndefined(metadata!.executionFinishedAt); + }); + }); + + suite('recordCellExecutionEnd', () => { + test('accumulates success and failure counters and records the error', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, 'cell-1', startTime); + tracker.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); + tracker.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, false, { + name: 'ValueError', + message: 'boom' + }); + + const metadata = tracker.getExecutionMetadata(notebookUri); + assert.isDefined(metadata); + assert.deepStrictEqual(metadata!.summary, { + blocksExecuted: 2, + blocksFailed: 1, + blocksSucceeded: 1, + totalDurationMs: 300 + }); + assert.strictEqual(metadata!.error!.name, 'ValueError'); + }); + + test('computes total duration from the session start', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 5000, true); + + const metadata = tracker.getExecutionMetadata(notebookUri); + assert.strictEqual(metadata!.summary!.totalDurationMs, 5000); + }); + + test('missed-start fallback: counts an end even when no start was recorded', () => { + const startTime = Date.now(); + + // Seed the session (as capture-before-execution would) but never record a start. + tracker.ensureExecutionState(notebookUri, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + + const metadata = tracker.getExecutionMetadata(notebookUri); + assert.isDefined(metadata); + assert.deepStrictEqual(metadata!.summary, { + blocksExecuted: 1, + blocksFailed: 0, + blocksSucceeded: 1, + totalDurationMs: 100 + }); + // No per-cell start was recorded, so there is no block metadata for it. + assert.isUndefined(tracker.getBlockExecutionMetadata(notebookUri, cellId)); + }); + + test('ignores an end for an untracked notebook', () => { + tracker.recordCellExecutionEnd(notebookUri, cellId, Date.now(), true); + + assert.isUndefined(tracker.getExecutionMetadata(notebookUri)); + assert.isUndefined(tracker.getExecutedBlockCount(notebookUri)); + }); + + test('counts a failure with no error object and leaves the summary error undefined', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, false); + + const metadata = tracker.getExecutionMetadata(notebookUri); + assert.isDefined(metadata); + assert.deepStrictEqual(metadata!.summary, { + blocksExecuted: 1, + blocksFailed: 1, + blocksSucceeded: 0, + totalDurationMs: 100 + }); + // A failure reported without an ExecutionError must not synthesize an error field. + assert.isUndefined(metadata!.error); + }); + }); + + suite('ensureExecutionState', () => { + test('seeds startedAt from the provided start time', () => { + const startTime = Date.now(); + + tracker.ensureExecutionState(notebookUri, startTime); + // A completed cell surfaces the summary carrying the seeded startedAt. + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + + const metadata = tracker.getExecutionMetadata(notebookUri); + assert.strictEqual(metadata!.startedAt, new Date(startTime).toISOString()); + }); + + test('preserves existing state and its startedAt on a later call', () => { + const firstStart = Date.now(); + const laterStart = firstStart + 10_000; + + tracker.ensureExecutionState(notebookUri, firstStart); + tracker.recordCellExecutionStart(notebookUri, cellId, firstStart + 50); + tracker.ensureExecutionState(notebookUri, laterStart); + tracker.recordCellExecutionEnd(notebookUri, cellId, firstStart + 100, true); + + const metadata = tracker.getExecutionMetadata(notebookUri); + // startedAt stays at the first seed, not the later ensureExecutionState call. + assert.strictEqual(metadata!.startedAt, new Date(firstStart).toISOString()); + }); + }); + + suite('getExecutedBlockCount', () => { + test('returns undefined for an untracked notebook', () => { + assert.isUndefined(tracker.getExecutedBlockCount('file:///unknown.deepnote')); + }); + + test('returns zero for a seeded-but-unexecuted notebook', () => { + tracker.ensureExecutionState(notebookUri, Date.now()); + + assert.strictEqual(tracker.getExecutedBlockCount(notebookUri), 0); + }); + + test('reflects the number of completed cells', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, 'cell-1', startTime); + tracker.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); + tracker.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, true); + + assert.strictEqual(tracker.getExecutedBlockCount(notebookUri), 2); + }); + }); + + suite('hasPendingCellStateChanges', () => { + test('is true while a started cell has not finished', () => { + tracker.recordCellExecutionStart(notebookUri, cellId, Date.now()); + + assert.isTrue(tracker.hasPendingCellStateChanges(notebookUri)); + }); + + test('is false once every started cell finishes', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + + assert.isFalse(tracker.hasPendingCellStateChanges(notebookUri)); + }); + + test('is false for an untracked notebook', () => { + assert.isFalse(tracker.hasPendingCellStateChanges('file:///unknown.deepnote')); + }); + + test('returns to pending when a finished cell is restarted', () => { + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + assert.isFalse(tracker.hasPendingCellStateChanges(notebookUri)); + + // Re-running the same cell clears its finished timestamp, so it is pending again. + tracker.recordCellExecutionStart(notebookUri, cellId, startTime + 200); + assert.isTrue(tracker.hasPendingCellStateChanges(notebookUri)); + }); + }); + + suite('clear', () => { + test('removes all state for the given notebook only', () => { + const otherUri = 'file:///other/notebook.deepnote'; + const startTime = Date.now(); + + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + tracker.recordCellExecutionStart(otherUri, 'other-cell', startTime); + tracker.recordCellExecutionEnd(otherUri, 'other-cell', startTime + 100, true); + + tracker.clear(notebookUri); + + assert.isUndefined(tracker.getExecutionMetadata(notebookUri)); + assert.isUndefined(tracker.getExecutedBlockCount(notebookUri)); + assert.isUndefined(tracker.getBlockExecutionMetadata(notebookUri, cellId)); + + // The other notebook's state is untouched. + assert.isDefined(tracker.getExecutionMetadata(otherUri)); + }); + }); +}); diff --git a/src/notebooks/deepnote/snapshots/snapshotFiles.ts b/src/notebooks/deepnote/snapshots/snapshotFiles.ts index 6f6ff9f5fd..33fff46dde 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.ts @@ -1,3 +1,4 @@ +import { generateSnapshotFilename, parseSnapshotFilename, slugifyProjectName } from '@deepnote/convert'; import { Uri } from 'vscode'; import { InvalidProjectNameError } from '../../../platform/errors/invalidProjectNameError'; @@ -5,8 +6,35 @@ import { InvalidProjectNameError } from '../../../platform/errors/invalidProject /** 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, '\\.')}$`); +export function buildSnapshotPath({ + notebookId, + projectId, + projectName, + projectUri, + variant +}: { + notebookId?: string; + projectId: string; + projectName: string; + projectUri: Uri; + variant: 'latest' | string; +}): Uri { + const parentDir = Uri.joinPath(projectUri, '..'); + + if (projectName.trim().length <= 0) { + throw new InvalidProjectNameError(); + } + + const slug = slugifyProjectName(projectName); + + if (!slug) { + throw new InvalidProjectNameError(); + } + + const filename = generateSnapshotFilename({ slug, projectId, notebookId, timestamp: variant }); + + return Uri.joinPath(parentDir, 'snapshots', filename); +} /** * Checks if a URI represents a snapshot file @@ -16,37 +44,14 @@ export function isSnapshotFile(uri: Uri): boolean { } /** - * Extracts the project ID from a snapshot file URI. - * Snapshot filenames follow: `${slug}_${projectId}_${variant}.snapshot.deepnote` + * Extracts the project ID from a snapshot file URI. Handles the notebook-scoped + * `${slug}_${projectId}_${encodedNotebookId}_${variant}` and legacy project-scoped + * `${slug}_${projectId}_${variant}` forms via convert's `parseSnapshotFilename`. * @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..4e78054a15 100644 --- a/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotFiles.unit.test.ts @@ -1,12 +1,8 @@ +import { generateSnapshotFilename, parseSnapshotFilename, slugifyProjectName } from '@deepnote/convert'; import { assert } from 'chai'; import { Uri } from 'vscode'; -import { - extractProjectIdFromSnapshotUri, - isSnapshotFile, - slugifyProjectName, - SNAPSHOT_FILE_SUFFIX -} from './snapshotFiles'; +import { extractProjectIdFromSnapshotUri, isSnapshotFile, SNAPSHOT_FILE_SUFFIX } from './snapshotFiles'; suite('snapshotFiles', () => { suite('SNAPSHOT_FILE_SUFFIX', () => { @@ -48,16 +44,18 @@ 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 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), 'abc-123'); + assert.strictEqual(extractProjectIdFromSnapshotUri(uri), projectId); }); test('should return undefined for non-snapshot files', () => { @@ -72,16 +70,69 @@ 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('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' + }); + + // 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'); + + const parsed = parseSnapshotFilename(filename); + + assert.strictEqual(parsed?.notebookId, trickyNotebookId); + }); - test('should handle project IDs containing underscores', () => { - const uri = Uri.file('/path/to/snapshots/slug_proj_id_with_parts_latest.snapshot.deepnote'); + 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' }); - assert.strictEqual(extractProjectIdFromSnapshotUri(uri), 'proj_id_with_parts'); + // 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 +149,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é Cliché'), 'cafe-cliche'); }); test('should collapse multiple spaces into single hyphen', () => { @@ -114,25 +169,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 6e4b99e96e..cc3cc58537 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.ts @@ -5,19 +5,33 @@ import { type DeepnoteBlock, type DeepnoteFile, type Environment, - type Execution, - type ExecutionError + type Execution } from '@deepnote/blocks'; +import { + countBlocksWithOutputs, + hasOutputs, + parseSnapshotFilename, + resolveSnapshotNotebookId +} 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 { logger } from '../../../platform/logging'; import { notebookCellExecutions, @@ -26,6 +40,8 @@ import { import { IDeepnoteNotebookManager } from '../../types'; import { DeepnoteDataConverter } from '../deepnoteDataConverter'; import { IEnvironmentCapture } from './environmentCapture.node'; +import { ExecutionMetadataTracker, type BlockExecutionMetadata } from './executionMetadataTracker'; +import { buildSnapshotPath } from './snapshotFiles'; /** * Platform-layer interface for snapshot metadata service. @@ -46,61 +62,36 @@ export interface ISnapshotMetadataService { clearExecutionState(notebookUri: string): void; } -/** - * Block-level execution metadata. - */ -export interface BlockExecutionMetadata { - /** SHA-256 hash of block source code (prefixed with "sha256:") */ - contentHash: string; - - /** ISO 8601 timestamp when block execution started */ - executionStartedAt?: string; - - /** ISO 8601 timestamp when block execution completed */ - executionFinishedAt?: string; -} +// Re-exported for the module's public surface; the type now lives with the tracker that owns it. +export type { BlockExecutionMetadata }; /** - * Internal state tracking for a notebook execution session. + * Per-notebook environment-capture state owned by the service. The cell-execution concern + * (counters, timings, per-cell metadata) lives in ExecutionMetadataTracker. */ -interface NotebookExecutionState { - /** Number of blocks executed so far */ - blocksExecuted: number; - - /** Number of blocks that failed */ - blocksFailed: number; - - /** Number of blocks that succeeded */ - blocksSucceeded: number; - +interface EnvironmentCaptureState { /** Promise that resolves when environment capture completes */ capturePromise?: Promise; - /** Per-cell execution metadata, keyed by cell ID */ - cellMetadata: Map; - /** Cached environment metadata */ environment?: Environment; /** Whether environment has been captured for this session */ environmentCaptured: boolean; - - /** Top-level error if any */ - error?: { name?: string; message?: string; traceback?: string[] }; - - /** ISO 8601 timestamp when last cell finished executing */ - finishedAt?: string; - - /** ISO 8601 timestamp when first cell started executing */ - startedAt: string; - - /** Total duration in milliseconds */ - totalDurationMs: number; } /** How long a written URI is considered "recent" and suppressed from file-change processing. */ const recentWriteExpirationMs = 2000; +/** Quiet window with no output/metadata change that must elapse before a deferred snapshot flushes. */ +const outputSettleQuietPeriodMs = 150; + +/** Upper bound (from the first arm) on how long repeated changes can defer the snapshot save. */ +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); @@ -124,16 +115,24 @@ function generateTimestamp(): string { @injectable() export class SnapshotService implements ISnapshotMetadataService, IExtensionSyncActivationService { private readonly converter = new DeepnoteDataConverter(); - private readonly executionStates = new Map(); + private readonly environmentStates = 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(); + private readonly tracker: ExecutionMetadataTracker; constructor( @inject(IEnvironmentCapture) private readonly environmentCapture: IEnvironmentCapture, @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, - @inject(IDeepnoteNotebookManager) @optional() private readonly notebookManager?: IDeepnoteNotebookManager - ) {} + @inject(IDeepnoteNotebookManager) @optional() private readonly notebookManager?: IDeepnoteNotebookManager, + @inject(ExecutionMetadataTracker) @optional() tracker?: ExecutionMetadataTracker + ) { + this.tracker = tracker ?? new ExecutionMetadataTracker(); + } activate(): void { logger.info('[Snapshot] SnapshotService activated'); @@ -144,12 +143,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 +181,11 @@ 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).catch((error) => + logger.error(`[Snapshot] onExecutionComplete failed for ${e.notebookUri}`, error) + ); }, this, this.disposables @@ -177,7 +195,10 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync async captureEnvironmentBeforeExecution(notebookUri: string): Promise { logger.info(`[Snapshot] captureEnvironmentBeforeExecution called for ${notebookUri}`); - const state = this.getOrCreateExecutionState(notebookUri, Date.now()); + // Seed the session start at capture time so `startedAt` reflects capture, not the first cell. + this.tracker.ensureExecutionState(notebookUri, Date.now()); + + const state = this.getOrCreateEnvironmentState(notebookUri); // If capture is already in progress, wait for it if (state.capturePromise) { @@ -192,7 +213,8 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } clearExecutionState(notebookUri: string): void { - this.executionStates.delete(notebookUri); + this.tracker.clear(notebookUri); + this.environmentStates.delete(notebookUri); logger.trace(`[Snapshot] Cleared execution state for ${notebookUri}`); } @@ -202,9 +224,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 +244,13 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync const { latestPath, content } = prepared; const timestamp = generateTimestamp(); - const timestampedPath = this.buildSnapshotPath(projectUri, projectId, projectName, timestamp); + const timestampedPath = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: timestamp, + notebookId + }); // Write to timestamped file first (safe - doesn't touch existing files) try { @@ -268,23 +304,17 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } getBlockExecutionMetadata(notebookUri: string, cellId: string): BlockExecutionMetadata | undefined { - const state = this.executionStates.get(notebookUri); - - if (!state) { - return; - } - - return state.cellMetadata.get(cellId); + return this.tracker.getBlockExecutionMetadata(notebookUri, cellId); } async getEnvironmentMetadata(notebookUri: string): Promise { - const state = this.executionStates.get(notebookUri); + const state = this.environmentStates.get(notebookUri); logger.info(`[Snapshot] getEnvironmentMetadata for ${notebookUri}`); logger.info(Boolean(state) ? '[Snapshot] State exists.' : '[Snapshot] No state found.'); if (!state) { - logger.info(`[Snapshot] Available URIs: ${Array.from(this.executionStates.keys()).join(', ')}`); + logger.info(`[Snapshot] Available URIs: ${Array.from(this.environmentStates.keys()).join(', ')}`); return; } @@ -313,34 +343,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } getExecutionMetadata(notebookUri: string): Execution | undefined { - const state = this.executionStates.get(notebookUri); - - if (!state) { - return; - } - - // Don't return execution metadata if no cells have been executed - if (state.blocksExecuted === 0) { - return; - } - - const execution: Execution = { - finishedAt: state.finishedAt || state.startedAt, - startedAt: state.startedAt, - summary: { - blocksExecuted: state.blocksExecuted, - blocksFailed: state.blocksFailed, - blocksSucceeded: state.blocksSucceeded, - totalDurationMs: state.totalDurationMs - }, - triggeredBy: 'user' - }; - - if (state.error) { - execution.error = state.error; - } - - return execution; + return this.tracker.getExecutionMetadata(notebookUri); } isSnapshotsEnabled(): boolean { @@ -390,8 +393,12 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync }; } - async readSnapshot(projectId: string): Promise | undefined> { - logger.debug(`[Snapshot] readSnapshot called for projectId=${projectId}`); + /** + * Reads the best snapshot for a project/notebook WITHOUT a path (`deserializeNotebook` gets only + * bytes): globs the workspace by `projectId`, ranks notebook-scoped → `latest` → newest outputs. + */ + 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 +411,84 @@ 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 on projectId only so legacy (no-notebook-id) snapshots are still discovered 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); + const pattern = new RelativePattern(folder, glob); + const files = await workspace.findFiles(pattern, null, maxSnapshotFilesPerFolder); - if (latestFiles.length > 0) { - logger.debug(`[Snapshot] Found latest snapshot: ${latestFiles[0].path}`); - - 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 and legacy no-id files. + 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}`); - // 2. Find timestamped snapshots across all workspace folders - const timestampedGlob = `**/snapshots/*_${projectId}_*.snapshot.deepnote`; - let allTimestampedFiles: Uri[] = []; + return; + } - for (const folder of workspaceFolders) { - const timestampedPattern = new RelativePattern(folder, timestampedGlob); - const files = await workspace.findFiles(timestampedPattern, null, 100); + candidates.sort((a, b) => this.compareSnapshotCandidates(a, b, notebookId)); - allTimestampedFiles = allTimestampedFiles.concat(files); - } + // Return the first candidate with real outputs; skip corrupt files and empty-output + // `latest` snapshots (which signal a save race). + for (const candidate of candidates) { + let file: DeepnoteFile; - // 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); + try { + const content = await workspace.fs.readFile(candidate.uri); + const contentString = new TextDecoder('utf-8').decode(content); - return nameB.localeCompare(nameA); - }); + file = deserializeDeepnoteFile(contentString); + } catch (error) { + logger.warn( + `[Snapshot] Failed to read/parse snapshot candidate: ${Utils.basename(candidate.uri)}`, + error + ); - if (sortedFiles.length === 0) { - logger.debug(`[Snapshot] No timestamped snapshots found`); + continue; + } - return; - } + if (candidate.timestamp === 'latest' && countBlocksWithOutputs(file) === 0) { + logger.debug( + `[Snapshot] Skipping empty-output latest snapshot (possible save race): ${Utils.basename( + candidate.uri + )}` + ); + + 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[] { @@ -490,23 +512,10 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return this.recentlyWrittenUris.has(uri.toString()); } - private buildSnapshotPath( - projectUri: Uri, - projectId: string, - projectName: string, - variant: 'latest' | string - ): Uri { - const parentDir = Uri.joinPath(projectUri, '..'); - const slug = slugifyProjectName(projectName); - const filename = `${slug}_${projectId}_${variant}.snapshot.deepnote`; - - return Uri.joinPath(parentDir, 'snapshots', filename); - } - private async captureEnvironmentForNotebook(notebookUri: string): Promise { logger.info(`[Snapshot] captureEnvironmentForNotebook called for ${notebookUri}`); - const state = this.executionStates.get(notebookUri); + const state = this.environmentStates.get(notebookUri); if (!state) { logger.info(`[Snapshot] Skipping capture: no state found`); @@ -581,14 +590,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.with({ query: '' }); - } - private getComparableProjectContent(data: DeepnoteFile): object { return { version: data.version, @@ -598,21 +599,14 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync }; } - private getOrCreateExecutionState(notebookUri: string, startTime: number): NotebookExecutionState { - let state = this.executionStates.get(notebookUri); + private getOrCreateEnvironmentState(notebookUri: string): EnvironmentCaptureState { + let state = this.environmentStates.get(notebookUri); if (!state) { state = { - blocksFailed: 0, - blocksExecuted: 0, - blocksSucceeded: 0, - cellMetadata: new Map(), - environmentCaptured: false, - startedAt: new Date(startTime).toISOString(), - totalDurationMs: 0 + environmentCaptured: false }; - this.executionStates.set(notebookUri, state); - logger.trace(`[Snapshot] Created new execution state for ${notebookUri}`); + this.environmentStates.set(notebookUri, state); } return state; @@ -629,14 +623,16 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync if (state === NotebookCellExecutionState.Executing) { const startTime = Date.now(); - this.recordCellExecutionStart(notebookUri, cellId, startTime); + // Cancel the previous run's deferred save so no snapshot is written mid-execution. + this.cancelPendingSnapshotSave(notebookUri); + this.tracker.recordCellExecutionStart(notebookUri, cellId, startTime); } else if (state === NotebookCellExecutionState.Idle) { const endTime = Date.now(); const executionSummary = cell.executionSummary; const actualEndTime = executionSummary?.timing?.endTime || endTime; const success = executionSummary?.success !== false; - this.recordCellExecutionEnd(notebookUri, cellId, actualEndTime, success); + this.tracker.recordCellExecutionEnd(notebookUri, cellId, actualEndTime, success); } } @@ -657,26 +653,6 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync } } - /** - * Checks if there are any cells with pending execution state changes. - * Returns true if any tracked cell started execution but hasn't finished yet. - */ - private hasPendingCellStateChanges(notebookUri: string): boolean { - const state = this.executionStates.get(notebookUri); - - if (!state) { - return false; - } - - for (const metadata of state.cellMetadata.values()) { - if (metadata.executionStartedAt && !metadata.executionFinishedAt) { - return true; - } - } - - return false; - } - /** * Waits for pending cell state change events to be processed. * Uses an event-driven approach with a fallback timeout. @@ -684,7 +660,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync private waitForPendingCellStateChanges(notebookUri: string, timeoutMs: number): Promise { return new Promise((resolve) => { // If no pending changes, resolve immediately - if (!this.hasPendingCellStateChanges(notebookUri)) { + if (!this.tracker.hasPendingCellStateChanges(notebookUri)) { resolve(); return; @@ -694,7 +670,7 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync const disposable = notebookCellExecutions.onDidChangeNotebookCellExecutionState((e) => { if (e.cell.notebook.uri.toString() === notebookUri && e.state === NotebookCellExecutionState.Idle) { // Check if all pending cells are now complete - if (!this.hasPendingCellStateChanges(notebookUri)) { + if (!this.tracker.hasPendingCellStateChanges(notebookUri)) { disposable.dispose(); resolve(); } @@ -709,6 +685,63 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync }); } + /** + * Arms (or re-arms) the deferred snapshot save, flushing once outputs settle but no 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); + this.performSnapshotSave(notebookUri).catch((error) => + logger.error(`[Snapshot] performSnapshotSave failed for ${notebookUri}`, error) + ); + }, 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 +756,23 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } + // Defer the write until outputs settle; a new execution, close, or disposal cancels it. + 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) { @@ -739,29 +789,27 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return; } - const originalProject = this.notebookManager?.getOriginalProject(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); + // Exact (projectId, notebookId) lookup: sibling files share a project.id, so a project-only + // lookup could return the wrong sibling and silently skip this notebook's snapshot. + const originalProject = this.notebookManager?.getProjectForNotebook(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; - - if (!notebookId) { - logger.warn(`[Snapshot] No notebook ID in notebook metadata`); - - return; - } + // Use the saved notebook's own URI so the snapshot lands next to this file (not a sibling + // sharing the project.id). + const projectUri = notebook.uri; const deepnoteNotebook = originalProject.project.notebooks?.find((nb) => nb.id === notebookId); @@ -771,93 +819,118 @@ 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[]; - } - - // 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; + 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[]; + } - if (isRunAll) { - logger.debug(`[Snapshot] Creating full snapshot (Run All mode)`); + // Scope the filename by the file's snapshot notebook id, falling back to the rendered + // notebook's metadata id for any unexpected multi-notebook shape. + const snapshotNotebookId = resolveSnapshotNotebookId(snapshotProject) ?? notebookId; + + const executedBlockCount = this.tracker.getExecutedBlockCount(notebookUri); + const totalCodeCells = notebook.getCells().filter((cell) => cell.kind === NotebookCellKind.Code).length; + // Keep `undefined` (untracked) distinct from a real count so the predicate doesn't flip for + // an untracked notebook with zero code cells. + const isRunAll = executedBlockCount !== undefined && executedBlockCount === 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 + ); - const snapshotUri = await this.createSnapshot( - projectUri, - projectId, - originalProject.project.name, - snapshotProject, - 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()}`); + if (snapshotUri) { + logger.info(`[Snapshot] Updated latest snapshot: ${snapshotUri.toString()}`); + } } - } else { - logger.debug(`[Snapshot] Updating latest snapshot only (partial run)`); - - const snapshotUri = await this.updateLatestSnapshot( - projectUri, - projectId, - originalProject.project.name, - snapshotProject, - notebookUri - ); + } catch (error) { + // Fire-and-forget save: swallow so a 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); + } + } - if (snapshotUri) { - logger.info(`[Snapshot] Updated latest snapshot: ${snapshotUri.toString()}`); + /** + * Ranks open-time snapshot candidates: the requested notebook's scoped match first, then + * `latest`, then newest timestamp. Legacy no-notebook-id entries sort after 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; } } - // Clear execution state so the next run starts fresh - this.clearExecutionState(notebookUri); + const aLatest = a.timestamp === 'latest'; + + if (aLatest !== (b.timestamp === 'latest')) { + return aLatest ? -1 : 1; + } + + return b.timestamp.localeCompare(a.timestamp); } - private async parseSnapshotFile(path: Uri): Promise> { + private extractOutputsFromFile(file: DeepnoteFile): Map { const outputsMap = new Map(); + let totalBlocks = 0; - logger.debug(`[Snapshot] Parsing snapshot file: ${path.path}`); - - 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); + 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 +939,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 = buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest', notebookId }); } catch (error) { if (error instanceof InvalidProjectNameError) { logger.warn('[Snapshot] Skipping snapshots due to invalid project name', error); @@ -923,63 +997,6 @@ export class SnapshotService implements ISnapshotMetadataService, IExtensionSync return { latestPath, content }; } - private recordCellExecutionEnd( - notebookUri: string, - cellId: string, - endTime: number, - success: boolean, - error?: ExecutionError - ): void { - const state = this.executionStates.get(notebookUri); - - if (!state) { - logger.warn(`[Snapshot] No execution state found for notebook ${notebookUri}`); - - return; - } - - const isoTimestamp = new Date(endTime).toISOString(); - const cellMetadata = state.cellMetadata.get(cellId); - - if (cellMetadata) { - cellMetadata.executionFinishedAt = isoTimestamp; - } - - state.blocksExecuted++; - - if (success) { - state.blocksSucceeded++; - } else { - state.blocksFailed++; - - if (error) { - state.error = error; - } - } - - state.finishedAt = isoTimestamp; - - const startMs = new Date(state.startedAt).getTime(); - - state.totalDurationMs = endTime - startMs; - - logger.trace(`[Snapshot] Cell ${cellId} execution ended at ${isoTimestamp} (success: ${success})`); - } - - private recordCellExecutionStart(notebookUri: string, cellId: string, startTime: number): void { - const state = this.getOrCreateExecutionState(notebookUri, startTime); - const isoTimestamp = new Date(startTime).toISOString(); - const cellMetadata = state.cellMetadata.get(cellId) || { contentHash: '' }; - - cellMetadata.executionStartedAt = isoTimestamp; - - delete cellMetadata.executionFinishedAt; - - state.cellMetadata.set(cellId, cellMetadata); - - logger.trace(`[Snapshot] Cell ${cellId} execution started at ${isoTimestamp}`); - } - private trackWrittenUri(uri: Uri): void { const key = uri.toString(); this.recentlyWrittenUris.add(key); @@ -1011,9 +1028,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 36666343a7..a9170facf4 100644 --- a/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/snapshotService.unit.test.ts @@ -1,29 +1,69 @@ +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 { FileType, NotebookCellKind, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; - -import type { DeepnoteBlock, DeepnoteFile, ExecutableBlock } from '@deepnote/blocks'; - +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { + FileType, + NotebookCell, + NotebookCellKind, + NotebookDocument, + NotebookDocumentCellChange, + NotebookDocumentChangeEvent, + TextDocument, + Uri, + WorkspaceConfiguration, + WorkspaceFolder +} from 'vscode'; + +import type { DeepnoteBlock, DeepnoteFile, Environment, ExecutableBlock } from '@deepnote/blocks'; + +import { NotebookCellExecutionState } from '../../../platform/notebooks/cellExecutionStateService'; import { IEnvironmentCapture } from './environmentCapture.node'; +import { ExecutionMetadataTracker } from './executionMetadataTracker'; +import { buildSnapshotPath } from './snapshotFiles'; import { SnapshotService } from './snapshotService'; +import { InvalidProjectNameError } from '../../../platform/errors/invalidProjectNameError'; import type { DeepnoteOutput } from '../../../platform/deepnote/deepnoteTypes'; +import { IDeepnoteNotebookManager } from '../../types'; import { IDisposableRegistry } from '../../../platform/common/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +/** + * Structural mirror of SnapshotService's private surface (snapshotService.ts). + * `internals` is the single typed seam these tests use to reach private methods. + */ +interface SnapshotServiceInternals { + armSnapshotSave(notebookUri: string): void; + cancelPendingSnapshotSave(notebookUri: string): void; + handleCellExecutionStateChange(cell: NotebookCell, state: NotebookCellExecutionState): void; + handleNotebookDocumentChange(event: NotebookDocumentChangeEvent): void; + performSnapshotSave(notebookUri: string): Promise; + updateLatestSnapshot( + projectUri: Uri, + projectId: string, + projectName: string, + projectData: DeepnoteFile, + notebookId?: string, + notebookUri?: string + ): Promise; +} + +function internals(service: SnapshotService): SnapshotServiceInternals { + return service as unknown as SnapshotServiceInternals; +} + suite('SnapshotService', () => { let service: SnapshotService; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let serviceAny: any; let mockEnvironmentCapture: IEnvironmentCapture; let mockDisposables: IDisposableRegistry; + let tracker: ExecutionMetadataTracker; setup(() => { resetVSCodeMocks(); mockEnvironmentCapture = mock(); mockDisposables = []; - service = new SnapshotService(instance(mockEnvironmentCapture), mockDisposables); - serviceAny = service; + tracker = new ExecutionMetadataTracker(); + service = new SnapshotService(instance(mockEnvironmentCapture), mockDisposables, undefined, tracker); }); function createProjectData(projectId = 'test-project-id-123', projectName = 'My Project'): DeepnoteFile { @@ -62,7 +102,12 @@ suite('SnapshotService', () => { const projectId = 'e132b172-b114-410e-8331-011517db664f'; const projectName = 'My Project'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: 'latest' + }); assert.include(result.fsPath, 'snapshots'); assert.include(result.fsPath, 'my-project'); @@ -77,7 +122,12 @@ suite('SnapshotService', () => { const projectName = 'My Project'; const timestamp = '2025-12-11T10-31-48'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, timestamp); + const result = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: timestamp + }); assert.include(result.fsPath, 'snapshots'); assert.include(result.fsPath, 'my-project'); @@ -91,7 +141,12 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'Customer Churn ML Playbook!'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: 'latest' + }); assert.include(result.fsPath, 'customer-churn-ml-playbook'); assert.notInclude(result.fsPath, '!'); @@ -103,9 +158,32 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'Test@#$%Project'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: '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 = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: 'latest', + notebookId + }); + + assert.include(result.fsPath, `${projectId}_notebook-1_latest.snapshot.deepnote`); }); test('should handle project names with multiple spaces', () => { @@ -113,7 +191,12 @@ suite('SnapshotService', () => { const projectId = 'abc-123'; const projectName = 'My Project Name'; - const result = serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'); + const result = buildSnapshotPath({ + projectUri, + projectId, + projectName, + variant: 'latest' + }); assert.include(result.fsPath, 'my-project-name'); assert.notInclude(result.fsPath, '--'); @@ -125,7 +208,7 @@ suite('SnapshotService', () => { const projectName = ''; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); @@ -136,7 +219,7 @@ suite('SnapshotService', () => { const projectName = '@#$%^&*()'; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); @@ -147,10 +230,46 @@ suite('SnapshotService', () => { const projectName = ' '; assert.throws( - () => serviceAny.buildSnapshotPath(projectUri, projectId, projectName, 'latest'), + () => buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }), 'Project name cannot be empty or contain only special characters' ); }); + + // performSnapshotSave catches this failure with `error instanceof InvalidProjectNameError` to + // skip snapshots gracefully; a generic Error with the same message would defeat that catch, so + // pin the TYPE (not just the message) at BOTH throw sites — the empty-name guard here... + test('throws an InvalidProjectNameError instance (not a generic Error) for an empty name', () => { + const projectUri = Uri.file('/path/to/file.deepnote'); + const projectId = 'abc-123'; + + assert.throws( + () => buildSnapshotPath({ projectUri, projectId, projectName: '', variant: 'latest' }), + InvalidProjectNameError + ); + }); + + // ...and the slug-empties-to-nothing guard, which passes the trim check but fails slugification. + test('throws an InvalidProjectNameError instance when the name slugifies to empty', () => { + const projectUri = Uri.file('/path/to/file.deepnote'); + const projectId = 'abc-123'; + + assert.throws( + () => buildSnapshotPath({ projectUri, projectId, projectName: '@#$', variant: 'latest' }), + InvalidProjectNameError + ); + }); + + // Complements the notebook-id-bearing shape above: with no notebookId the projectId must abut + // the variant directly, with no notebook segment leaking between them. + test('builds the legacy (no notebookId) filename shape with the projectId immediately before the variant', () => { + const projectUri = Uri.file('/path/to/my-project.deepnote'); + const projectId = 'e132b172-b114-410e-8331-011517db664f'; + const projectName = 'My Project'; + + const result = buildSnapshotPath({ projectUri, projectId, projectName, variant: 'latest' }); + + assert.include(result.fsPath, `${projectId}_latest.snapshot.deepnote`); + }); }); suite('mergeOutputsIntoBlocks', () => { @@ -498,7 +617,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 +643,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 +653,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 +707,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 +749,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 +757,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 +768,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 +781,375 @@ 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}' +`; + } + + /** + * Dispatches readFile bytes per-URI by fsPath: ts-mockito's single-arg matcher returns one value + * for every call, so without this every candidate reads identical bytes and "which file won" is moot. + */ + 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'); + }); + }); + + 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(); + + // Stub the flush so the test observes only whether/when it fires, never real I/O. The armed + // timer reads this.performSnapshotSave at fire time, so stubbing the instance property works. + performSaveStub = sinon.stub(internals(service), '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 { + const notebook = mock(); + when(notebook.uri).thenReturn(Uri.parse(notebookUri)); + + const event = mock(); + when(event.notebook).thenReturn(instance(notebook)); + when(event.cellChanges).thenReturn([{ outputs: [] } as unknown as NotebookDocumentCellChange]); + + internals(service).handleNotebookDocumentChange(instance(event)); + } + + test('does NOT save immediately when execution completes — only after the quiet period elapses (catches writing a snapshot before outputs settle)', () => { + internals(service).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)', () => { + internals(service).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)', () => { + internals(service).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)', () => { + internals(service).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 cellNotebook = mock(); + when(cellNotebook.uri).thenReturn(Uri.parse(notebookUri)); + + const cell = mock(); + when(cell.notebook).thenReturn(instance(cellNotebook)); + when(cell.metadata).thenReturn({ id: 'cell-1' }); + + internals(service).handleCellExecutionStateChange(instance(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)', () => { + internals(service).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. + internals(service).cancelPendingSnapshotSave(notebookUri); + + clock.tick(2000); + assert.isFalse(performSaveStub.called, 'closing the notebook must cancel the pending deferred save'); + }); + + test('does NOT arm a deferred save when an output change arrives with no save pending (guards the pending-save precondition)', () => { + // No prior armSnapshotSave: pendingSnapshotSaves is empty, so handleNotebookDocumentChange + // must ignore even an output-bearing change rather than arming a fresh deferred save. + fireOutputChange(); + + // Well past the max-wait bound: had the change armed a save, it would have flushed by now. + clock.tick(3000); + assert.isFalse( + performSaveStub.called, + 'an output change with no pending save must not arm a deferred save' + ); }); }); @@ -734,7 +1201,9 @@ project: const mockFs = mock(); // Directory exists - when(mockFs.stat(anything())).thenResolve({ type: FileType.Directory } as any); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); // Return same content as existing const existingYaml = ` metadata: @@ -768,7 +1237,9 @@ project: const projectData = createProjectData(); const mockFs = mock(); - when(mockFs.stat(anything())).thenResolve({ type: FileType.Directory } as any); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); when(mockFs.readFile(anything())).thenReject(new Error('ENOENT')); when(mockFs.writeFile(anything(), anything())).thenReject(new Error('Write failed')); when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); @@ -782,7 +1253,9 @@ project: const projectData = createProjectData(); const mockFs = mock(); - when(mockFs.stat(anything())).thenResolve({ type: FileType.Directory } as any); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); when(mockFs.readFile(anything())).thenReject(new Error('ENOENT')); let writeCallCount = 0; @@ -805,7 +1278,7 @@ project: }); }); - // Metadata tracking tests (now using serviceAny for private methods) + // Metadata tracking tests (private methods reached via internals()) suite('execution metadata tracking', () => { const notebookUri = 'file:///path/to/notebook.deepnote'; const cellId = 'cell-123'; @@ -814,7 +1287,7 @@ project: test('should record cell execution start time', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); const metadata = service.getBlockExecutionMetadata(notebookUri, cellId); assert.isDefined(metadata); @@ -825,7 +1298,7 @@ project: test('should initialize notebook execution state', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); const executionMetadata = service.getExecutionMetadata(notebookUri); // Should not have execution metadata yet since no cells have completed @@ -835,8 +1308,8 @@ project: test('should handle multiple cells in same notebook', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, 'cell-1', startTime); - serviceAny.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 1000); + tracker.recordCellExecutionStart(notebookUri, 'cell-1', startTime); + tracker.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 1000); const metadata1 = service.getBlockExecutionMetadata(notebookUri, 'cell-1'); const metadata2 = service.getBlockExecutionMetadata(notebookUri, 'cell-2'); @@ -852,8 +1325,8 @@ project: const startTime = Date.now(); const endTime = startTime + 1000; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, endTime, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, endTime, true); const metadata = service.getBlockExecutionMetadata(notebookUri, cellId); assert.isDefined(metadata); @@ -865,8 +1338,8 @@ project: const startTime = Date.now(); const endTime = startTime + 1000; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, endTime, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, endTime, true); const executionMetadata = service.getExecutionMetadata(notebookUri); assert.isDefined(executionMetadata); @@ -880,8 +1353,8 @@ project: const startTime = Date.now(); const endTime = startTime + 1000; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, endTime, false); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, endTime, false); const executionMetadata = service.getExecutionMetadata(notebookUri); assert.isDefined(executionMetadata); @@ -896,8 +1369,8 @@ project: const endTime = startTime + 1000; const error = { name: 'TypeError', message: 'undefined is not a function' }; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, endTime, false, error); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, endTime, false, error); const executionMetadata = service.getExecutionMetadata(notebookUri); assert.isDefined(executionMetadata); @@ -910,14 +1383,14 @@ project: const startTime = Date.now(); // Execute 3 cells: 2 successful, 1 failed - serviceAny.recordCellExecutionStart(notebookUri, 'cell-1', startTime); - serviceAny.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-1', startTime); + tracker.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); - serviceAny.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); - serviceAny.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); + tracker.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, true); - serviceAny.recordCellExecutionStart(notebookUri, 'cell-3', startTime + 400); - serviceAny.recordCellExecutionEnd(notebookUri, 'cell-3', startTime + 500, false); + tracker.recordCellExecutionStart(notebookUri, 'cell-3', startTime + 400); + tracker.recordCellExecutionEnd(notebookUri, 'cell-3', startTime + 500, false); const executionMetadata = service.getExecutionMetadata(notebookUri); assert.isDefined(executionMetadata); @@ -931,8 +1404,8 @@ project: const startTime = Date.now(); const endTime = startTime + 5000; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, endTime, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, endTime, true); const executionMetadata = service.getExecutionMetadata(notebookUri); assert.isDefined(executionMetadata); @@ -949,7 +1422,7 @@ project: test('should return undefined if no cells have been executed', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); const metadata = service.getExecutionMetadata(notebookUri); assert.isUndefined(metadata); @@ -957,8 +1430,8 @@ project: test('should include ISO timestamps', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); const metadata = service.getExecutionMetadata(notebookUri); assert.isDefined(metadata); @@ -978,7 +1451,7 @@ project: test('should return undefined for unknown cell', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); const metadata = service.getBlockExecutionMetadata(notebookUri, 'unknown-cell'); assert.isUndefined(metadata); @@ -988,8 +1461,8 @@ project: suite('clearExecutionState', () => { test('should clear all state for a notebook', () => { const startTime = Date.now(); - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); service.clearExecutionState(notebookUri); @@ -1004,11 +1477,11 @@ project: const startTime = Date.now(); const otherNotebookUri = 'file:///other/notebook.deepnote'; - serviceAny.recordCellExecutionStart(notebookUri, cellId, startTime); - serviceAny.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 1000, true); - serviceAny.recordCellExecutionStart(otherNotebookUri, 'other-cell', startTime); - serviceAny.recordCellExecutionEnd(otherNotebookUri, 'other-cell', startTime + 1000, true); + tracker.recordCellExecutionStart(otherNotebookUri, 'other-cell', startTime); + tracker.recordCellExecutionEnd(otherNotebookUri, 'other-cell', startTime + 1000, true); service.clearExecutionState(notebookUri); @@ -1018,6 +1491,71 @@ project: // Second notebook should still have state assert.isDefined(service.getExecutionMetadata(otherNotebookUri)); }); + + // Execution state is split across two owners — the tracker (cell metadata) and the service's + // own environment map. Clearing must wipe BOTH; a half-clear would leak a stale environment + // from the previous session into the next snapshot. + test('clears the captured environment as well as the tracker metadata', async () => { + const notebook = mock(); + when(notebook.uri).thenReturn(Uri.parse(notebookUri)); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([instance(notebook)]); + + const capturedEnvironment: Environment = { + hash: 'sha256:abc', + packages: {}, + platform: 'linux-x64', + python: { environment: 'venv', version: '3.12.0' } + }; + when(mockEnvironmentCapture.captureEnvironment(anything())).thenResolve(capturedEnvironment); + + // Populate both sides: capture the environment and record a completed cell. + await service.captureEnvironmentBeforeExecution(notebookUri); + const startTime = Date.now(); + tracker.recordCellExecutionStart(notebookUri, cellId, startTime); + tracker.recordCellExecutionEnd(notebookUri, cellId, startTime + 100, true); + + assert.isDefined(service.getExecutionMetadata(notebookUri)); + assert.deepStrictEqual(await service.getEnvironmentMetadata(notebookUri), capturedEnvironment); + + service.clearExecutionState(notebookUri); + + assert.isUndefined(service.getExecutionMetadata(notebookUri)); + assert.isUndefined(service.getBlockExecutionMetadata(notebookUri, cellId)); + // The environment side must be gone too — not a stale hit from the pre-clear capture. + assert.isUndefined(await service.getEnvironmentMetadata(notebookUri)); + }); + }); + + suite('default tracker (no injected tracker)', () => { + test('records and reads metadata through an internally-created tracker', () => { + // Construct WITHOUT the optional tracker: the constructor must default one, otherwise + // every this.tracker.* call below would dereference undefined. + const defaultService = new SnapshotService(instance(mockEnvironmentCapture), mockDisposables); + + const cellNotebook = mock(); + when(cellNotebook.uri).thenReturn(Uri.parse(notebookUri)); + + const cell = mock(); + when(cell.notebook).thenReturn(instance(cellNotebook)); + when(cell.metadata).thenReturn({ id: cellId }); + when(cell.executionSummary).thenReturn({ success: true }); + + // Drive a full start->end cycle through the real handler that delegates to the tracker. + internals(defaultService).handleCellExecutionStateChange( + instance(cell), + NotebookCellExecutionState.Executing + ); + internals(defaultService).handleCellExecutionStateChange( + instance(cell), + NotebookCellExecutionState.Idle + ); + + const metadata = defaultService.getExecutionMetadata(notebookUri); + assert.isDefined(metadata); + assert.strictEqual(metadata!.summary!.blocksExecuted, 1); + assert.strictEqual(metadata!.summary!.blocksSucceeded, 1); + assert.isDefined(defaultService.getBlockExecutionMetadata(notebookUri, cellId)); + }); }); suite('multiple notebooks', () => { @@ -1027,11 +1565,11 @@ project: const startTime = Date.now(); // Execute cells in different notebooks - serviceAny.recordCellExecutionStart(notebook1, 'cell-1', startTime); - serviceAny.recordCellExecutionEnd(notebook1, 'cell-1', startTime + 100, true); + tracker.recordCellExecutionStart(notebook1, 'cell-1', startTime); + tracker.recordCellExecutionEnd(notebook1, 'cell-1', startTime + 100, true); - serviceAny.recordCellExecutionStart(notebook2, 'cell-2', startTime); - serviceAny.recordCellExecutionEnd(notebook2, 'cell-2', startTime + 200, false); + tracker.recordCellExecutionStart(notebook2, 'cell-2', startTime); + tracker.recordCellExecutionEnd(notebook2, 'cell-2', startTime + 200, false); const metadata1 = service.getExecutionMetadata(notebook1); const metadata2 = service.getExecutionMetadata(notebook2); @@ -1049,6 +1587,45 @@ project: }); suite('Run All auto-detection', () => { + function mockCell(options: { + id: string; + kind?: NotebookCellKind; + languageId?: string; + source?: string; + }): NotebookCell { + const { id, kind = NotebookCellKind.Code, languageId = 'python', source = 'print(1)' } = options; + + const document = mock(); + when(document.getText()).thenReturn(source); + when(document.languageId).thenReturn(languageId); + + const cell = mock(); + when(cell.kind).thenReturn(kind); + when(cell.document).thenReturn(instance(document)); + when(cell.metadata).thenReturn({ id }); + when(cell.outputs).thenReturn([]); + when(cell.executionSummary).thenReturn({ success: true }); + + return instance(cell); + } + + function mockNotebookDoc(options: { + cells: NotebookCell[]; + notebookId: string; + projectId: string; + uri: Uri; + }): NotebookDocument { + const { cells, notebookId, projectId, uri } = options; + + const doc = mock(); + when(doc.uri).thenReturn(uri); + when(doc.notebookType).thenReturn('deepnote'); + when(doc.metadata).thenReturn({ deepnoteProjectId: projectId, deepnoteNotebookId: notebookId }); + when(doc.getCells()).thenReturn(cells); + + return instance(doc); + } + test('should detect Run All when all code cells are executed', async () => { // Set up mocks const mockConfig = mock(); @@ -1059,45 +1636,24 @@ project: const notebookId = 'test-notebook-id'; // Create mock cells - 3 code cells and 1 markdown - const mockCodeCell1 = { - kind: NotebookCellKind.Code, - document: { getText: () => 'print(1)', languageId: 'python' }, - metadata: { id: 'cell-1' }, - outputs: [], - executionSummary: { success: true } - }; - const mockCodeCell2 = { - kind: NotebookCellKind.Code, - document: { getText: () => 'print(2)', languageId: 'python' }, - metadata: { id: 'cell-2' }, - outputs: [], - executionSummary: { success: true } - }; - const mockCodeCell3 = { - kind: NotebookCellKind.Code, - document: { getText: () => 'print(3)', languageId: 'python' }, - metadata: { id: 'cell-3' }, - outputs: [], - executionSummary: { success: true } - }; - const mockMarkdownCell = { - kind: NotebookCellKind.Markup, - document: { getText: () => '# Title', languageId: 'markdown' }, - metadata: { id: 'cell-md' }, - outputs: [] - }; - - const mockNotebook = { + const mockNotebook = mockNotebookDoc({ uri: Uri.parse(notebookUri), - notebookType: 'deepnote', - metadata: { - deepnoteProjectId: projectId, - deepnoteNotebookId: notebookId - }, - getCells: () => [mockCodeCell1, mockCodeCell2, mockMarkdownCell, mockCodeCell3] - }; - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([mockNotebook as any]); + projectId, + notebookId, + cells: [ + mockCell({ id: 'cell-1', source: 'print(1)' }), + mockCell({ id: 'cell-2', source: 'print(2)' }), + mockCell({ + id: 'cell-md', + kind: NotebookCellKind.Markup, + languageId: 'markdown', + source: '# Title' + }), + mockCell({ id: 'cell-3', source: 'print(3)' }) + ] + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([mockNotebook]); // Create mock notebook manager with original project const originalProject: DeepnoteFile = { @@ -1116,41 +1672,43 @@ project: } }; - const mockNotebookManager = { - getOriginalProject: sinon.stub().returns(originalProject) - }; + const mockNotebookManager = mock(); + when(mockNotebookManager.getProjectForNotebook(anything(), anything())).thenReturn(originalProject); // Create a new service with the mock notebook manager const testService = new SnapshotService( instance(mockEnvironmentCapture), mockDisposables, - mockNotebookManager as any + instance(mockNotebookManager), + tracker ); - const testServiceAny = testService as any; // Record execution for all 3 code cells const startTime = Date.now(); - testServiceAny.recordCellExecutionStart(notebookUri, 'cell-1', startTime); - testServiceAny.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); - testServiceAny.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); - testServiceAny.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, true); - testServiceAny.recordCellExecutionStart(notebookUri, 'cell-3', startTime + 400); - testServiceAny.recordCellExecutionEnd(notebookUri, 'cell-3', startTime + 500, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-1', startTime); + tracker.recordCellExecutionEnd(notebookUri, 'cell-1', startTime + 100, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-2', startTime + 200); + tracker.recordCellExecutionEnd(notebookUri, 'cell-2', startTime + 300, true); + tracker.recordCellExecutionStart(notebookUri, 'cell-3', startTime + 400); + tracker.recordCellExecutionEnd(notebookUri, 'cell-3', startTime + 500, true); // Spy on createSnapshot and updateLatestSnapshot - const createSnapshotSpy = sinon.spy(testServiceAny, 'createSnapshot'); - const updateLatestSnapshotSpy = sinon.spy(testServiceAny, 'updateLatestSnapshot'); + const createSnapshotSpy = sinon.spy(testService, 'createSnapshot'); + const updateLatestSnapshotSpy = sinon.spy(internals(testService), 'updateLatestSnapshot'); // Mock file system operations for snapshot creation const mockFs = mock(); - when(mockFs.stat(anything())).thenResolve({ type: FileType.Directory } as any); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); 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)); - // 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 internals(testService).performSnapshotSave(notebookUri); // ASSERT: createSnapshot should be called (full snapshot, not just latest) assert.isTrue( @@ -1162,6 +1720,153 @@ project: 'updateLatestSnapshot should NOT be called when all code cells are executed' ); }); + + 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 siblingA = mockNotebookDoc({ + uri: Uri.parse(siblingAUri), + projectId: sharedProjectId, + notebookId: 'notebook-a', + cells: [mockCell({ id: 'cell-a' })] + }); + const notebookB = mockNotebookDoc({ + uri: Uri.parse(targetBUri), + projectId: sharedProjectId, + notebookId: 'notebook-b', + cells: [mockCell({ id: 'cell-b' })] + }); + + // Sibling A is enumerated FIRST — a projectId-only lookup would pick A's folder. + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([siblingA, notebookB]); + + 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 = mock(); + when(mockNotebookManager.getProjectForNotebook(anything(), anything())).thenReturn(originalProject); + + const testService = new SnapshotService( + instance(mockEnvironmentCapture), + mockDisposables, + instance(mockNotebookManager), + tracker + ); + + const startTime = Date.now(); + tracker.recordCellExecutionStart(targetBUri, 'cell-b', startTime); + tracker.recordCellExecutionEnd(targetBUri, 'cell-b', startTime + 100, true); + + const mockFs = mock(); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); + when(mockFs.readFile(anything())).thenReject(new Error('ENOENT')); + let writtenSnapshotUri: Uri | undefined; + when(mockFs.writeFile(anything(), anything())).thenCall((uri: Uri) => { + writtenSnapshotUri = writtenSnapshotUri ?? uri; + + return Promise.resolve(); + }); + when(mockFs.copy(anything(), anything(), anything())).thenResolve(); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + await internals(testService).performSnapshotSave(targetBUri); + + // The snapshot dir derives from projectUri's parent, so the snapshot must land in notebook B's + // OWN folder (/bar/snapshots), not sibling A's (/foo) — even though A shares the id and enumerates first. + assert.isDefined(writtenSnapshotUri, 'a snapshot file must be written'); + assert.include( + writtenSnapshotUri!.path, + '/workspace/bar/snapshots/', + 'snapshot must be written under the saved notebook own directory, not a sibling sharing the project id' + ); + assert.notInclude( + writtenSnapshotUri!.path, + '/workspace/foo/', + 'snapshot must not land in a sibling directory that merely shares the project id' + ); + }); + + // getExecutedBlockCount returns undefined (not 0) for a notebook the tracker never saw. The + // Run-All predicate must treat undefined as "not Run-All"; collapsing it to 0 would make a + // zero-code-cell notebook (0 === 0) falsely take the full-snapshot path. + test('treats an untracked notebook as a partial run, never Run-All', async () => { + const mockConfig = mock(); + when(mockConfig.get('snapshots.enabled', true)).thenReturn(true); + when(mockedVSCodeNamespaces.workspace.getConfiguration('deepnote')).thenReturn(instance(mockConfig)); + + const projectId = 'test-project-id'; + const notebookId = 'test-notebook-id'; + + // A markdown-only notebook (zero code cells) whose URI was never recorded in the tracker. + const mockNotebook = mockNotebookDoc({ + uri: Uri.parse(notebookUri), + projectId, + notebookId, + cells: [ + mockCell({ + id: 'cell-md', + kind: NotebookCellKind.Markup, + languageId: 'markdown', + source: '# Title' + }) + ] + }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([mockNotebook]); + + const originalProject: DeepnoteFile = { + metadata: { createdAt: '2025-01-01T00:00:00Z' }, + version: '1.0.0', + project: { + id: projectId, + name: 'Test Project', + notebooks: [{ id: notebookId, name: 'Test Notebook', blocks: [] }] + } + }; + const mockNotebookManager = mock(); + when(mockNotebookManager.getProjectForNotebook(anything(), anything())).thenReturn(originalProject); + + const testService = new SnapshotService( + instance(mockEnvironmentCapture), + mockDisposables, + instance(mockNotebookManager), + tracker + ); + + const createSnapshotSpy = sinon.spy(testService, 'createSnapshot'); + const updateLatestSnapshotSpy = sinon.spy(internals(testService), 'updateLatestSnapshot'); + + const mockFs = mock(); + when(mockFs.stat(anything())).thenReturn( + Promise.resolve({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }) + ); + 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)); + + await internals(testService).performSnapshotSave(notebookUri); + + assert.isFalse(createSnapshotSpy.called, 'an untracked notebook must not take the Run-All path'); + assert.isTrue( + updateLatestSnapshotSpy.calledOnce, + 'an untracked notebook must take the partial-run (latest-only) path' + ); + }); }); suite('captureEnvironmentBeforeExecution', () => { @@ -1169,6 +1874,42 @@ project: await service.captureEnvironmentBeforeExecution(notebookUri); // Should complete without error }); + + test('seeds startedAt at capture time, not the first recorded cell start', async () => { + const captureBefore = Date.now(); + await service.captureEnvironmentBeforeExecution(notebookUri); + const captureAfter = Date.now(); + + // A cell that starts long after capture must not move the session start. + const laterCellStart = captureAfter + 60_000; + tracker.recordCellExecutionStart(notebookUri, 'cell-1', laterCellStart); + tracker.recordCellExecutionEnd(notebookUri, 'cell-1', laterCellStart + 100, true); + + const metadata = service.getExecutionMetadata(notebookUri); + assert.isDefined(metadata); + + const startedAtMs = new Date(metadata!.startedAt!).getTime(); + assert.isAtLeast(startedAtMs, captureBefore); + assert.isAtMost(startedAtMs, captureAfter); + assert.notStrictEqual(startedAtMs, laterCellStart); + }); + + test('a second capture does not reset the session startedAt (ensureExecutionState is idempotent)', async () => { + await service.captureEnvironmentBeforeExecution(notebookUri); + + // Record a completed cell so the seeded startedAt becomes observable via the summary. + const firstStart = Date.now(); + tracker.recordCellExecutionStart(notebookUri, cellId, firstStart); + tracker.recordCellExecutionEnd(notebookUri, cellId, firstStart + 100, true); + + const seededStartedAt = service.getExecutionMetadata(notebookUri)!.startedAt; + + // A second capture (e.g. a re-run before the state is cleared) must not re-seed the + // session; ensureExecutionState is a no-op once state exists. + await service.captureEnvironmentBeforeExecution(notebookUri); + + assert.strictEqual(service.getExecutionMetadata(notebookUri)!.startedAt, seededStartedAt); + }); }); suite('getEnvironmentMetadata', () => { diff --git a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts index e2aee175fa..5bf312977c 100644 --- a/src/notebooks/deepnote/sqlCellStatusBarProvider.ts +++ b/src/notebooks/deepnote/sqlCellStatusBarProvider.ts @@ -230,7 +230,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.getOriginalProject(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); @@ -326,13 +327,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.getOriginalProject(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 f008154bcd..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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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 3a46a272ec..9b83fd7470 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -74,13 +74,15 @@ import { IDeepnoteServerProvider, IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper, - IDeepnoteLspClientManager + IDeepnoteLspClientManager, + IServerHandleRegistry } from '../kernels/deepnote/types'; import { DeepnoteAgentSkillsManager } from '../kernels/deepnote/deepnoteAgentSkillsManager.node'; import { DeepnoteToolkitInstaller } from '../kernels/deepnote/deepnoteToolkitInstaller.node'; import { DeepnoteServerStarter } from '../kernels/deepnote/deepnoteServerStarter.node'; import { DeepnoteKernelAutoSelector } from './deepnote/deepnoteKernelAutoSelector.node'; import { DeepnoteServerProvider } from '../kernels/deepnote/deepnoteServerProvider.node'; +import { ServerHandleRegistry } from '../kernels/deepnote/deepnoteServerHandleRegistry.node'; import { DeepnoteLspClientManager } from '../kernels/deepnote/deepnoteLspClientManager.node'; import { DeepnoteInitNotebookRunner, IDeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; @@ -102,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); @@ -240,11 +243,13 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addBinding(IDeepnoteServerStarter, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteServerProvider, DeepnoteServerProvider); serviceManager.addBinding(IDeepnoteServerProvider, IExtensionSyncActivationService); + serviceManager.addSingleton(IServerHandleRegistry, ServerHandleRegistry); serviceManager.addSingleton(IDeepnoteKernelAutoSelector, DeepnoteKernelAutoSelector); serviceManager.addBinding(IDeepnoteKernelAutoSelector, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteLspClientManager, DeepnoteLspClientManager); serviceManager.addBinding(IDeepnoteLspClientManager, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteInitNotebookRunner, DeepnoteInitNotebookRunner); + serviceManager.addBinding(IDeepnoteInitNotebookRunner, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteRequirementsHelper, DeepnoteRequirementsHelper); serviceManager.addSingleton( IExtensionSyncActivationService, @@ -258,6 +263,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/notebooks/types.ts b/src/notebooks/types.ts index a12dacf52a..13b6adeef3 100644 --- a/src/notebooks/types.ts +++ b/src/notebooks/types.ts @@ -37,23 +37,20 @@ export interface ProjectIntegration { export const IDeepnoteNotebookManager = Symbol('IDeepnoteNotebookManager'); export interface IDeepnoteNotebookManager { - getCurrentNotebookId(projectId: string): string | undefined; - getOriginalProject(projectId: string): DeepnoteProject | undefined; - getTheSelectedNotebookForAProject(projectId: string): string | undefined; - selectNotebookForProject(projectId: string, notebookId: string): void; - storeOriginalProject(projectId: string, project: DeepnoteProject, notebookId: string): void; - updateCurrentNotebookId(projectId: string, notebookId: string): void; + /** + * 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. + */ + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined; + storeOriginalProject(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; - - hasInitNotebookBeenRun(projectId: string): boolean; - markInitNotebookAsRun(projectId: string): void; } diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 2d650927e3..39f3381a0c 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'; @@ -250,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/deepnoteProjectFileReader.ts b/src/platform/deepnote/deepnoteProjectFileReader.ts new file mode 100644 index 0000000000..91142375a8 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectFileReader.ts @@ -0,0 +1,13 @@ +import { deserializeDeepnoteFile, type DeepnoteFile } from '@deepnote/blocks'; +import { Uri, workspace } from 'vscode'; + +/** + * Reads and parses a `.deepnote` file into a {@link DeepnoteFile}. The single source of truth for + * turning a file URI into a parsed Deepnote project — use this instead of ad-hoc reads. + */ +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..d2de7cef01 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectFileReader.unit.test.ts @@ -0,0 +1,86 @@ +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'); + }); +}); diff --git a/src/platform/deepnote/deepnoteProjectIdResolver.ts b/src/platform/deepnote/deepnoteProjectIdResolver.ts new file mode 100644 index 0000000000..624672782f --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectIdResolver.ts @@ -0,0 +1,36 @@ +import { NotebookDocument, Uri } from 'vscode'; + +import { logger } from '../logging'; +import { readDeepnoteProjectFile } from './deepnoteProjectFileReader'; + +/** + * Reads the `.deepnote` file at `fileUri` and returns its `project.id`. + * I/O and parse errors are swallowed so callers treat an unreadable file as "no project". + */ +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 a notebook's Deepnote `project.id`, preferring the `deepnoteProjectId` metadata and + * falling back to reading the underlying file (query/fragment stripped from the URI). + */ +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..8145196276 --- /dev/null +++ b/src/platform/deepnote/deepnoteProjectIdResolver.unit.test.ts @@ -0,0 +1,128 @@ +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())).thenReturn(Promise.resolve(new TextEncoder().encode(value))); + } + + 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'); + }); + }); +}); 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..5379c860d2 --- /dev/null +++ b/src/platform/deepnote/deepnoteServerUtils.node.unit.test.ts @@ -0,0 +1,21 @@ +import { assert } from 'chai'; +import { Uri } from 'vscode'; + +import { createDeepnoteServerConfigHandle } from './deepnoteServerUtils.node'; + +/** + * The handle is a producer/consumer match invariant: if its formula drifts between the kernel + * selector (producer) and the clear/dispose consumers, handles stop matching and cleanup silently no-ops. + */ +suite('DeepnoteServerUtils - createDeepnoteServerConfigHandle', () => { + 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' + ); + }); +}); diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index a5a827b575..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.getOriginalProject(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 a00da868c1..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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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.getOriginalProject('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 6ae06bc731..84c6522155 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 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. + */ + getProjectForNotebook(projectId: string, notebookId: string): DeepnoteProject | undefined; } diff --git a/src/test/mocks/deepnoteRuntimeCore.ts b/src/test/mocks/deepnoteRuntimeCore.ts new file mode 100644 index 0000000000..26e25d6797 --- /dev/null +++ b/src/test/mocks/deepnoteRuntimeCore.ts @@ -0,0 +1,69 @@ +import type { ServerInfo, ServerOptions } from '@deepnote/runtime-core'; +import type { ChildProcess } from 'child_process'; + +/** + * Mock of @deepnote/runtime-core for unit tests: the real startServer/stopServer spawn and + * kill Python processes, so this records calls and returns fake server info instead. + * + * build/mocha-esm-loader.js resolves the '@deepnote/runtime-core' specifier to this module, + * so code under test and tests importing the __ helpers below share one module instance. + * The exports are typed against the real package so the mock cannot drift from its API. + */ + +type RuntimeCore = typeof import('@deepnote/runtime-core'); + +const startServerCalls: ServerOptions[] = []; +const stopServerCalls: ServerInfo[] = []; +let nextServerId = 0; +let startServerImpl: RuntimeCore['startServer'] | null = null; + +function makeFakeProcess(id: number): ChildProcess { + // Partial stand-in: only the members DeepnoteServerStarter touches. + return { + pid: 40000 + id, + stdout: { on() {}, off() {} }, + stderr: { on() {}, off() {} }, + kill() {} + } as unknown as ChildProcess; +} + +export const startServer: RuntimeCore['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: RuntimeCore['stopServer'] = async (info) => { + stopServerCalls.push(info); +}; + +// Test-only helpers (prefixed with __ to signal they are not part of the real API). +export function __getStartServerCalls(): ServerOptions[] { + return startServerCalls; +} + +export function __getStopServerCalls(): ServerInfo[] { + return stopServerCalls; +} + +export function __setStartServerImpl(impl: RuntimeCore['startServer'] | null): void { + startServerImpl = impl; +} + +export function __resetRuntimeCoreMock(): void { + startServerCalls.length = 0; + stopServerCalls.length = 0; + nextServerId = 0; + startServerImpl = null; +} 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 } }; diff --git a/test/e2e/fixtures/bootstrap-only.deepnote b/test/e2e/fixtures/bootstrap-only.deepnote new file mode 100644 index 0000000000..1cbac0e77a --- /dev/null +++ b/test/e2e/fixtures/bootstrap-only.deepnote @@ -0,0 +1,21 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: dddddddd-dddd-4ddd-8ddd-dddddddddddd + initNotebookId: d-nb-init + name: Bootstrap Only + notebooks: + - blocks: + - id: blk-0013 + blockGroup: grp-0013 + sortingKey: "000000" + type: code + content: print("this file only contains the init notebook") + metadata: {} + executionMode: block + id: d-nb-init + name: Bootstrap +version: 1.0.0 diff --git a/test/e2e/fixtures/etl-pipeline-extract.deepnote b/test/e2e/fixtures/etl-pipeline-extract.deepnote new file mode 100644 index 0000000000..5a643623d4 --- /dev/null +++ b/test/e2e/fixtures/etl-pipeline-extract.deepnote @@ -0,0 +1,24 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb + initNotebookId: b-nb-init + name: ETL Pipeline + notebooks: + - blocks: + - id: blk-0008 + blockGroup: grp-0008 + sortingKey: "000000" + type: code + content: >- + # After the kernel starts, INIT_MARKER should already be defined by the init notebook + + print(INIT_MARKER) + metadata: {} + executionMode: block + id: b-nb-extract + name: Extract +version: 1.0.0 diff --git a/test/e2e/fixtures/etl-pipeline-init.deepnote b/test/e2e/fixtures/etl-pipeline-init.deepnote new file mode 100644 index 0000000000..0649a0760c --- /dev/null +++ b/test/e2e/fixtures/etl-pipeline-init.deepnote @@ -0,0 +1,26 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb + initNotebookId: b-nb-init + name: ETL Pipeline + notebooks: + - blocks: + - id: blk-0007 + blockGroup: grp-0007 + sortingKey: "000000" + type: code + content: >- + # Runs automatically (hidden) in every kernel for this project + + INIT_MARKER = "init-ran" + + print("init notebook executed") + metadata: {} + executionMode: block + id: b-nb-init + name: Init +version: 1.0.0 diff --git a/test/e2e/fixtures/etl-pipeline.deepnote b/test/e2e/fixtures/etl-pipeline.deepnote new file mode 100644 index 0000000000..4cdee9b48e --- /dev/null +++ b/test/e2e/fixtures/etl-pipeline.deepnote @@ -0,0 +1,49 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb + initNotebookId: b-nb-init + name: ETL Pipeline + notebooks: + - blocks: + - id: blk-0007 + blockGroup: grp-0007 + sortingKey: "000000" + type: code + content: >- + # Runs automatically (hidden) in every kernel for this project + + INIT_MARKER = "init-ran" + + print("init notebook executed") + metadata: {} + executionMode: block + id: b-nb-init + name: Init + - blocks: + - id: blk-0008 + blockGroup: grp-0008 + sortingKey: "000000" + type: code + content: >- + # After the kernel starts, INIT_MARKER should already be defined by the init notebook + + print(INIT_MARKER) + metadata: {} + executionMode: block + id: b-nb-extract + name: Extract + - blocks: + - id: blk-0009 + blockGroup: grp-0009 + sortingKey: "000000" + type: code + content: print("Transform:", INIT_MARKER.upper()) + metadata: {} + executionMode: block + id: b-nb-transform + name: Transform +version: 1.0.0 diff --git a/test/e2e/fixtures/legacy-snapshot-demo.deepnote b/test/e2e/fixtures/legacy-snapshot-demo.deepnote new file mode 100644 index 0000000000..8baee45d1e --- /dev/null +++ b/test/e2e/fixtures/legacy-snapshot-demo.deepnote @@ -0,0 +1,20 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-20T10:00:00.000Z +project: + id: ffffffff-ffff-4fff-8fff-ffffffffffff + name: Legacy Snapshot Demo + notebooks: + - blocks: + - id: blk-snap-0001 + blockGroup: grp-snap-0001 + sortingKey: "000000" + type: code + content: print("hello") + metadata: {} + executionMode: block + id: f-nb-snapshot + name: Main +version: 1.0.0 diff --git a/test/e2e/fixtures/marketing-campaigns.deepnote b/test/e2e/fixtures/marketing-campaigns.deepnote new file mode 100644 index 0000000000..0d3a95e8a2 --- /dev/null +++ b/test/e2e/fixtures/marketing-campaigns.deepnote @@ -0,0 +1,20 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-11T10:00:00.000Z +project: + id: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee + name: Marketing + notebooks: + - blocks: + - id: blk-0016 + blockGroup: grp-0016 + sortingKey: "000000" + type: code + content: print("campaigns") + metadata: {} + executionMode: block + id: e-nb-campaigns + name: Campaigns +version: 1.0.0 diff --git a/test/e2e/fixtures/marketing-metrics.deepnote b/test/e2e/fixtures/marketing-metrics.deepnote new file mode 100644 index 0000000000..d2d6de8712 --- /dev/null +++ b/test/e2e/fixtures/marketing-metrics.deepnote @@ -0,0 +1,20 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-12T10:00:00.000Z +project: + id: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee + name: Marketing + notebooks: + - blocks: + - id: blk-0017 + blockGroup: grp-0017 + sortingKey: "000000" + type: code + content: print("metrics") + metadata: {} + executionMode: block + id: e-nb-metrics + name: Metrics +version: 1.0.0 diff --git a/test/e2e/fixtures/marketing-overview.deepnote b/test/e2e/fixtures/marketing-overview.deepnote new file mode 100644 index 0000000000..75be2d2e5b --- /dev/null +++ b/test/e2e/fixtures/marketing-overview.deepnote @@ -0,0 +1,26 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-10T10:00:00.000Z +project: + id: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee + name: Marketing + notebooks: + - blocks: + - id: blk-0014 + blockGroup: grp-0014 + sortingKey: "000000" + type: text-cell-h1 + content: Marketing + metadata: {} + - id: blk-0015 + blockGroup: grp-0015 + sortingKey: "000001" + type: code + content: print("overview") + metadata: {} + executionMode: block + id: e-nb-overview + name: Overview +version: 1.0.0 diff --git a/test/e2e/fixtures/quick-notes.deepnote b/test/e2e/fixtures/quick-notes.deepnote new file mode 100644 index 0000000000..723fbea293 --- /dev/null +++ b/test/e2e/fixtures/quick-notes.deepnote @@ -0,0 +1,32 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-15T14:30:00.000Z +project: + id: cccccccc-cccc-4ccc-8ccc-cccccccccccc + name: Quick Notes + notebooks: + - blocks: + - id: blk-0010 + blockGroup: grp-0010 + sortingKey: "000000" + type: text-cell-h1 + content: Quick Notes + metadata: {} + - id: blk-0011 + blockGroup: grp-0011 + sortingKey: "000001" + type: text-cell-p + content: A plain single-notebook file. Opening it must NOT show the split prompt. + metadata: {} + - id: blk-0012 + blockGroup: grp-0012 + sortingKey: "000002" + type: code + content: print("hello from a single-notebook file") + metadata: {} + executionMode: block + id: c-nb-main + name: Quick Notes +version: 1.0.0 diff --git a/test/e2e/fixtures/sales-analytics-revenue.deepnote b/test/e2e/fixtures/sales-analytics-revenue.deepnote new file mode 100644 index 0000000000..f331bcdf6a --- /dev/null +++ b/test/e2e/fixtures/sales-analytics-revenue.deepnote @@ -0,0 +1,36 @@ +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-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 +version: 1.0.0 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/fixtures/snapshots/legacy-snapshot-demo_ffffffff-ffff-4fff-8fff-ffffffffffff_latest.snapshot.deepnote b/test/e2e/fixtures/snapshots/legacy-snapshot-demo_ffffffff-ffff-4fff-8fff-ffffffffffff_latest.snapshot.deepnote new file mode 100644 index 0000000000..8b3a129560 --- /dev/null +++ b/test/e2e/fixtures/snapshots/legacy-snapshot-demo_ffffffff-ffff-4fff-8fff-ffffffffffff_latest.snapshot.deepnote @@ -0,0 +1,25 @@ +environment: {} +execution: {} +metadata: + createdAt: 2026-06-01T09:00:00.000Z + modifiedAt: 2026-06-20T10:00:00.000Z +project: + id: ffffffff-ffff-4fff-8fff-ffffffffffff + name: Legacy Snapshot Demo + notebooks: + - blocks: + - id: blk-snap-0001 + blockGroup: grp-snap-0001 + sortingKey: "000000" + type: code + content: print("hello") + metadata: {} + outputs: + - output_type: stream + name: stdout + text: | + SNAPSHOT_OUTPUT_MARKER + executionMode: block + id: f-nb-snapshot + name: Main +version: 1.0.0 diff --git a/test/e2e/helpers/constants.ts b/test/e2e/helpers/constants.ts index 3ddd57859c..fdaa2eaecb 100644 --- a/test/e2e/helpers/constants.ts +++ b/test/e2e/helpers/constants.ts @@ -38,8 +38,11 @@ export const OPTIONAL_PROMPT_TIMEOUT = 5_000; // The in-window simple file/folder dialog needs a beat to resolve a typed path before it accepts. export const DIALOG_RESOLVE_DELAY = 1_500; -export const FOLDER_OPEN_ATTEMPTS = 5; -export const FOLDER_RELOAD_TIMEOUT = 12_000; +// "Open Folder" navigates one level toward the typed path per OK, so we re-click OK up to +// FOLDER_OPEN_TIMEOUT, waiting RELOAD_POLL_TIMEOUT for the reload and pausing FOLDER_OK_RETRY_DELAY between. +export const FOLDER_OPEN_TIMEOUT = 45_000; +export const RELOAD_POLL_TIMEOUT = 2_500; +export const FOLDER_OK_RETRY_DELAY = 400; // Selectors that only exist inside the notebook output iframe (`#active-frame`), // so reading them cannot accidentally match the cell's source in the editor. diff --git a/test/e2e/helpers/deepnoteTree.ts b/test/e2e/helpers/deepnoteTree.ts new file mode 100644 index 0000000000..6d49642703 --- /dev/null +++ b/test/e2e/helpers/deepnoteTree.ts @@ -0,0 +1,110 @@ +import { ActivityBar, By, SideBarView, VSBrowser, WebElement, type ViewItem } from 'vscode-extension-tester'; + +import { WORKBENCH_TIMEOUT } from './constants'; + +/** A row of the Deepnote Explorer tree: a project group ("N files") or a notebook leaf ("N cells"). */ +export interface DeepnoteTreeRow { + item: ViewItem; + label: string; + description: string; + /** A project group reads "N files". */ + isGroup: boolean; + /** A single-notebook leaf reads "N cells". */ + isLeaf: boolean; +} + +/** Opens the Deepnote view container and returns its "Explorer" tree section. */ +export async function getDeepnoteExplorerSection() { + const control = await new ActivityBar().getViewControl('Deepnote'); + await control?.openView(); + await VSBrowser.instance.driver.sleep(1200); + + const content = new SideBarView().getContent(); + const named = await content.getSection('Explorer').catch(() => undefined); + + return named ?? (await content.getSections())[0]; +} + +async function labelOf(item: ViewItem): Promise { + return (item as unknown as { getLabel(): Promise }).getLabel().catch(() => ''); +} + +async function descriptionOf(item: ViewItem): Promise { + const description = await (item as unknown as { getDescription(): Promise }) + .getDescription() + .catch(() => ''); + + return description ?? ''; +} + +/** Reads the currently-visible tree rows, classifying each as a project group or a notebook leaf. */ +export async function readDeepnoteTreeRows( + section: Awaited> +): Promise { + const rows: DeepnoteTreeRow[] = []; + + for (const item of await section.getVisibleItems().catch(() => [] as ViewItem[])) { + const description = await descriptionOf(item); + rows.push({ + item, + label: await labelOf(item), + description, + isGroup: /\d+\s+files?\b/.test(description), + isLeaf: /\d+\s+cells?\b/.test(description) + }); + } + + return rows; +} + +/** Finds a project-group tree item by label. */ +export async function findDeepnoteGroup( + section: Awaited>, + label: string +): Promise { + return (await readDeepnoteTreeRows(section)).find((row) => row.label === label && row.isGroup)?.item; +} + +/** Expands the (single) project group, then finds a notebook leaf by name. */ +export async function findDeepnoteLeaf( + section: Awaited>, + label: string +): Promise { + const group = (await readDeepnoteTreeRows(section)).find((row) => row.isGroup)?.item; + await (group as unknown as { expand(): Promise } | undefined)?.expand().catch(() => undefined); + await VSBrowser.instance.driver.sleep(800); + + return (await readDeepnoteTreeRows(section)).find((row) => row.label === label && row.isLeaf)?.item; +} + +/** + * Right-clicks a tree item and invokes a context-menu command by label via raw DOM, avoiding + * ExTester's ContextMenu model (whose lazy re-query throws for modal-opening commands). + */ +export async function selectDeepnoteContextMenu(item: ViewItem, command: string): Promise { + const driver = VSBrowser.instance.driver; + + await driver + .actions() + .contextClick(item as unknown as WebElement) + .perform(); + await driver.sleep(500); + + const menuItem = await driver.wait( + async () => { + for (const element of await driver.findElements(By.css('.monaco-menu .action-label')).catch(() => [])) { + if ((await element.getText().catch(() => '')).trim() === command) { + return element; + } + } + + return undefined; + }, + WORKBENCH_TIMEOUT, + `context menu item "${command}" did not appear` + ); + if (!menuItem) { + throw new Error(`context menu item "${command}" not found`); + } + await driver.actions().move({ origin: menuItem }).click().perform(); +} diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index c33f734de7..262117a02a 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -1,8 +1,14 @@ // Barrel re-export so suites can `import { … } from '../helpers'`. export * from './constants'; export * from './deepnoteEnvironment'; +export * from './deepnoteTree'; export * from './fixtures'; +export * from './modals'; export * from './notebook'; export * from './notifications'; export * from './quickInput'; +export * from './screenshots'; +export * from './statusBar'; +export * from './views'; export * from './workspace'; +export * from './yaml'; diff --git a/test/e2e/helpers/modals.ts b/test/e2e/helpers/modals.ts new file mode 100644 index 0000000000..993935171f --- /dev/null +++ b/test/e2e/helpers/modals.ts @@ -0,0 +1,47 @@ +import { By, VSBrowser } from 'vscode-extension-tester'; + +import { WORKBENCH_TIMEOUT } from './constants'; + +/** + * Confirms a `{modal:true}` dialog by clicking the button matching `label`, driving the raw + * `.monaco-dialog-box` (ExTester's `ModalDialog` attaches unreliably); `messageIncludes` disambiguates. + */ +export async function confirmModalDialog(label: string, options?: { messageIncludes?: string }): Promise { + const driver = VSBrowser.instance.driver; + const messageIncludes = options?.messageIncludes; + + await driver.wait( + async () => { + for (const box of await driver.findElements(By.css('.monaco-dialog-box')).catch(() => [])) { + const text = await box.getText().catch(() => ''); + if (!messageIncludes || text.includes(messageIncludes)) { + return true; + } + } + + return false; + }, + WORKBENCH_TIMEOUT, + `modal dialog${messageIncludes ? ` containing "${messageIncludes}"` : ''} did not appear` + ); + + const button = await driver.wait( + async () => { + const selector = '.monaco-dialog-box .dialog-buttons .monaco-button, .monaco-dialog-box .monaco-button'; + for (const element of await driver.findElements(By.css(selector)).catch(() => [])) { + if ((await element.getText().catch(() => '')).trim() === label) { + return element; + } + } + + return undefined; + }, + WORKBENCH_TIMEOUT, + `modal dialog button "${label}" did not appear` + ); + if (!button) { + throw new Error(`modal dialog button "${label}" not found`); + } + + await driver.actions().move({ origin: button }).click().perform(); +} diff --git a/test/e2e/helpers/screenshots.ts b/test/e2e/helpers/screenshots.ts new file mode 100644 index 0000000000..72cabb520c --- /dev/null +++ b/test/e2e/helpers/screenshots.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { VSBrowser } from 'vscode-extension-tester'; + +// ExTester's own screenshot helper writes flat and cannot create per-test sub-paths, so we organise +// output into one directory per test spec (resolved from cwd, matching fixtures.ts). +const SCREENSHOT_ROOT = path.resolve(process.cwd(), 'test', 'e2e', 'screenshots'); + +function stripSpecExtension(basename: string): string { + return basename.replace(/\.e2e\.test\.(js|ts)$/i, '').replace(/\.(js|ts)$/i, ''); +} + +function resolveSpecFile(context: Mocha.Context): string | undefined { + // runnable() resolves the file even 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; +} + +// Throws when the spec file cannot be resolved (e.g. an arrow-function test, where `this` is unbound). +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; +} + +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. */ +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 auto-numbers `NN-