From c197ca2e12401d8ab2fd9ad4ac8f09e4a267c991 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 18 Feb 2026 16:30:22 +0100 Subject: [PATCH 1/5] feat: Expose the environment <-> project mapping to agents. --- .../deepnoteExtensionSidecarWriter.node.ts | 319 ++++++++++++ ...eepnoteExtensionSidecarWriter.unit.test.ts | 488 ++++++++++++++++++ .../deepnoteNotebookEnvironmentMapper.node.ts | 25 +- src/kernels/deepnote/types.ts | 16 + src/notebooks/serviceRegistry.node.ts | 7 + 5 files changed, 852 insertions(+), 3 deletions(-) create mode 100644 src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts create mode 100644 src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts new file mode 100644 index 0000000000..8ba18dcd53 --- /dev/null +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts @@ -0,0 +1,319 @@ +import { inject, injectable } from 'inversify'; +import * as yaml from 'js-yaml'; +import { env, NotebookDocument, Uri, workspace } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../../platform/activation/types'; +import { IDisposableRegistry } from '../../../platform/common/types'; +import { logger } from '../../../platform/logging'; +import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; + +const SIDECAR_FILENAME = 'deepnote.json'; + +/** + * Returns the editor-specific settings folder name based on the current app. + * - VS Code → `.vscode` + * - Cursor → `.cursor` + * - Antigravity → `.antigravity` + * - Unknown → `.vscode` (safe default) + */ +function getEditorSettingsFolder(): string { + const appName = env.appName.toLowerCase(); + if (appName.includes('cursor')) { + return '.cursor'; + } + if (appName.includes('antigravity')) { + return '.antigravity'; + } + return '.vscode'; +} + +interface SidecarEntry { + environmentId: string; + venvPath: string; +} + +interface SidecarFile { + mappings: Record; +} + +/** + * Writes a `deepnote.json` sidecar file in the editor settings + * folder (e.g. `.vscode/`, `.cursor/`, `.antigravity/`) so that external + * tools (e.g. the Deepnote CLI) can discover the selected venv path for + * each project without reading VS Code workspace state. + */ +@injectable() +export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationService { + /** Reverse map: notebookUri.fsPath → projectId (populated from sidecar + set calls). */ + private readonly fsPathToProjectId = new Map(); + /** Serializes sidecar writes to avoid read-modify-write races. */ + private writeQueue: Promise = Promise.resolve(); + + constructor( + @inject(IDeepnoteNotebookEnvironmentMapper) private readonly mapper: IDeepnoteNotebookEnvironmentMapper, + @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, + @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry + ) {} + + public activate(): void { + this.disposables.push( + this.mapper.onDidSetEnvironment((e) => this.handleSetEnvironment(e)), + this.mapper.onDidRemoveEnvironment((e) => this.handleRemoveEnvironment(e)), + this.environmentManager.onDidChangeEnvironments(() => this.handleEnvironmentsChanged()), + workspace.onDidOpenNotebookDocument((doc) => this.handleNotebookOpened(doc)) + ); + + // Sync existing mappings so the sidecar is up-to-date for existing users + // who already have workspace-state mappings but no sidecar file yet. + void this.syncExistingMappings(); + } + + private async handleEnvironmentsChanged(): Promise { + try { + await this.enqueueWrite(async (sidecar) => { + if (Object.keys(sidecar.mappings).length === 0) { + return false; + } + + let changed = false; + for (const [projectId, entry] of Object.entries(sidecar.mappings)) { + const environment = this.environmentManager.getEnvironment(entry.environmentId); + if (!environment) { + delete sidecar.mappings[projectId]; + changed = true; + } else if (environment.venvPath.fsPath !== entry.venvPath) { + sidecar.mappings[projectId] = { + environmentId: entry.environmentId, + venvPath: environment.venvPath.fsPath + }; + changed = true; + } + } + + return changed; + }); + } catch (error) { + logger.warn('[SidecarWriter] Failed to handle environments changed', error); + } + } + + /** + * When a notebook is opened after activation, check if it already has + * a mapping and add it to the sidecar. + */ + private async handleNotebookOpened(doc: NotebookDocument): Promise { + if (doc.notebookType !== 'deepnote') { + return; + } + + try { + const notebookUri = doc.uri.with({ query: '', fragment: '' }); + const projectId = doc.metadata?.deepnoteProjectId as string | undefined; + if (!projectId) { + return; + } + + const environmentId = this.mapper.getEnvironmentForNotebook(notebookUri); + if (!environmentId) { + return; + } + + const environment = this.environmentManager.getEnvironment(environmentId); + if (!environment) { + return; + } + + this.fsPathToProjectId.set(notebookUri.fsPath, projectId); + + await this.enqueueWrite(async (sidecar) => { + const existing = sidecar.mappings[projectId]; + if (existing?.environmentId === environmentId && existing?.venvPath === environment.venvPath.fsPath) { + return false; + } + sidecar.mappings[projectId] = { + environmentId, + venvPath: environment.venvPath.fsPath + }; + return true; + }); + } catch (error) { + logger.warn('[SidecarWriter] Failed to handle notebook opened', error); + } + } + + private async handleRemoveEnvironment({ notebookUri }: { notebookUri: Uri }): Promise { + try { + const projectId = this.fsPathToProjectId.get(notebookUri.fsPath) ?? this.resolveProjectId(notebookUri); + if (!projectId) { + return; + } + + this.fsPathToProjectId.delete(notebookUri.fsPath); + + await this.enqueueWrite(async (sidecar) => { + if (!(projectId in sidecar.mappings)) { + return false; + } + delete sidecar.mappings[projectId]; + return true; + }); + } catch (error) { + logger.warn('[SidecarWriter] Failed to handle remove environment', error); + } + } + + private async handleSetEnvironment({ + notebookUri, + environmentId + }: { + notebookUri: Uri; + environmentId: string; + }): Promise { + try { + const projectId = this.resolveProjectId(notebookUri); + if (!projectId) { + return; + } + + const environment = this.environmentManager.getEnvironment(environmentId); + if (!environment) { + return; + } + + this.fsPathToProjectId.set(notebookUri.fsPath, projectId); + + await this.enqueueWrite(async (sidecar) => { + sidecar.mappings[projectId] = { + environmentId, + venvPath: environment.venvPath.fsPath + }; + return true; + }); + } catch (error) { + logger.warn('[SidecarWriter] Failed to handle set environment', error); + } + } + + /** + * On activation, iterate all persisted mapper entries (not just open + * notebooks) and write their mappings to the sidecar. For each entry + * we read the `.deepnote` file to extract the project ID. + */ + private async syncExistingMappings(): Promise { + try { + await this.environmentManager.waitForInitialization(); + + const allMappings = this.mapper.getAllMappings(); + if (allMappings.size === 0) { + return; + } + + // Collect all project IDs outside the write queue to avoid holding the lock during I/O. + const entries: Array<{ fsPath: string; projectId: string; environmentId: string; venvPath: string }> = []; + for (const [fsPath, environmentId] of allMappings) { + const environment = this.environmentManager.getEnvironment(environmentId); + if (!environment) { + continue; + } + + const projectId = await this.readProjectIdFromFile(Uri.file(fsPath)); + if (!projectId) { + continue; + } + + this.fsPathToProjectId.set(fsPath, projectId); + entries.push({ fsPath, projectId, environmentId, venvPath: environment.venvPath.fsPath }); + } + + if (entries.length === 0) { + return; + } + + await this.enqueueWrite(async (sidecar) => { + for (const entry of entries) { + sidecar.mappings[entry.projectId] = { + environmentId: entry.environmentId, + venvPath: entry.venvPath + }; + } + return true; + }); + } catch (error) { + logger.warn('[SidecarWriter] Failed to sync existing mappings', error); + } + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /** + * Serializes all sidecar mutations through a queue so that concurrent + * read-modify-write cycles don't clobber each other. The `mutate` callback + * receives the current sidecar contents and returns `true` if it modified + * the object (i.e. should be written back). + */ + private enqueueWrite(mutate: (sidecar: SidecarFile) => Promise): Promise { + this.writeQueue = this.writeQueue.then(async () => { + const sidecar = await this.readSidecar(); + const changed = await mutate(sidecar); + if (changed) { + await this.writeSidecar(sidecar); + } + }); + return this.writeQueue; + } + + private getSidecarUri(): Uri | undefined { + const folder = workspace.workspaceFolders?.[0]; + if (!folder) { + return undefined; + } + return Uri.joinPath(folder.uri, getEditorSettingsFolder(), SIDECAR_FILENAME); + } + + private async readProjectIdFromFile(fileUri: Uri): Promise { + try { + const raw = await workspace.fs.readFile(fileUri); + const parsed = yaml.load(Buffer.from(raw).toString('utf-8')) as { project?: { id?: string } } | undefined; + return parsed?.project?.id; + } catch { + return undefined; + } + } + + private async readSidecar(): Promise { + const uri = this.getSidecarUri(); + if (!uri) { + return { mappings: {} }; + } + + try { + const raw = await workspace.fs.readFile(uri); + const parsed = JSON.parse(Buffer.from(raw).toString('utf-8')) as SidecarFile; + if (parsed?.mappings && typeof parsed.mappings === 'object') { + return parsed; + } + } catch { + // File doesn't exist or is invalid — start fresh. + } + + return { mappings: {} }; + } + + private resolveProjectId(notebookUri: Uri): string | undefined { + const doc = workspace.notebookDocuments.find( + (d) => + d.notebookType === 'deepnote' && d.uri.with({ query: '', fragment: '' }).fsPath === notebookUri.fsPath + ); + return doc?.metadata?.deepnoteProjectId as string | undefined; + } + + private async writeSidecar(sidecar: SidecarFile): Promise { + const uri = this.getSidecarUri(); + if (!uri) { + return; + } + + const content = JSON.stringify(sidecar, undefined, 2) + '\n'; + await workspace.fs.writeFile(uri, Buffer.from(content, 'utf-8')); + } +} diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts new file mode 100644 index 0000000000..7024ce87f7 --- /dev/null +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts @@ -0,0 +1,488 @@ +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter, NotebookDocument, Uri, WorkspaceFolder } from 'vscode'; + +import type { IDisposableRegistry } from '../../../platform/common/types'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; +import { DeepnoteEnvironment } from './deepnoteEnvironment'; +import { DeepnoteExtensionSidecarWriter } from './deepnoteExtensionSidecarWriter.node'; + +const waitForTimeoutMs = 5000; +const waitForIntervalMs = 50; + +async function waitFor( + condition: () => boolean, + timeoutMs = waitForTimeoutMs, + intervalMs = waitForIntervalMs +): 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, intervalMs)); + } +} + +function makeEnvironment(overrides: Partial & { id: string }): DeepnoteEnvironment { + return { + name: 'Test Env', + pythonInterpreter: { id: 'python3', uri: Uri.file('/usr/bin/python3') } as any, + venvPath: Uri.file('/home/user/.venvs/test'), + managedVenv: true, + createdAt: new Date(), + lastUsedAt: new Date(), + ...overrides + }; +} + +function makeDeepnoteYaml(projectId: string): string { + return `version: '1.0'\nproject:\n id: ${projectId}\n name: Test\n notebooks: []\n`; +} + +function createMockNotebook(opts: { uri: Uri; projectId?: string; notebookType?: string }): NotebookDocument { + return { + uri: opts.uri, + notebookType: opts.notebookType ?? 'deepnote', + metadata: { deepnoteProjectId: opts.projectId ?? 'project-1' }, + isDirty: false, + isUntitled: false, + isClosed: false, + version: 1, + cellCount: 0, + cellAt: () => { + throw new Error('Not implemented'); + }, + getCells: () => [], + save: async () => true + } as unknown as NotebookDocument; +} + +suite('DeepnoteExtensionSidecarWriter', () => { + let writer: DeepnoteExtensionSidecarWriter; + let disposables: IDisposableRegistry; + let mockMapper: IDeepnoteNotebookEnvironmentMapper; + let mockEnvironmentManager: IDeepnoteEnvironmentManager; + + let onDidSetEnvironment: EventEmitter<{ notebookUri: Uri; environmentId: string }>; + let onDidRemoveEnvironment: EventEmitter<{ notebookUri: Uri }>; + let onDidChangeEnvironments: EventEmitter; + let onDidOpenNotebookDocument: EventEmitter; + + let writtenContent: string | undefined; + let writeFileCallCount: number; + let readFileContent: string; + /** Per-file read responses keyed by fsPath — used for .deepnote YAML files. */ + let fileContents: Map; + + const workspaceUri = Uri.file('/workspace'); + + setup(() => { + resetVSCodeMocks(); + writtenContent = undefined; + writeFileCallCount = 0; + readFileContent = ''; + fileContents = new Map(); + + disposables = []; + + // Set up event emitters + onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); + onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); + onDidChangeEnvironments = new EventEmitter(); + onDidOpenNotebookDocument = new EventEmitter(); + disposables.push( + onDidSetEnvironment, + onDidRemoveEnvironment, + onDidChangeEnvironments, + onDidOpenNotebookDocument + ); + + // Set up mapper mock + mockMapper = mock(); + when(mockMapper.onDidSetEnvironment).thenReturn(onDidSetEnvironment.event); + when(mockMapper.onDidRemoveEnvironment).thenReturn(onDidRemoveEnvironment.event); + when(mockMapper.getAllMappings()).thenReturn(new Map()); + + // Set up environment manager mock + mockEnvironmentManager = mock(); + when(mockEnvironmentManager.onDidChangeEnvironments).thenReturn(onDidChangeEnvironments.event); + when(mockEnvironmentManager.waitForInitialization()).thenResolve(); + + // Set up workspace folder and onDidOpenNotebookDocument + const workspaceFolder = { uri: workspaceUri, name: 'workspace', index: 0 } as WorkspaceFolder; + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); + when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(onDidOpenNotebookDocument.event); + + // Set up workspace.fs mock + setupMockFs(); + + writer = new DeepnoteExtensionSidecarWriter( + instance(mockMapper), + instance(mockEnvironmentManager), + disposables + ); + }); + + teardown(() => { + sinon.restore(); + for (const d of disposables) { + d.dispose(); + } + }); + + function setupMockFs() { + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall((uri: Uri) => { + // Check per-file map first (for .deepnote YAML files) + const perFile = fileContents.get(uri.fsPath); + if (perFile !== undefined) { + return Promise.resolve(Buffer.from(perFile, 'utf-8')); + } + // Fall back to global sidecar content + if (!readFileContent) { + return Promise.reject(new Error('File not found')); + } + return Promise.resolve(Buffer.from(readFileContent, 'utf-8')); + }); + when(mockFs.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { + writtenContent = Buffer.from(content).toString('utf-8'); + writeFileCallCount++; + return Promise.resolve(); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + } + + function parseSidecar(): { mappings: Record } { + assert.isDefined(writtenContent, 'Expected sidecar to be written'); + return JSON.parse(writtenContent!); + } + + test('set mapping writes sidecar with correct projectId, environmentId, and venvPath', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ + id: 'env-1', + venvPath: Uri.file('/home/user/.venvs/my-env') + }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + + await waitFor(() => writeFileCallCount > 0); + + const sidecar = parseSidecar(); + assert.deepStrictEqual(sidecar.mappings['proj-abc'], { + environmentId: 'env-1', + venvPath: '/home/user/.venvs/my-env' + }); + }); + + test('remove mapping removes entry from sidecar', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + // First set, then remove + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + await waitFor(() => writeFileCallCount >= 1); + + // Set the sidecar content so the next read picks it up + readFileContent = writtenContent!; + + onDidRemoveEnvironment.fire({ notebookUri }); + await waitFor(() => writeFileCallCount >= 2); + + const sidecar = parseSidecar(); + assert.isUndefined(sidecar.mappings['proj-abc']); + assert.deepStrictEqual(sidecar.mappings, {}); + }); + + test('multiple projects accumulate entries in a single sidecar', async () => { + const uri1 = Uri.file('/workspace/project1.deepnote'); + const uri2 = Uri.file('/workspace/project2.deepnote'); + const nb1 = createMockNotebook({ uri: uri1, projectId: 'proj-1' }); + const nb2 = createMockNotebook({ uri: uri2, projectId: 'proj-2' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([nb1, nb2]); + + const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); + const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); + when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri: uri1, environmentId: 'env-1' }); + await waitFor(() => writeFileCallCount >= 1); + readFileContent = writtenContent!; + + onDidSetEnvironment.fire({ notebookUri: uri2, environmentId: 'env-2' }); + await waitFor(() => writeFileCallCount >= 2); + + const sidecar = parseSidecar(); + assert.strictEqual(Object.keys(sidecar.mappings).length, 2); + assert.strictEqual(sidecar.mappings['proj-1'].environmentId, 'env-1'); + assert.strictEqual(sidecar.mappings['proj-2'].environmentId, 'env-2'); + }); + + test('error reading sidecar does not throw', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + // readFile will reject (default behaviour when readFileContent is empty) + writer.activate(); + + // Should not throw even though readFile fails + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + await waitFor(() => writeFileCallCount >= 1); + + // Still writes successfully with a fresh sidecar + const sidecar = parseSidecar(); + assert.isDefined(sidecar.mappings['proj-abc']); + }); + + test('error writing sidecar does not throw', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + // Make writeFile throw + const mockFs = mock(); + when(mockFs.readFile(anything())).thenReject(new Error('File not found')); + when(mockFs.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + writer.activate(); + + // Should not throw + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + + // Give time for the async operation to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + // No crash — test passes if we get here + }); + + test('environment changed refreshes sidecar with updated venvPath', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/old-path') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + // Set initial mapping + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + await waitFor(() => writeFileCallCount >= 1); + readFileContent = writtenContent!; + + // Now change the env venvPath + const updatedEnv = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/new-path') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(updatedEnv); + + onDidChangeEnvironments.fire(); + await waitFor(() => writeFileCallCount >= 2); + + const sidecar = parseSidecar(); + assert.strictEqual(sidecar.mappings['proj-abc'].venvPath, '/venvs/new-path'); + }); + + test('environment deleted removes entry on environments changed', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + await waitFor(() => writeFileCallCount >= 1); + readFileContent = writtenContent!; + + // Now the environment no longer exists + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(undefined); + + onDidChangeEnvironments.fire(); + await waitFor(() => writeFileCallCount >= 2); + + const sidecar = parseSidecar(); + assert.isUndefined(sidecar.mappings['proj-abc']); + }); + + test('no-op when no workspace folder is open', async () => { + when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); + + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.strictEqual(writeFileCallCount, 0, 'Should not write when no workspace folder'); + }); + + test('activation syncs existing mappings to sidecar by reading .deepnote files', async () => { + const fsPath = '/workspace/project.deepnote'; + + // Mapper has a persisted entry — notebook is NOT open + when(mockMapper.getAllMappings()).thenReturn(new Map([[fsPath, 'env-existing']])); + fileContents.set(fsPath, makeDeepnoteYaml('proj-existing')); + + const env = makeEnvironment({ id: 'env-existing', venvPath: Uri.file('/venvs/existing') }); + when(mockEnvironmentManager.getEnvironment('env-existing')).thenReturn(env); + + writer.activate(); + + await waitFor(() => writeFileCallCount >= 1); + + const sidecar = parseSidecar(); + assert.deepStrictEqual(sidecar.mappings['proj-existing'], { + environmentId: 'env-existing', + venvPath: '/venvs/existing' + }); + }); + + test('activation syncs multiple projects including closed notebooks', async () => { + const path1 = '/workspace/proj1.deepnote'; + const path2 = '/workspace/proj2.deepnote'; + + when(mockMapper.getAllMappings()).thenReturn( + new Map([ + [path1, 'env-1'], + [path2, 'env-2'] + ]) + ); + fileContents.set(path1, makeDeepnoteYaml('proj-1')); + fileContents.set(path2, makeDeepnoteYaml('proj-2')); + + const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); + const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); + when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); + + writer.activate(); + + await waitFor(() => writeFileCallCount >= 1); + + const sidecar = parseSidecar(); + assert.strictEqual(Object.keys(sidecar.mappings).length, 2); + assert.strictEqual(sidecar.mappings['proj-1'].environmentId, 'env-1'); + assert.strictEqual(sidecar.mappings['proj-2'].environmentId, 'env-2'); + }); + + test('activation skips entries whose .deepnote file cannot be read', async () => { + const goodPath = '/workspace/good.deepnote'; + const badPath = '/workspace/missing.deepnote'; + + when(mockMapper.getAllMappings()).thenReturn( + new Map([ + [goodPath, 'env-1'], + [badPath, 'env-2'] + ]) + ); + fileContents.set(goodPath, makeDeepnoteYaml('proj-good')); + // badPath not in fileContents → readFile will reject + + const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); + const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); + when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); + + writer.activate(); + + await waitFor(() => writeFileCallCount >= 1); + + const sidecar = parseSidecar(); + assert.strictEqual(Object.keys(sidecar.mappings).length, 1); + assert.isDefined(sidecar.mappings['proj-good']); + assert.isUndefined(sidecar.mappings['proj-missing']); + }); + + test('opening a notebook with existing mapping writes to sidecar', async () => { + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + + writer.activate(); + + // Wait for initial sync (no-op since no notebooks open) + await new Promise((resolve) => setTimeout(resolve, 100)); + + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-opened' }); + + const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + when(mockMapper.getEnvironmentForNotebook(anything())).thenReturn('env-1'); + + onDidOpenNotebookDocument.fire(notebook); + + await waitFor(() => writeFileCallCount >= 1); + + const sidecar = parseSidecar(); + assert.deepStrictEqual(sidecar.mappings['proj-opened'], { + environmentId: 'env-1', + venvPath: '/venvs/env1' + }); + }); + + test('no-op when notebook has no projectId in metadata', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + // Notebook without projectId + const notebook = { + uri: notebookUri, + notebookType: 'deepnote', + metadata: {}, + isDirty: false, + isUntitled: false, + isClosed: false, + version: 1, + cellCount: 0, + cellAt: () => { + throw new Error('Not implemented'); + }, + getCells: () => [], + save: async () => true + } as unknown as NotebookDocument; + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.strictEqual(writeFileCallCount, 0, 'Should not write when no projectId'); + }); +}); diff --git a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts index 77345abce7..dbeeb2f7b9 100644 --- a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts +++ b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts @@ -2,8 +2,8 @@ // Licensed under the MIT License. import { injectable, inject } from 'inversify'; -import { Uri, Memento } from 'vscode'; -import { IExtensionContext } from '../../../platform/common/types'; +import { EventEmitter, Uri, Memento } from 'vscode'; +import { IDisposableRegistry, IExtensionContext } from '../../../platform/common/types'; import { logger } from '../../../platform/logging'; /** @@ -16,10 +16,20 @@ export class DeepnoteNotebookEnvironmentMapper { private readonly workspaceState: Memento; private mappings: Map; // notebookUri.fsPath -> environmentId - constructor(@inject(IExtensionContext) context: IExtensionContext) { + private readonly _onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); + public readonly onDidSetEnvironment = this._onDidSetEnvironment.event; + + private readonly _onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); + public readonly onDidRemoveEnvironment = this._onDidRemoveEnvironment.event; + + constructor( + @inject(IExtensionContext) context: IExtensionContext, + @inject(IDisposableRegistry) disposables: IDisposableRegistry + ) { this.workspaceState = context.workspaceState; this.mappings = new Map(); this.loadMappings(); + disposables.push(this._onDidSetEnvironment, this._onDidRemoveEnvironment); } /** @@ -42,6 +52,7 @@ export class DeepnoteNotebookEnvironmentMapper { this.mappings.set(key, environmentId); await this.saveMappings(); logger.info(`Mapped notebook ${notebookUri.fsPath} to environment ${environmentId}`); + this._onDidSetEnvironment.fire({ notebookUri, environmentId }); } /** @@ -53,6 +64,7 @@ export class DeepnoteNotebookEnvironmentMapper { this.mappings.delete(key); await this.saveMappings(); logger.info(`Removed environment mapping for notebook ${notebookUri.fsPath}`); + this._onDidRemoveEnvironment.fire({ notebookUri }); } /** @@ -70,6 +82,13 @@ export class DeepnoteNotebookEnvironmentMapper { return notebooks; } + /** + * Get all notebook-to-environment mappings + */ + public getAllMappings(): ReadonlyMap { + return this.mappings; + } + /** * Load mappings from workspace state */ diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index 63c2537ddf..8fe67063a9 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -350,6 +350,22 @@ export interface IDeepnoteNotebookEnvironmentMapper { * @returns Array of notebook URIs */ getNotebooksUsingEnvironment(environmentId: string): vscode.Uri[]; + + /** + * Get all notebook-to-environment mappings + * @returns Map of notebookUri.fsPath → environmentId + */ + getAllMappings(): ReadonlyMap; + + /** + * Event fired when an environment is set for a notebook + */ + onDidSetEnvironment: vscode.Event<{ notebookUri: vscode.Uri; environmentId: string }>; + + /** + * Event fired when an environment mapping is removed for a notebook + */ + onDidRemoveEnvironment: vscode.Event<{ notebookUri: vscode.Uri }>; } export const IDeepnoteLspClientManager = Symbol('IDeepnoteLspClientManager'); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 991f5b4fe2..ef43e3f9d4 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -80,6 +80,7 @@ import { DeepnoteEnvironmentManager } from '../kernels/deepnote/environments/dee import { DeepnoteEnvironmentStorage } from '../kernels/deepnote/environments/deepnoteEnvironmentStorage.node'; import { DeepnoteEnvironmentsView } from '../kernels/deepnote/environments/deepnoteEnvironmentsView.node'; import { DeepnoteEnvironmentsActivationService } from '../kernels/deepnote/environments/deepnoteEnvironmentsActivationService'; +import { DeepnoteExtensionSidecarWriter } from '../kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node'; import { DeepnoteNotebookEnvironmentMapper } from '../kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node'; import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; @@ -253,6 +254,12 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea DeepnoteNotebookEnvironmentMapper ); + // Sidecar file writer (exposes env mappings for external tools) + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteExtensionSidecarWriter + ); + // Snapshot service serviceManager.addSingleton(IEnvironmentCapture, EnvironmentCapture); serviceManager.addSingleton(SnapshotService, SnapshotService); From 501a26fa06c6d4cbbbb17bc0e11ef74937ef46fd Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Wed, 18 Feb 2026 17:36:25 +0100 Subject: [PATCH 2/5] spelling --- .../environments/deepnoteExtensionSidecarWriter.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts index 7024ce87f7..218cbac743 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts @@ -244,7 +244,7 @@ suite('DeepnoteExtensionSidecarWriter', () => { const env = makeEnvironment({ id: 'env-1' }); when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - // readFile will reject (default behaviour when readFileContent is empty) + // readFile will reject (default behavior when readFileContent is empty) writer.activate(); // Should not throw even though readFile fails From 20e8eaa86835676d65b2bd6c751ecfb1043a14fe Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Thu, 19 Feb 2026 09:45:03 +0100 Subject: [PATCH 3/5] CR Feedback --- .../deepnoteExtensionSidecarWriter.node.ts | 54 +++++++++++-------- ...eepnoteExtensionSidecarWriter.unit.test.ts | 14 ++--- .../deepnoteNotebookEnvironmentMapper.node.ts | 8 +-- src/kernels/deepnote/types.ts | 8 +-- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts index 8ba18dcd53..c86c4f2bbc 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts @@ -77,16 +77,20 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS let changed = false; for (const [projectId, entry] of Object.entries(sidecar.mappings)) { - const environment = this.environmentManager.getEnvironment(entry.environmentId); - if (!environment) { - delete sidecar.mappings[projectId]; - changed = true; - } else if (environment.venvPath.fsPath !== entry.venvPath) { - sidecar.mappings[projectId] = { - environmentId: entry.environmentId, - venvPath: environment.venvPath.fsPath - }; - changed = true; + try { + const environment = this.environmentManager.getEnvironment(entry.environmentId); + if (!environment) { + delete sidecar.mappings[projectId]; + changed = true; + } else if (environment.venvPath.fsPath !== entry.venvPath) { + sidecar.mappings[projectId] = { + environmentId: entry.environmentId, + venvPath: environment.venvPath.fsPath + }; + changed = true; + } + } catch (entryError) { + logger.warn(`[SidecarWriter] Failed to process mapping for project ${projectId}`, entryError); } } @@ -211,18 +215,22 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS // Collect all project IDs outside the write queue to avoid holding the lock during I/O. const entries: Array<{ fsPath: string; projectId: string; environmentId: string; venvPath: string }> = []; for (const [fsPath, environmentId] of allMappings) { - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - continue; - } + try { + const environment = this.environmentManager.getEnvironment(environmentId); + if (!environment) { + continue; + } - const projectId = await this.readProjectIdFromFile(Uri.file(fsPath)); - if (!projectId) { - continue; - } + const projectId = await this.readProjectIdFromFile(Uri.file(fsPath)); + if (!projectId) { + continue; + } - this.fsPathToProjectId.set(fsPath, projectId); - entries.push({ fsPath, projectId, environmentId, venvPath: environment.venvPath.fsPath }); + this.fsPathToProjectId.set(fsPath, projectId); + entries.push({ fsPath, projectId, environmentId, venvPath: environment.venvPath.fsPath }); + } catch (entryError) { + logger.warn(`[SidecarWriter] Failed to process mapping for ${fsPath}`, entryError); + } } if (entries.length === 0) { @@ -252,14 +260,16 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS * the object (i.e. should be written back). */ private enqueueWrite(mutate: (sidecar: SidecarFile) => Promise): Promise { - this.writeQueue = this.writeQueue.then(async () => { + const op = this.writeQueue.then(async () => { const sidecar = await this.readSidecar(); const changed = await mutate(sidecar); if (changed) { await this.writeSidecar(sidecar); } }); - return this.writeQueue; + // eslint-disable-next-line @typescript-eslint/no-empty-function -- keep queue chain alive after rejection + this.writeQueue = op.catch(() => {}); + return op; } private getSidecarUri(): Uri | undefined { diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts index 218cbac743..a5e90e37bd 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts @@ -231,9 +231,10 @@ suite('DeepnoteExtensionSidecarWriter', () => { await waitFor(() => writeFileCallCount >= 2); const sidecar = parseSidecar(); - assert.strictEqual(Object.keys(sidecar.mappings).length, 2); - assert.strictEqual(sidecar.mappings['proj-1'].environmentId, 'env-1'); - assert.strictEqual(sidecar.mappings['proj-2'].environmentId, 'env-2'); + assert.deepStrictEqual(sidecar.mappings, { + 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1' }, + 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2' } + }); }); test('error reading sidecar does not throw', async () => { @@ -394,9 +395,10 @@ suite('DeepnoteExtensionSidecarWriter', () => { await waitFor(() => writeFileCallCount >= 1); const sidecar = parseSidecar(); - assert.strictEqual(Object.keys(sidecar.mappings).length, 2); - assert.strictEqual(sidecar.mappings['proj-1'].environmentId, 'env-1'); - assert.strictEqual(sidecar.mappings['proj-2'].environmentId, 'env-2'); + assert.deepStrictEqual(sidecar.mappings, { + 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1' }, + 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2' } + }); }); test('activation skips entries whose .deepnote file cannot be read', async () => { diff --git a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts index dbeeb2f7b9..61070f8217 100644 --- a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts +++ b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts @@ -3,6 +3,7 @@ import { injectable, inject } from 'inversify'; import { EventEmitter, Uri, Memento } from 'vscode'; + import { IDisposableRegistry, IExtensionContext } from '../../../platform/common/types'; import { logger } from '../../../platform/logging'; @@ -16,11 +17,10 @@ export class DeepnoteNotebookEnvironmentMapper { private readonly workspaceState: Memento; private mappings: Map; // notebookUri.fsPath -> environmentId - private readonly _onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); - public readonly onDidSetEnvironment = this._onDidSetEnvironment.event; - private readonly _onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); + private readonly _onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); public readonly onDidRemoveEnvironment = this._onDidRemoveEnvironment.event; + public readonly onDidSetEnvironment = this._onDidSetEnvironment.event; constructor( @inject(IExtensionContext) context: IExtensionContext, @@ -86,7 +86,7 @@ export class DeepnoteNotebookEnvironmentMapper { * Get all notebook-to-environment mappings */ public getAllMappings(): ReadonlyMap { - return this.mappings; + return new Map(this.mappings); } /** diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index 8fe67063a9..ef64ae04e0 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -358,14 +358,14 @@ export interface IDeepnoteNotebookEnvironmentMapper { getAllMappings(): ReadonlyMap; /** - * Event fired when an environment is set for a notebook + * Event fired when an environment mapping is removed for a notebook */ - onDidSetEnvironment: vscode.Event<{ notebookUri: vscode.Uri; environmentId: string }>; + onDidRemoveEnvironment: vscode.Event<{ notebookUri: vscode.Uri }>; /** - * Event fired when an environment mapping is removed for a notebook + * Event fired when an environment is set for a notebook */ - onDidRemoveEnvironment: vscode.Event<{ notebookUri: vscode.Uri }>; + onDidSetEnvironment: vscode.Event<{ notebookUri: vscode.Uri; environmentId: string }>; } export const IDeepnoteLspClientManager = Symbol('IDeepnoteLspClientManager'); From 4fd475bacea04e5aec360303ea40924e3a87338f Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Thu, 19 Feb 2026 15:24:35 +0100 Subject: [PATCH 4/5] feat: Include pythonInterpreter path in deepnote.json sidecar file --- .../deepnoteExtensionSidecarWriter.node.ts | 40 +++++++++++++++---- ...eepnoteExtensionSidecarWriter.unit.test.ts | 21 ++++++---- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts index c86c4f2bbc..d900cc7b1a 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts @@ -30,6 +30,7 @@ function getEditorSettingsFolder(): string { interface SidecarEntry { environmentId: string; venvPath: string; + pythonInterpreter: string; } interface SidecarFile { @@ -82,10 +83,14 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS if (!environment) { delete sidecar.mappings[projectId]; changed = true; - } else if (environment.venvPath.fsPath !== entry.venvPath) { + } else if ( + environment.venvPath.fsPath !== entry.venvPath || + environment.pythonInterpreter.uri.fsPath !== entry.pythonInterpreter + ) { sidecar.mappings[projectId] = { environmentId: entry.environmentId, - venvPath: environment.venvPath.fsPath + venvPath: environment.venvPath.fsPath, + pythonInterpreter: environment.pythonInterpreter.uri.fsPath }; changed = true; } @@ -131,12 +136,17 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS await this.enqueueWrite(async (sidecar) => { const existing = sidecar.mappings[projectId]; - if (existing?.environmentId === environmentId && existing?.venvPath === environment.venvPath.fsPath) { + if ( + existing?.environmentId === environmentId && + existing?.venvPath === environment.venvPath.fsPath && + existing?.pythonInterpreter === environment.pythonInterpreter.uri.fsPath + ) { return false; } sidecar.mappings[projectId] = { environmentId, - venvPath: environment.venvPath.fsPath + venvPath: environment.venvPath.fsPath, + pythonInterpreter: environment.pythonInterpreter.uri.fsPath }; return true; }); @@ -189,7 +199,8 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS await this.enqueueWrite(async (sidecar) => { sidecar.mappings[projectId] = { environmentId, - venvPath: environment.venvPath.fsPath + venvPath: environment.venvPath.fsPath, + pythonInterpreter: environment.pythonInterpreter.uri.fsPath }; return true; }); @@ -213,7 +224,13 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS } // Collect all project IDs outside the write queue to avoid holding the lock during I/O. - const entries: Array<{ fsPath: string; projectId: string; environmentId: string; venvPath: string }> = []; + const entries: Array<{ + fsPath: string; + projectId: string; + environmentId: string; + venvPath: string; + pythonInterpreter: string; + }> = []; for (const [fsPath, environmentId] of allMappings) { try { const environment = this.environmentManager.getEnvironment(environmentId); @@ -227,7 +244,13 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS } this.fsPathToProjectId.set(fsPath, projectId); - entries.push({ fsPath, projectId, environmentId, venvPath: environment.venvPath.fsPath }); + entries.push({ + fsPath, + projectId, + environmentId, + venvPath: environment.venvPath.fsPath, + pythonInterpreter: environment.pythonInterpreter.uri.fsPath + }); } catch (entryError) { logger.warn(`[SidecarWriter] Failed to process mapping for ${fsPath}`, entryError); } @@ -241,7 +264,8 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS for (const entry of entries) { sidecar.mappings[entry.projectId] = { environmentId: entry.environmentId, - venvPath: entry.venvPath + venvPath: entry.venvPath, + pythonInterpreter: entry.pythonInterpreter }; } return true; diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts index a5e90e37bd..51d7b7a0ce 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts @@ -155,7 +155,9 @@ suite('DeepnoteExtensionSidecarWriter', () => { when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); } - function parseSidecar(): { mappings: Record } { + function parseSidecar(): { + mappings: Record; + } { assert.isDefined(writtenContent, 'Expected sidecar to be written'); return JSON.parse(writtenContent!); } @@ -180,7 +182,8 @@ suite('DeepnoteExtensionSidecarWriter', () => { const sidecar = parseSidecar(); assert.deepStrictEqual(sidecar.mappings['proj-abc'], { environmentId: 'env-1', - venvPath: '/home/user/.venvs/my-env' + venvPath: '/home/user/.venvs/my-env', + pythonInterpreter: '/usr/bin/python3' }); }); @@ -232,8 +235,8 @@ suite('DeepnoteExtensionSidecarWriter', () => { const sidecar = parseSidecar(); assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2' } + 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, + 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } }); }); @@ -368,7 +371,8 @@ suite('DeepnoteExtensionSidecarWriter', () => { const sidecar = parseSidecar(); assert.deepStrictEqual(sidecar.mappings['proj-existing'], { environmentId: 'env-existing', - venvPath: '/venvs/existing' + venvPath: '/venvs/existing', + pythonInterpreter: '/usr/bin/python3' }); }); @@ -396,8 +400,8 @@ suite('DeepnoteExtensionSidecarWriter', () => { const sidecar = parseSidecar(); assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2' } + 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, + 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } }); }); @@ -451,7 +455,8 @@ suite('DeepnoteExtensionSidecarWriter', () => { const sidecar = parseSidecar(); assert.deepStrictEqual(sidecar.mappings['proj-opened'], { environmentId: 'env-1', - venvPath: '/venvs/env1' + venvPath: '/venvs/env1', + pythonInterpreter: '/usr/bin/python3' }); }); From 21d565c7cecf762e768ebb70e107801fcf5ddcc1 Mon Sep 17 00:00:00 2001 From: Christoffer Artmann Date: Thu, 19 Feb 2026 15:35:46 +0100 Subject: [PATCH 5/5] feat: Create editor settings folder before writing deepnote.json --- .../deepnoteExtensionSidecarWriter.node.ts | 4 ++++ ...eepnoteExtensionSidecarWriter.unit.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts index d900cc7b1a..e7bf122dfb 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts @@ -347,6 +347,10 @@ export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationS return; } + // Ensure the editor settings folder exists (e.g. .vscode/). + const folderUri = Uri.joinPath(uri, '..'); + await workspace.fs.createDirectory(folderUri); + const content = JSON.stringify(sidecar, undefined, 2) + '\n'; await workspace.fs.writeFile(uri, Buffer.from(content, 'utf-8')); } diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts index 51d7b7a0ce..5db130e25d 100644 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts @@ -73,6 +73,7 @@ suite('DeepnoteExtensionSidecarWriter', () => { let writtenContent: string | undefined; let writeFileCallCount: number; + let createDirectoryUris: Uri[]; let readFileContent: string; /** Per-file read responses keyed by fsPath — used for .deepnote YAML files. */ let fileContents: Map; @@ -83,6 +84,7 @@ suite('DeepnoteExtensionSidecarWriter', () => { resetVSCodeMocks(); writtenContent = undefined; writeFileCallCount = 0; + createDirectoryUris = []; readFileContent = ''; fileContents = new Map(); @@ -147,6 +149,10 @@ suite('DeepnoteExtensionSidecarWriter', () => { } return Promise.resolve(Buffer.from(readFileContent, 'utf-8')); }); + when(mockFs.createDirectory(anything())).thenCall((uri: Uri) => { + createDirectoryUris.push(uri); + return Promise.resolve(); + }); when(mockFs.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { writtenContent = Buffer.from(content).toString('utf-8'); writeFileCallCount++; @@ -492,4 +498,22 @@ suite('DeepnoteExtensionSidecarWriter', () => { assert.strictEqual(writeFileCallCount, 0, 'Should not write when no projectId'); }); + + test('creates the editor settings folder before writing', async () => { + const notebookUri = Uri.file('/workspace/project.deepnote'); + const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + + const env = makeEnvironment({ id: 'env-1' }); + when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); + + writer.activate(); + + onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); + + await waitFor(() => writeFileCallCount > 0); + + assert.strictEqual(createDirectoryUris.length, 1); + assert.strictEqual(createDirectoryUris[0].fsPath, Uri.file('/workspace/.vscode').fsPath); + }); });