Skip to content

Commit 9105222

Browse files
authored
feat: Expose the environment <-> project mapping to agents. (#326)
1 parent 693dff4 commit 9105222

5 files changed

Lines changed: 921 additions & 3 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { inject, injectable } from 'inversify';
2+
import * as yaml from 'js-yaml';
3+
import { env, NotebookDocument, Uri, workspace } from 'vscode';
4+
5+
import { IExtensionSyncActivationService } from '../../../platform/activation/types';
6+
import { IDisposableRegistry } from '../../../platform/common/types';
7+
import { logger } from '../../../platform/logging';
8+
import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types';
9+
10+
const SIDECAR_FILENAME = 'deepnote.json';
11+
12+
/**
13+
* Returns the editor-specific settings folder name based on the current app.
14+
* - VS Code → `.vscode`
15+
* - Cursor → `.cursor`
16+
* - Antigravity → `.antigravity`
17+
* - Unknown → `.vscode` (safe default)
18+
*/
19+
function getEditorSettingsFolder(): string {
20+
const appName = env.appName.toLowerCase();
21+
if (appName.includes('cursor')) {
22+
return '.cursor';
23+
}
24+
if (appName.includes('antigravity')) {
25+
return '.antigravity';
26+
}
27+
return '.vscode';
28+
}
29+
30+
interface SidecarEntry {
31+
environmentId: string;
32+
venvPath: string;
33+
pythonInterpreter: string;
34+
}
35+
36+
interface SidecarFile {
37+
mappings: Record<string, SidecarEntry>;
38+
}
39+
40+
/**
41+
* Writes a `deepnote.json` sidecar file in the editor settings
42+
* folder (e.g. `.vscode/`, `.cursor/`, `.antigravity/`) so that external
43+
* tools (e.g. the Deepnote CLI) can discover the selected venv path for
44+
* each project without reading VS Code workspace state.
45+
*/
46+
@injectable()
47+
export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationService {
48+
/** Reverse map: notebookUri.fsPath → projectId (populated from sidecar + set calls). */
49+
private readonly fsPathToProjectId = new Map<string, string>();
50+
/** Serializes sidecar writes to avoid read-modify-write races. */
51+
private writeQueue: Promise<void> = Promise.resolve();
52+
53+
constructor(
54+
@inject(IDeepnoteNotebookEnvironmentMapper) private readonly mapper: IDeepnoteNotebookEnvironmentMapper,
55+
@inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager,
56+
@inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry
57+
) {}
58+
59+
public activate(): void {
60+
this.disposables.push(
61+
this.mapper.onDidSetEnvironment((e) => this.handleSetEnvironment(e)),
62+
this.mapper.onDidRemoveEnvironment((e) => this.handleRemoveEnvironment(e)),
63+
this.environmentManager.onDidChangeEnvironments(() => this.handleEnvironmentsChanged()),
64+
workspace.onDidOpenNotebookDocument((doc) => this.handleNotebookOpened(doc))
65+
);
66+
67+
// Sync existing mappings so the sidecar is up-to-date for existing users
68+
// who already have workspace-state mappings but no sidecar file yet.
69+
void this.syncExistingMappings();
70+
}
71+
72+
private async handleEnvironmentsChanged(): Promise<void> {
73+
try {
74+
await this.enqueueWrite(async (sidecar) => {
75+
if (Object.keys(sidecar.mappings).length === 0) {
76+
return false;
77+
}
78+
79+
let changed = false;
80+
for (const [projectId, entry] of Object.entries(sidecar.mappings)) {
81+
try {
82+
const environment = this.environmentManager.getEnvironment(entry.environmentId);
83+
if (!environment) {
84+
delete sidecar.mappings[projectId];
85+
changed = true;
86+
} else if (
87+
environment.venvPath.fsPath !== entry.venvPath ||
88+
environment.pythonInterpreter.uri.fsPath !== entry.pythonInterpreter
89+
) {
90+
sidecar.mappings[projectId] = {
91+
environmentId: entry.environmentId,
92+
venvPath: environment.venvPath.fsPath,
93+
pythonInterpreter: environment.pythonInterpreter.uri.fsPath
94+
};
95+
changed = true;
96+
}
97+
} catch (entryError) {
98+
logger.warn(`[SidecarWriter] Failed to process mapping for project ${projectId}`, entryError);
99+
}
100+
}
101+
102+
return changed;
103+
});
104+
} catch (error) {
105+
logger.warn('[SidecarWriter] Failed to handle environments changed', error);
106+
}
107+
}
108+
109+
/**
110+
* When a notebook is opened after activation, check if it already has
111+
* a mapping and add it to the sidecar.
112+
*/
113+
private async handleNotebookOpened(doc: NotebookDocument): Promise<void> {
114+
if (doc.notebookType !== 'deepnote') {
115+
return;
116+
}
117+
118+
try {
119+
const notebookUri = doc.uri.with({ query: '', fragment: '' });
120+
const projectId = doc.metadata?.deepnoteProjectId as string | undefined;
121+
if (!projectId) {
122+
return;
123+
}
124+
125+
const environmentId = this.mapper.getEnvironmentForNotebook(notebookUri);
126+
if (!environmentId) {
127+
return;
128+
}
129+
130+
const environment = this.environmentManager.getEnvironment(environmentId);
131+
if (!environment) {
132+
return;
133+
}
134+
135+
this.fsPathToProjectId.set(notebookUri.fsPath, projectId);
136+
137+
await this.enqueueWrite(async (sidecar) => {
138+
const existing = sidecar.mappings[projectId];
139+
if (
140+
existing?.environmentId === environmentId &&
141+
existing?.venvPath === environment.venvPath.fsPath &&
142+
existing?.pythonInterpreter === environment.pythonInterpreter.uri.fsPath
143+
) {
144+
return false;
145+
}
146+
sidecar.mappings[projectId] = {
147+
environmentId,
148+
venvPath: environment.venvPath.fsPath,
149+
pythonInterpreter: environment.pythonInterpreter.uri.fsPath
150+
};
151+
return true;
152+
});
153+
} catch (error) {
154+
logger.warn('[SidecarWriter] Failed to handle notebook opened', error);
155+
}
156+
}
157+
158+
private async handleRemoveEnvironment({ notebookUri }: { notebookUri: Uri }): Promise<void> {
159+
try {
160+
const projectId = this.fsPathToProjectId.get(notebookUri.fsPath) ?? this.resolveProjectId(notebookUri);
161+
if (!projectId) {
162+
return;
163+
}
164+
165+
this.fsPathToProjectId.delete(notebookUri.fsPath);
166+
167+
await this.enqueueWrite(async (sidecar) => {
168+
if (!(projectId in sidecar.mappings)) {
169+
return false;
170+
}
171+
delete sidecar.mappings[projectId];
172+
return true;
173+
});
174+
} catch (error) {
175+
logger.warn('[SidecarWriter] Failed to handle remove environment', error);
176+
}
177+
}
178+
179+
private async handleSetEnvironment({
180+
notebookUri,
181+
environmentId
182+
}: {
183+
notebookUri: Uri;
184+
environmentId: string;
185+
}): Promise<void> {
186+
try {
187+
const projectId = this.resolveProjectId(notebookUri);
188+
if (!projectId) {
189+
return;
190+
}
191+
192+
const environment = this.environmentManager.getEnvironment(environmentId);
193+
if (!environment) {
194+
return;
195+
}
196+
197+
this.fsPathToProjectId.set(notebookUri.fsPath, projectId);
198+
199+
await this.enqueueWrite(async (sidecar) => {
200+
sidecar.mappings[projectId] = {
201+
environmentId,
202+
venvPath: environment.venvPath.fsPath,
203+
pythonInterpreter: environment.pythonInterpreter.uri.fsPath
204+
};
205+
return true;
206+
});
207+
} catch (error) {
208+
logger.warn('[SidecarWriter] Failed to handle set environment', error);
209+
}
210+
}
211+
212+
/**
213+
* On activation, iterate all persisted mapper entries (not just open
214+
* notebooks) and write their mappings to the sidecar. For each entry
215+
* we read the `.deepnote` file to extract the project ID.
216+
*/
217+
private async syncExistingMappings(): Promise<void> {
218+
try {
219+
await this.environmentManager.waitForInitialization();
220+
221+
const allMappings = this.mapper.getAllMappings();
222+
if (allMappings.size === 0) {
223+
return;
224+
}
225+
226+
// Collect all project IDs outside the write queue to avoid holding the lock during I/O.
227+
const entries: Array<{
228+
fsPath: string;
229+
projectId: string;
230+
environmentId: string;
231+
venvPath: string;
232+
pythonInterpreter: string;
233+
}> = [];
234+
for (const [fsPath, environmentId] of allMappings) {
235+
try {
236+
const environment = this.environmentManager.getEnvironment(environmentId);
237+
if (!environment) {
238+
continue;
239+
}
240+
241+
const projectId = await this.readProjectIdFromFile(Uri.file(fsPath));
242+
if (!projectId) {
243+
continue;
244+
}
245+
246+
this.fsPathToProjectId.set(fsPath, projectId);
247+
entries.push({
248+
fsPath,
249+
projectId,
250+
environmentId,
251+
venvPath: environment.venvPath.fsPath,
252+
pythonInterpreter: environment.pythonInterpreter.uri.fsPath
253+
});
254+
} catch (entryError) {
255+
logger.warn(`[SidecarWriter] Failed to process mapping for ${fsPath}`, entryError);
256+
}
257+
}
258+
259+
if (entries.length === 0) {
260+
return;
261+
}
262+
263+
await this.enqueueWrite(async (sidecar) => {
264+
for (const entry of entries) {
265+
sidecar.mappings[entry.projectId] = {
266+
environmentId: entry.environmentId,
267+
venvPath: entry.venvPath,
268+
pythonInterpreter: entry.pythonInterpreter
269+
};
270+
}
271+
return true;
272+
});
273+
} catch (error) {
274+
logger.warn('[SidecarWriter] Failed to sync existing mappings', error);
275+
}
276+
}
277+
278+
// ── Helpers ──────────────────────────────────────────────────────────
279+
280+
/**
281+
* Serializes all sidecar mutations through a queue so that concurrent
282+
* read-modify-write cycles don't clobber each other. The `mutate` callback
283+
* receives the current sidecar contents and returns `true` if it modified
284+
* the object (i.e. should be written back).
285+
*/
286+
private enqueueWrite(mutate: (sidecar: SidecarFile) => Promise<boolean>): Promise<void> {
287+
const op = this.writeQueue.then(async () => {
288+
const sidecar = await this.readSidecar();
289+
const changed = await mutate(sidecar);
290+
if (changed) {
291+
await this.writeSidecar(sidecar);
292+
}
293+
});
294+
// eslint-disable-next-line @typescript-eslint/no-empty-function -- keep queue chain alive after rejection
295+
this.writeQueue = op.catch(() => {});
296+
return op;
297+
}
298+
299+
private getSidecarUri(): Uri | undefined {
300+
const folder = workspace.workspaceFolders?.[0];
301+
if (!folder) {
302+
return undefined;
303+
}
304+
return Uri.joinPath(folder.uri, getEditorSettingsFolder(), SIDECAR_FILENAME);
305+
}
306+
307+
private async readProjectIdFromFile(fileUri: Uri): Promise<string | undefined> {
308+
try {
309+
const raw = await workspace.fs.readFile(fileUri);
310+
const parsed = yaml.load(Buffer.from(raw).toString('utf-8')) as { project?: { id?: string } } | undefined;
311+
return parsed?.project?.id;
312+
} catch {
313+
return undefined;
314+
}
315+
}
316+
317+
private async readSidecar(): Promise<SidecarFile> {
318+
const uri = this.getSidecarUri();
319+
if (!uri) {
320+
return { mappings: {} };
321+
}
322+
323+
try {
324+
const raw = await workspace.fs.readFile(uri);
325+
const parsed = JSON.parse(Buffer.from(raw).toString('utf-8')) as SidecarFile;
326+
if (parsed?.mappings && typeof parsed.mappings === 'object') {
327+
return parsed;
328+
}
329+
} catch {
330+
// File doesn't exist or is invalid — start fresh.
331+
}
332+
333+
return { mappings: {} };
334+
}
335+
336+
private resolveProjectId(notebookUri: Uri): string | undefined {
337+
const doc = workspace.notebookDocuments.find(
338+
(d) =>
339+
d.notebookType === 'deepnote' && d.uri.with({ query: '', fragment: '' }).fsPath === notebookUri.fsPath
340+
);
341+
return doc?.metadata?.deepnoteProjectId as string | undefined;
342+
}
343+
344+
private async writeSidecar(sidecar: SidecarFile): Promise<void> {
345+
const uri = this.getSidecarUri();
346+
if (!uri) {
347+
return;
348+
}
349+
350+
// Ensure the editor settings folder exists (e.g. .vscode/).
351+
const folderUri = Uri.joinPath(uri, '..');
352+
await workspace.fs.createDirectory(folderUri);
353+
354+
const content = JSON.stringify(sidecar, undefined, 2) + '\n';
355+
await workspace.fs.writeFile(uri, Buffer.from(content, 'utf-8'));
356+
}
357+
}

0 commit comments

Comments
 (0)