diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts new file mode 100644 index 000000000..4e6393bc1 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.ts @@ -0,0 +1,181 @@ +import { + CancellationTokenSource, + NotebookCellData, + NotebookCellOutput, + NotebookDocument, + NotebookEdit, + NotebookRange, + Uri, + WorkspaceEdit, + workspace +} from 'vscode'; +import { inject, injectable, optional } from 'inversify'; + +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { IDisposableRegistry } from '../../platform/common/types'; +import { logger } from '../../platform/logging'; +import { IDeepnoteNotebookManager } from '../types'; +import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; +import { isSnapshotFile } from './snapshots/snapshotFiles'; +import { SnapshotService } from './snapshots/snapshotService'; + +const debounceTimeInMilliseconds = 500; + +/** + * Watches .deepnote files for external changes and reloads open notebook editors. + * + * When AI agents (Cursor, Claude Code) modify a .deepnote file on disk, + * VS Code's NotebookSerializer does not reliably detect and reload the notebook. + * This service bridges that gap by watching the filesystem and applying edits + * to open notebook documents when their underlying files change externally. + */ +@injectable() +export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationService { + private readonly debounceTimers = new Map>(); + private readonly serializer: DeepnoteNotebookSerializer; + + constructor( + @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService + ) { + this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService); + } + + public activate(): void { + const watcher = workspace.createFileSystemWatcher('**/*.deepnote'); + + this.disposables.push(watcher); + this.disposables.push(watcher.onDidChange((uri) => this.handleFileChange(uri))); + this.disposables.push({ dispose: () => this.clearAllTimers() }); + } + + private cellsMatchNotebook(notebook: NotebookDocument, newCells: NotebookCellData[]): boolean { + const liveCells = notebook.getCells(); + + if (liveCells.length !== newCells.length) { + return false; + } + + return liveCells.every( + (live, i) => live.document.getText() === newCells[i].value && live.kind === newCells[i].kind + ); + } + + private clearAllTimers(): void { + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + + this.debounceTimers.clear(); + } + + private getBlockIdFromMetadata(metadata: Record | undefined): string | undefined { + return (metadata?.id ?? metadata?.__deepnoteBlockId) as string | undefined; + } + + private handleFileChange(uri: Uri): void { + if (isSnapshotFile(uri)) { + return; + } + + const key = uri.toString(); + const existing = this.debounceTimers.get(key); + + if (existing) { + clearTimeout(existing); + } + + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key); + + void this.reloadNotebooksForFile(uri); + }, debounceTimeInMilliseconds) + ); + } + + private async reloadNotebooksForFile(uri: Uri): Promise { + const uriString = uri.toString(); + const affectedNotebooks = workspace.notebookDocuments.filter( + (doc) => + doc.notebookType === 'deepnote' && doc.uri.with({ query: '', fragment: '' }).toString() === uriString + ); + + if (affectedNotebooks.length === 0) { + return; + } + + let content: Uint8Array; + + try { + content = await workspace.fs.readFile(uri); + } catch (error) { + logger.warn(`[FileChangeWatcher] Failed to read changed file: ${uri.path}`, error); + + return; + } + + const tokenSource = new CancellationTokenSource(); + let newData; + try { + newData = await this.serializer.deserializeNotebook(content, tokenSource.token); + } catch (error) { + logger.warn(`[FileChangeWatcher] Failed to parse changed file: ${uri.path}`, error); + + return; + } finally { + tokenSource.dispose(); + } + + for (const notebook of affectedNotebooks) { + try { + const newCells = newData.cells.map((cell) => ({ ...cell })); + + if (this.cellsMatchNotebook(notebook, newCells)) { + continue; + } + + // Preserve outputs from live cells that the deserialized data may lack. + // In snapshot mode the main file has outputs stripped; AI agents + // typically don't preserve outputs when editing code. + const liveCells = notebook.getCells(); + const liveOutputsByBlockId = new Map(); + for (const liveCell of liveCells) { + const blockId = this.getBlockIdFromMetadata(liveCell.metadata); + if (blockId && liveCell.outputs.length > 0) { + liveOutputsByBlockId.set(blockId, liveCell.outputs); + } + } + + for (const cell of newCells) { + const blockId = this.getBlockIdFromMetadata(cell.metadata); + if (blockId && (!cell.outputs || cell.outputs.length === 0)) { + const liveOutputs = liveOutputsByBlockId.get(blockId); + if (liveOutputs) { + cell.outputs = [...liveOutputs]; + } + } + } + + const edit = new WorkspaceEdit(); + edit.set(notebook.uri, [NotebookEdit.replaceCells(new NotebookRange(0, notebook.cellCount), newCells)]); + const applied = await workspace.applyEdit(edit); + if (!applied) { + logger.warn(`[FileChangeWatcher] Failed to apply edit: ${notebook.uri.path}`); + continue; + } + + // Save immediately so VS Code updates its internal mtime for the file. + // Without this, the user gets a "content is newer" conflict dialog on + // their next manual save because VS Code still remembers the old mtime. + await workspace.save(notebook.uri); + + logger.info(`[FileChangeWatcher] Reloaded notebook from external change: ${notebook.uri.path}`); + } catch (error) { + logger.error(`[FileChangeWatcher] Failed to reload notebook: ${notebook.uri.path}`, error); + } + } + } +} diff --git a/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts new file mode 100644 index 000000000..189c2f56f --- /dev/null +++ b/src/notebooks/deepnote/deepnoteFileChangeWatcher.unit.test.ts @@ -0,0 +1,338 @@ +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter, FileSystemWatcher, NotebookCellKind, NotebookDocument, Uri } from 'vscode'; + +import type { IDisposableRegistry } from '../../platform/common/types'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { IDeepnoteNotebookManager } from '../types'; +import { DeepnoteFileChangeWatcher } from './deepnoteFileChangeWatcher'; + +const waitForTimeoutMs = 5000; +const waitForIntervalMs = 50; +const debounceWaitMs = 800; +const rapidChangeIntervalMs = 100; + +/** + * Polls until a condition is met or a timeout is reached. + */ +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)); + } +} + +suite('DeepnoteFileChangeWatcher', () => { + let watcher: DeepnoteFileChangeWatcher; + let mockDisposables: IDisposableRegistry; + let mockNotebookManager: IDeepnoteNotebookManager; + let onDidChangeFile: EventEmitter; + let readFileCalls: number; + let applyEditCount: number; + let saveCount: number; + + setup(() => { + resetVSCodeMocks(); + readFileCalls = 0; + applyEditCount = 0; + saveCount = 0; + + mockDisposables = []; + mockNotebookManager = instance(mock()); + + // Set up FileSystemWatcher mock + onDidChangeFile = new EventEmitter(); + const fsWatcher = mock(); + when(fsWatcher.onDidChange).thenReturn(onDidChangeFile.event); + when(fsWatcher.dispose()).thenReturn(); + + when(mockedVSCodeNamespaces.workspace.createFileSystemWatcher(anything())).thenReturn(instance(fsWatcher)); + + when(mockedVSCodeNamespaces.workspace.applyEdit(anything())).thenCall(() => { + applyEditCount++; + return Promise.resolve(true); + }); + when(mockedVSCodeNamespaces.workspace.save(anything())).thenCall(() => { + saveCount++; + return Promise.resolve(Uri.file('/workspace/test.deepnote')); + }); + + watcher = new DeepnoteFileChangeWatcher(mockDisposables, mockNotebookManager); + watcher.activate(); + }); + + teardown(() => { + sinon.restore(); + for (const d of mockDisposables) { + d.dispose(); + } + onDidChangeFile.dispose(); + }); + + function createMockNotebook(opts: { + uri: Uri; + isDirty?: boolean; + notebookType?: string; + cellCount?: number; + metadata?: Record; + cells?: Array<{ + metadata?: Record; + outputs: any[]; + kind?: number; + document?: { getText: () => string }; + }>; + }): NotebookDocument { + const cells = (opts.cells ?? []).map((c) => ({ + ...c, + kind: c.kind ?? NotebookCellKind.Code, + document: c.document ?? { getText: () => '' } + })); + + return { + uri: opts.uri, + isDirty: opts.isDirty ?? false, + notebookType: opts.notebookType ?? 'deepnote', + cellCount: opts.cellCount ?? (cells.length || 1), + metadata: opts.metadata ?? { + deepnoteProjectId: 'project-1', + deepnoteNotebookId: 'notebook-1' + }, + getCells: () => cells + } as unknown as NotebookDocument; + } + + function setupMockFs(yamlContent: string) { + const mockFs = mock(); + when(mockFs.readFile(anything())).thenCall(() => { + readFileCalls++; + return Promise.resolve(new TextEncoder().encode(yamlContent)); + }); + when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); + + return mockFs; + } + + const validYaml = ` +version: '1.0' +metadata: + createdAt: '2025-01-01T00:00:00Z' +project: + id: project-1 + name: Test Project + notebooks: + - id: notebook-1 + name: Notebook 1 + blocks: + - id: block-1 + type: code + sortingKey: a0 + content: print("hello") +`; + + test('should skip reload when content matches notebook cells', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + // Create a notebook whose cell content already matches validYaml + const notebook = createMockNotebook({ + uri, + cells: [ + { + metadata: { id: 'block-1' }, + outputs: [], + kind: NotebookCellKind.Code, + document: { getText: () => 'print("hello")' } + } + ] + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + onDidChangeFile.fire(uri); + + // Wait for debounce + deserialization + await waitFor(() => readFileCalls > 0); + + // File was read, but applyEdit should NOT be called because cells match + assert.isAtLeast(readFileCalls, 1, 'readFile should be called'); + assert.strictEqual(applyEditCount, 0, 'applyEdit should not be called when cells match'); + }); + + test('should reload on external change', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const notebook = createMockNotebook({ uri, cellCount: 0 }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + // Fire file change without a preceding save + onDidChangeFile.fire(uri); + + // Wait for the full async chain: debounce + deserialize + applyEdit + save + await waitFor(() => saveCount > 0); + + assert.isAtLeast(applyEditCount, 1, 'applyEdit should be called'); + assert.isAtLeast(saveCount, 1, 'save should be called after applyEdit'); + }); + + test('should skip snapshot files', async () => { + const snapshotUri = Uri.file('/workspace/snapshots/project_abc_latest.snapshot.deepnote'); + setupMockFs(validYaml); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); + + onDidChangeFile.fire(snapshotUri); + + // Wait well past debounce + await new Promise((resolve) => setTimeout(resolve, debounceWaitMs)); + + // Should not attempt to read the file at all + assert.strictEqual(readFileCalls, 0, 'readFile should not be called for snapshot files'); + }); + + test('should reload dirty notebooks', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const notebook = createMockNotebook({ uri, isDirty: true }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + onDidChangeFile.fire(uri); + + // Wait for the full async chain to finish + await waitFor(() => saveCount > 0); + + // Dirty notebooks should now be reloaded and saved to prevent mtime conflicts + assert.isAtLeast(readFileCalls, 1, 'readFile should be called'); + assert.isAtLeast(applyEditCount, 1, 'applyEdit should be called'); + assert.isAtLeast(saveCount, 1, 'save should be called'); + }); + + test('should debounce rapid changes', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const notebook = createMockNotebook({ uri, cellCount: 0 }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + // Fire multiple changes rapidly + onDidChangeFile.fire(uri); + await new Promise((resolve) => setTimeout(resolve, rapidChangeIntervalMs)); + onDidChangeFile.fire(uri); + await new Promise((resolve) => setTimeout(resolve, rapidChangeIntervalMs)); + onDidChangeFile.fire(uri); + + // Wait for debounce from the last event + processing + await waitFor(() => applyEditCount > 0); + + // readFile should only be called once (debounced) + assert.strictEqual(readFileCalls, 1, 'readFile should be called exactly once after debounce'); + }); + + test('should handle parse errors gracefully', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const notebook = createMockNotebook({ uri, cellCount: 0 }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs('this is: [invalid: yaml: content'); + + onDidChangeFile.fire(uri); + + // Wait for debounce + processing + await waitFor(() => readFileCalls > 0); + + // Parse errors are caught and logged; applyEdit should not be called + assert.strictEqual(applyEditCount, 0, 'applyEdit should not be called on parse error'); + }); + + test('should preserve live cell outputs during reload', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const fakeOutput = { items: [{ mime: 'text/plain', data: new Uint8Array([72]) }] }; + const notebook = createMockNotebook({ + uri, + cells: [ + { + metadata: { id: 'block-1' }, + outputs: [fakeOutput] + } + ] + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + onDidChangeFile.fire(uri); + + await waitFor(() => applyEditCount > 0); + + // applyEdit should be called — the output preservation runs before it + assert.isAtLeast(applyEditCount, 1, 'applyEdit should be called'); + }); + + test('should reload dirty notebooks and preserve outputs', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + const fakeOutput = { items: [{ mime: 'text/plain', data: new Uint8Array([72]) }] }; + const notebook = createMockNotebook({ + uri, + isDirty: true, + cells: [ + { + metadata: { id: 'block-1' }, + outputs: [fakeOutput] + } + ] + }); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + onDidChangeFile.fire(uri); + + await waitFor(() => applyEditCount > 0); + + // Dirty notebook should still be reloaded with outputs preserved + assert.isAtLeast(applyEditCount, 1, 'applyEdit should be called'); + }); + + test('should not suppress real changes after auto-save', async () => { + const uri = Uri.file('/workspace/test.deepnote'); + + // First change: notebook has no cells, YAML has one cell -> different -> reload + const notebook = createMockNotebook({ uri, cellCount: 0, cells: [] }); + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + setupMockFs(validYaml); + + onDidChangeFile.fire(uri); + await waitFor(() => applyEditCount >= 1); + + // Second change: use different YAML content + const changedYaml = ` +version: '1.0' +metadata: + createdAt: '2025-01-01T00:00:00Z' +project: + id: project-1 + name: Test Project + notebooks: + - id: notebook-1 + name: Notebook 1 + blocks: + - id: block-1 + type: code + sortingKey: a0 + content: print("world") +`; + setupMockFs(changedYaml); + onDidChangeFile.fire(uri); + await waitFor(() => applyEditCount >= 2, waitForTimeoutMs); + + assert.isAtLeast(applyEditCount, 2, 'applyEdit should be called for both external changes'); + }); +}); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index e61383207..991f5b4fe 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -92,6 +92,7 @@ import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; import { IntegrationKernelRestartHandler } from './deepnote/integrations/integrationKernelRestartHandler'; import { ISnapshotMetadataService, SnapshotService } from './deepnote/snapshots/snapshotService'; import { EnvironmentCapture, IEnvironmentCapture } from './deepnote/snapshots/environmentCapture.node'; +import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -258,6 +259,12 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addBinding(SnapshotService, IExtensionSyncActivationService); serviceManager.addBinding(SnapshotService, ISnapshotMetadataService); + // File change watcher for external edits + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteFileChangeWatcher + ); + // File export/import serviceManager.addSingleton(IFileConverter, FileConverter); serviceManager.addSingleton(ExportInterpreterFinder, ExportInterpreterFinder); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 708e65787..2488ff73d 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -54,6 +54,7 @@ import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNu import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { IntegrationKernelRestartHandler } from './deepnote/integrations/integrationKernelRestartHandler'; +import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { registerControllerTypes(serviceManager, isDevMode); @@ -136,6 +137,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, IntegrationKernelRestartHandler ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteFileChangeWatcher + ); serviceManager.addSingleton(IExportBase, ExportBase); serviceManager.addSingleton(IFileConverter, FileConverter);