Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions src/notebooks/deepnote/deepnoteFileChangeWatcher.ts
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

@dinohamzic dinohamzic Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious if you figured out why this is the case / root cause? What makes a .deepnote file behave differently?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's mostly because our multiple editors (notebooks) are in a single file setup.

* 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<string, ReturnType<typeof setTimeout>>();
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<string, unknown> | 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<void> {
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<string, readonly NotebookCellOutput[]>();
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);
}
}
}
}
Loading
Loading