|
| 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