Skip to content

Commit 2e89a35

Browse files
authored
fix: Detect external changes to notebooks (#319)
Fixes #315
1 parent 45599b1 commit 2e89a35

4 files changed

Lines changed: 531 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import {
2+
CancellationTokenSource,
3+
NotebookCellData,
4+
NotebookCellOutput,
5+
NotebookDocument,
6+
NotebookEdit,
7+
NotebookRange,
8+
Uri,
9+
WorkspaceEdit,
10+
workspace
11+
} from 'vscode';
12+
import { inject, injectable, optional } from 'inversify';
13+
14+
import { IExtensionSyncActivationService } from '../../platform/activation/types';
15+
import { IDisposableRegistry } from '../../platform/common/types';
16+
import { logger } from '../../platform/logging';
17+
import { IDeepnoteNotebookManager } from '../types';
18+
import { DeepnoteNotebookSerializer } from './deepnoteSerializer';
19+
import { isSnapshotFile } from './snapshots/snapshotFiles';
20+
import { SnapshotService } from './snapshots/snapshotService';
21+
22+
const debounceTimeInMilliseconds = 500;
23+
24+
/**
25+
* Watches .deepnote files for external changes and reloads open notebook editors.
26+
*
27+
* When AI agents (Cursor, Claude Code) modify a .deepnote file on disk,
28+
* VS Code's NotebookSerializer does not reliably detect and reload the notebook.
29+
* This service bridges that gap by watching the filesystem and applying edits
30+
* to open notebook documents when their underlying files change externally.
31+
*/
32+
@injectable()
33+
export class DeepnoteFileChangeWatcher implements IExtensionSyncActivationService {
34+
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
35+
private readonly serializer: DeepnoteNotebookSerializer;
36+
37+
constructor(
38+
@inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry,
39+
@inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager,
40+
@inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService
41+
) {
42+
this.serializer = new DeepnoteNotebookSerializer(this.notebookManager, this.snapshotService);
43+
}
44+
45+
public activate(): void {
46+
const watcher = workspace.createFileSystemWatcher('**/*.deepnote');
47+
48+
this.disposables.push(watcher);
49+
this.disposables.push(watcher.onDidChange((uri) => this.handleFileChange(uri)));
50+
this.disposables.push({ dispose: () => this.clearAllTimers() });
51+
}
52+
53+
private cellsMatchNotebook(notebook: NotebookDocument, newCells: NotebookCellData[]): boolean {
54+
const liveCells = notebook.getCells();
55+
56+
if (liveCells.length !== newCells.length) {
57+
return false;
58+
}
59+
60+
return liveCells.every(
61+
(live, i) => live.document.getText() === newCells[i].value && live.kind === newCells[i].kind
62+
);
63+
}
64+
65+
private clearAllTimers(): void {
66+
for (const timer of this.debounceTimers.values()) {
67+
clearTimeout(timer);
68+
}
69+
70+
this.debounceTimers.clear();
71+
}
72+
73+
private getBlockIdFromMetadata(metadata: Record<string, unknown> | undefined): string | undefined {
74+
return (metadata?.id ?? metadata?.__deepnoteBlockId) as string | undefined;
75+
}
76+
77+
private handleFileChange(uri: Uri): void {
78+
if (isSnapshotFile(uri)) {
79+
return;
80+
}
81+
82+
const key = uri.toString();
83+
const existing = this.debounceTimers.get(key);
84+
85+
if (existing) {
86+
clearTimeout(existing);
87+
}
88+
89+
this.debounceTimers.set(
90+
key,
91+
setTimeout(() => {
92+
this.debounceTimers.delete(key);
93+
94+
void this.reloadNotebooksForFile(uri);
95+
}, debounceTimeInMilliseconds)
96+
);
97+
}
98+
99+
private async reloadNotebooksForFile(uri: Uri): Promise<void> {
100+
const uriString = uri.toString();
101+
const affectedNotebooks = workspace.notebookDocuments.filter(
102+
(doc) =>
103+
doc.notebookType === 'deepnote' && doc.uri.with({ query: '', fragment: '' }).toString() === uriString
104+
);
105+
106+
if (affectedNotebooks.length === 0) {
107+
return;
108+
}
109+
110+
let content: Uint8Array;
111+
112+
try {
113+
content = await workspace.fs.readFile(uri);
114+
} catch (error) {
115+
logger.warn(`[FileChangeWatcher] Failed to read changed file: ${uri.path}`, error);
116+
117+
return;
118+
}
119+
120+
const tokenSource = new CancellationTokenSource();
121+
let newData;
122+
try {
123+
newData = await this.serializer.deserializeNotebook(content, tokenSource.token);
124+
} catch (error) {
125+
logger.warn(`[FileChangeWatcher] Failed to parse changed file: ${uri.path}`, error);
126+
127+
return;
128+
} finally {
129+
tokenSource.dispose();
130+
}
131+
132+
for (const notebook of affectedNotebooks) {
133+
try {
134+
const newCells = newData.cells.map((cell) => ({ ...cell }));
135+
136+
if (this.cellsMatchNotebook(notebook, newCells)) {
137+
continue;
138+
}
139+
140+
// Preserve outputs from live cells that the deserialized data may lack.
141+
// In snapshot mode the main file has outputs stripped; AI agents
142+
// typically don't preserve outputs when editing code.
143+
const liveCells = notebook.getCells();
144+
const liveOutputsByBlockId = new Map<string, readonly NotebookCellOutput[]>();
145+
for (const liveCell of liveCells) {
146+
const blockId = this.getBlockIdFromMetadata(liveCell.metadata);
147+
if (blockId && liveCell.outputs.length > 0) {
148+
liveOutputsByBlockId.set(blockId, liveCell.outputs);
149+
}
150+
}
151+
152+
for (const cell of newCells) {
153+
const blockId = this.getBlockIdFromMetadata(cell.metadata);
154+
if (blockId && (!cell.outputs || cell.outputs.length === 0)) {
155+
const liveOutputs = liveOutputsByBlockId.get(blockId);
156+
if (liveOutputs) {
157+
cell.outputs = [...liveOutputs];
158+
}
159+
}
160+
}
161+
162+
const edit = new WorkspaceEdit();
163+
edit.set(notebook.uri, [NotebookEdit.replaceCells(new NotebookRange(0, notebook.cellCount), newCells)]);
164+
const applied = await workspace.applyEdit(edit);
165+
if (!applied) {
166+
logger.warn(`[FileChangeWatcher] Failed to apply edit: ${notebook.uri.path}`);
167+
continue;
168+
}
169+
170+
// Save immediately so VS Code updates its internal mtime for the file.
171+
// Without this, the user gets a "content is newer" conflict dialog on
172+
// their next manual save because VS Code still remembers the old mtime.
173+
await workspace.save(notebook.uri);
174+
175+
logger.info(`[FileChangeWatcher] Reloaded notebook from external change: ${notebook.uri.path}`);
176+
} catch (error) {
177+
logger.error(`[FileChangeWatcher] Failed to reload notebook: ${notebook.uri.path}`, error);
178+
}
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)